(二十)深入了解 AVFoundation-编辑:使用 AVMutableVideoComposition 实现视频加水印与图层合成(下)——实战篇
博客专栏链接:AVFoundation架构与实践
博客专栏源码链接:AVFoundation-建议转存大量实战源码
一.引言
在上一章节 (十九)深入了解 AVFoundation-编辑:使用 AVMutableVideoComposition 实现视频加水印与图层合成(上)——理论篇 中,我们从原理层面剖析了如何利用 AVMutableVideoComposition 与 AVVideoCompositionCoreAnimationTool,将 Core Animation 图层与视频帧进行无缝融合,并讨论了时间同步、坐标系翻转、动画控制等关键细节。
本篇作为实战篇,我们将通过一个完整的示例,从零开始实现一段带有图片水印、文字标题和动画效果的视频编辑流程,涵盖从图层构建、播放预览到视频导出的全链路。我们不仅会让水印在视频中淡入淡出,还会为图片添加 3D 旋转与弹跳效果,让成品视频更具动感与层次感。
如果说理论篇是“造船图纸”,那么这一篇就是下水试航——带你把代码真正跑起来,看到水印与视频完美融合的成品效果。
二.Demo 结构概览
为了方便理解与维护,我们将本次实战的 Demo 拆分为数据模型层、合成构建层、播放与导出层三个部分,每个部分的职责清晰,互不耦合:
数据模型层
- PHMaskItem:封装单个水印元素的信息,包括文字、图片、显示位置 (bounds)、出现时间 (startTime) 和持续时间 (timeRange),并提供生成对应 CALayer 的方法。
- 每个水印元素内部可定义多种动画(如淡入、3D 旋转、弹跳等),并与视频时间轴同步。
合成构建层
PHOverlayCompositionBuilder:负责根据 PHTimeLine 中的视频、音频和水印元素,构建最终可播放和可导出的视频合成对象。
核心职责包括:
- 添加视频与音频轨道到 AVMutableComposition
- 构建 AVAudioMix 实现音量渐变等效果
- 使用 AVMutableVideoComposition 定义视频渲染尺寸、帧率与图层指令
- 创建水印总图层(包含文字与图片子图层)
播放与导出层
- PHOverlayComposition:根据构建好的视频合成和水印图层,生成可在 AVPlayer 中播放的 AVPlayerItem,并在导出时将水印与视频合成为新文件。
- PHCompositionExporter:封装导出逻辑,负责生成 AVAssetExportSession,设置输出格式、保存路径,并支持自动保存到相册。
播放水印预览
- 通过 AVSynchronizedLayer 将水印图层与播放器时间轴同步,实现与导出效果一致的播放预览,所见即所得。
整体流程可以理解为:
PHMaskItem(定义水印) ➡ PHOverlayCompositionBuilder(合成) ➡ PHOverlayComposition(播放/导出) ➡ PHCompositionExporter(落地文件)。
三. 构建可动画水印图层(PHMaskItem)
3.1 数据模型设计
我们设计了 PHMaskItem 类,继承自基础的 PHMediaItem,用于描述单个水印元素的基本属性和行为:
- text:可选的显示文字内容
- image:可选的水印图片
- bounds:水印图层显示的区域尺寸
- startTime 和 timeRange:控制水印出现的时间和持续时长
这两个时间属性直接关联视频时间轴,确保动画与视频同步。
class PHMaskItem: PHMediaItem {var text: String?var image: UIImage?var bounds: CGRect = .zerooverride init() {super.init()// 示例:从第2秒开始,持续5秒self.startTime = CMTime(seconds: 2, preferredTimescale: 600)self.timeRange = CMTimeRange(start: self.startTime, duration: CMTime(seconds: 5, preferredTimescale: 600))}init(text: String? = nil, image: UIImage? = nil, bounds: CGRect) {self.text = textself.image = imageself.bounds = boundssuper.init()self.startTime = CMTime(seconds: 2, preferredTimescale: 600)self.timeRange = CMTimeRange(start: self.startTime, duration: CMTime(seconds: 5, preferredTimescale: 600))}
}
3.2 创建图层方法
水印图层是通过 buildLayer() 方法生成的,返回一个 CALayer。
图层结构:
- 根图层 layer,尺寸为 bounds
- 子图层 imageLayer(如果有图片),包含图片内容,并带 3D 旋转动画
- 子图层 textLayer(如果有文字),显示文字
并给整个根图层添加淡入动画,实现渐显效果。
func buildLayer() -> CALayer {let layer = CALayer()layer.bounds = boundslayer.opacity = 0.0if let image = image {let imageLayer = buildImageLayer()layer.addSublayer(imageLayer)imageLayer.bounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)imageLayer.position = CGPoint(x: image.size.width / 2 + 10, y: 80)layer.allowsEdgeAntialiasing = trueadd3DRotationAnimation(to: imageLayer)}if let text = text {let textLayer = buildTextLayer()layer.addSublayer(textLayer)textLayer.bounds = CGRect(x: 0, y: 0, width: 200, height: 40)textLayer.position = CGPoint(x: 110, y: 200)}addFadeInAnimation(to: layer)return layer
}
3.3 图片和文字图层创建
func buildImageLayer() -> CALayer {let imageLayer = CALayer()imageLayer.contents = image?.cgImageimageLayer.contentsGravity = .resizeAspectFillreturn imageLayer
}func buildTextLayer() -> CATextLayer {let textLayer = CATextLayer()textLayer.string = texttextLayer.alignmentMode = .centertextLayer.foregroundColor = UIColor.white.cgColortextLayer.fontSize = 20textLayer.contentsScale = UIScreen.main.scalereturn textLayer
}
3.4 动画实现
淡入动画:改变根图层的 opacity,从 0 渐变到 1,时间由 startTime 和 timeRange.duration 决定。
func addFadeInAnimation(to layer: CALayer) {let animation = CABasicAnimation(keyPath: "opacity")animation.fromValue = 0animation.toValue = 1animation.beginTime = startTime.secondsanimation.duration = timeRange.duration.secondsanimation.isRemovedOnCompletion = falselayer.add(animation, forKey: "fadeIn")
}
3D 旋转动画(针对图片图层):绕 Y 轴旋转一圈,带透视效果。无限循环。
func add3DRotationAnimation(to layer: CALayer) {var perspective = CATransform3DIdentityperspective.m34 = -1.0 / 500.0layer.transform = perspectivelet animation = CABasicAnimation(keyPath: "transform.rotation.y")animation.fromValue = 0animation.toValue = CGFloat.pi * 2animation.beginTime = startTime.secondsanimation.duration = timeRange.duration.secondsanimation.repeatCount = Float.infinityanimation.isRemovedOnCompletion = falselayer.add(animation, forKey: "3DRotation")
}
通过 PHMaskItem,我们把视频水印抽象成一个可配置的对象,轻松添加文字、图片以及炫酷动画。后续在播放与导出时,我们只需调用 buildLayer() 获取动画图层,便可方便地融合到视频中。
四. 构建带水印的 Composition (PHOverlayCompositionBuilder)
4.1 轨道管理与同步插入
在 PHOverlayCompositionBuilder 中,我们首先需要向 AVMutableComposition 添加视频和音频轨道。
这里注意,必须确保轨道插入是同步完成的,避免异步加载导致轨道时间不完整。示例:
private func addTrack(with mediaType: AVMediaType, mediaItems: [PHMediaItem]) -> AVMutableCompositionTrack? {let trackId = kCMPersistentTrackID_Invalidguard let compositionTrack = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: trackId) else {return nil}var cursorTime = CMTime.zerofor mediaItem in mediaItems {// 这里假设 mediaItem.asset 是已经加载好的 AVAssetguard let track = mediaItem.asset?.tracks(withMediaType: mediaType).first else {continue}do {try compositionTrack.insertTimeRange(mediaItem.timeRange, of: track, at: cursorTime)cursorTime = CMTimeAdd(cursorTime, mediaItem.timeRange.duration)} catch {print("Error inserting track: \(error)")}}return compositionTrack
}
4.2 构建视频合成指令
构建 AVMutableVideoComposition,设置 renderSize 和 frameDuration,并添加视频轨道的图层指令。
private func buildVideoComposition(for videoTrack: AVMutableCompositionTrack) -> AVMutableVideoComposition {let videoComposition = AVMutableVideoComposition()videoComposition.renderSize = CGSize(width: 1280, height: 720)videoComposition.frameDuration = CMTime(value: 1, timescale: 30)let instruction = AVMutableVideoCompositionInstruction()instruction.timeRange = CMTimeRange(start: .zero, duration: composition.duration)let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)instruction.layerInstructions = [layerInstruction]videoComposition.instructions = [instruction]return videoComposition
}
4.3 构建音频混合
针对背景音乐,构建 AVAudioMix 实现渐变音量效果:
private func buildAudioMix(track: AVMutableCompositionTrack) -> AVAudioMix? {guard let musicItem = timeLine.musicItems.first else {return nil}let audioMix = AVMutableAudioMix()let parameters = AVMutableAudioMixInputParameters(track: track)for automation in musicItem.volumeAutomation {parameters.setVolumeRamp(fromStartVolume: automation.startVolume,toEndVolume: automation.endVolume,timeRange: automation.timeRange)}audioMix.inputParameters = [parameters]return audioMix
}
4.4 创建水印图层
调用 PHMaskItem.buildLayer() 生成最终的水印 CALayer:
private func buildMaskLayer() -> CALayer? {let maskItem = timeLine.maskItemreturn maskItem?.buildLayer()
}
4.5 构建最终 Composition
整合以上部分,构建包含视频、音频、水印的合成对象:
func buildComposition() -> PHOverlayComposition? {guard let videoTrack = addTrack(with: .video, mediaItems: timeLine.videoItmes) else {return nil}_ = addTrack(with: .audio, mediaItems: timeLine.audioItems)let musicTrack = addTrack(with: .audio, mediaItems: timeLine.musicItems)let audioMix = musicTrack.flatMap { buildAudioMix(track: $0) }let videoComposition = buildVideoComposition(for: videoTrack)let maskLayer = buildMaskLayer()return PHOverlayComposition(audioMix: audioMix,videoComposition: videoComposition,composition: composition,maskLayer: maskLayer)
}
五. 播放端水印预览(addSynchronizedLayer)
为了实现播放时水印与视频内容同步显示,我们利用 AVSynchronizedLayer。它能绑定到 AVPlayerItem,使水印图层跟随视频播放时间精准同步。
下面是实现水印预览的关键方法:
private func addSynchronizedLayer(synchronizedLayer: CALayer) {// 先移除之前的水印视图self.maskView.removeFromSuperview()// 设置水印图层大小,与视频尺寸匹配let bounds = CGRect(x: 0, y: 0, width: 1280, height: 720)synchronizedLayer.bounds = boundssynchronizedLayer.position = CGPoint(x: bounds.width / 2, y: bounds.height / 2)// 清零 maskView frame,准备添加水印图层self.maskView.frame = .zeroself.maskView.layer.addSublayer(synchronizedLayer)// 计算缩放比例,保证水印层在不同屏幕上保持视频比例let scaleWidth = self.avPlayerViewController.view.bounds.width / bounds.widthlet scaleHeight = self.avPlayerViewController.view.bounds.height / bounds.heightlet scale = CGFloat(fminf(Float(scaleWidth), Float(scaleHeight)))// 计算视频实际显示区域(考虑了 Letterbox)let videoRect = AVMakeRect(aspectRatio: bounds.size, insideRect: self.avPlayerViewController.view.bounds)// 水印层居中显示在视频区域中央self.maskView.center = CGPoint(x: CGRectGetMidX(videoRect), y: CGRectGetMidY(videoRect))// 根据比例缩放self.maskView.transform = CGAffineTransform(scaleX: scale, y: scale)// 添加到播放器覆盖层self.avPlayerViewController.contentOverlayView?.addSubview(maskView)
}
- synchronizedLayer.bounds 和 position 需与视频尺寸匹配,保证水印坐标系统和视频帧一致。
- 通过 AVMakeRect(aspectRatio:insideRect:) 计算视频实际画面区域,避免 Letterbox 黑边影响位置。
- 利用 maskView.transform 按比例缩放水印层,确保水印在各种设备屏幕尺寸下大小合适。
- 水印视图层次添加到 contentOverlayView,不会影响视频播放本身。
播放时的坐标系是 UIKit 坐标系(左上为原点,Y 轴向下),而导出时 Core Animation 坐标系是视频帧像素坐标(左下为原点,Y 轴向上),因此导出时需要对 animationLayer.isGeometryFlipped = true 以做上下翻转调整。
通过 AVSynchronizedLayer,水印层的动画与视频播放时间完美同步,用户在播放时就能实时看到带水印的效果,为导出提供准确预览。
六. 导出视频及动画合成(PHOverlayComposition.makeExportSession)
在导出阶段,我们要把视频内容和水印动画烘焙成最终文件。核心是使用 AVVideoCompositionCoreAnimationTool 将 CALayer 动画叠加到视频帧中。
6.1 核心代码示例
func makeExportSession(presetName: String) -> AVAssetExportSession? {guard let asset = composition.copy() as? AVAsset else {return nil}if let maskLayer = maskLayer {let animationLayer = CALayer()animationLayer.bounds = CGRect(x: 0, y: 0, width: 1280, height: 720)animationLayer.position = CGPoint(x: 1280/2, y: 720/2)print("Animation Layer Frame: \(animationLayer.frame)")let videoLayer = CALayer()videoLayer.frame = animationLayer.frameanimationLayer.addSublayer(videoLayer)animationLayer.addSublayer(maskLayer)maskLayer.position = videoLayer.positionanimationLayer.isGeometryFlipped = truevideoComposition.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: videoLayer,in: animationLayer)}let exportSession = AVAssetExportSession(asset: asset, presetName: presetName)exportSession?.audioMix = audioMixexportSession?.videoComposition = videoCompositionreturn exportSession}
在完成了水印与视频的合成之后,最后一步就是将合成视频导出并保存到系统相册,供用户分享或回看。
6.2 创建导出会话
我们通过 PHComposition 协议的 makeExportSession(presetName:) 方法获取 AVAssetExportSession。导出配置如下:
func beginExport() {guard let composition = self.composition else {print("Composition is nil, cannot begin export.")return}self.exportSession = composition.makeExportSession(presetName: AVAssetExportPresetHighestQuality)guard let exportSession = self.exportSession else {print("Failed to create export session.")return}// 设置导出格式为 mp4exportSession.outputFileType = .mp4// 生成输出路径guard let outputURL = self.buildExportPath() else {print("Failed to build export path.")return}exportSession.outputURL = outputURL// 异步导出exportSession.exportAsynchronously {switch exportSession.status {case .completed:print("Export succeeded: \(outputURL)")self.saveToPhotoLibrary(url: outputURL)case .failed:print("Export failed: \(exportSession.error?.localizedDescription ?? "Unknown error")")case .cancelled:print("Export cancelled.")default:break}}
}
6.3 生成导出路径
采用临时目录与时间戳命名,避免覆盖:
func buildExportPath() -> URL? {let timestamp = Int(Date().timeIntervalSince1970)let fileName = "exportedVideo_\(timestamp).mp4"let tempDir = FileManager.default.temporaryDirectoryreturn tempDir.appendingPathComponent(fileName)
}
6.4 保存到相册
导出完成后,我们使用 Photos 框架将视频保存到系统相册:
func saveToPhotoLibrary(url: URL) {PHPhotoLibrary.shared().performChanges({PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)}) { success, error inif success {print("Saved video to photo library successfully.")} else {print("Failed to save video: \(error?.localizedDescription ?? "Unknown error")")}}
}
七.结语
本篇实战篇完整演示了如何基于 AVMutableVideoComposition 结合 Core Animation,实现视频水印的播放预览与导出功能。
我们从水印数据模型 PHMaskItem 入手,细致打造文字与图片图层及动画效果;通过 PHOverlayCompositionBuilder 组装视频、音频和水印轨道;利用 AVSynchronizedLayer 实现播放时的水印动画同步;最终借助 AVVideoCompositionCoreAnimationTool 将水印烘焙进导出视频文件。
这一流程不仅满足了“所见即所得”的需求,也保证了代码结构清晰、易扩展。你可以在此基础上,灵活加入更多复杂的动画、滤镜或多层水印,打造更炫酷的视频编辑体验。
感谢你的持续关注,我们的 AVFoundation 视频编辑系列博客还会继续,敬请期待下一篇精彩内容!