使用AVMutableComposition合并视频时的画面变形问题求助
竖屏视频合并后画面旋转、拉伸/镜像问题排查与修复
问题描述
我有一个AVURLAsset数组,包含三类竖屏素材:
- 后置摄像头拍摄的视频
- 未镜像的前置摄像头拍摄视频
- 图片转换而来的视频
编写合并函数后,最终播放的视频出现画面旋转、拉伸/挤压或镜像问题,但单独处理单个素材时显示正常。
当前实现代码
func merge(assets: [AVURLAsset], completion: @escaping (URL?, AVAssetExportSession?) -> Void) { let mainComposition = AVMutableComposition() var lastTime = CMTime.zero guard let videoCompositionTrack = mainComposition.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else { return } guard let audioCompositionTrack = mainComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else { return } let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoCompositionTrack) for asset in assets { if let videoTrack = asset.tracks(withMediaType: .video)[safe: 0] { let t = videoTrack.preferredTransform layerInstruction.setTransform(t, at: lastTime) if let audioTrack = asset.tracks(withMediaType: .audio)[safe: 0] { do { try videoCompositionTrack.insertTimeRange(CMTimeRangeMake(start: .zero, duration: asset.duration), of: videoTrack, at: lastTime) try audioCompositionTrack.insertTimeRange(CMTimeRangeMake(start: .zero, duration: asset.duration), of: audioTrack, at: lastTime) print("Inserted audio + video track") } catch { print("Failed to insert audio or video track") return } lastTime = CMTimeAdd(lastTime, asset.duration) } else { do { try videoCompositionTrack.insertTimeRange(CMTimeRangeMake(start: .zero, duration: asset.duration), of: videoTrack, at: lastTime) print("Inserted just video track") } catch { print("Failed to insert just video track") return } lastTime = CMTimeAdd(lastTime, asset.duration) } } } let outputUrl = NSURL.fileURL(withPath: NSTemporaryDirectory() + "test" + ".mp4") let videoComposition = AVMutableVideoComposition() let instruction = AVMutableVideoCompositionInstruction() instruction.layerInstructions = [layerInstruction] instruction.timeRange = videoCompositionTrack.timeRange videoComposition.instructions = [instruction] videoComposition.frameDuration = videoCompositionTrack.minFrameDuration videoComposition.renderSize = CGSize(width: 720, height: 1280) // Adjust as per your video dimensions guard let exporter = AVAssetExportSession(asset: mainComposition, presetName: AVAssetExportPresetHighestQuality) else { return } exporter.outputURL = outputUrl exporter.outputFileType = .mp4 exporter.shouldOptimizeForNetworkUse = true exporter.videoComposition = videoComposition exporter.exportAsynchronously { if let outputUrl = exporter.outputURL { completion(outputUrl, exporter) } } play(video: exporter.asset) }
问题根源与修复方案
核心问题分析
- 单一LayerInstruction复用错误:所有素材共用同一个
AVMutableVideoCompositionLayerInstruction,后续素材的transform会覆盖之前的设置,导致只有最后一个素材的transform生效,前面的素材画面异常。 - Transform未适配输出尺寸:直接使用原视频的
preferredTransform,没有考虑输出渲染尺寸的适配,会导致画面拉伸/挤压。 - 前置摄像头镜像处理缺失:前置摄像头拍摄的视频默认需要镜像修正,原代码未处理该逻辑。
修复后的完整代码
func merge(assets: [AVURLAsset], completion: @escaping (URL?, AVAssetExportSession?) -> Void) { let mainComposition = AVMutableComposition() var lastTime = CMTime.zero let outputRenderSize = CGSize(width: 720, height: 1280) // 统一输出尺寸 guard let videoCompositionTrack = mainComposition.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else { completion(nil, nil) return } guard let audioCompositionTrack = mainComposition.addMutableTrack(withMediaType: .audio, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else { completion(nil, nil) return } var layerInstructions = [AVMutableVideoCompositionLayerInstruction]() for asset in assets { guard let videoTrack = asset.tracks(withMediaType: .video).first else { continue } // 插入视频轨道 do { let timeRange = CMTimeRange(start: .zero, duration: asset.duration) try videoCompositionTrack.insertTimeRange(timeRange, of: videoTrack, at: lastTime) // 插入音频轨道(如果有) if let audioTrack = asset.tracks(withMediaType: .audio).first { try audioCompositionTrack.insertTimeRange(timeRange, of: audioTrack, at: lastTime) } } catch { print("插入轨道失败: \(error.localizedDescription)") completion(nil, nil) return } // 处理每个素材的Transform let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoCompositionTrack) let originalTransform = videoTrack.preferredTransform let assetNaturalSize = videoTrack.naturalSize // 修正前置摄像头镜像 var adjustedTransform = originalTransform if isFrontCameraVideo(asset: asset) { // 对前置视频应用水平镜像 let mirrorTransform = CGAffineTransform(scaleX: -1, y: 1) adjustedTransform = originalTransform.concatenating(mirrorTransform) } // 适配输出渲染尺寸,避免拉伸 let scaledTransform = calculateScaledTransform(originalSize: assetNaturalSize, targetSize: outputRenderSize, transform: adjustedTransform) layerInstruction.setTransform(scaledTransform, at: lastTime) layerInstructions.append(layerInstruction) lastTime = CMTimeAdd(lastTime, asset.duration) } // 配置视频合成指令 let videoComposition = AVMutableVideoComposition() let instruction = AVMutableVideoCompositionInstruction() instruction.layerInstructions = layerInstructions instruction.timeRange = CMTimeRange(start: .zero, duration: lastTime) videoComposition.instructions = [instruction] videoComposition.frameDuration = CMTimeMake(value: 1, timescale: 30) // 统一帧率,可选 videoComposition.renderSize = outputRenderSize // 导出配置 let outputUrl = URL(fileURLWithPath: NSTemporaryDirectory() + "merged_video.mp4") // 删除已存在的文件 if FileManager.default.fileExists(atPath: outputUrl.path) { try? FileManager.default.removeItem(at: outputUrl) } guard let exporter = AVAssetExportSession(asset: mainComposition, presetName: AVAssetExportPresetHighestQuality) else { completion(nil, nil) return } exporter.outputURL = outputUrl exporter.outputFileType = .mp4 exporter.shouldOptimizeForNetworkUse = true exporter.videoComposition = videoComposition exporter.exportAsynchronously { DispatchQueue.main.async { if exporter.status == .completed { completion(outputUrl, exporter) } else { print("导出失败: \(exporter.error?.localizedDescription ?? "未知错误")") completion(nil, exporter) } } } } // 辅助函数:判断是否为前置摄像头拍摄的视频 func isFrontCameraVideo(asset: AVURLAsset) -> Bool { guard let tracks = asset.tracks(withMediaType: .video).first else { return false } guard let metadata = tracks.metadata(forFormat: AVMetadataFormatQuickTimeUserData).first(where: { $0.commonKey == .quickTimeUserDataCameraIdentifier }) else { return false } return (metadata.value as? String) == "com.apple.avfoundation.camera.front" } // 辅助函数:计算适配输出尺寸的Transform,避免拉伸 func calculateScaledTransform(originalSize: CGSize, targetSize: CGSize, transform: CGAffineTransform) -> CGAffineTransform { // 判断原视频是否是旋转后的尺寸(比如竖屏视频naturalSize是宽>高,但实际是竖屏) let isPortrait = transform.a == 0 && transform.d == 0 let assetDisplaySize = isPortrait ? CGSize(width: originalSize.height, height: originalSize.width) : originalSize // 计算缩放比例,保持宽高比 let scaleX = targetSize.width / assetDisplaySize.width let scaleY = targetSize.height / assetDisplaySize.height let scale = min(scaleX, scaleY) // 计算居中偏移 let offsetX = (targetSize.width - assetDisplaySize.width * scale) / 2 let offsetY = (targetSize.height - assetDisplaySize.height * scale) / 2 // 构建缩放和平移的Transform let scaleTransform = CGAffineTransform(scaleX: scale, y: scale) let translateTransform = CGAffineTransform(translationX: offsetX, y: offsetY) // 合并原Transform、缩放、平移 return transform.concatenating(scaleTransform).concatenating(translateTransform) }
关键修复点说明
- 每个素材单独创建LayerInstruction:不再复用同一个指令,确保每个时间段的Transform设置独立生效。
- 前置摄像头镜像修正:通过元数据判断是否为前置摄像头视频,添加水平镜像Transform。
- 尺寸适配Transform:计算保持原画面宽高比的缩放比例,配合居中偏移,避免画面拉伸/挤压。
- 完善错误处理:添加文件删除、导出状态判断,确保流程鲁棒性。
内容的提问来源于stack exchange,提问作者Chris
相关产品推荐
相关产品推荐

