(二十)深入了解 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:控制水印出现的时间和持续时长

这两个时间属性直接关联视频时间轴,确保动画与视频同步。

Swift 复制代码
class PHMaskItem: PHMediaItem {
    var text: String?
    var image: UIImage?
    var bounds: CGRect = .zero
    
    override 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 = text
        self.image = image
        self.bounds = bounds
        super.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(如果有文字),显示文字

并给整个根图层添加淡入动画,实现渐显效果。

Swift 复制代码
func buildLayer() -> CALayer {
    let layer = CALayer()
    layer.bounds = bounds
    layer.opacity = 0.0
    
    if 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 = true
        add3DRotationAnimation(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 图片和文字图层创建

Swift 复制代码
func buildImageLayer() -> CALayer {
    let imageLayer = CALayer()
    imageLayer.contents = image?.cgImage
    imageLayer.contentsGravity = .resizeAspectFill
    return imageLayer
}

func buildTextLayer() -> CATextLayer {
    let textLayer = CATextLayer()
    textLayer.string = text
    textLayer.alignmentMode = .center
    textLayer.foregroundColor = UIColor.white.cgColor
    textLayer.fontSize = 20
    textLayer.contentsScale = UIScreen.main.scale
    return textLayer
}

3.4 动画实现

淡入动画:改变根图层的 opacity,从 0 渐变到 1,时间由 startTime 和 timeRange.duration 决定。

Swift 复制代码
func addFadeInAnimation(to layer: CALayer) {
    let animation = CABasicAnimation(keyPath: "opacity")
    animation.fromValue = 0
    animation.toValue = 1
    animation.beginTime = startTime.seconds
    animation.duration = timeRange.duration.seconds
    animation.isRemovedOnCompletion = false
    layer.add(animation, forKey: "fadeIn")
}

3D 旋转动画(针对图片图层):绕 Y 轴旋转一圈,带透视效果。无限循环。

Swift 复制代码
func add3DRotationAnimation(to layer: CALayer) {
    var perspective = CATransform3DIdentity
    perspective.m34 = -1.0 / 500.0
    layer.transform = perspective
    
    let animation = CABasicAnimation(keyPath: "transform.rotation.y")
    animation.fromValue = 0
    animation.toValue = CGFloat.pi * 2
    animation.beginTime = startTime.seconds
    animation.duration = timeRange.duration.seconds
    animation.repeatCount = Float.infinity
    animation.isRemovedOnCompletion = false
    layer.add(animation, forKey: "3DRotation")
}

通过 PHMaskItem,我们把视频水印抽象成一个可配置的对象,轻松添加文字、图片以及炫酷动画。后续在播放与导出时,我们只需调用 buildLayer() 获取动画图层,便可方便地融合到视频中。

四. 构建带水印的 Composition (PHOverlayCompositionBuilder)

4.1 轨道管理与同步插入

在 PHOverlayCompositionBuilder 中,我们首先需要向 AVMutableComposition 添加视频和音频轨道。

这里注意,必须确保轨道插入是同步完成的,避免异步加载导致轨道时间不完整。示例:

Swift 复制代码
private func addTrack(with mediaType: AVMediaType, mediaItems: [PHMediaItem]) -> AVMutableCompositionTrack? {
    let trackId = kCMPersistentTrackID_Invalid
    guard let compositionTrack = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: trackId) else {
        return nil
    }
    
    var cursorTime = CMTime.zero
    for mediaItem in mediaItems {
        // 这里假设 mediaItem.asset 是已经加载好的 AVAsset
        guard 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,并添加视频轨道的图层指令。

Swift 复制代码
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 实现渐变音量效果:

Swift 复制代码
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:

Swift 复制代码
private func buildMaskLayer() -> CALayer? {
    let maskItem = timeLine.maskItem
    return maskItem?.buildLayer()
}

4.5 构建最终 Composition

整合以上部分,构建包含视频、音频、水印的合成对象:

Swift 复制代码
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,使水印图层跟随视频播放时间精准同步。

下面是实现水印预览的关键方法:

Swift 复制代码
private func addSynchronizedLayer(synchronizedLayer: CALayer) {
    // 先移除之前的水印视图
    self.maskView.removeFromSuperview()
    
    // 设置水印图层大小,与视频尺寸匹配
    let bounds = CGRect(x: 0, y: 0, width: 1280, height: 720)
    synchronizedLayer.bounds = bounds
    synchronizedLayer.position = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
    
    // 清零 maskView frame,准备添加水印图层
    self.maskView.frame = .zero
    self.maskView.layer.addSublayer(synchronizedLayer)
    
    // 计算缩放比例,保证水印层在不同屏幕上保持视频比例
    let scaleWidth = self.avPlayerViewController.view.bounds.width / bounds.width
    let scaleHeight = self.avPlayerViewController.view.bounds.height / bounds.height
    let 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 核心代码示例

Swift 复制代码
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.frame
            
            animationLayer.addSublayer(videoLayer)
            animationLayer.addSublayer(maskLayer)
            maskLayer.position = videoLayer.position
            animationLayer.isGeometryFlipped = true
            
            videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(
                postProcessingAsVideoLayer: videoLayer,
                in: animationLayer
            )
        }
        
        let exportSession = AVAssetExportSession(asset: asset, presetName: presetName)
        exportSession?.audioMix = audioMix
        exportSession?.videoComposition = videoComposition

        return exportSession
    }

在完成了水印与视频的合成之后,最后一步就是将合成视频导出并保存到系统相册,供用户分享或回看。

6.2 创建导出会话

我们通过 PHComposition 协议的 makeExportSession(presetName:) 方法获取 AVAssetExportSession。导出配置如下:

Swift 复制代码
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
    }
    
    // 设置导出格式为 mp4
    exportSession.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 生成导出路径

采用临时目录与时间戳命名,避免覆盖:

Swift 复制代码
func buildExportPath() -> URL? {
    let timestamp = Int(Date().timeIntervalSince1970)
    let fileName = "exportedVideo_\(timestamp).mp4"
    let tempDir = FileManager.default.temporaryDirectory
    return tempDir.appendingPathComponent(fileName)
}

6.4 保存到相册

导出完成后,我们使用 Photos 框架将视频保存到系统相册:

Swift 复制代码
func saveToPhotoLibrary(url: URL) {
    PHPhotoLibrary.shared().performChanges({
        PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
    }) { success, error in
        if 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 视频编辑系列博客还会继续,敬请期待下一篇精彩内容!

相关推荐
小曾同学.com3 小时前
【每天学点‘音视频’】前向纠错 和 漏包重传
音视频·fec·前向纠错
AI浩3 小时前
跟踪不稳定目标:基于外观引导的运动建模实现无人机视频中的鲁棒多目标跟踪
目标跟踪·音视频·无人机
小学生波波6 小时前
如何免费给视频加字幕
音视频·免费字幕·加字幕·剪映加字幕
0x000712 小时前
C#项目集成海康SDK指南:从搭建环境到实现视频预览、录制、截屏
音视频
音视频牛哥13 小时前
如何计算 PCM 音频与 YUV/RGB 原始视频文件大小?
音视频·pcm·大牛直播sdk·rtsp播放器·rtmp播放器·yuv rgb计算大小·pcm计算大小
音视频牛哥15 小时前
从H.264到AV1:音视频技术演进与模块化SDK架构全解析
人工智能·音视频·大牛直播sdk·rtsp h.265·h.264 h.265 av1·h.265和h.266·enhenced rtmp
Antonio91516 小时前
【音视频】WebRTC 一对一通话 peerconnection_client 分析
音视频·webrtc
恒拓高科WorkPlus1 天前
局域网视频软件BeeWorks,内网顺畅沟通
音视频
关键帧-Keyframe1 天前
音视频面试题集锦第 26 期
面试·音视频