2015 年,Apple 推出 iPhone 6s 和 iPhone 6s Plus,一起推出了实况相片(Live Photos)功用。在其时,这是一项开创性的、全新的摄影办法,能以动态办法记载精彩瞬间,为静态相片注入生命力。拍照实况相片时,iPhone 会录下摄影前后各 1.5 秒所产生的全部。用户能够选择不同的封面相片、增加有趣的作用、编辑实况相片,并与家人或朋友进行分享。

本文将介绍 Live Photo 相关技能概念,并运用 Swift 完结 Live Photo 的分化、组成功用。分化和组成的演示如下:

将实况照片(Live Photos)添加到 APP 中
将实况照片(Live Photos)添加到 APP 中
将 Live Photo 分化为相片和视频 运用(不相关的)相片和视频组成 Live Photo

文章一切涉及的 API 基于 iOS 16.0+,运用了较多 Swift 的结构化并发的相关概念,阅读需求有一定根底。

文章项目代码已经开源,欢迎参阅这儿。

Live Photo 格局

以下是一张曾于武汉大学拍照的樱花实况相片。

将实况照片(Live Photos)添加到 APP 中

咱们假如直接将 Live Photo 隔空投送到 Mac,能够得到一张 HEIC 格局的相片。但若咱们在分享页面,进行**「选项 -> 一切相片数据」**的勾选,那么咱们投送后将得到一个文件夹,内部包含一张HEIC 格局的相片、一个 MOV 格局的视频:

将实况照片(Live Photos)添加到 APP 中

正如咱们所见,一张 Live Photo 由配对的两个资源组成,相同的 Identifier 进行配对:

具有特别 Metadata 的 JPEG 图画

图片拥有特点,关于大多数图画文件格局,运用 CGImageSource 类型能够有效地读取数据。能够运用 The Photo Investigator 应用检查相片中的一切 Metadata:

将实况照片(Live Photos)添加到 APP 中

拍照相片时,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.

将实况照片(Live Photos)添加到 APP 中

AVAsset 存储关于其媒体的描绘性 Metadata。AVFoundation 经过使 其 AVMetadataItem 类简化了对 Metadata 的处理。最简单的讲,AVMetadataItem 的实例是一个键值对,表明单个 Metadata 值,比方电影的标题或专辑的插图。AVFoundation 结构将相关 Metadata 分组到 keySpace 中:

  • 特定格局的 keySpace。AVFoundation 结构定义了几个特定格局的 Metadata,大致与特定容器或文件格局相关,例如 quickTimeMetadataiTunesid3 等。单个资源或许包含跨多个 keySpace 的元数据值。
  • Common keySpace。有几个常见的元数据值,为了协助规范化对公共 Metadata 如例如创立日期或描绘的拜访,供给了一个common keySpace,允许拜访几个 keySpace 共有的一组有限 Metadata 值。

Live Photo 需求 keySpaceAVMetadataKeySpace.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 中,咱们能够运用 UIImagePickerControllerPHAssetPHImageManager 从用户的相册中获取 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,需求进行判别或提示。

运用 PHAssetPHImageManager 的示例代码如下:

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
    }
    // ...
}

咱们先看这部分代码:

  1. assetResources(for:) 函数回来与 Asset 相关的数据资源列表 [PHAssetResource]。因为咱们的入参是 PHLivePhoto 因而,这儿将取得两个资源,咱们能够从控制台检查资源类型:
(lldb) po assetResources[0].uniformTypeIdentifier
"public.heic"
(lldb) po assetResources[1].uniformTypeIdentifier
"com.apple.quicktime-movie"
  1. 咱们希望将两个资源别离转换成 Data 类型的目标,这儿运用用 requestData(for:options:dataReceivedHandler:completionHandler:) 函数完结。该函数异步的恳求指定财物资源的底层数据。咱们为 optionsisNetworkAccessAllowed 设置为 true,指定相片能够从 iCloud 下载。handler 供给恳求数据的块,咱们自行将其组合。completionHandler 中,咱们取得终究的成果。
  2. 因为咱们运用异步函数,因而运用 withCheckedThrowingContinuation(function:_:) 挂起当前使命,调用闭包,直到得到成果或抛出过错,然后桥接代码到新的并发模型上。
  3. 咱们运用 completionHandler 里的 error 参数来为 continuation 供给成果或抛出过错。
  4. 因为咱们有两个资源,咱们希望并行处理资源的转换,咱们运用 withThrowingTaskGroup(of:returning:body:) 启动两个子使命。
  5. 咱们等候 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
}
  1. 咱们依据 PHAssetResourcetype 特点,找到相片元组和视频元组,若未找到则抛出过错。
  2. 咱们将 PHAssetResource 对应的 Data 写入缓存文件夹中。
  3. uniformTypeIdentifier 是资源的一致类型标识符,Apple Inc. 供给的软件上运用的标识符,用于仅有标识给定类别或类型的项目。这儿用 UTTypeinit(_:) 将其转换为 heicmov 作为文件的后缀。

至此,咱们得到 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

入参为 photoURLvideoURL、进展回调 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
    }
}

在上述代码中:

  1. 运用 CGImageSourceCreateWithURL(_:_:) 创立从 URL 指定的方位读取的图画源,类型为 CGImageSource,运用该类型能够读取大多数图画文件格局的数据,获取 Metadata 、缩略图等。url 参数为图片的 URL;options 参数为指定附加创立选项的字典,如指示是否缓存解码图画、指定是否创立缩略图等。
  2. 运用 CGImageSourceCreateImageAtIndex(_:_:_:) 在图画源中指定索引处的数据创立图画目标,类型为 CGImageisrc 参数为包含图画数据的图画源;index 为所需图画的从零开端的索引;options 为指定附加创立选项的字典。

假如咱们乐意,也能够经过 Data 的办法获取 imageRef

let data = try? Data(contentsOf: imageURL)
let imageRef = UIImage(data: data)?.cgImage
  1. 运用 CGImageSourceCopyPropertiesAtIndex(_:_:_:) 回来图画源中指定方位的图画特点,类型为 CFDictionary。参数相同为 isrcindexoptions

  2. 将特别的 Metadata 写入 imagePropertieskCGImagePropertyMakerAppleDictionary 中。

  3. 运用 CGImageDestinationCreateWithURL(_:_:_:_:) 将图画数据写入指定的 URL,回来值类型为 CGImageDestination,供给了一个用于保存图画数据的笼统接口,例如咱们能够创立还包含缩略图的图画、能够运用 CGImageDestination 向图画增加 Metadata。url 是写入图画数据的 URL,此目标会覆盖指定 URL 中的任何数据;type 为生成的图画文件的一致类型标识符,映射到 MIME 和文件类型的通用类型;count 是要包含在图画文件中的图画数量;options 是预留参数,暂时还没有用,指定为 nil 即可。

  4. 运用 CGImageDestinationAddImage(_:_:_:) 将图画增加到 CGImageDestination,参数 idst 是要修改的 CGImageDestinationimage 是要增加的图画、properties 是一个可选的字典,指定增加图画的特点。

  5. CGImageDestinationFinalize(_:) 是作为保存图画的最终一步,回来保存成果的 Bool,在调用此办法之前的输出无效。调用此函数后,咱们无法再向 CGImageDestination 增加任何数据。

将 Metadata 增加到视频

相关类和接口介绍

将这些数据增加到视频中会复杂一些。 咱们需求运用 AVFoundation 的 AVAssetReaderAVAssetWriter 重写视频。 咱们先来简单看下它们的概念和咱们将运用到的函数:

AVAssetReader

  1. AVAssetReader 与一个 AVAsset 相关,是一个视频目标。需求为 AVAssetReader 增加 AVAssetReaderOutput 来读取数据,AVAssetReaderOutput 相同需求 AVAssetReader 才干完结功用。一个 AVAssetReader 能够相关多个 AVAssetReaderOutput

  2. AVAssetReaderTrackOutputAVAssetReaderOutput 的子类,是从 AVAssetTrack 读取媒体数据的目标。能够经过 AVAsset 指定 AVMediaType 的 Track 创立一个AVAssetReaderTrackOutput 作为 Track 数据读取器。

  3. assetReader.startReading() 表明 AVAssetReaderTrackOutput 能够开端读取数据了。 它能够是音频数据、视频数据或其他数据。

  4. assetReaderOutput.copyNextSampleBuffer() 表明读取下一条数据。

  5. assetReader.cancelReading() 表明中止读取数据。

AVAssetWriter

  1. AVAssetWriter 是写管理器, AVAssetWriterInput 是数据写入器。 一个 AVAssetWriter 能够有多个 AVAssetWriterInput
  2. assetWriter.startWriting() 表明 AVAssetWriterInput 能够开端写入。
  3. assetWriter.startSession(atSourceTime: .zero) 表明数据从零秒开端写入。
  4. assetWriterInput.isReadyForMoreMediaData,一个布尔值,表明输入准备好承受更多媒体数据。
  5. 假如有多个 AVAssetWriterInput,当其间一个 AVAssetWriterInput 填满缓冲区时,数据不会被处理,而是等候其他数据被 AVAssetWriterInput 写入相应的时长,然后才会处理数据。

全体过程

咱们组成 Live Photo 的全体过程如下:

  1. 初始化 AVAssetReader ,创立对应的 AVAssetReaderTrackOutput,包含 videoReaderOutputaudioReaderOutput
  2. 初始化AVAssetWriter,创立及对应的 AVAssetWriterInput,包含 videoWriterInputaudioWriterInput
  3. 运用 AVAssetWriter 写入结构好的 Identifier Metadata(只能在入开端前设置)。
  4. AVAssetWriter 增加来自 AVAssetWriterInputMetadataAdaptorassetWriterInput
  5. AVAssetWriter 进入写状况。
  6. 运用 AVAssetReaderAVAssetWriterInputMetadataAdaptor 写入 Timed Metadata Track 的 Metadata。
  7. videoReaderOutputaudioReaderOutputvideoWriterInputaudioWriterInput进入读写写状况。
  8. 一旦 AVAssetReaderOuput 读取 Track 数据,运用 AVAssetWriterInput 写入 Track 数据。
  9. 读取完一切数据后,让 AVAssetReader 中止读取。 使一切 AVAssetWriterInput 符号完结。
  10. 等候 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,包含 videoReaderOutputaudioReaderOutput。初始化AVAssetWriter,创立及对应的 AVAssetWriterInput,包含 videoWriterInputaudioWriterInput。对应全体过程的 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 增加来自 AVAssetWriterInputMetadataAdaptorassetWriterInputAVAssetWriter 进入写状况。对应全体过程的 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)
    // ...
}

接着,咱们运用 AVAssetReaderAVAssetWriterInputMetadataAdaptor 写入 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))
 }
}

接着videoReaderOutputaudioReaderOutputvideoWriterInputaudioWriterInput进入读写写状况。一旦 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 的异步函数,将 pairedPhotoURLpairedVideoURL 作为回来值同时回来:

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