简介:这份 Swift 实况图制作 Demo 面向具备 iOS 基础、希望掌握 Live Photo 合成与展示的开发者,聚焦把静态 JPEG 与短视频合成为可交互实况图这一典型场景。压缩包共 21 个文件、约 4.37MB,包含 Swift 源码、plist 配置、json 数据、storyboard 界面以及 jpeg/mp4 示例素材,工程结构涉及视图控制、工具封装与资源管理等模块,便于直接运行和对照修改。已有 1333 人学习,说明其在实况图入门与工程实践中具备一定参考价值。读者可借助完整 Xcode 工程理解素材准备、内容合成、请求参数设置、PHLivePhoto 展示与进度监听等关键环节,并参考封装好的使用案例,快速把实况图能力迁移到自己的 iOS 应用中,减少从零摸索与联调成本,也可为动态壁纸等扩展玩法提供实现思路。工程基于苹果 Photos 框架,适合作为实况图功能原型、课程示例或二次开发起点。
1. 相册里多出一张图和一段视频,点开却不会动
这是把素材转实况图时最典型的出错情况:一张 HEIC 加一段 MOV 放进相册,系统只当它们是两张互不相干的资源。原因在于系统并不看文件是不是相邻存放,它只认两处字节:静态图元数据里的 asset identifier,以及视频里那条 still-image-time 元数据轨,两边必须严格对上。这个 swift-livePhoto 实况图制作 demo 做的就是把这件事拆开给你看——用 Swift 把任意一张图加一段视频重新封装成 iOS 相册认得的 Live Photo,并把制作、预览、写回相册三条链路串起来。适合三类人:做相机或修图类 App 需要导出实况图的、要把短视频批量转成实况封面的、以及想搞明白实况图到底靠哪几个字节生效的 iOS 开发者。跟着走一遍,你应该能自己写一个不依赖任何第三方库的转换器。
2. 拆开骨架:四层职责与一条能复现的数据流
拿到这类 demo 之后,先不要急着点 Run,因为多数人的第一次失败都发生在“不知道哪一层出了问题”。这个包的思路很清楚:把“拼元数据”和“改像素”彻底分开,中间用一条固定的数据流串起来。按职责找文件,文件名各家写法不同,但分层基本一致。
2.1 四层职责,以及一条不能越界的红线
素材层负责把输入统一成CGImage加AVAsset,相机拍的、相册挑的、网络下载的都要归一化成同一形态。拼装层只做一件事:写元数据、搬容器,绝不改像素,它的输出是两个 URL——一个带 17 号键的静态图、一个带两条元数据轨的 MOV。转码层才允许动像素,裁剪、滤镜、变速、补帧都在这里,它的输入输出仍然是视频帧序列。写回层只碰 PhotoKit,把两个 URL 变成一个PHAsset。
这条红线要守住:拼装层不能顺手压缩图片尺寸,一转尺寸,视频和静态图比例就对不上,预览时长按会先闪一下再拉伸;转码层不能重新生成 identifier,一旦重新生成,前面写好的静态图就白写了。
2.2 从两个 URL 到相册里一张会动的图:七步
第一步归一化输入。视频统一成正方形或 4:3、30fps、时长裁到 3 秒以内;静态图从视频里取一帧转CGImage,或者由用户单独指定。第二步生成一个UUID().uuidString作为本次制作的 identifier,后面所有环节共用这一个字符串。
第三步写静态图,把 identifier 塞进 MakerApple 字典。第四步重封装 MOV,写入 content.identifier 和 still-image-time 两条元数据轨。第五步离线校验,把两个文件重新读回来比对 identifier、检查元数据轨是否存在、确认时长——这一步很多人省掉,结果只能靠相册里反复试。
第六步写相册,PHAssetCreationRequest先加.photo再加.pairedVideo。第七步预览验收,用PHLivePhoto.request(withResourceFileURLs:...)把两个本地文件直接拉起来播,能播说明字节层面已经合法,再去相册确认长按效果。
// 第七步:不写相册也能先验证这一对文件合不合法 PHLivePhoto.request(withResourceFileURLs: [imageURL, videoURL], placeholderImage: nil, targetSize: CGSize(width: 1200, height: 1200), contentMode: .aspectFit) { livePhoto, info in if let error = info[PHLivePhotoInfoErrorKey] as? Error { print("配对失败:", error) // 走这里基本就是 identifier 或元数据轨的问题 return } if info[PHLivePhotoInfoIsDegradedKey] as? Bool == true { return // 降级结果,等下一次回调 } previewView.livePhoto = livePhoto // PHLivePhotoView 直接赋值 }targetSize别给得太小,太小系统会返回降级结果,你会误以为文件坏了;给到 1200 上下够用。回调会多次触发,IsDegraded为 true 的那次先丢掉,只认最后一次。
2.3 工程配置:权限、部署版本、真机验收
只写入不读取的 App,理论上NSPhotoLibraryAddUsageDescription就够;但只要调了PHAsset相关的读取(比如编辑回写路径),两个都得声明,缺一个会在performChanges里抛权限错。
<key>NSPhotoLibraryAddUsageDescription</key> <string>用于把制作好的实况图保存到相册</string> <key>NSPhotoLibraryUsageDescription</key> <string>用于读取相册中的实况图做编辑回写</string>部署版本上,这套 API 门槛不高,iOS 12 都能跑;只有统一类型标识那套写法需要 iOS 14,低版本用"public.heic"、"com.apple.quicktime-movie"这种字符串常量兜底即可。模拟器能跑通写入,但长按播放的表现和真机有差异,验收一定上真机。
注意:从 iOS 16 起,
AVAsset.tracks这类同步属性会打并发警告,demo 里如果用的同步写法,包一层 async 就行;不包也能跑,但警告会掩盖真正的错误日志。
3. 配对协议落到字节上:MakerApple 的 17 号键与两条元数据轨
实况图没有一个“实况图文件”格式,它是两套已有格式的约定。静态图用 HEIC 或 JPEG,视频用 QuickTime MOV,两者靠一个字符串绑定。搞清这一点,后面所有报错都能定位。
3.1 静态图侧:把 identifier 写进 MakerApple 字典
写静态图不是UIImage.jpegData就完事,那个接口不给你塞私有元数据的机会。得走 ImageIO 的CGImageDestination,在属性字典里加上kCGImagePropertyMakerAppleDictionary,里面放一个键为"17"、值为 identifier 的字符串。
import ImageIO import UniformTypeIdentifiers func writeStillImage(_ image: CGImage, to url: URL, assetIdentifier: String, quality: CGFloat = 0.92) throws { // HEIC 体积小,但个别系统版本对私有元数据更挑;不稳就先换 "public.jpeg" 验证 let type = UTType.heic.identifier as CFString guard let dest = CGImageDestinationCreateWithURL(url as CFURL, type, 1, nil) else { throw LivePhotoError.destinationFailed } let properties: [CFString: Any] = [ kCGImagePropertyMakerAppleDictionary: ["17": assetIdentifier], kCGImageDestinationLossyCompressionQuality: quality ] CGImageDestinationAddImage(dest, image, properties as CFDictionary) guard CGImageDestinationFinalize(dest) else { throw LivePhotoError.writeFailed } } // 写完立刻读回来确认,别等相册告诉你 func readAssetIdentifier(from imageURL: URL) -> String? { guard let src = CGImageSourceCreateWithURL(imageURL as CFURL, nil), let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any], let maker = props[kCGImagePropertyMakerAppleDictionary] as? [String: Any] else { return nil } return maker["17"] as? String }quality给 0.9 以上,太低会让封面帧出现块状噪点,长按播放起点不好看。kCGImagePropertyMakerAppleDictionary的值类型必须是[String: Any],用NSString当值也能过,但取的时候记得转回String比较。如果读回来是 nil,八成是CGImageDestination的类型参数和文件扩展名不一致,或者写的是 JPEG 却给了 HEIC 的 UTI。
3.2 视频侧:content.identifier 与 still-image-time
MOV 里要写两条 QuickTime 元数据轨,它们的标识符都是mdta/开头的全限定名,数据类型一个是 UTF-8 字符串,一个是 8 位整数。
| 轨道标识符 | 数据类型 | 值 | 作用 |
|---|---|---|---|
mdta/com.apple.quicktime.content.identifier | com.apple.metadata.datatype.utf-8 | 与静态图一致的 identifier | 把视频和静态图绑在一起 |
mdta/com.apple.quicktime.still-image-time | com.apple.metadata.datatype.int8 | 0 | 标记这是一张实况图,并给出封面帧位置 |
缺第一条,相册会把它当普通视频;缺第二条,相册会把它当普通图片加一段视频。两条都在但 identifier 不一致,表现和缺第一条一样,这也是最容易被忽略的一种:文件明明都生成成功了,就是不动。
still-image-time 的值写0就行,覆盖范围我一般设成从.zero到视频结尾。也见过只在封面帧时刻写一个单样本的实现,同样能被识别,但在部分系统版本上会被忽略,所以覆盖全时长更稳。
3.3 用 AVAssetWriter 重封装一段合法的配对视频
关键点是不重编码。把原视频轨用outputSettings: nil直通读出来,再原样写进去,只额外挂两条元数据轨。这样速度快、没有画质损失,也不会因为重编码改变帧时间戳导致封面帧偏移。
import AVFoundation private func metadataFormatDescription(identifier: String, dataType: String) throws -> CMMetadataFormatDescription { let spec: [String: Any] = [ kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier as String: identifier, kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType as String: dataType ] var desc: CMMetadataFormatDescription? let status = CMMetadataFormatDescriptionCreateWithMetadataSpecifications( allocator: kCFAllocatorDefault, metadataType: kCMMetadataFormatDescriptionMetadataType, specifications: [spec] as CFArray, formatDescriptionOut: &desc) guard status == noErr, let result = desc else { throw LivePhotoError.metadataFormatFailed } return result } private func metadataItem(key: String, dataType: String, value: NSCopying & NSObjectProtocol) -> AVMutableMetadataItem { let item = AVMutableMetadataItem() item.identifier = AVMetadataItem.identifier(forKey: key, keySpace: .quickTimeMetadata) item.dataType = dataType item.value = value item.extendedLanguageTag = "und" // 不写这一行,部分播放器解不出来 return item } func composePairedVideo(source: URL, assetIdentifier: String, output: URL) throws { let asset = AVURLAsset(url: source) guard let videoTrack = asset.tracks(withMediaType: .video).first, let formatHint = videoTrack.formatDescriptions.first as? CMFormatDescription else { throw LivePhotoError.noVideoTrack } let duration = asset.duration try? FileManager.default.removeItem(at: output) let reader = try AVAssetReader(asset: asset) let trackOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: nil) // nil 即直通 reader.add(trackOutput) let writer = try AVAssetWriter(outputURL: output, fileType: .mov) let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: nil, sourceFormatDescription: formatHint) videoInput.expectsMediaDataInRealTime = false writer.add(videoInput) let idInput = AVAssetWriterInput(mediaType: .metadata, outputSettings: nil, sourceFormatDescription: try metadataFormatDescription( identifier: "mdta/com.apple.quicktime.content.identifier", dataType: "com.apple.metadata.datatype.utf-8")) writer.add(idInput) let idAdaptor = AVAssetWriterInputMetadataAdaptor(assetWriter <p> <a href="https://download.csdn.net/download/lyh1083908486/87521908" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>