iOS里做视频编辑,系统相册的编辑界面能够应付简单的裁剪和滤镜,但一旦涉及自定义水印、多段音轨混音或者精确到帧的裁剪,就必须借助AVFoundation直接操作媒体数据。很多人上来就找第三方库,其实用AVMutableComposition、AVMutableVideoComposition和AVAssetExportSession这三个核心类,就能搭出一套完整的视频合成流程。本文会把导出带水印、背景音乐与裁剪功能的关键步骤拆开讲清楚,代码示例全部基于Swift,但Objective-C的开发者也能很容易地对应迁移。

理解AVFoundation的视频合成管线:时间线与渲染层
视频合成的底层模型并不是简单的“叠图片”,而是两条独立运作的管线:一条是时间线,另一条是渲染层。时间线由AVMutableComposition承载,它把不同来源的AVAsset中的视频轨、音频轨按时间顺序拼接起来,每个轨道对应一个AVMutableCompositionTrack。比如你有一段原始视频,要把它的前30秒剪掉,再把后面剩下的部分和另一段视频连起来,就是在composition里重新组织这些轨道片段的timeRange。时间线只负责“什么时候播放什么内容”,它本身不处理画面效果,水印、缩放、裁剪这些视觉变化全部发生在渲染层。
渲染层的入口是AVMutableVideoComposition。它包含一个instructions数组,每个AVMutableVideoCompositionInstruction描述某一段时间内视频轨应该按什么方式渲染。关键属性有两个:timeRange决定这条指令生效的时间段,layerInstructions则告诉渲染器每个视频轨的变换方式,比如平移、缩放、透明度变化。如果你需要叠加水印,就得在渲染管线上再挂一个AVVideoCompositionCoreAnimationTool,它接收一个包含水印子层的CALayer树,导出时会把每一帧画面先按layerInstructions变换好,再把这个动画工具生成的叠加层合成上去。很多教程只给一段add水印的代码,却不解释为什么水印不能直接加在视频轨上,原因就在这里:视频轨是数据轨道,水印是绘制层,二者必须通过video composition的animationTool桥接。
导出的时候要用AVAssetExportSession,它同时接收composition和videoComposition两个对象。注意如果你给exportSession设置了videoComposition,导出过程就会走离屏渲染,性能会明显下降,尤其是高分辨率视频。手机端处理4K素材时,建议先把视频缩到1080p以下再做合成,否则导出时间可能超过一分钟,用户会以为应用卡死了。AVAssetExportSession有一个supportedFileTypes属性,导出前要检查目标格式是否包含mp4或mov,否则模拟器上可能报错。另外,presetName尽量用AVAssetExportPresetHighestQuality,不要手动指定码率,交给系统去匹配最合适的参数。
添加图片水印:CALayer叠加与渲染时机
图片水印最常见的做法是把一个UIImageView或者直接构造一个CALayer,里面放上水印图片,再把整个layer树挂到AVVideoCompositionCoreAnimationTool上。核心代码并不复杂:创建一个父CALayer作为容器,设置它的frame为视频渲染尺寸(注意不是屏幕尺寸,而是视频的像素尺寸),然后创建一个子CALayer,contents设置成水印图片的CGImage,调整position和bounds把水印放到角落。之后创建animationTool,调用AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer:in:)方法,第一个参数是视频层,第二个参数就是你的父layer。
// 假设videoSize是视频渲染尺寸,watermarkImage是UIImage
let parentLayer = CALayer()
parentLayer.frame = CGRect(origin: .zero, size: videoSize)
let videoLayer = CALayer()
videoLayer.frame = CGRect(origin: .zero, size: videoSize)
parentLayer.addSublayer(videoLayer)
let watermarkLayer = CALayer()
watermarkLayer.contents = watermarkImage.cgImage
let margin: CGFloat = 20
let watermarkSize = CGSize(width: 120, height: 40)
watermarkLayer.frame = CGRect(
x: videoSize.width - watermarkSize.width - margin,
y: videoSize.height - watermarkSize.height - margin,
width: watermarkSize.width,
height: watermarkSize.height
)
watermarkLayer.opacity = 0.8
parentLayer.addSublayer(watermarkLayer)
let animationTool = AVVideoCompositionCoreAnimationTool(
postProcessingAsVideoLayer: videoLayer,
in: parentLayer
)
videoComposition.animationTool = animationTool
这里有几个容易忽略的细节。第一,watermarkLayer的contents是CGImage,但直接设置有时会出现图片上下颠倒的问题,因为CALayer的坐标系原点在左下角,而UIImage的坐标系原点在左上角。解决办法是在设置contents之前把layer的isGeometryFlipped设为true,或者用contentsGravity配合contentsRect做翻转。第二,postProcessingAsVideoLayer方法要求传入的videoLayer必须与视频轨的实际渲染层对应,如果只有一条视频轨,直接创建一个占满frame的layer即可;如果有多条视频轨需要混合,就要用AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayers:in:)传入数组。第三,水印层在导出过程中不会被用户交互影响,所以动画属性如果需要跟随时间变化,比如水印移动或者淡入淡出,就要用CAKeyframeAnimation或者CABasicAnimation加到watermarkLayer上,并把动画的beginTime设置为合适的值,系统在渲染每一帧时会根据动画时间计算属性。
文字水印更灵活一些,可以动态生成包含文字的UIImage,再走上面的图片水印流程。但如果你想在导出前让用户可以实时预览文字位置,最好用CoreText直接绘制到CGContext里生成图片,这样可以精确控制字体、颜色、描边。另一个常见需求是水印在视频中某个时间段才出现,比如片头显示logo三秒然后消失。这不能通过简单控制layer的hidden实现,因为animationTool的layer树是整个导出期间都存在的,时间控制需要靠layer上的动画:添加一个opacity动画,从0到1再到0,时间区间对应你想显示的时间段。
混入背景音乐:音频轨道合并与音量调节
给视频加背景音乐不是简单地把音频文件放在视频旁边一起导出。你需要把背景音乐作为一个独立的AVMutableCompositionTrack插入到composition的音频轨道集合里,同时保留原始视频自带的音轨。如果原始视频没有声音,那只需要一条音频轨;如果有声音,你要决定背景音乐是替代原声还是混合。替换的话,把原视频的音轨移除或者不插入即可;混合的话就要同时插入两条音频轨,导出时系统会自动混音。AVMutableComposition的addMutableTrack(withMediaType:preferredTrackID:)方法分别创建视频轨、原声轨和背景音乐轨,然后把AVAsset对应轨道的timeRange插入到目标轨中。
let composition = AVMutableComposition()
// 视频轨
let videoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
)
let videoAsset = AVURLAsset(url: videoURL)
let videoAssetTrack = videoAsset.tracks(withMediaType: .video).first!
try videoTrack?.insertTimeRange(
CMTimeRange(start: .zero, duration: videoAsset.duration),
of: videoAssetTrack,
at: .zero
)
// 原声轨(如果有)
if let originalAudioTrack = videoAsset.tracks(withMediaType: .audio).first {
let originalAudio = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
)
try originalAudio?.insertTimeRange(
CMTimeRange(start: .zero, duration: videoAsset.duration),
of: originalAudioTrack,
at: .zero
)
}
// 背景音乐轨
let bgmAsset = AVURLAsset(url: bgmURL)
if let bgmTrack = bgmAsset.tracks(withMediaType: .audio).first {
let bgmCompositionTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
)
// 背景音乐可能比视频长,只取视频长度部分
let insertDuration = CMTimeMinimum(bgmAsset.duration, videoAsset.duration)
try bgmCompositionTrack?.insertTimeRange(
CMTimeRange(start: .zero, duration: insertDuration),
of: bgmTrack,
at: .zero
)
}
音量控制是混音环节最容易出问题的地方。背景音乐如果直接以原始音量插入,很可能盖过视频原声,听感很差。AVMutableCompositionTrack本身没有音量属性,你需要借助AVAudioMix来调节每条音频轨的音量。创建AVMutableAudioMix,然后为每条音频轨创建AVMutableAudioMixInputParameters,设置trackID对应到composition里的音频轨,最后用setVolume(_:at:)方法在时间轴上设置音量关键点。通常背景音乐设置到0.2~0.4,原声保持1.0,还可以做淡入淡出:在开头0.5秒内从0升到目标音量,结尾0.5秒内从目标音量降到0。
let audioMix = AVMutableAudioMix() let bgmParameters = AVMutableAudioMixInputParameters(track: bgmCompositionTrack) bgmParameters.setVolume(0.3, at: .zero) // 假设视频时长5秒,最后1秒淡出 let fadeOutStart = CMTime(seconds: 4.0, preferredTimescale: 600) bgmParameters.setVolume(0.3, at: fadeOutStart) bgmParameters.setVolume(0.0, at: CMTime(seconds: 5.0, preferredTimescale: 600)) // 原声参数 let originalParameters = AVMutableAudioMixInputParameters(track: originalAudioTrack) originalParameters.setVolume(1.0, at: .zero) audioMix.inputParameters = [bgmParameters, originalParameters] // 导出时设置exportSession.audioMix = audioMix
如果背景音乐比视频短,循环播放需要额外处理:可以分段插入多次,每次时间偏移增加bgm时长;或者用AVMutableCompositionTrack的scaleTimeRange调整速度,但不自然。最简单的方式是在插入前判断bgm时长小于视频时长时,用循环插入代码块重复插直到覆盖整个视频区间。另外,注意AVURLAsset加载本地音频文件时,tracks(withMediaType:)可能返回空,因为有些mp3文件的音频轨延迟加载,需要先调用loadValuesAsynchronously或者使用asset.tracks同步访问前确保状态是.loaded。在iOS 15以后,AVURLAsset的异步加载API变化较大,建议把音频文件转换成.m4a格式,或者用AVAudioFile读取后用AVAssetReader重新打包。
裁剪视频画面:不是缩放,而是变换矩阵与渲染尺寸
很多开发者理解的“裁剪”是把视频分辨率改小,比如从1920x1080裁掉上下黑边变成1920x800。实际上在AVFoundation中,裁剪是渲染阶段的事情,它通过修改视频轨的transform和videoComposition的renderSize来实现。核心思路分两步:第一,在AVMutableVideoCompositionInstruction里为视频轨设置一个偏移动画(AVMutableVideoCompositionLayerInstruction),把画面在渲染区域内平移和缩放,只让需要保留的部分出现在可视区域内;第二,调整AVMutableVideoComposition的renderSize,使其等于你裁剪后的目标尺寸。
常见场景是把横屏视频裁剪成竖屏比例,比如把16:9的画面裁成9:16用于短视频平台。此时renderSize设置为1080x1920(或按比例),而原始视频轨的画面是1920x1080,直接缩放到竖屏会导致画面严重变形。你需要计算等比缩放系数,先把视频画面放大到宽度填满竖屏容器(即宽度从1080放大到1920,高度从608放大到1080),然后上下各裁掉一部分,让高度从1920变成1080。这个过程用CGAffineTransform组合完成:先缩放,再平移。
let naturalSize = videoAssetTrack.naturalSize let targetSize = CGSize(width: 1080, height: 1920) // 计算缩放比例,使宽度填满1080 let scale = targetSize.width / naturalSize.width // 1080 / 1920 = 0.5625 let scaledHeight = naturalSize.height * scale // 1080 * 0.5625 = 607.5 // 需要垂直居中,计算y方向偏移 let yOffset = (targetSize.height - scaledHeight) / 2 // (1920 - 607.5)/2 = 656.25 var transform = CGAffineTransform(scaleX: scale, y: scale) transform = transform.translatedBy(x: 0, y: yOffset / scale) // 注意translate在scale之后 let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) layerInstruction.setTransform(transform, at: .zero) let instruction = AVMutableVideoCompositionInstruction() instruction.timeRange = CMTimeRange(start: .zero, duration: videoAsset.duration) instruction.layerInstructions = [layerInstruction] let videoComposition = AVMutableVideoComposition() videoComposition.renderSize = targetSize videoComposition.frameDuration = CMTime(value: 1, timescale: 30) // 根据原视频帧率设置 videoComposition.instructions = [instruction]
上面的代码有一个隐蔽问题:transform的平移量是在缩放后的坐标系里计算的,所以yOffset需要除以scale。因为CGAffineTransform的translatedBy方法是在已有变换基础上追加平移,相当于先缩放再在缩放后的画布上移动。如果你把顺序反过来,用translatedBy然后再scale,结果会完全不同。建议用CGAffineTransformConcat手动组合:let finalTransform = CGAffineTransformConcat(CGAffineTransform(scaleX: scale, y: scale), CGAffineTransform(translationX: 0, y: yOffset / scale))。另外,设置frameDuration时不要随便用固定值,应该从原视频轨的nominalFrameRate读取,避免帧率不匹配导致的卡顿或音画不同步。
裁剪还有一个更直观的API:AVAssetReader/AVAssetWriter组合可以逐帧处理像素,灵活度更高,但性能开销大很多,适合做滤镜或逐帧特效。对于普通裁剪需求,用AVMutableVideoComposition的layerInstruction足以应付。如果裁剪区域不是居中,而是自定义选框,只需要调整transform中的平移量。例如要保留画面左上角1/4区域,缩放系数变为2倍(放大两倍再只取左上角),平移量根据选框位置计算。需要注意的是,当transform包含旋转时(比如处理iPhone竖拍视频的90度旋转),naturalSize可能带有旋转信息,要先通过preferredTransform把画面摆正,再计算缩放和平移,否则裁剪区域会错位。
导出前别忘了检查AVAssetExportSession的exportPresets(compatibleWith:)是否支持你的composition和videoComposition组合。如果目标设备是旧款iPhone,preset太高可能在导出中途报错。完整的导出流程是异步的,用exportAsynchronously启动,然后在回调里检查status,成功后再用outputURL保存到相册。把这些功能封装成一个VideoEditor类,对外暴露裁剪区域、水印图片、背景音乐URL和导出文件路径四个参数,内部按顺序构建composition、videoComposition、audioMix和exportSession,就能在项目中复用了。

