当前位置: 首页 > java >正文

(二十)深入了解 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 视频编辑系列博客还会继续,敬请期待下一篇精彩内容!

http://www.xdnf.cn/news/18114.html

相关文章:

  • Google 的 Opal:重新定义自动化的 AI 平台
  • Git版本控制与协作
  • 4.9 配置 开发服务器 和 请求代理
  • 汽车之家联合HarmonyOS SDK,深度构建鸿蒙生态体系
  • 使用Idea安装JDK
  • 从零开始,系统学习AI与机器学习:一份真诚的学习路线图
  • 容器化 Android 开发效率:cpolar 内网穿透服务优化远程协作流程
  • Baumer高防护相机如何通过YoloV8深度学习模型实现网球运动员和网球速度的检测分析(C#代码UI界面版)
  • WPF中BindingList<T>和List<T>
  • Conda技巧:修改Conda环境目录,节省系统盘空间
  • 学习:各种不同类型的for循环遍历,forEach/map/filter/every/some/includes/reduce的详细用法(1)
  • 【项目】分布式Json-RPC框架 - 项目介绍与前置知识准备
  • [Linux]学习笔记系列 --[mm][list_lru]
  • Redis-缓存-穿透-布隆过滤器
  • 测试Windows10IoT系统是否可以正常运行KingSCSDA3.8软件
  • Transformer架构的数学本质:从注意力机制到大模型时代的技术内核
  • 蓝凌EKP产品:JSP 性能优化和 JSTL/EL要点检查列表
  • Excel 表格数据自动填充
  • C语言私人学习笔记分享
  • Canny边缘检测
  • pyecharts可视化图表组合组件_Grid:打造专业数据仪表盘
  • python pandas库 series如何使用
  • 电脑上搭建HTTP服务器在局域网内其它客户端无法访问的解决方案
  • 【Tech Arch】Hive技术解析:大数据仓库的SQL桥梁
  • 【从零开始学习Redis】项目实战-黑马点评D2
  • Conda 环境 在AI 私有化部署 有怎么用?
  • 迅速掌握Git通用指令
  • C++内存模型
  • 数据结构代码分享-1 顺序表
  • Redis面试精讲 Day 23:Redis与数据库数据一致性保障