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

自定义Video Compositor导致视频左旋90度的问题求助

解决方案:自定义Compositor中手动应用视频变换并匹配字幕层尺寸

当启用自定义AVVideoCompositing时,系统不会自动执行AVMutableVideoCompositionLayerInstruction中设置的transform,这是导致视频旋转问题的核心原因。你需要在自定义Compositor中手动处理视频帧的变换,同时确保字幕层的尺寸与最终输出尺寸匹配,避免字幕错位。

步骤1:调整合成代码,传递变换信息并匹配字幕层尺寸

首先在合成代码中计算正确的输出尺寸,将视频变换信息传递给自定义指令,并设置字幕层的尺寸为最终输出尺寸:

let subtitleLayer = VideoSubtitleLayer()
// 计算应用transform后的正确输出尺寸
let transformedSize = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
let outputSize = CGSize(width: abs(transformedSize.width), height: abs(transformedSize.height))
subtitleLayer.frame = CGRect(origin: .zero, size: outputSize) // 匹配输出尺寸

let videoComposition = AVMutableVideoComposition()
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
videoComposition.renderSize = outputSize // 使用正确的输出尺寸,无需手动交换宽高

let defaultPosition = CGPoint(x: outputSize.width * 0.5, y: outputSize.height * 0.9)

let instruction = VideoSubtitleInstruction(
    timeRange: CMTimeRange(start: .zero, duration: videoAsset.duration),
    subtitleLayer: subtitleLayer,
    savedSubtitlePosition: savedSubtitlePosition ?? defaultPosition
)
instruction.preferredTransform = videoTrack.preferredTransform // 传递变换信息

let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
// 自定义Compositor会手动处理transform,这里可以移除setTransform调用,或者保留不影响
// layerInstruction.setTransform(videoTrack.preferredTransform, at: .zero)

instruction.layerInstructions = [layerInstruction]
videoComposition.instructions = [instruction]
videoComposition.customVideoCompositorClass = SubtitleCompositor.self

同时需要给VideoSubtitleInstruction新增存储变换的属性:

class VideoSubtitleInstruction: AVMutableVideoCompositionInstruction {
    var subtitleLayer: VideoSubtitleLayer?
    var savedSubtitlePosition: CGPoint?
    var preferredTransform: CGAffineTransform? // 新增属性
}

步骤2:修改自定义Compositor,手动应用视频变换

替换原来的copyPixelBuffer方法,改为通过CIImage应用变换并绘制源视频帧,确保视频方向正确:

class SubtitleCompositor: NSObject, AVVideoCompositing {
    let subtitles: [Subtitle]

    override init() {
        self.subtitles = SubtitleStore.shared.subtitles
        super.init()
    }

    var requiredPixelBufferAttributesForRenderContext: [String : Any] = [
        kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB
    ]

    var sourcePixelBufferAttributes: [String : Any]? = [
        kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB
    ]

    private var renderContext: AVVideoCompositionRenderContext?
    private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) // 复用CIContext提升性能

    func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {
        renderContext = newRenderContext
    }

    func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {
        guard let renderContext = renderContext else {
            print("Render context is nil.")
            request.finish(with: NSError(domain: "SubtitleCompositor", code: -1, userInfo: nil))
            return
        }

        guard let sourcePixelBuffer = request.sourceFrame(byTrackID: request.sourceTrackIDs[0].int32Value) else {
            print("Source pixel buffer is nil.")
            request.finish(with: NSError(domain: "SubtitleCompositor", code: -2, userInfo: nil))
            return
        }

        guard let destinationPixelBuffer = renderContext.newPixelBuffer() else {
            print("Failed to create destination pixel buffer.")
            request.finish(with: NSError(domain: "SubtitleCompositor", code: -3, userInfo: nil))
            return
        }

        guard let videoInstruction = request.videoCompositionInstruction as? VideoSubtitleInstruction,
              let subtitleLayer = videoInstruction.subtitleLayer,
              let transform = videoInstruction.preferredTransform else {
            print("Video instruction, subtitle layer or transform is nil.")
            request.finish(withComposedVideoFrame: destinationPixelBuffer)
            return
        }

        // 应用变换绘制源视频到目标Buffer
        drawSourcePixelBuffer(sourcePixelBuffer, to: destinationPixelBuffer, transform: transform)

        DispatchQueue.main.sync {
            let currentTime = request.compositionTime.seconds

            if let subtitle = subtitles.first(where: { $0.startTime <= currentTime && $0.endTime > currentTime }) {
                subtitleLayer.updateSubtitle(subtitle: subtitle, time: CFTimeInterval(currentTime))
            } else {
                subtitleLayer.updateSubtitle(subtitle: nil, time: CFTimeInterval(currentTime))
            }

            if let position = videoInstruction.savedSubtitlePosition {
                subtitleLayer.setInitialPosition(position)
            }

            renderLayer(subtitleLayer, to: destinationPixelBuffer)
        }

        request.finish(withComposedVideoFrame: destinationPixelBuffer)
    }

    // 替换原copyPixelBuffer方法,手动应用变换绘制源视频
    private func drawSourcePixelBuffer(_ sourcePixelBuffer: CVPixelBuffer, to destinationPixelBuffer: CVPixelBuffer, transform: CGAffineTransform) {
        CVPixelBufferLockBaseAddress(sourcePixelBuffer, .readOnly)
        CVPixelBufferLockBaseAddress(destinationPixelBuffer, [])
        
        defer {
            CVPixelBufferUnlockBaseAddress(sourcePixelBuffer, .readOnly)
            CVPixelBufferUnlockBaseAddress(destinationPixelBuffer, [])
        }
        
        guard let context = CGContext(data: CVPixelBufferGetBaseAddress(destinationPixelBuffer),
                                      width: CVPixelBufferGetWidth(destinationPixelBuffer),
                                      height: CVPixelBufferGetHeight(destinationPixelBuffer),
                                      bitsPerComponent: 8,
                                      bytesPerRow: CVPixelBufferGetBytesPerRow(destinationPixelBuffer),
                                      space: CGColorSpaceCreateDeviceRGB(),
                                      bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue) else {
            return
        }
        
        // 清空目标Buffer背景
        context.clear(context.bounds)
        
        // 将源像素Buffer转为CIImage并应用变换
        let sourceImage = CIImage(cvPixelBuffer: sourcePixelBuffer)
        let transformedImage = sourceImage.transformed(by: transform)
        
        // 绘制变换后的视频帧到目标Buffer
        ciContext.draw(transformedImage, in: context.bounds, from: transformedImage.extent)
    }

    private func renderLayer(_ layer: CALayer, to pixelBuffer: CVPixelBuffer) {
        CVPixelBufferLockBaseAddress(pixelBuffer, [])

        guard let context = CGContext(data: CVPixelBufferGetBaseAddress(pixelBuffer),
                                      width: CVPixelBufferGetWidth(pixelBuffer),
                                      height: CVPixelBufferGetHeight(pixelBuffer),
                                      bitsPerComponent: 8,
                                      bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
                                      space: CGColorSpaceCreateDeviceRGB(),
                                      bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue) else {
            CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
            return
        }

        // 翻转上下文匹配CALayer坐标系
        context.translateBy(x: 0, y: CGFloat(CVPixelBufferGetHeight(pixelBuffer)))
        context.scaleBy(x: 1.0, y: -1.0)

        // 渲染字幕层
        if !layer.bounds.isEmpty && layer.sublayers?.isEmpty == false {
            layer.render(in: context)
        }

        CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
    }
}

关键说明

  1. 自定义Compositor的独立性:系统默认的合成逻辑会自动处理layerInstruction中的变换,但自定义Compositor完全接管了渲染流程,所以必须手动应用视频的preferredTransform。
  2. 尺寸匹配:字幕层的尺寸必须与最终输出的renderSize一致,否则会出现字幕位置偏移或拉伸的问题。
  3. 性能优化:复用CIContext避免每次渲染都创建新实例,提升合成效率。

内容的提问来源于stack exchange,提问作者Azat

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 12:44:51