Explore low-latency video encoding with VideoToolbox
Audio & Video 高级 24m

探索 VideoToolbox 低延迟视频编码

Explore low-latency video encoding with VideoToolbox

2021年6月11日

在 Apple 官方观看视频

一句话判断

如果你的视频会议或云游戏 App 的端到端延迟还在 200ms 以上,VideoToolbox 今年的低延迟编码改进能帮你砍掉至少一帧的延迟——在实时交互场景中,33ms 就是”能用”和”不能用”的区别。

这场 Session 讲了什么

Session 介绍了 VideoToolbox 框架在 iOS 15 和 macOS Monterey 中针对低延迟视频编码场景的改进。覆盖了 H.264、HEVC 和 ProRes 编码器的新参数配置、零延迟编码模式、以及 Apple Silicon 上的硬件加速细节。

低延迟编码的三个关键指标是:编码延迟(从原始帧到编码输出的时间)、缓冲延迟(编码器内部缓存的帧数)、和网络传输延迟。VideoToolbox 今年的改进主要集中在前两个。新增的 kVTCompressionPropertyKey_MaxFrameDelayCount 属性可以把编码器的缓冲帧数从默认的 3-5 帧降到 0——每一帧进来就立刻编码输出,不等待后续帧做 B-frame 参考。代价是压缩效率下降约 20-30%,但在实时场景中这个权衡是完全值得的。

值得深挖的点

Zero-delay 编码模式的配置要点

零延迟编码不是简单地设一个属性就完成了。你需要同时配置多个参数:MaxFrameDelayCount = 0(不缓冲帧)、AllowFrameReordering = false(不使用 B-frame)、MaxKeyFrameInterval = 0(不强制 GOP 大小,由编码器决定何时插入 I-frame)。还有一个容易被忽略的属性:RealTime = true——告诉编码器这是实时场景,编码器会在质量和速度之间偏向速度。Apple Silicon 的硬件编码器在 RealTime 模式下的编码延迟通常在 2-5ms,这个数字非常可观。

码率控制在低延迟场景下的特殊性

CBR(Constant Bitrate)在低延迟场景中比 VBR(Variable Bitrate)更合适,因为 CBR 的输出码率可预测,网络传输的抖动更小。但传统 CBR 的问题是画质波动大。VideoToolbox 新增了 kVTCompressionPropertyKey_ConstantBitRate 属性,配合 DataRateLimits 可以实现更精细的码率控制:设定一个目标码率和一个波动上限,编码器在这个范围内自适应调整。

代码片段

// 配置低延迟视频编码器
func setupLowLatencyEncoder(width: Int, height: Int) -> VTCompressionSession? {
    var session: VTCompressionSession?
    
    let status = VTCompressionSessionCreate(
        allocator: nil,
        width: Int32(width),
        height: Int32(height),
        codecType: kCMVideoCodecType_H264,
        encoderSpecification: nil,
        sourceImageBufferAttributes: nil,
        compressedDataAllocator: nil,
        outputCallback: encodingCallback,
        outputCallbackRefCon: nil,
        compressionSessionOut: &session
    )
    
    guard status == noErr, let encoder = session else { return nil }
    
    // 关键:零延迟配置
    var zeroFrameDelay: Int = 0
    VTSessionSetProperty(encoder, 
                         key: kVTCompressionPropertyKey_MaxFrameDelayCount,
                         value: NSNumber(value: zeroFrameDelay))
    
    // 禁用 B-frame 重排序
    var noReorder: Boolean = 0
    VTSessionSetProperty(encoder,
                         key: kVTCompressionPropertyKey_AllowFrameReordering,
                         value: NSNumber(value: noReorder))
    
    // 标记为实时场景
    var realtime: Boolean = 1
    VTSessionSetProperty(encoder,
                         key: kVTCompressionPropertyKey_RealTime,
                         value: NSNumber(value: realtime))
    
    // 目标码率(2 Mbps)
    let bitrate: Int = 2_000_000
    VTSessionSetProperty(encoder,
                         key: kVTCompressionPropertyKey_AverageBitRate,
                         value: NSNumber(value: bitrate))
    
    // 准备编码
    VTCompressionSessionPrepareToEncodeFrames(encoder)
    
    return encoder
}
// 编码单帧并立即输出
func encodeFrame(session: VTCompressionSession, 
                 pixelBuffer: CVPixelBuffer,
                 timestamp: CMTime) {
    // 每帧设置 presentation timestamp
    // 在零延迟模式下,编码完成后回调立即触发
    let status = VTCompressionSessionEncodeFrame(
        session,
        imageBuffer: pixelBuffer,
        presentationTimeStamp: timestamp,
        duration: .invalid,
        frameProperties: nil,
        sourceFrameRefCon: nil,
        infoFlagsOut: nil
    )
    
    if status != noErr {
        print("编码失败: \(status)")
    }
}
// 编码回调——在零延迟模式下帧进来就立刻触发
let encodingCallback: VTCompressionOutputCallback = { 
    _, status, infoFlags, sampleBuffer in
    
    guard status == noErr, 
          let buffer = sampleBuffer,
          CMSampleBufferDataIsReady(buffer) else {
        print("编码输出无效")
        return
    }
    
    // 判断是否为关键帧
    let attachments = CMSampleBufferGetSampleAttachmentsArray(buffer, 
                                                               createIfNecessary: false)
    let isKeyFrame = (attachments?.firstObject as? [CFString: Any])?
        .filter { $0.key == kCMSampleAttachmentKey_DependsOnOthers }
        .first?.value as? Bool ?? true == false
    
    // 立即发送编码后的数据
    let dataBuffer = CMSampleBufferGetDataBuffer(buffer)!
    var length = CMBlockBufferGetDataLength(dataBuffer)
    var data = Data(count: length)
    data.withUnsafeMutableBytes { ptr in
        CMBlockBufferCopyDataBytes(dataBuffer, atOffset: 0, 
                                    dataLength: length, destination: ptr.baseAddress!)
    }
    
    // 发送到网络或写入文件
    NetworkSender.sendVideoFrame(data, isKeyFrame: !isKeyFrame)
}

最佳实践

  • 低延迟编码器的分辨率和帧率选择要保守:720p@30fps 比尝试 1080p@60fps 更容易维持稳定延迟。在 Apple Silicon 上,1080p@30fps 的编码延迟约 3-5ms,60fps 时会翻倍。
  • 使用 VTCompressionSessionEncodeFrame 的异步模式时,确保你的回调不会阻塞。网络发送操作应该在回调中只做数据拷贝,实际发送放在另一个队列。
  • 监控编码器的实际输出码率——如果持续低于目标码率,说明场景太简单编码器在偷懒;如果持续高于目标,网络可能会丢包导致画面撕裂。

还有什么值得关注

  • ProRes 编码器在 macOS Monterey 上支持低延迟模式,适合专业视频制作中的实时预览场景。
  • HEVC 的低延迟编码在 Apple Silicon 上的性能比 H.264 更好(同等画质下码率低 40%),如果你的目标平台支持 HEVC 解码,优先选用。
  • VideoToolbox 的硬件编码器在 Intel Mac 和 Apple Silicon Mac 上的行为有细微差异——需要在两种平台上分别测试延迟指标。
WWDC 2021