2015 年,Apple 推出 iPhone 6s 和 iPhone 6s Plus,一起推出了实况相片(Live Photos)功用。在其时,这是一项开创性的、全新的摄影办法,能以动态办法记载精彩瞬间,为静态相片注入生命力。拍照实况相片时,iPhone 会录下摄影前后各 1.5 秒所产生的全部。用户能够选择不同的封面相片、增加有趣的作用、编辑实况相片,并与家人或朋友进行分享。
本文将介绍 Live Photo 相关技能概念,并运用 Swift 完结 Live Photo 的分化、组成功用。分化和组成的演示如下:
将 Live Photo 分化为相片和视频 | 运用(不相关的)相片和视频组成 Live Photo |
文章一切涉及的 API 基于 iOS 16.0+,运用了较多 Swift 的结构化并发的相关概念,阅读需求有一定根底。
文章项目代码已经开源,欢迎参阅这儿。
Live Photo 格局
以下是一张曾于武汉大学拍照的樱花实况相片。
咱们假如直接将 Live Photo 隔空投送到 Mac,能够得到一张 HEIC
格局的相片。但若咱们在分享页面,进行**「选项 -> 一切相片数据」**的勾选,那么咱们投送后将得到一个文件夹,内部包含一张HEIC
格局的相片、一个 MOV
格局的视频:
正如咱们所见,一张 Live Photo 由配对的两个资源组成,相同的 Identifier 进行配对:
具有特别 Metadata 的 JPEG 图画
图片拥有特点,关于大多数图画文件格局,运用 CGImageSource
类型能够有效地读取数据。能够运用 The Photo Investigator 应用检查相片中的一切 Metadata:
拍照相片时,Apple 相时机自动为相片增加不同种类的 Metadata。大多数元数据都很好了解,如方位存储在 GPS Metadata 中、相机信息坐落 EXIF Metadata 中。
其间 kCGImagePropertyMakerAppleDictionary
是 Apple 相机拍照的相片的键值对字典。“17” 是 Maker Apple 中的 LivePhotoVideoIndex,是 Live Photo 的 Identifier Key,完整列表能够参阅 Apple Tags。
Live Photo 需求有特别 Metadata 的 JPEG 图画:
[kCGImagePropertyMakerAppleDictionary : [17 : <Identifier>]]
具有特别 Metadata 的 MOV 视频文件
默许情况下,Live Photo 捕获运用 H.264 编解码器对 Live Photo 的视频部分进行编码。
AVAsset
是模仿定时视听媒体的类。其自身不是媒体资源(例如 QuickTime 电影或 MP3 音频文件、以及运用 HTTP 实时流式传输 (HLS) 流式传输的媒体等),可是它能够作为媒体资源的容器。
一个 AVAsset
是一个或多个 AVAssetTrack
实例的容器目标,它模仿一致类型的媒体轨迹。最常用的轨迹类型是 audio
音频和 video
视频,也或许包含弥补轨迹,如 closedCaption
隐藏式字幕、subtitle
副标题和 metadata
元数据等。
static let audio: AVMediaType // The media contains audio media.
static let closedCaption: AVMediaType //The media contains closed-caption content.
static let depthData: AVMediaType // The media contains depth data.
static let metadataObject: AVMediaType // The media contains metadata objects.
static let muxed: AVMediaType // The media contains muxed media.
static let subtitle: AVMediaType // The media contains subtitles.
static let text: AVMediaType // The media contains text.
static let timecode: AVMediaType //The media contains a time code.
static let video: AVMediaType // The media contains video.
AVAsset
存储关于其媒体的描绘性 Metadata。AVFoundation 经过使 其 AVMetadataItem
类简化了对 Metadata 的处理。最简单的讲,AVMetadataItem
的实例是一个键值对,表明单个 Metadata 值,比方电影的标题或专辑的插图。AVFoundation 结构将相关 Metadata 分组到 keySpace
中:
- 特定格局的
keySpace
。AVFoundation 结构定义了几个特定格局的 Metadata,大致与特定容器或文件格局相关,例如quickTimeMetadata
、iTunes
、id3
等。单个资源或许包含跨多个keySpace
的元数据值。 - Common
keySpace
。有几个常见的元数据值,为了协助规范化对公共 Metadata 如例如创立日期或描绘的拜访,供给了一个commonkeySpace
,允许拜访几个keySpace
共有的一组有限 Metadata 值。
Live Photo 需求 keySpace
为 AVMetadataKeySpace.quickTimeMetadata
的特定 top-level Metadata:
["com.apple.quicktime.content.identifier" : <Identifier>]
“com.apple.quicktime.content.identifier” 即
AVMetadataKey.quickTimeMetadataKeyContentIdentifier
这儿的 Identifier 同 「具有特别 Metadata 的 JPEG 图画」的 Identifier。
静止图画的 Timed Metadata Track:
["MetadataIdentifier" : "mdta/com.apple.quicktime.still-image-time",
"MetadataDataType" : "com.apple.metadata.datatype.int8"]
“MetadataIdentifier” 即
kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier
“mdta” 即AVMetadataKeySpace.quickTimeMetadata
“MetadataDataType” 即kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType
“com.apple.metadata.datatype.int8” 即kCMMetadataBaseDataType_SInt8
静止图画的 Timed Metadata Track 的 Metadata,即让系统知道图画在视频 Timeline 中的方位:
["com.apple.quicktime.still-image-time" : 0]
PHLivePhoto 和 PHLivePhotoView
class PHLivePhoto : NSObject
class PHLivePhotoView : UIView
PHLivePhoto
是 Live Photo 的可显现表明、代码中的实例。在 iOS 中,咱们能够运用此类从用户的相册等当地引用 Live Photo,将 PHLivePhoto
分配给 PHLivePhotoView
然后进行显现。PHLivePhotoView
供给了显现 Live Photo 的办法,一起供给与相册中相同的交互式播放功用。
PHLivePhoto
关于 Live Photo,类似于与 UIImage
关于静态图画。UIImage
不只是图画的数据文件,而是能够在 UIImageView
中显现的即用型图画。PHLivePhoto
相同也不只是相册中的 Live Photo 数据资源,而是准备好在 PHLivePhotoView
上显现的 Live Photo。
在 iOS 中,咱们能够运用 UIImagePickerController
、PHAsset
和 PHImageManager
从用户的相册中获取 Live Photo,或者经过相册资源创立一个 Live Photo。在 iOS 14.0 及以上版别,咱们也能够运用 PHPickerViewController
从用户的相册中获取 Live Photo。
运用 UIImagePickerController
的示例代码如下:
func pickLivePhoto(_ sender: AnyObject) {
let imagePicker = UIImagePickerController()
imagePicker.sourceType = UIImagePickerControllerSourceType.photoLibrary
imagePicker.allowsEditing = false
imagePicker.delegate = self
imagePicker.mediaTypes = [kUTTypeLivePhoto, kUTTypeImage] as [String]
present(imagePicker, animated: true, completion: nil)
}
// MARK: UIImagePickerControllerDelegate
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [String : Any]
) {
guard let mediaType = info[UIImagePickerControllerMediaType] as? NSString,
mediaType == kUTTypeLivePhoto,
let livePhoto = info[UIImagePickerControllerLivePhoto] as? PHLivePhoto else {
return
}
livePhotoView.livePhoto = livePhoto
}
这儿需求留意,咱们在指定
mediaTypes
时,除了kUTTypeLivePhoto
,还有kUTTypeImage
,否则在运行时将抛出反常:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'The Live Photo type cannot be specified without the Image media type' terminating with uncaught exception of type NSException
这就导致咱们经过署理拿到的
mediaType
,或许并非kUTTypeLivePhoto
而是静态相片kUTTypeImage
,需求进行判别或提示。
运用 PHAsset
和 PHImageManager
的示例代码如下:
let fetchOptions = PHFetchOptions()
fetchOptions.predicate = NSPredicate(
format: "(mediaSubtype & %d) != 0",
PHAssetMediaSubtype.photoLive.rawValue)
let images = PHAsset.fetchAssets(with: .image, options: fetchOptions)
PHImageManager.default().requestLivePhoto(
for: images.firstObject!,
targetSize: .zero,
contentMode: .default,
options: nil) { [weak self] livePhoto, _ in
guard let self else { return }
self.livePhotoView.livePhoto = livePhoto
}
运用 PHPickerViewController
的示例代码如下:
func pickLivePhoto(_ sender: UIButton) {
var config = PHPickerConfiguration()
config.selectionLimit = 1
config.filter = .any(of: [.livePhotos])
config.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: config)
picker.delegate = self
present(picker, animated: true, completion: nil)
}
// MARK: - PHPickerViewControllerDelegate
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
defer { picker.dismiss(animated: true) }
guard let itemProvider = results.first?.itemProvider,
itemProvider.canLoadObject(ofClass: PHLivePhoto.self) else {
return
}
itemProvider.loadObject(ofClass: PHLivePhoto.self) { [weak self] livePhoto, _ in
Task { @MainActor in
guard let self, let livePhoto else { return }
self.livePhotoView.livePhoto = livePhoto
}
}
}
PHPickerResult
是从相册中选择的 Asset 的类型。其 let itemProvider: NSItemProvider
特点是所选 Asset 的表明。NSItemProvider
用于在进程之间传输数据或文件。canLoadObject(ofClass:)
指示其是否能够加载指定类的目标。loadObject(ofClass:completionHandler:)
将异步加载指定类的目标。终究,咱们获取到 livePhoto
进行展示和分化。
这儿需求留意
loadObject(ofClass:)
的回调并非主线程,需求回到主线程进行 UI 更新。
PHPickerViewController
内置隐私(无需完整的相册拜访权限)、独立进程、支撑多选、支撑搜索等,详细能够参阅 Meet the new Photos picker。后文的完结将运用该办法。
将 Live Photo 分化为相片和视频
在后文代码示例中,咱们运用 actor LivePhotos
完结 Live Photo 的分化和组成。LivePhotos
已供给单例 sharedInstance
:
// LivePhotos.swift
actor LivePhotos {
static let sharedInstance = LivePhotos()
}
在示例项目的 LivePhotosViewController+Disassemble.swift
中,咱们这样运用 disassemble(livePhoto:)
来分化 Live Photo:
func disassemble(livePhoto: PHLivePhoto) {
Task {
do {
// Disassemble the livePhoto
let (photoURL, videoURL) = try await LivePhotos.sharedInstance.disassemble(livePhoto: livePhoto)
// Show the photo
if FileManager.default.fileExists(atPath: photoURL.path) {
guard let photo = UIImage(contentsOfFile: photoURL.path) else { return }
await MainActor.run { leftImageView.image = photo }
}
// show the video
if FileManager.default.fileExists(atPath: videoURL.path) {
playVideo(URL(fileURLWithPath: videoURL.path))
}
} catch {
await MainActor.run { Toast.show("Disassemble failed") }
}
}
}
在这儿咱们能够看到,disassemble(livePhoto:)
是一个异步函数,且能够抛出过错,因而咱们运用 try await
调用,并用 Task {...}
进行包裹。函数回来两个 URL,别离是图片的 URL 和 视频的 URL,运用这两个 URL 进行展示。假如在分化过程中抛出过错,将进行提示。
接着,咱们来看 disassemble(livePhoto:)
的详细完结:
func disassemble(livePhoto: PHLivePhoto) async throws -> (URL, URL) {
// 1
let assetResources = PHAssetResource.assetResources(for: livePhoto)
// 5
let list = try await withThrowingTaskGroup(of: (PHAssetResource, Data).self) { taskGroup in
for assetResource in assetResources {
taskGroup.addTask {
// 3
return try await withCheckedThrowingContinuation { continuation in
let dataBuffer = NSMutableData()
// 2
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = true
PHAssetResourceManager.default().requestData(for: assetResource, options: options) { data in
dataBuffer.append(data)
} completionHandler: { error in
// 4
guard error == nil else {
continuation.resume(throwing: LivePhotosDisassembleError.requestDataFailed)
return
}
continuation.resume(returning: (assetResource, dataBuffer as Data))
}
}
}
}
// 6
var results: [(PHAssetResource, Data)] = []
for try await result in taskGroup {
results.append(result)
}
return results
}
// ...
}
咱们先看这部分代码:
-
assetResources(for:)
函数回来与 Asset 相关的数据资源列表[PHAssetResource]
。因为咱们的入参是PHLivePhoto
因而,这儿将取得两个资源,咱们能够从控制台检查资源类型:
(lldb) po assetResources[0].uniformTypeIdentifier
"public.heic"
(lldb) po assetResources[1].uniformTypeIdentifier
"com.apple.quicktime-movie"
- 咱们希望将两个资源别离转换成
Data
类型的目标,这儿运用用requestData(for:options:dataReceivedHandler:completionHandler:)
函数完结。该函数异步的恳求指定财物资源的底层数据。咱们为options
的isNetworkAccessAllowed
设置为true
,指定相片能够从 iCloud 下载。handler
供给恳求数据的块,咱们自行将其组合。completionHandler
中,咱们取得终究的成果。 - 因为咱们运用异步函数,因而运用
withCheckedThrowingContinuation(function:_:)
挂起当前使命,调用闭包,直到得到成果或抛出过错,然后桥接代码到新的并发模型上。 - 咱们运用
completionHandler
里的error
参数来为continuation
供给成果或抛出过错。 - 因为咱们有两个资源,咱们希望并行处理资源的转换,咱们运用
withThrowingTaskGroup(of:returning:body:)
启动两个子使命。 - 咱们等候 Task Group 中的子使命完结,回来
[(PHAssetResource, Data)]
类型的成果。
咱们来看剩下的部分:
func disassemble(livePhoto: PHLivePhoto) async throws -> (URL, URL) {
// ...
// 7
guard let photo = (list.first { $0.0.type == .photo }),
let video = (list.first { $0.0.type == .pairedVideo }) else {
throw LivePhotosDisassembleError.requestDataFailed
}
// 8
let cachesDirectory = try cachesDirectory()
let photoURL = try save(photo.0, data: photo.1, to: cachesDirectory)
let videoURL = try save(video.0, data: video.1, to: cachesDirectory)
return (photoURL, videoURL)
}
private func save(_ assetResource: PHAssetResource, data: Data, to url: URL) throws -> URL {
// 9
guard let ext = UTType(assetResource.uniformTypeIdentifier)?.preferredFilenameExtension else {
throw LivePhotosDisassembleError.noFilenameExtension
}
let destinationURL = url.appendingPathComponent(NSUUID().uuidString).appendingPathExtension(ext as String)
try data.write(to: destinationURL, options: [Data.WritingOptions.atomic])
return destinationURL
}
- 咱们依据
PHAssetResource
的type
特点,找到相片元组和视频元组,若未找到则抛出过错。 - 咱们将
PHAssetResource
对应的Data
写入缓存文件夹中。 -
uniformTypeIdentifier
是资源的一致类型标识符,Apple Inc. 供给的软件上运用的标识符,用于仅有标识给定类别或类型的项目。这儿用UTType
的init(_:)
将其转换为heic
、mov
作为文件的后缀。
至此,咱们得到 Live Photo 分化得到的图片和视频 URL,以供展示或保存。
运用相片和视频创立 Live Photo
正如前文提到的,创立 Live Photo 需求运用 Identifier 将相片和视频配对。咱们要将此 Identifier 增加到相片和视频的 Metadata 中,然后生成有效的 Live Photo。
在示例项目的 LivePhotosViewController+Asemble.swift
中,咱们将经过以下办法运用创立 Live Photo API:
func assemble(photo: URL, video: URL) {
progressView.progress = 0
Task {
let livePhoto = try await LivePhotos.sharedInstance.assemble(photoURL:photo, videoURL:video) { [weak self] process in
guard let self else { return }
self.progressView.progress = process
}
Task { @MainActor in
self.livePhotoView.livePhoto = livePhoto
}
}
}
和成 Live Photo 的函数签名如下:
func assemble(photoURL: URL, videoURL: URL, progress: ((Float) -> Void)? = nil) async throws -> PHLivePhoto
入参为 photoURL
、videoURL
、进展回调 progress
,该异步函数终究回来一个 PHLivePhoto
目标。
和成一共分为三步:获取处理好的 pairedPhotoURL
、获取处理好的 pairedVideoURL
、运用两个 URL 创立 PHLivePhoto
:
func assemble(photoURL: URL, videoURL: URL, progress: ((Float) -> Void)? = nil) async throws -> PHLivePhoto {
let cacheDirectory = try cachesDirectory()
let identifier = UUID().uuidString
// 1
let pairedPhotoURL = addIdentifier(
identifier,
fromImageURL: photoURL,
to: cacheDirectory.appendingPathComponent(identifier).appendingPathExtension("jpg"))
// 2
let pairedVideoURL = try await addIdentifier(
identifier,
fromVideoURL: videoURL,
to: cacheDirectory.appendingPathComponent(identifier).appendingPathExtension("mov"),
progress: progress)
// 3
return try await withCheckedThrowingContinuation({ continuation in
// Create a `PHLivePhoto` with the `pairedPhotoURL` and the `pairedVideoURL`.
})
}
将 Metadata 增加到相片
Image I/O Framework 允许咱们打开一个图画,然后将 Identifier 写入 kCGImagePropertyMakerAppleDictionary
一个特别的特点 Key 17
:
private func addIdentifier(
_ identifier: String,
fromPhotoURL photoURL: URL,
to destinationURL: URL
) throws -> URL {
// 1
guard let imageSource = CGImageSourceCreateWithURL(photoURL as CFURL, nil),
// 2
let imageRef = CGImageSourceCreateImageAtIndex(imageSource, 0, nil),
// 3
var imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [AnyHashable : Any] else {
throw LivePhotosAssembleError.addPhotoIdentifierFailed
}
// 4
let identifierInfo = ["17" : identifier]
imageProperties[kCGImagePropertyMakerAppleDictionary] = identifierInfo
// 5
guard let imageDestination = CGImageDestinationCreateWithURL(destinationURL as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
throw LivePhotosAssembleError.createDestinationImageFailed
}
// 6
CGImageDestinationAddImage(imageDestination, imageRef, imageProperties as CFDictionary)
// 7
if CGImageDestinationFinalize(imageDestination) {
return destinationURL
} else {
throw LivePhotosAssembleError.createDestinationImageFailed
}
}
在上述代码中:
- 运用
CGImageSourceCreateWithURL(_:_:)
创立从 URL 指定的方位读取的图画源,类型为CGImageSource
,运用该类型能够读取大多数图画文件格局的数据,获取 Metadata 、缩略图等。url
参数为图片的 URL;options
参数为指定附加创立选项的字典,如指示是否缓存解码图画、指定是否创立缩略图等。 - 运用
CGImageSourceCreateImageAtIndex(_:_:_:)
在图画源中指定索引处的数据创立图画目标,类型为CGImage
。isrc
参数为包含图画数据的图画源;index
为所需图画的从零开端的索引;options
为指定附加创立选项的字典。
假如咱们乐意,也能够经过
Data
的办法获取imageRef
:
let data = try? Data(contentsOf: imageURL) let imageRef = UIImage(data: data)?.cgImage
-
运用
CGImageSourceCopyPropertiesAtIndex(_:_:_:)
回来图画源中指定方位的图画特点,类型为CFDictionary
。参数相同为isrc
、index
、options
。 -
将特别的 Metadata 写入
imageProperties
的kCGImagePropertyMakerAppleDictionary
中。 -
运用
CGImageDestinationCreateWithURL(_:_:_:_:)
将图画数据写入指定的 URL,回来值类型为CGImageDestination
,供给了一个用于保存图画数据的笼统接口,例如咱们能够创立还包含缩略图的图画、能够运用CGImageDestination
向图画增加 Metadata。url
是写入图画数据的 URL,此目标会覆盖指定 URL 中的任何数据;type
为生成的图画文件的一致类型标识符,映射到 MIME 和文件类型的通用类型;count
是要包含在图画文件中的图画数量;options
是预留参数,暂时还没有用,指定为nil
即可。 -
运用
CGImageDestinationAddImage(_:_:_:)
将图画增加到CGImageDestination
,参数idst
是要修改的CGImageDestination
、image
是要增加的图画、properties
是一个可选的字典,指定增加图画的特点。 -
CGImageDestinationFinalize(_:)
是作为保存图画的最终一步,回来保存成果的Bool
,在调用此办法之前的输出无效。调用此函数后,咱们无法再向CGImageDestination
增加任何数据。
将 Metadata 增加到视频
相关类和接口介绍
将这些数据增加到视频中会复杂一些。 咱们需求运用 AVFoundation 的 AVAssetReader
、AVAssetWriter
重写视频。 咱们先来简单看下它们的概念和咱们将运用到的函数:
AVAssetReader
-
AVAssetReader
与一个AVAsset
相关,是一个视频目标。需求为AVAssetReader
增加AVAssetReaderOutput
来读取数据,AVAssetReaderOutput
相同需求AVAssetReader
才干完结功用。一个AVAssetReader
能够相关多个AVAssetReaderOutput
。 -
AVAssetReaderTrackOutput
是AVAssetReaderOutput
的子类,是从AVAssetTrack
读取媒体数据的目标。能够经过AVAsset
指定AVMediaType
的 Track 创立一个AVAssetReaderTrackOutput
作为 Track 数据读取器。 -
assetReader.startReading()
表明AVAssetReaderTrackOutput
能够开端读取数据了。 它能够是音频数据、视频数据或其他数据。 -
assetReaderOutput.copyNextSampleBuffer()
表明读取下一条数据。 -
assetReader.cancelReading()
表明中止读取数据。
AVAssetWriter
-
AVAssetWriter
是写管理器,AVAssetWriterInput
是数据写入器。 一个AVAssetWriter
能够有多个AVAssetWriterInput
。 -
assetWriter.startWriting()
表明AVAssetWriterInput
能够开端写入。 -
assetWriter.startSession(atSourceTime: .zero)
表明数据从零秒开端写入。 -
assetWriterInput.isReadyForMoreMediaData
,一个布尔值,表明输入准备好承受更多媒体数据。 - 假如有多个
AVAssetWriterInput
,当其间一个AVAssetWriterInput
填满缓冲区时,数据不会被处理,而是等候其他数据被AVAssetWriterInput
写入相应的时长,然后才会处理数据。
全体过程
咱们组成 Live Photo 的全体过程如下:
- 初始化
AVAssetReader
,创立对应的AVAssetReaderTrackOutput
,包含videoReaderOutput
、audioReaderOutput
。 - 初始化
AVAssetWriter
,创立及对应的AVAssetWriterInput
,包含videoWriterInput
、audioWriterInput
。 - 运用
AVAssetWriter
写入结构好的 Identifier Metadata(只能在入开端前设置)。 -
AVAssetWriter
增加来自AVAssetWriterInputMetadataAdaptor
的assetWriterInput
。 -
AVAssetWriter
进入写状况。 - 运用
AVAssetReader
和AVAssetWriterInputMetadataAdaptor
写入 Timed Metadata Track 的 Metadata。 -
videoReaderOutput
、audioReaderOutput
、videoWriterInput
、audioWriterInput
进入读写写状况。 - 一旦
AVAssetReaderOuput
读取 Track 数据,运用AVAssetWriterInput
写入 Track 数据。 - 读取完一切数据后,让
AVAssetReader
中止读取。 使一切AVAssetWriterInput
符号完结。 - 等候
AVAssetWriter
变为完结状况,视频创立完结。
代码完结
下面咱们来看详细代码完结,首要是核心「具有特别 Metadata 的 MOV 视频文件」的 Mata 部分。
创立一个具有 Identifier 的 Metadata 的 AVMetadataItem
,这儿的 Identifier 与相片的 Identifier 相同:
private func metadataItem(for identifier: String) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.keySpace = AVMetadataKeySpace.quickTimeMetadata // "mdta"
item.dataType = "com.apple.metadata.datatype.UTF-8"
item.key = AVMetadataKey.quickTimeMetadataKeyContentIdentifier as any NSCopying & NSObjectProtocol // "com.apple.quicktime.content.identifier"
item.value = identifier as any NSCopying & NSObjectProtocol
return item
}
创立静止图画的 Timed Metadata Track:
private func stillImageTimeMetadataAdaptor() -> AVAssetWriterInputMetadataAdaptor {
let quickTimeMetadataKeySpace = AVMetadataKeySpace.quickTimeMetadata.rawValue // "mdta"
let stillImageTimeKey = "com.apple.quicktime.still-image-time"
let spec: [NSString : Any] = [
kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier as NSString : "\(quickTimeMetadataKeySpace)/\(stillImageTimeKey)",
kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType as NSString : kCMMetadataBaseDataType_SInt8]
var desc : CMFormatDescription? = nil
CMMetadataFormatDescriptionCreateWithMetadataSpecifications(
allocator: kCFAllocatorDefault,
metadataType: kCMMetadataFormatType_Boxed,
metadataSpecifications: [spec] as CFArray,
formatDescriptionOut: &desc)
let input = AVAssetWriterInput(
mediaType: .metadata,
outputSettings: nil,
sourceFormatHint: desc)
return AVAssetWriterInputMetadataAdaptor(assetWriterInput: input)
}
创立静止图画的 Timed Metadata Track 的 Metadata:
private func stillImageTimeMetadataItem() -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.key = "com.apple.quicktime.still-image-time" as any NSCopying & NSObjectProtocol
item.keySpace = AVMetadataKeySpace.quickTimeMetadata // "mdta"
item.value = 0 as any NSCopying & NSObjectProtocol
item.dataType = kCMMetadataBaseDataType_SInt8 as String // "com.apple.metadata.datatype.int8"
return item
}
接着,咱们详细来看增加 Identifier 逻辑。首要,咱们初始化 AVAssetReader
,创立对应的 AVAssetReaderTrackOutput
,包含 videoReaderOutput
、audioReaderOutput
。初始化AVAssetWriter
,创立及对应的 AVAssetWriterInput
,包含 videoWriterInput
、audioWriterInput
。对应全体过程的 1、2:
private func addIdentifier(
_ identifier: String,
fromVideoURL videoURL: URL,
to destinationURL: URL,
progress: ((Float) -> Void)? = nil
) async throws -> URL {
let asset = AVURLAsset(url: videoURL)
// --- Reader ---
// Create the video reader
let videoReader = try AVAssetReader(asset: asset)
// Create the video reader output
guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else {
throw LivePhotosAssembleError.loadTracksFailed
}
let videoReaderOutputSettings : [String : Any] = [kCVPixelBufferPixelFormatTypeKey as String : kCVPixelFormatType_32BGRA]
let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoReaderOutputSettings)
// Add the video reader output to video reader
videoReader.add(videoReaderOutput)
// Create the audio reader
let audioReader = try AVAssetReader(asset: asset)
// Create the audio reader output
guard let audioTrack = try await asset.loadTracks(withMediaType: .audio).first else {
throw LivePhotosAssembleError.loadTracksFailed
}
let audioReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: nil)
// Add the audio reader output to audioReader
audioReader.add(audioReaderOutput)
// --- Writer ---
// Create the asset writer
let assetWriter = try AVAssetWriter(outputURL: destinationURL, fileType: .mov)
// Create the video writer input
let videoWriterInputOutputSettings : [String : Any] = [
AVVideoCodecKey : AVVideoCodecType.h264,
AVVideoWidthKey : try await videoTrack.load(.naturalSize).width,
AVVideoHeightKey : try await videoTrack.load(.naturalSize).height]
let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoWriterInputOutputSettings)
videoWriterInput.transform = try await videoTrack.load(.preferredTransform)
videoWriterInput.expectsMediaDataInRealTime = true
// Add the video writer input to asset writer
assetWriter.add(videoWriterInput)
// Create the audio writer input
let audioWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
audioWriterInput.expectsMediaDataInRealTime = false
// Add the audio writer input to asset writer
assetWriter.add(audioWriterInput)
// ...
}
接着,咱们运用 AVAssetWriter
写入结构好的 Identifier Metadata(只能在入开端前设置)。AVAssetWriter
增加来自 AVAssetWriterInputMetadataAdaptor
的 assetWriterInput
。AVAssetWriter
进入写状况。对应全体过程的 3、4、5:
private func addIdentifier(
_ identifier: String,
fromVideoURL videoURL: URL,
to destinationURL: URL,
progress: ((Float) -> Void)? = nil
) async throws -> URL? {
// ...
// Create the identifier metadata
let identifierMetadata = metadataItem(for: identifier)
// Create still image time metadata track
let stillImageTimeMetadataAdaptor = stillImageTimeMetadataAdaptor()
assetWriter.metadata = [identifierMetadata]
assetWriter.add(stillImageTimeMetadataAdaptor.assetWriterInput)
// Start the asset writer
assetWriter.startWriting()
assetWriter.startSession(atSourceTime: .zero)
// ...
}
接着,咱们运用 AVAssetReader
和 AVAssetWriterInputMetadataAdaptor
写入 Timed Metadata Track 的 Metadata。对应全体过程的 6:
private func addIdentifier(
_ identifier: String,
fromVideoURL videoURL: URL,
to destinationURL: URL,
progress: ((Float) -> Void)? = nil
) async throws -> URL {
// ...
let frameCount = try await asset.frameCount()
let stillImagePercent: Float = 0.5
await stillImageTimeMetadataAdaptor.append(
AVTimedMetadataGroup(
items: [stillImageTimeMetadataItem()],
timeRange: try asset.makeStillImageTimeRange(percent: stillImagePercent, inFrameCount: frameCount)))
// ...
}
其间,涉及获取
AVAsset
帧数、静止图画CMTimeRange
的办法:
extension AVAsset { func frameCount(exact: Bool = false) async throws -> Int { let videoReader = try AVAssetReader(asset: self) guard let videoTrack = try await self.loadTracks(withMediaType: .video).first else { return 0 } if !exact { async let duration = CMTimeGetSeconds(self.load(.duration)) async let nominalFrameRate = Float64(videoTrack.load(.nominalFrameRate)) return try await Int(duration * nominalFrameRate) } let videoReaderOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: nil) videoReader.add(videoReaderOutput) videoReader.startReading() var frameCount = 0 while let _ = videoReaderOutput.copyNextSampleBuffer() { frameCount += 1 } videoReader.cancelReading() return frameCount } func makeStillImageTimeRange(percent: Float, inFrameCount: Int = 0) async throws -> CMTimeRange { var time = try await self.load(.duration) var frameCount = inFrameCount if frameCount == 0 { frameCount = try await self.frameCount(exact: true) } let duration = Int64(Float(time.value) / Float(frameCount)) time.value = Int64(Float(time.value) * percent) return CMTimeRangeMake(start: time, duration: CMTimeMake(value: duration, timescale: time.timescale)) } }
接着videoReaderOutput
、 audioReaderOutput
、videoWriterInput
、audioWriterInput
进入读写写状况。一旦 AVAssetReaderOuput
读取 Track 数据,运用 AVAssetWriterInput
写入 Track 数据。对应全体过程的 7、8、9:
private func addIdentifier(
_ identifier: String,
fromVideoURL videoURL: URL,
to destinationURL: URL,
progress: ((Float) -> Void)? = nil
) async throws -> URL {
// ...
async let writingVideoFinished: Bool = withCheckedThrowingContinuation { continuation in
Task {
videoReader.startReading()
var currentFrameCount = 0
videoWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "videoWriterInputQueue")) {
while videoWriterInput.isReadyForMoreMediaData {
if let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() {
currentFrameCount += 1
if let progress {
let progressValue = min(Float(currentFrameCount)/Float(frameCount), 1.0)
Task { @MainActor in
progress(progressValue)
}
}
if !videoWriterInput.append(sampleBuffer) {
videoReader.cancelReading()
continuation.resume(throwing: LivePhotosAssembleError.writingVideoFailed)
return
}
} else {
videoWriterInput.markAsFinished()
continuation.resume(returning: true)
return
}
}
}
}
}
async let writingAudioFinished: Bool = withCheckedThrowingContinuation { continuation in
Task {
audioReader.startReading()
audioWriterInput.requestMediaDataWhenReady(on: DispatchQueue(label: "audioWriterInputQueue")) {
while audioWriterInput.isReadyForMoreMediaData {
if let sampleBuffer = audioReaderOutput.copyNextSampleBuffer() {
if !audioWriterInput.append(sampleBuffer) {
audioReader.cancelReading()
continuation.resume(throwing: LivePhotosAssembleError.writingAudioFailed)
return
}
} else {
audioWriterInput.markAsFinished()
continuation.resume(returning: true)
return
}
}
}
}
}
await (_, _) = try (writingVideoFinished, writingAudioFinished)
// ...
}
最终,等候 AVAssetWriter
变为完结状况,视频创立完结。对应全体过程的 10:
private func addIdentifier(
_ identifier: String,
fromVideoURL videoURL: URL,
to destinationURL: URL,
progress: ((Float) -> Void)? = nil
) async throws -> URL? {
// ...
await assetWriter.finishWriting()
return destinationURL
// ...
}
带有具有特别 Metadata 的 MOV 视频文件创立完结,可回到「运用相片和视频创立 Live Photo」检查图片、视频的组成。
将 Live Photo 保存到本地
咱们能够调整下组成 Live Photo 的异步函数,将 pairedPhotoURL
、pairedVideoURL
作为回来值同时回来:
func assemble(photoURL: URL, videoURL: URL, progress: ((Float) -> Void)? = nil) async throws -> (PHLivePhoto, (URL, URL)) {
let pairedPhotoURL = // ...
let pairedVideoURL = // ...
let livePhoto = // ...
return (livePhoto, (pairedPhotoURL, pairedVideoURL))
}
咱们将图片、视频别离保存即可:
func saveButtonDidSelect(_ sender: UIButton) {
guard let (photoURL, videURL) = asembleURLs.value,
let photoURL, let videURL else {
return
}
PHPhotoLibrary.shared().performChanges({
let creationRequest = PHAssetCreationRequest.forAsset()
let options = PHAssetResourceCreationOptions()
creationRequest.addResource(with: PHAssetResourceType.photo, fileURL: photoURL, options: options)
creationRequest.addResource(with: PHAssetResourceType.pairedVideo, fileURL: videURL, options: options)
}, completionHandler: { success, _ in
Toast.show(success ? "Saved successfully" : "An error occurred")
})
}
参阅资料
[1] Apple Introduces iPhone 6s & iPhone 6s Plus
[2] Take and edit Live Photos
[3] What is the “Maker Apple” Metadata in iPhone Photos?
[4] Displaying Live Photos
[5] How to make Live Photo and save it in photo library in iOS.
[6] LimitPoint LivePhoto