You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift实现TikTok式上下/并排视频合成问题求助

TikTok风格分屏视频合成解决方案

问题说明

需要实现类似TikTok的视频合成功能,支持两种分屏模式:

  • 上下分屏:两个视频各占全屏宽度,上下排布
  • 左右分屏:两个视频各占全屏高度,左右均分

当前基于AVFoundation实现的两个函数(mergeVideosTopBottom和mergeVideosSideBySide)输出视频仅显示一个视频且无法填满屏幕,第三方库DPVideoMerger效果不符合预期。

问题根源

原代码未正确处理视频的旋转变换与布局适配:

  1. 直接使用视频的preferredTransform导致两个视频层位置重叠,仅显示上层视频
  2. 未对视频进行缩放和位移,无法适配分屏后的输出尺寸
  3. 输出尺寸计算未考虑视频旋转后的实际显示尺寸

修复后的实现代码

通用工具函数(处理视频尺寸与变换)

首先添加一个工具函数,用于计算视频旋转后的实际显示尺寸,并生成适配目标布局的变换矩阵:

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 08:18:15