Swift实现TikTok式上下/并排视频合成问题求助
TikTok风格分屏视频合成解决方案
问题说明
需要实现类似TikTok的视频合成功能,支持两种分屏模式:
- 上下分屏:两个视频各占全屏宽度,上下排布
- 左右分屏:两个视频各占全屏高度,左右均分
当前基于AVFoundation实现的两个函数(mergeVideosTopBottom和mergeVideosSideBySide)输出视频仅显示一个视频且无法填满屏幕,第三方库DPVideoMerger效果不符合预期。
问题根源
原代码未正确处理视频的旋转变换与布局适配:
- 直接使用视频的
preferredTransform导致两个视频层位置重叠,仅显示上层视频 - 未对视频进行缩放和位移,无法适配分屏后的输出尺寸
- 输出尺寸计算未考虑视频旋转后的实际显示尺寸
修复后的实现代码
通用工具函数(处理视频尺寸与变换)
首先添加一个工具函数,用于计算视频旋转后的实际显示尺寸,并生成适配目标布局的变换矩阵:
func calculateVideoTransform(for track: AVAssetTrack, targetSize: CGSize, position: CGPoint) -> CGAffineTransform { let naturalSize = track.naturalSize let transform = track.preferredTransform // 计算视频旋转后的实际尺寸 let rotatedSize: CGSize switch transform.a { case 0, -0: rotatedSize = CGSize(width: naturalSize.height, height: naturalSize.width) default: rotatedSize = naturalSize } // 计算缩放比例,让视频填满目标宽度/高度 let scaleX = targetSize.width / rotatedSize.width let scaleY = targetSize.height / rotatedSize.height let scale = min(scaleX, scaleY) // 先缩放,再平移到目标位置 var finalTransform = CGAffineTransform(scaleX: scale, y: scale) finalTransform = finalTransform.translatedBy(x: position.x, y: position.y) // 叠加原视频的旋转变换 finalTransform = finalTransform.concatenating(transform) return finalTransform }
上下分屏合成函数
func mergeVideosTopBottom(videoURL1: URL, videoURL2: URL, outputURL: URL, completion: @escaping (Bool, Error?) -> Void) { let asset1 = AVAsset(url: videoURL1) let asset2 = AVAsset(url: videoURL2) guard let videoTrack1 = asset1.tracks(withMediaType: .video).first, let videoTrack2 = asset2.tracks(withMediaType: .video).first else { completion(false, NSError(domain: "VideoMergeError", code: -1, userInfo: [NSLocalizedDescriptionKey: "无法获取视频轨道"])) return } let composition = AVMutableComposition() guard let compositionTrack1 = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), let compositionTrack2 = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { completion(false, NSError(domain: "VideoMergeError", code: -2, userInfo: [NSLocalizedDescriptionKey: "无法添加合成轨道"])) return } // 插入视频片段,取两个视频中较长的时长 let maxDuration = CMTimeMaximum(asset1.duration, asset2.duration) do { try compositionTrack1.insertTimeRange(CMTimeRange(start: .zero, duration: maxDuration), of: videoTrack1, at: .zero) try compositionTrack2.insertTimeRange(CMTimeRange(start: .zero, duration: maxDuration), of: videoTrack2, at: .zero) } catch { completion(false, error) return } // 配置视频合成指令 let instruction = AVMutableVideoCompositionInstruction() instruction.timeRange = CMTimeRange(start: .zero, duration: maxDuration) let transformer1 = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionTrack1) let transformer2 = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionTrack2) // 定义输出尺寸:宽度取两个视频旋转后的最大宽度,高度为两个视频旋转后的高度之和 let rotatedSize1 = videoTrack1.preferredTransform.a == 0 ? CGSize(width: videoTrack1.naturalSize.height, height: videoTrack1.naturalSize.width) : videoTrack1.naturalSize let rotatedSize2 = videoTrack2.preferredTransform.a == 0 ? CGSize(width: videoTrack2.naturalSize.height, height: videoTrack2.naturalSize.width) : videoTrack2.naturalSize let outputWidth = max(rotatedSize1.width, rotatedSize2.width) let outputHeight = rotatedSize1.height + rotatedSize2.height let outputSize = CGSize(width: outputWidth, height: outputHeight) // 计算两个视频的变换矩阵:第一个视频在顶部,第二个在底部 let topVideoPosition = CGPoint(x: 0, y: 0) let topTargetSize = CGSize(width: outputWidth, height: rotatedSize1.height) let transform1 = calculateVideoTransform(for: videoTrack1, targetSize: topTargetSize, position: topVideoPosition) let bottomVideoPosition = CGPoint(x: 0, y: rotatedSize1.height) let bottomTargetSize = CGSize(width: outputWidth, height: rotatedSize2.height) let transform2 = calculateVideoTransform(for: videoTrack2, targetSize: bottomTargetSize, position: bottomVideoPosition) transformer1.setTransform(transform1, at: .zero) transformer2.setTransform(transform2, at: .zero) let videoComposition = AVMutableVideoComposition() videoComposition.instructions = [instruction] videoComposition.frameDuration = CMTime(value: 1, timescale: 30) videoComposition.renderSize = outputSize instruction.layerInstructions = [transformer1, transformer2] // 配置导出会话 guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { completion(false, NSError(domain: "VideoMergeError", code: -3, userInfo: [NSLocalizedDescriptionKey: "无法创建导出会话"])) return } exporter.outputURL = outputURL exporter.outputFileType = .mp4 exporter.shouldOptimizeForNetworkUse = true exporter.videoComposition = videoComposition exporter.exportAsynchronously { switch exporter.status { case .completed: DispatchQueue.main.async { guard let url = exporter.outputURL else { completion(false, nil) return } let playerVC = AVPlayerViewController() playerVC.player = AVPlayer(url: url) self.present(playerVC, animated: true) { playerVC.player?.play() } completion(true, nil) } case .failed: completion(false, exporter.error) case .cancelled: completion(false, NSError(domain: "VideoMergeError", code: -4, userInfo: [NSLocalizedDescriptionKey: "导出被取消"])) default: break } } }
左右分屏合成函数
func mergeVideosSideBySide(videoURL1: URL, videoURL2: URL, outputURL: URL, completion: @escaping (Bool, Error?) -> Void) { let asset1 = AVAsset(url: videoURL1) let asset2 = AVAsset(url: videoURL2) guard let videoTrack1 = asset1.tracks(withMediaType: .video).first, let videoTrack2 = asset2.tracks(withMediaType: .video).first else { completion(false, NSError(domain: "VideoMergeError", code: -1, userInfo: [NSLocalizedDescriptionKey: "无法获取视频轨道"])) return } let composition = AVMutableComposition() guard let compositionTrack1 = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), let compositionTrack2 = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { completion(false, NSError(domain: "VideoMergeError", code: -2, userInfo: [NSLocalizedDescriptionKey: "无法添加合成轨道"])) return } // 插入视频片段,取两个视频中较长的时长 let maxDuration = CMTimeMaximum(asset1.duration, asset2.duration) do { try compositionTrack1.insertTimeRange(CMTimeRange(start: .zero, duration: maxDuration), of: videoTrack1, at: .zero) try compositionTrack2.insertTimeRange(CMTimeRange(start: .zero, duration: maxDuration), of: videoTrack2, at: .zero) } catch { completion(false, error) return } // 配置视频合成指令 let instruction = AVMutableVideoCompositionInstruction() instruction.timeRange = CMTimeRange(start: .zero, duration: maxDuration) let transformer1 = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionTrack1) let transformer2 = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionTrack2) // 定义输出尺寸:高度取两个视频旋转后的最大高度,宽度为两个视频旋转后的宽度之和 let rotatedSize1 = videoTrack1.preferredTransform.a == 0 ? CGSize(width: videoTrack1.naturalSize.height, height: videoTrack1.naturalSize.width) : videoTrack1.naturalSize let rotatedSize2 = videoTrack2.preferredTransform.a == 0 ? CGSize(width: videoTrack2.naturalSize.height, height: videoTrack2.naturalSize.width) : videoTrack2.naturalSize let outputHeight = max(rotatedSize1.height, rotatedSize2.height) let outputWidth = rotatedSize1.width + rotatedSize2.width let outputSize = CGSize(width: outputWidth, height: outputHeight) // 计算两个视频的变换矩阵:第一个视频在左侧,第二个在右侧 let leftVideoPosition = CGPoint(x: 0, y: 0) let leftTargetSize = CGSize(width: rotatedSize1.width, height: outputHeight) let transform1 = calculateVideoTransform(for: videoTrack1, targetSize: leftTargetSize, position: leftVideoPosition) let rightVideoPosition = CGPoint(x: rotatedSize1.width, y: 0) let rightTargetSize = CGSize(width: rotatedSize2.width, height: outputHeight) let transform2 = calculateVideoTransform(for: videoTrack2, targetSize: rightTargetSize, position: rightVideoPosition) transformer1.setTransform(transform1, at: .zero) transformer2.setTransform(transform2, at: .zero) let videoComposition = AVMutableVideoComposition() videoComposition.instructions = [instruction] videoComposition.frameDuration = CMTime(value: 1, timescale: 30) videoComposition.renderSize = outputSize instruction.layerInstructions = [transformer1, transformer2] // 配置导出会话 guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { completion(false, NSError(domain: "VideoMergeError", code: -3, userInfo: [NSLocalizedDescriptionKey: "无法创建导出会话"])) return } exporter.outputURL = outputURL exporter.outputFileType = .mp4 exporter.shouldOptimizeForNetworkUse = true exporter.videoComposition = videoComposition exporter.exportAsynchronously { switch exporter.status { case .completed: DispatchQueue.main.async { guard let url = exporter.outputURL else { completion(false, nil) return } let playerVC = AVPlayerViewController() playerVC.player = AVPlayer(url: url) self.present(playerVC, animated: true) { playerVC.player?.play() } completion(true, nil) } case .failed: completion(false, exporter.error) case .cancelled: completion(false, NSError(domain: "VideoMergeError", code: -4, userInfo: [NSLocalizedDescriptionKey: "导出被取消"])) default: break } } }
关键改进点
- 新增工具函数处理视频旋转后的尺寸计算与变换矩阵生成,确保视频正确缩放并放置到目标位置
- 修复输出尺寸计算逻辑,基于视频旋转后的实际尺寸确定分屏布局
- 完善错误处理,返回具体错误信息便于调试
- 确保两个视频层无重叠,分别占据目标分屏区域
内容的提问来源于stack exchange,提问作者Irfan Saeed
相关产品推荐
相关产品推荐

