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

使用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)
}

问题根源与修复方案

核心问题分析

  1. 单一LayerInstruction复用错误:所有素材共用同一个AVMutableVideoCompositionLayerInstruction,后续素材的transform会覆盖之前的设置,导致只有最后一个素材的transform生效,前面的素材画面异常。
  2. Transform未适配输出尺寸:直接使用原视频的preferredTransform,没有考虑输出渲染尺寸的适配,会导致画面拉伸/挤压。
  3. 前置摄像头镜像处理缺失:前置摄像头拍摄的视频默认需要镜像修正,原代码未处理该逻辑。

修复后的完整代码

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 14:17:03