Browse / github.com/storytold/filmcraft / patch
platform: VideoToolbox export takes the RGBA pictures and converts them itself (#3030)
platform: VideoToolbox export takes the RGBA pictures and converts them itself (#3030) Fixes the hardware half of #3030 ("High CPU usage and slow export on macOS (Apple Silicon)"; duplicate of #1741, and #2367 is the same report with ProRes sources). It does not change the default export path, which is the software encoder by design (see *Not in this PR*). ### Measured root cause On macOS, Export ▸ Hardware encoding (VideoToolbox H.264 / H.265) was CPU-bound by its own colour conversion, not by compositing or encoding: - `platform::hardware_encode` converted every RGBA frame to NV12 with `rgba_to_yuv420_8` **on the export's encode thread**, the one serial thread of an export.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ZJHeepfixer (imported from pull request #3255, open)
- Base
fa550ce2d07bdfee6b1d7b6835e8abc8c6913d00- Size
- +590 −27 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fb67eaaa17bc7eaf6c7d7fe0
How it went
No other operator has reported using it yet.
Diff
raw · crates/platform/README.md crates/platform/src/hardware_encode.rs crates/platform/src/videotoolbox_encode.rs crates/platform/tests/it/hardware_encode.rs docs/architecture.md docs/hardware-parity.md docs/performance.md
@@ -143,7 +143,15 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") - **What it takes:** H.264 in MP4 / MOV, 8-bit SDR, even picture sizes up to 8192, Baseline / Main / High (the level is chosen by the OS), constant or one-pass variable bitrate with the settings' target and ceiling, the keyframe distance (closed GOPs: every keyframe is an IDR picture).- Pictures are converted exactly like the built-in encoder's (BT.709, limited range), 4:2:0 NV12.+- **Pictures go in as the export's RGBA** (`32BGRA` buffers tagged BT.709 primaries and transfer)+ and VideoToolbox converts them to limited-range BT.709 4:2:0 itself, so the export's encode+ thread only copies the picture into the input buffer. The CPU conversion used to run on that+ thread, where it competed with the render of the next frames for the thread pool and took longer+ than the hardware encode: 20 ms a 1080p frame on an M2 Max, 88 % of the export's wall time, with+ the hardware encoder idle meanwhile. A VideoToolbox that refuses RGB input (known from the first+ picture; creating the session does not tell) gets that CPU conversion into NV12 instead, exactly+ like the built-in encoder's (BT.709, limited range), and the stream starts over from its first+ frame; `encode_rgba` returns the copy's time, which goes into `perf.stats` `export.stages.convertMs`. - **No B-frames.** On real 1080p footage the quality is the same with and without them (±0.3 dB at equal bitrate) and the bitrate lands closer to the target without them, so frame reordering is off: compressed frames come out in presentation order, with no composition offsets or edit list.@@ -19,7 +19,7 @@ use filmcraft_export::{BitrateMode, EncodedPacket, EncoderFrame, ExportError, Ex use filmcraft_isobmff::{AvcConfig, HevcConfig, SampleEntry}; use filmcraft_time::FrameRate; -use crate::videotoolbox_encode::{ParamSets, VtCodec, VtConfig, VtEncoder, VtPacket, VtProfile, VtRate};+use crate::videotoolbox_encode::{ParamSets, VtCodec, VtConfig, VtEncoder, VtInput, VtPacket, VtProfile, VtRate}; /// The hardware encoder's configuration for an export, or why it is not used for it. pub fn config_for(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Result<VtConfig, String> {@@ -137,10 +137,10 @@ pub fn videotoolbox_encoder_factory( return None; } };- match VtEncoder::new(config) {- Ok(vt) => {+ match HardwareEncoder::open(config) {+ Ok(enc) => { filmcraft_export::note_hw_encode_session();- Some(Ok(Box::new(HardwareEncoder { vt, y: Vec::new(), u: Vec::new(), v: Vec::new(), decoded: 0 })))+ Some(Ok(Box::new(enc))) } Err(why) => { log::info!("hardware {name} encoding declined: {why}");@@ -150,6 +150,18 @@ pub fn videotoolbox_encoder_factory( } } +/// The session for `config`: one that takes the export's RGBA pictures as they are, so that+/// VideoToolbox converts them to 4:2:0 and the encode thread only copies (the CPU conversion ran+/// there before, where it competed with the render of the next frames for the thread pool and took+/// longer than the hardware encode itself); when that cannot be created, the planar session with+/// the CPU conversion. `rgba` stands for [`VtEncoder::with_rgba_input`] so tests can refuse it.+fn open_with(config: VtConfig, rgba: impl FnOnce(VtConfig) -> Result<VtEncoder, String>) -> Result<VtEncoder, String> {+ rgba(config.clone()).or_else(|why| {+ log::info!("VideoToolbox RGBA input declined ({why}): converting to 4:2:0 on the CPU");+ VtEncoder::new(config)+ })+}+ /// The `hvcC` configuration of an HEVC stream: VideoToolbox's own record when it wrote one. fn hevc_config(p: &ParamSets) -> Result<HevcConfig, String> { let record = p.hvcc.as_deref().ok_or("VideoToolbox wrote no hvcC record for the stream")?;@@ -163,6 +175,7 @@ fn hevc_config(p: &ParamSets) -> Result<HevcConfig, String> { /// [`VtEncoder`] as a `VideoEncoder`: RGBA pictures in, MP4 samples out. pub struct HardwareEncoder { vt: VtEncoder,+ /// The planar path's conversion buffers (empty while VideoToolbox converts the RGBA pictures). y: Vec<u8>, u: Vec<u8>, v: Vec<u8>,@@ -171,6 +184,35 @@ pub struct HardwareEncoder { } impl HardwareEncoder {+ /// The encoder for `config` (see [`open_with`]), or why there is none.+ pub fn open(config: VtConfig) -> Result<Self, String> {+ Ok(Self::around(open_with(config, VtEncoder::with_rgba_input)?))+ }++ fn around(vt: VtEncoder) -> Self {+ HardwareEncoder { vt, y: Vec::new(), u: Vec::new(), v: Vec::new(), decoded: 0 }+ }++ /// Whether VideoToolbox converts the RGBA pictures itself (else the CPU converts them to 4:2:0 first).+ pub fn takes_rgba(&self) -> bool {+ self.vt.input() == VtInput::Bgra+ }++ /// Replace the RGB session by a planar one for the rest of the export. Only for a session that+ /// has produced nothing yet (the first picture), so the stream starts over from its first frame.+ fn fall_back_to_planar(&mut self, why: &str) -> filmcraft_export::Result<()> {+ log::info!("VideoToolbox did not take an RGBA picture ({why}): converting to 4:2:0 on the CPU");+ self.vt = VtEncoder::new(self.vt.config().clone()).map_err(ExportError::Encode)?;+ Ok(())+ }++ /// Convert on the CPU and submit to the planar session.+ fn encode_planar(&mut self, f: &EncoderFrame) -> filmcraft_export::Result<Vec<VtPacket>> {+ let (w, h) = (f.width as usize, f.height as usize);+ filmcraft_export::timed(filmcraft_export::Stage::Convert, || filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v));+ self.vt.encode(f.index, &self.y, &self.u, &self.v).map_err(ExportError::Encode)+ }+ /// MP4 samples from compressed frames. Without frame reordering every frame is presented at /// its decode time, so composition offsets are 0; a frame whose time is not `n × duration` /// after the first (a dropped or reordered frame) is an error, never a file with wrong times.@@ -225,8 +267,25 @@ impl VideoEncoder for HardwareEncoder { if f.rgba.len() < w.saturating_mul(h).saturating_mul(4) { return Err(ExportError::Encode("the RGBA picture is smaller than its size".into())); }- filmcraft_export::timed(filmcraft_export::Stage::Convert, || filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v));- let packets = self.vt.encode(f.index, &self.y, &self.u, &self.v).map_err(ExportError::Encode)?;+ let packets = if self.takes_rgba() {+ match self.vt.encode_rgba(f.index, f.rgba) {+ Ok((packets, copied)) => {+ // the CPU's share of the conversion: the copy into the input buffer+ filmcraft_export::note_stage(filmcraft_export::Stage::Convert, copied);+ packets+ }+ // Whether this VideoToolbox converts RGB itself is only known from the first picture+ // (the session is created without complaint either way). Nothing has come out of it+ // yet, so the export goes on from this frame with the planar path, the CPU converting.+ Err(why) if f.index == 0 => {+ self.fall_back_to_planar(&why)?;+ self.encode_planar(f)?+ }+ Err(why) => return Err(ExportError::Encode(why)),+ }+ } else {+ self.encode_planar(f)?+ }; filmcraft_export::note_hw_encode_frame(); // the muxer needs the parameter sets after the first group of pictures if f.index == 0 {@@ -243,3 +302,111 @@ impl VideoEncoder for HardwareEncoder { self.samples(packets) } }++#[cfg(test)]+mod tests {+ use super::*;++ const W: u32 = 320;+ const H: u32 = 180;++ fn config() -> VtConfig {+ VtConfig {+ width: W,+ height: H,+ timescale: 30,+ frame_duration: 1,+ keyframe_interval: 30,+ profile: VtProfile::High,+ rate: VtRate::Vbr { target_kbps: 2000, max_kbps: 3000 },+ }+ }++ /// A gradient picture with a bar at `i`.+ fn picture(i: u64) -> Vec<u8> {+ let (w, h) = (W as usize, H as usize);+ let mut px = vec![255u8; w * h * 4];+ for y in 0..h {+ for x in 0..w {+ let o = (y * w + x) * 4;+ px[o] = (x * 255 / w) as u8;+ px[o + 1] = (y * 255 / h) as u8;+ px[o + 2] = if x / 10 == i as usize { 255 } else { 40 };+ }+ }+ px+ }++ /// Three pictures through the `VideoEncoder` adapter: one compressed frame each.+ fn encodes_three(enc: &mut HardwareEncoder) {+ let mut packets = Vec::new();+ for i in 0..3u64 {+ let px = picture(i);+ packets.extend(enc.encode(&EncoderFrame { width: W, height: H, rgba: &px, hdr: None, index: i }).expect("encode"));+ }+ packets.extend(enc.flush().expect("flush"));+ assert_eq!(packets.len(), 3);+ assert!(packets[0].key);+ }++ #[test]+ fn rgba_input_is_tried_first() {+ let mut attempted = false;+ let opened = open_with(config(), |c| {+ attempted = true;+ assert_eq!((c.width, c.height), (W, H));+ VtEncoder::with_rgba_input(c)+ });+ assert!(attempted);+ match opened {+ Ok(vt) => assert_eq!(vt.input(), VtInput::Bgra),+ Err(why) => eprintln!("SKIPPED: no VideoToolbox hardware encoder ({why})"),+ }+ }++ /// Where the RGB session cannot be created, the planar one takes the export (the CPU converts).+ #[test]+ fn refused_rgba_input_falls_back_to_the_planar_session() {+ let opened = open_with(config(), |_| Err("no RGB input (test)".into()));+ let Ok(vt) = opened else {+ eprintln!("SKIPPED: no VideoToolbox hardware encoder");+ return;+ };+ assert_eq!(vt.input(), VtInput::Nv12);+ let mut enc = HardwareEncoder::around(vt);+ assert!(!enc.takes_rgba());+ encodes_three(&mut enc);+ }++ /// Where the RGB session refuses the first picture, the encoder starts over with a planar session+ /// and the export goes on from its first frame.+ #[test]+ fn a_refused_first_picture_restarts_with_the_planar_session() {+ let Ok(vt) = VtEncoder::with_rgba_input(config()) else {+ eprintln!("SKIPPED: no VideoToolbox hardware encoder");+ return;+ };+ let mut enc = HardwareEncoder::around(vt);+ assert!(enc.takes_rgba());+ enc.fall_back_to_planar("test").expect("the planar session opens");+ assert!(!enc.takes_rgba());+ encodes_three(&mut enc);+ }++ /// A picture the RGB session fails after the first frame is an error, not a silent restart (the+ /// stream already has frames the planar session could not continue).+ #[test]+ fn a_later_failure_is_an_error() {+ let Ok(vt) = VtEncoder::with_rgba_input(config()) else {+ eprintln!("SKIPPED: no VideoToolbox hardware encoder");+ return;+ };+ let mut enc = HardwareEncoder::around(vt);+ let px = picture(0);+ enc.encode(&EncoderFrame { width: W, height: H, rgba: &px, hdr: None, index: 0 }).expect("first frame");+ // a short picture is refused before it reaches VideoToolbox; so is one of another size+ assert!(enc.encode(&EncoderFrame { width: W, height: H, rgba: &px[..100], hdr: None, index: 1 }).is_err());+ assert!(enc.encode(&EncoderFrame { width: W + 2, height: H, rgba: &px, hdr: None, index: 1 }).is_err());+ assert!(enc.takes_rgba(), "no restart after the first frame");+ }+}@@ -9,11 +9,17 @@ //! is declined up front. Frame reordering (B-frames) is off, so compressed frames come out in //! presentation order with decode time = presentation time. //!-//! Pictures go in as 8-bit 4:2:0 (NV12) pixel buffers filled from planar BT.709 limited-range-//! Y'CbCr, the layout of `filmcraft_export::rgba_to_yuv420_8`, so the colours match the built-in-//! encoder's. VideoToolbox encodes asynchronously; its output callback (on a VideoToolbox thread)-//! copies each compressed frame (length-prefixed NAL units) into a queue that [`VtEncoder::encode`]-//! and [`VtEncoder::flush`] drain.+//! Pictures go in one of two ways ([`VtInput`]). On the RGB path ([`VtEncoder::with_rgba_input`],+//! [`VtEncoder::encode_rgba`]) the export's straight sRGB RGBA8 picture is copied into a `32BGRA`+//! pixel buffer tagged BT.709, and VideoToolbox converts it to the encoder's 4:2:0 itself, off the+//! export's encode thread: there the CPU conversion competed with the render of the next frames for+//! the thread pool and took longer than the hardware encode (20 ms a 1080p frame), leaving the+//! encoder waiting. On the planar path ([`VtEncoder::new`], [`VtEncoder::encode`]) pictures go in as+//! 8-bit 4:2:0 (NV12) pixel buffers filled from planar BT.709 limited-range Y'CbCr, the layout of+//! `filmcraft_export::rgba_to_yuv420_8`, so the colours match the built-in encoder's; it is the+//! fallback for a VideoToolbox that does not take RGB input. VideoToolbox encodes asynchronously;+//! its output callback (on a VideoToolbox thread) copies each compressed frame (length-prefixed NAL+//! units) into a queue that [`VtEncoder::encode_picture`] and [`VtEncoder::flush`] drain. //! //! The first frame is completed straight away: the parameter sets the container needs (SPS / PPS //! for H.264, VPS / SPS / PPS and VideoToolbox's own `hvcC` record for HEVC) come with the first@@ -31,10 +37,11 @@ use objc2_core_media::{ kCMVideoCodecType_HEVC, }; use objc2_core_video::{- CVImageBuffer, CVPixelBufferCreate, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane, CVPixelBufferGetHeightOfPlane,- CVPixelBufferGetWidthOfPlane, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferPool, CVPixelBufferUnlockBaseAddress,- kCVImageBufferColorPrimaries_ITU_R_709_2, kCVImageBufferTransferFunction_ITU_R_709_2, kCVImageBufferYCbCrMatrix_ITU_R_709_2, kCVPixelBufferHeightKey,- kCVPixelBufferIOSurfacePropertiesKey, kCVPixelBufferPixelFormatTypeKey, kCVPixelBufferWidthKey,+ CVAttachmentMode, CVImageBuffer, CVPixelBufferCreate, CVPixelBufferGetBaseAddress, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRow,+ CVPixelBufferGetBytesPerRowOfPlane, CVPixelBufferGetHeight, CVPixelBufferGetHeightOfPlane, CVPixelBufferGetWidth, CVPixelBufferGetWidthOfPlane,+ CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferPool, CVPixelBufferUnlockBaseAddress, kCVImageBufferColorPrimaries_ITU_R_709_2,+ kCVImageBufferColorPrimariesKey, kCVImageBufferTransferFunction_ITU_R_709_2, kCVImageBufferTransferFunctionKey, kCVImageBufferYCbCrMatrix_ITU_R_709_2,+ kCVPixelBufferHeightKey, kCVPixelBufferIOSurfacePropertiesKey, kCVPixelBufferPixelFormatTypeKey, kCVPixelBufferWidthKey, }; use objc2_video_toolbox::{ VTCompressionSession, VTEncodeInfoFlags, VTSessionCopyProperty, VTSessionSetProperty, kVTCompressionPropertyKey_AllowFrameReordering,@@ -46,8 +53,10 @@ use objc2_video_toolbox::{ kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder, }; -/// The pixel format of the buffers we hand over: biplanar Y + interleaved CbCr, video range.+/// The pixel format of the planar input path: biplanar Y + interleaved CbCr, video range. const NV12_VIDEO: u32 = u32::from_be_bytes(*b"420v");+/// The pixel format of the RGB input path: 8-bit B, G, R, A bytes (`kCVPixelFormatType_32BGRA`).+const BGRA_8: u32 = u32::from_be_bytes(*b"BGRA"); /// Largest compressed frame accepted from the callback (a sanity cap on a size we do not control). const MAX_FRAME_BYTES: usize = 256 << 20; /// Largest picture the hardware encoder is asked for.@@ -88,6 +97,16 @@ pub enum VtRate { Vbr { target_kbps: u32, max_kbps: u32 }, } +/// The pixel format a session takes its pictures in.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum VtInput {+ /// Biplanar 8-bit 4:2:0 (NV12), filled from planar Y'CbCr by [`VtEncoder::encode`].+ Nv12,+ /// 8-bit BGRA, filled from the export's straight RGBA8 picture by [`VtEncoder::encode_rgba`];+ /// VideoToolbox converts it to 4:2:0.+ Bgra,+}+ /// What to encode and how. Times are in a timescale of `timescale` units per second, one frame /// lasting `frame_duration` units (the frame rate's numerator and denominator). #[derive(Clone, Debug)]@@ -400,11 +419,42 @@ fn fill(pb: &CVImageBuffer, w: usize, h: usize, y: &[u8], u: &[u8], v: &[u8]) -> Ok(()) } +/// Copy a straight RGBA8 picture (`w`×`h`, tightly packed) into a BGRA buffer, swapping R and B.+fn fill_bgra(pb: &CVImageBuffer, w: usize, h: usize, rgba: &[u8]) -> Result<(), String> {+ let row_bytes = w.checked_mul(4).ok_or("picture width overflows")?;+ if rgba.len() < row_bytes.checked_mul(h).ok_or("picture size overflows")? {+ return Err("the RGBA picture is smaller than its size".into());+ }+ // SAFETY: `pb` is a valid pixel buffer (the caller holds it retained).+ if unsafe { CVPixelBufferLockBaseAddress(pb, CVPixelBufferLockFlags(0)) } != 0 {+ return Err("cannot lock the input buffer".into());+ }+ let lock = Locked(pb);+ let base = CVPixelBufferGetBaseAddress(pb) as *mut u8;+ let stride = CVPixelBufferGetBytesPerRow(pb);+ if base.is_null() || stride < row_bytes || CVPixelBufferGetWidth(pb) < w || CVPixelBufferGetHeight(pb) < h {+ return Err("the input buffer is smaller than the picture".into());+ }+ let len = stride.checked_mul(h).ok_or("buffer size overflows")?;+ // SAFETY: the buffer's base address is locked for writing while `lock` lives and the slice is+ // only used before it is dropped; CoreVideo maps `bytes_per_row * height` bytes from the base,+ // of which `len` covers the `h` rows written.+ let dst = unsafe { std::slice::from_raw_parts_mut(base, len) };+ for (row, src) in dst.chunks_mut(stride).zip(rgba.chunks_exact(row_bytes)).take(h) {+ for (d, s) in row[..row_bytes].as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) {+ *d = [s[2], s[1], s[0], s[3]];+ }+ }+ drop(lock);+ Ok(())+}+ /// A hardware H.264 or HEVC encoder: feed pictures, collect compressed frames (in presentation /// order, which is decoding order since nothing is reordered). pub struct VtEncoder { session: Session, config: VtConfig,+ input: VtInput, pool: Option<CFRetained<CVPixelBufferPool>>, attrs: CFRetained<CFDictionary<CFString, CFType>>, /// Pictures submitted so far.@@ -454,8 +504,21 @@ fn require(status: i32, what: &str) -> Result<(), String> { } impl VtEncoder {- /// Create the session, or say why hardware encoding is not used for this configuration.+ /// Create a session that takes planar Y'CbCr pictures ([`VtEncoder::encode`]), or say why+ /// hardware encoding is not used for this configuration. pub fn new(config: VtConfig) -> Result<VtEncoder, String> {+ Self::with_input(config, VtInput::Nv12)+ }++ /// Create a session that takes the export's RGBA pictures as they are ([`VtEncoder::encode_rgba`]):+ /// VideoToolbox converts them to 4:2:0 itself, so the export's encode thread only copies. Whether+ /// this VideoToolbox converts RGB is known from the first picture, not from creating the session:+ /// `hardware_encode` replaces a session that refuses it with a planar one ([`VtEncoder::new`]).+ pub fn with_rgba_input(config: VtConfig) -> Result<VtEncoder, String> {+ Self::with_input(config, VtInput::Bgra)+ }++ fn with_input(config: VtConfig, input: VtInput) -> Result<VtEncoder, String> { let (w, h) = (config.width, config.height); if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { return Err(format!("picture size {w}x{h}"));@@ -474,7 +537,11 @@ impl VtEncoder { let require_key: &CFString = unsafe { kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder }; let spec = CFDictionary::<CFString, CFType>::from_slices(&[require_key], &[CFBoolean::new(true).as_ref()]); - let (pf, cw, ch) = (CFNumber::new_i32(NV12_VIDEO as i32), CFNumber::new_i32(w as i32), CFNumber::new_i32(h as i32));+ let pixel_format = match input {+ VtInput::Nv12 => NV12_VIDEO,+ VtInput::Bgra => BGRA_8,+ };+ let (pf, cw, ch) = (CFNumber::new_i32(pixel_format as i32), CFNumber::new_i32(w as i32), CFNumber::new_i32(h as i32)); let io = CFDictionary::<CFString, CFType>::from_slices(&[], &[]); // SAFETY: reading immutable framework constants. let (pf_key, w_key, h_key, io_key): (&CFString, &CFString, &CFString, &CFString) =@@ -511,7 +578,7 @@ impl VtEncoder { require(unsafe { session.session.prepare_to_encode_frames() }, "to prepare the encoder")?; // SAFETY: the session is valid; the pool (if any) is returned retained. let pool = unsafe { session.session.pixel_buffer_pool() };- Ok(VtEncoder { session, config, pool, attrs, submitted: 0 })+ Ok(VtEncoder { session, config, input, pool, attrs, submitted: 0 }) } fn configure(session: &VTCompressionSession, c: &VtConfig) -> Result<(), String> {@@ -614,6 +681,7 @@ impl VtEncoder { self.session.shared.params.lock().unwrap_or_else(PoisonError::into_inner).clone() } + /// An input buffer in the session's pixel format (from its pool when it has one). fn new_buffer(&self) -> Result<CFRetained<CVImageBuffer>, String> { let mut raw: *mut CVImageBuffer = std::ptr::null_mut(); let status = match &self.pool {@@ -625,7 +693,10 @@ impl VtEncoder { None, self.config.width as usize, self.config.height as usize,- NV12_VIDEO,+ match self.input {+ VtInput::Nv12 => NV12_VIDEO,+ VtInput::Bgra => BGRA_8,+ }, Some(self.attrs.as_ref()), NonNull::from(&mut raw), )@@ -636,16 +707,68 @@ impl VtEncoder { Ok(unsafe { CFRetained::from_raw(raw) }) } - /// Submit picture number `index` (planar BT.709 limited-range Y'CbCr, tightly packed) and return- /// the compressed frames that came out meanwhile.+ /// Submit picture number `index` (planar BT.709 limited-range Y'CbCr, tightly packed) to a+ /// planar session ([`VtEncoder::new`]) and return the compressed frames that came out meanwhile. pub fn encode(&mut self, index: u64, y: &[u8], u: &[u8], v: &[u8]) -> Result<Vec<VtPacket>, String> {+ if self.input != VtInput::Nv12 {+ return Err("this session takes RGBA pictures, not Y'CbCr planes".into());+ } let buffer = self.new_buffer()?; fill(&buffer, self.config.width as usize, self.config.height as usize, y, u, v)?;+ self.encode_picture(index, &buffer)+ }++ /// Submit picture number `index` (the export's straight RGBA8 picture, `width × height × 4`+ /// bytes, tightly packed) to an RGB session ([`VtEncoder::with_rgba_input`]) and return the+ /// compressed frames that came out meanwhile, with the time the copy into the input buffer+ /// took (the CPU's whole share of the conversion; VideoToolbox does the rest). The buffer is+ /// tagged BT.709 primaries and transfer, like the stream, so VideoToolbox applies no transfer+ /// conversion: only the session's BT.709 matrix to limited-range 4:2:0, as the planar path does+ /// on the CPU.+ pub fn encode_rgba(&mut self, index: u64, rgba: &[u8]) -> Result<(Vec<VtPacket>, std::time::Duration), String> {+ if self.input != VtInput::Bgra {+ return Err("this session takes Y'CbCr planes, not RGBA pictures".into());+ }+ let t = std::time::Instant::now();+ let buffer = self.new_buffer()?;+ fill_bgra(&buffer, self.config.width as usize, self.config.height as usize, rgba)?;+ // SAFETY: reading immutable framework constants.+ let (primaries_key, transfer_key, primaries, transfer): (&CFString, &CFString, &CFString, &CFString) = unsafe {+ (+ kCVImageBufferColorPrimariesKey,+ kCVImageBufferTransferFunctionKey,+ kCVImageBufferColorPrimaries_ITU_R_709_2,+ kCVImageBufferTransferFunction_ITU_R_709_2,+ )+ };+ // SAFETY: `buffer` is a valid pixel buffer we hold retained; the keys are framework+ // constants and the values the CFStrings CoreVideo documents for them.+ unsafe {+ buffer.set_attachment(primaries_key, primaries.as_ref(), CVAttachmentMode::ShouldPropagate);+ buffer.set_attachment(transfer_key, transfer.as_ref(), CVAttachmentMode::ShouldPropagate);+ }+ let copied = t.elapsed();+ Ok((self.encode_picture(index, &buffer)?, copied))+ }++ /// The format pictures go in as.+ pub fn input(&self) -> VtInput {+ self.input+ }++ /// Pictures submitted so far.+ pub fn submitted(&self) -> u64 {+ self.submitted+ }++ /// Submit a filled input buffer as picture number `index` and return the compressed frames+ /// that came out meanwhile.+ fn encode_picture(&mut self, index: u64, buffer: &CVImageBuffer) -> Result<Vec<VtPacket>, String> { let value = i64::try_from(index).ok().and_then(|i| i.checked_mul(i64::from(self.config.frame_duration))).ok_or("frame index out of range")?; let (pts, duration) = (cm_time(value, self.config.timescale), cm_time(i64::from(self.config.frame_duration), self.config.timescale)); // SAFETY: the session and buffer are valid (VideoToolbox retains the buffer as long as it // needs it); no frame properties, refcon or info-flags out-pointer.- let status = unsafe { self.session.session.encode_frame(&buffer, pts, duration, None, std::ptr::null_mut(), std::ptr::null_mut()) };+ let status = unsafe { self.session.session.encode_frame(buffer, pts, duration, None, std::ptr::null_mut(), std::ptr::null_mut()) }; if status != 0 { return Err(format!("VTCompressionSessionEncodeFrame failed ({status})")); }@@ -16,8 +16,8 @@ use filmcraft_frame::PixelData; use filmcraft_isobmff::{CodecConfig, SampleEntry}; use filmcraft_media::generators::GeneratorSource; use filmcraft_media::{DemoScene, MediaSource};-use filmcraft_platform::hardware_encode::{config_for, hevc_available, videotoolbox_encoder_factory};-use filmcraft_platform::videotoolbox_encode::{VtConfig, VtEncoder, VtProfile, VtRate};+use filmcraft_platform::hardware_encode::{HardwareEncoder, config_for, hevc_available, videotoolbox_encoder_factory};+use filmcraft_platform::videotoolbox_encode::{VtConfig, VtEncoder, VtInput, VtProfile, VtRate}; use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; use filmcraft_render::SourceMap; use filmcraft_time::{FrameRate, Tick, TimeRange};@@ -100,6 +100,197 @@ fn psnr(a: &[u8], b: &[u8]) -> f64 { if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() } } +fn mean_abs_diff(a: &[u8], b: &[u8]) -> f64 {+ assert_eq!(a.len(), b.len());+ a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).abs()).sum::<f64>() / a.len() as f64+}++/// Decode `packets` (decoding order, no reordering) with our software decoder: every picture's+/// Y, Cb and Cr planes in presentation order.+fn decode_planes(entry: &SampleEntry, packets: &[EncodedPacket]) -> Vec<[Vec<u8>; 3]> {+ let mut dec = filmcraft_codecs::software_video_decoder(entry).expect("software decoder");+ let mut out = Vec::new();+ let take = |frames: Vec<filmcraft_codecs::DecodedFrame>, out: &mut Vec<(i64, [Vec<u8>; 3])>| {+ for f in frames {+ let PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("expected 8-bit planes") };+ out.push((f.pts, [planes[0].to_vec(), planes[1].to_vec(), planes[2].to_vec()]));+ }+ };+ for (i, p) in packets.iter().enumerate() {+ take(dec.decode(&p.data, i as i64).expect("decode"), &mut out);+ }+ take(dec.flush(), &mut out);+ out.sort_by_key(|(pts, _)| *pts);+ out.into_iter().map(|(_, planes)| planes).collect()+}++/// The sample entry of a session's stream (known once its first frame came out).+fn entry_of(vt: &VtEncoder) -> SampleEntry {+ let p = vt.parameter_sets().expect("parameter sets after the first frame");+ SampleEntry::avc(filmcraft_isobmff::AvcConfig::new(p.sps, p.pps, 4), W as u16, H as u16)+}++/// RGBA pictures handed to VideoToolbox as they are (`32BGRA`, VideoToolbox converting) encode the+/// same pictures as the planar path (our own BT.709 limited-range conversion into NV12): every+/// plane of both decodes is within a few codes of our conversion of the source. A swapped channel,+/// a full-range conversion, another matrix or a transfer-function conversion would be tens of codes+/// off in some plane. The hardware output differs between the two paths by rate control and chroma+/// filtering only, so the quality is compared, not the bytes.+#[test]+fn rgba_pictures_encode_like_the_cpu_conversion() {+ let cfg = config_for(W, H, FPS, &hardware_settings()).expect("a hardware configuration");+ let Ok(mut rgb) = VtEncoder::with_rgba_input(cfg.clone()) else {+ eprintln!("SKIPPED: no VideoToolbox hardware H.264 encoder");+ return;+ };+ let mut planar = VtEncoder::new(cfg).expect("the planar session opens where the RGB one does");+ assert_eq!((rgb.input(), planar.input()), (VtInput::Bgra, VtInput::Nv12));+ let n = 24u64;+ let (mut a, mut b) = (Vec::new(), Vec::new());+ let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());+ let mut copy = std::time::Duration::ZERO;+ for i in 0..n {+ let px = picture(i);+ match rgb.encode_rgba(i, &px) {+ Ok((packets, copied)) => {+ a.extend(packets);+ copy += copied;+ }+ Err(why) => {+ eprintln!("SKIPPED: this VideoToolbox does not take RGBA pictures ({why})");+ return;+ }+ }+ rgba_to_yuv420_8(&px, W as usize, H as usize, &mut y, &mut u, &mut v);+ b.extend(planar.encode(i, &y, &u, &v).expect("planar encode"));+ }+ a.extend(rgb.flush().expect("flush"));+ b.extend(planar.flush().expect("flush"));+ let a: Vec<EncodedPacket> = a.into_iter().map(|p| EncodedPacket { data: p.data, key: p.key, duration: 1, composition_offset: 0 }).collect();+ let b: Vec<EncodedPacket> = b.into_iter().map(|p| EncodedPacket { data: p.data, key: p.key, duration: 1, composition_offset: 0 }).collect();+ assert_eq!((a.len() as u64, b.len() as u64), (n, n));+ let (da, db) = (decode_planes(&entry_of(&rgb), &a), decode_planes(&entry_of(&planar), &b));+ assert_eq!((da.len() as u64, db.len() as u64), (n, n));+ let mut worst = [99.0f64; 3];+ let mut gap = [0.0f64; 3];+ let mut apart = [0.0f64; 3];+ for i in 0..n as usize {+ rgba_to_yuv420_8(&picture(i as u64), W as usize, H as usize, &mut y, &mut u, &mut v);+ for (c, reference) in [&y, &u, &v].into_iter().enumerate() {+ let (pa, pb) = (psnr(&da[i][c], reference), psnr(&db[i][c], reference));+ worst[c] = worst[c].min(pa);+ gap[c] = gap[c].max(pb - pa);+ apart[c] = apart[c].max(mean_abs_diff(&da[i][c], &db[i][c]));+ }+ }+ // The planar path's chroma is our conversion itself (2×2 average), so against that reference it+ // scores near-perfectly where VideoToolbox's filtered chroma siting scores ~40 dB: the gap is+ // reported, the bounds are the absolute ones. (M2 Max: Y/Cb/Cr 52 / 41 / 37 dB, paths 0.2 / 0.6 /+ // 0.8 codes apart; a swapped channel or a range or matrix mistake is tens of codes.)+ eprintln!(+ "RGBA input: worst PSNR Y/Cb/Cr {worst:.1?} dB, at most {gap:.1?} dB under the planar path, mean |Δ| between the paths {apart:.2?} codes; copy {:.2} ms a frame",+ copy.as_secs_f64() * 1000.0 / n as f64+ );+ for c in 0..3 {+ assert!(worst[c] > 30.0, "plane {c}: worst PSNR {:.1} dB against our conversion", worst[c]);+ assert!(apart[c] < 2.0, "plane {c}: the paths are {:.2} codes apart on average", apart[c]);+ }+ assert!(gap[0] < 3.0, "luma {:.1} dB below the planar path", gap[0]);+}++/// The adapter the factory registers prefers RGB input; the pictures it writes decode at the same quality.+#[test]+fn the_encoder_takes_rgba_pictures_where_videotoolbox_converts() {+ let cfg = config_for(W, H, FPS, &hardware_settings()).expect("a hardware configuration");+ let Ok(enc) = HardwareEncoder::open(cfg) else {+ eprintln!("SKIPPED: no VideoToolbox hardware H.264 encoder");+ return;+ };+ assert!(enc.takes_rgba(), "the RGB session is tried first");+ let mut enc: Box<dyn VideoEncoder> = Box::new(enc);+ let n = 20u64;+ let packets = encode_all(&mut enc, n);+ assert_eq!(packets.len() as u64, n);+ let decoded = decode(&enc.sample_entry(), &packets);+ let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());+ let mut worst = 99.0f64;+ for (i, (_, luma)) in decoded.iter().enumerate() {+ rgba_to_yuv420_8(&picture(i as u64), W as usize, H as usize, &mut y, &mut u, &mut v);+ worst = worst.min(psnr(&y, luma));+ }+ assert!(worst > 30.0, "worst luma PSNR {worst:.1} dB");+}++/// Hostile RGBA pictures and the wrong kind of picture for a session give errors, never crashes.+#[test]+fn rgba_input_refuses_what_does_not_fit() {+ let cfg = config_for(W, H, FPS, &hardware_settings()).expect("a hardware configuration");+ let Ok(mut rgb) = VtEncoder::with_rgba_input(cfg.clone()) else {+ eprintln!("SKIPPED: no VideoToolbox hardware H.264 encoder");+ return;+ };+ let (w, h) = (W as usize, H as usize);+ assert!(calm("empty", || rgb.encode_rgba(0, &[])).is_err());+ assert!(calm("short", || rgb.encode_rgba(0, &vec![0; w * h * 4 - 1])).is_err());+ assert!(calm("planes to an RGB session", || rgb.encode(0, &vec![16; w * h], &vec![128; w * h / 4], &vec![128; w * h / 4])).is_err());+ let mut planar = VtEncoder::new(cfg).expect("planar session");+ assert!(calm("RGBA to a planar session", || planar.encode_rgba(0, &vec![0; w * h * 4])).is_err());+ // a picture larger than its size is fine (the extra bytes are ignored)+ let _ = calm("long", || rgb.encode_rgba(0, &vec![0; w * h * 4 + 64]));+ let _ = calm("flush", || rgb.flush());+}++/// An export at another frame rate than the sequence's (frames are repeated or dropped on the way) and+/// a cancelled export followed by a complete one in the same process: the hardware path must give the+/// asked-for frame count, release its sessions and start clean again.+#[test]+fn rate_conversion_and_cancel_then_export_again() {+ filmcraft_platform::register();+ let probe = ExportSettings { include_audio: false, ..hardware_settings() };+ if hardware(&probe, W, H).is_none() {+ return;+ }+ let (frames, (p, seq, m)) = (60i64, project(60));+ // 60 frames at 30 fps become 120 at 60 fps and 50 at 25 fps+ for (rate, want) in [(FrameRate::FPS_60, 120usize), (FrameRate::FPS_25, 50)] {+ let path = tmp(&format!("rate-{}.mp4", rate.num));+ let s = ExportSettings { path: path.clone(), frame_rate: Some(rate), ..probe.clone() };+ let report = export(&p, seq, &s, &m, &Progress::default()).expect("export");+ assert_eq!(report.frames as usize, want, "{rate:?}");+ let (samples, pictures, _) = inspect(&path);+ assert_eq!((samples, pictures), (want, want), "{rate:?}");+ }+ // cancel from another thread once a few frames are done+ let before = hw_encode_stats();+ let path = tmp("cancelled.mp4");+ let s = ExportSettings { path: path.clone(), ..probe.clone() };+ let progress = Progress::default();+ let result = std::thread::scope(|scope| {+ scope.spawn(|| {+ while progress.done.load(std::sync::atomic::Ordering::Relaxed) < 8 && !progress.finished.load(std::sync::atomic::Ordering::Relaxed) {+ std::thread::sleep(std::time::Duration::from_millis(2));+ }+ progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);+ });+ export(&p, seq, &s, &m, &progress)+ });+ match result {+ Err(filmcraft_export::ExportError::Cancelled) => {}+ Ok(r) => eprintln!("the export finished before the cancel landed ({} frames)", r.frames),+ Err(e) => panic!("a cancelled export must report Cancelled or finish, not {e}"),+ }+ // and the next export in the same process is complete+ let path = tmp("after-cancel.mp4");+ let s = ExportSettings { path: path.clone(), ..probe };+ let report = export(&p, seq, &s, &m, &Progress::default()).expect("export after a cancel");+ assert_eq!(report.frames, frames as u64);+ let (samples, pictures, _) = inspect(&path);+ assert_eq!((samples, pictures), (frames as usize, frames as usize));+ let after = hw_encode_stats();+ assert!(after.sessions >= before.sessions + 2, "both exports opened a hardware session");+ assert_eq!(after.declined, before.declined, "nothing was declined to the software encoder");+}+ #[test] fn declines_what_the_hardware_path_does_not_take() { let ok = hardware_settings();@@ -298,6 +298,12 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see `filmcraft_export::register_format_probe`, so the H.265 format exists where an HEVC encoder does, and choosing it is the opt-in (the Hardware encoding toggle governs H.264 only). With no software H.265 encoder, what NVENC declines is counted and ends the export with an error that says why.+ On macOS, `platform::hardware_encode` (VideoToolbox H.264, opt-in the same way, and H.265) is+ registered likewise. It too takes the RGBA pictures as they are (`32BGRA` buffers tagged BT.709)+ and VideoToolbox converts them to 4:2:0, so the encode thread only copies: converting on the CPU+ there competed with the render of the next frames for the thread pool and cost more than the+ hardware encode (20 ms a 1080p frame). A VideoToolbox that refuses RGB input on the first picture+ gets the CPU's conversion into NV12 instead, and the export goes on. - **Compositor.** `render` is the reference for monitors, thumbnails and export. `render::plan` turns a frame into GPU layers, each with its opacity and blend mode. All 27 blend modes run on the GPU (`filmcraft-gpu`): Normal and Dissolve with fixed-function "over" blending, the others by@@ -34,7 +34,7 @@ and re-uploaded to wgpu (**no zero-copy** yet), which Premiere avoids. | Codec | Premiere macOS | **Ours macOS** | Premiere Windows | **Ours Windows** | **Ours Linux** | |---|---|---|---|---|---|-| H.264 8-bit | yes | **yes** (VideoToolbox, opt-in, no B-frames) | Intel QSV, NVENC, AMD | **NVENC only** (opt-in) | **NVENC only** |+| H.264 8-bit | yes | **yes** (VideoToolbox, opt-in, no B-frames; takes the RGBA pictures and converts them itself) | Intel QSV, NVENC, AMD | **NVENC only** (opt-in) | **NVENC only** | | H.264 10-bit / 4:2:2 | yes (Apple silicon) | no | Intel | no | no | | HEVC Main 8-bit | yes | **yes** (hardware-only format) | QSV, NVENC, AMD | **NVENC only** | no | | HEVC Main 10 / HDR (PQ, HLG) | yes | **no** (tone-mapped to 8-bit SDR) | yes | **NVENC yes** (HDR10 SEI, `mdcv` / `clli`) | no |@@ -80,6 +80,74 @@ What remains per live decoder is unchanged: the H.264 decoder publishes each mac picture as its own allocations (`Frame::make_row`, five per row), which is where the 290 MB per decoder comes from. +## Results (VideoToolbox export takes the RGBA pictures as they are, #3030, before → after)++Export ▸ Hardware encoding on macOS handed VideoToolbox NV12 planes converted on the CPU by the+encode thread (`rgba_to_yuv420_8`), and that conversion, not the hardware encode, was the export's+critical path: the encode thread is the one serial thread of an export, its `par_chunks_mut`+conversion is injected into the rayon pool while the pool renders the next batch, and so a 1080p+conversion took 20–27 ms a frame (5 ms with an idle pool). `perf.stats` `export.stages` of a+60 s export: `convertMs` 29.6 s of `encodeMs` 30.3 s, with `renderMs` 30.6 s and `waitMs` 0.1 s+(encode-bound), the media engine idle most of the time, CPU 570 %, GPU 1 %. Now the encoder+takes the export's RGBA pictures in `32BGRA` buffers tagged BT.709 and VideoToolbox converts+them, as NVENC has since #486; the encode thread copies 8 MB a frame (0.3–0.4 ms) and waits for+the render (`waitMs` 18–25 s: render-bound). A VideoToolbox that refuses RGB input on the first+picture gets the NV12 path back, so the output never depends on it.++Apple M2 Max (12 cores, 64 GB, macOS 26.6), 2026-10-23, a shared machine with other agents'+builds running (1-minute load average 57–170 across the runs, noted per pair). The timeline is+the #3030 shape: 60 s 1080p23.976 sequence, a 1080p H.264 clip on V1, a 4K H.264 clip on V2 as a+40 % picture-in-picture, stereo WAV on A1 (ffmpeg `testsrc2` + noise fixtures), exported to+H.264 20 Mb/s + AAC through `filmcraft-cli … run`, hardware decoding on. Before / after binaries+alternated (A/B/A/B/A/B), median of three; `convert`, `encode`, `render`, `wait` are the export's+own stage timers (render overlaps encode, so they do not add up to the wall time).++| Export ▸ Hardware encoding on, CPU render | before (3 runs) | **after** (3 runs) |+|---|---|---|+| wall, median | 35.8 s (33.7 / 35.8 / 44.9) | **29.4 s** (26.8 / 29.4 / 35.5) |+| encode thread: convert | 29–39 s (20–27 ms a frame) | **0.46–0.59 s** (0.3–0.4 ms a frame) |+| encode thread: whole encode stage | 30–40 s | **1.9–6.6 s** |+| wait for the render | 0.05–0.8 s | 18–25 s |+| render (parallel, overlapped) | 30–40 s | 23–35 s |+| CPU, mean (process) | 433–566 % | 497–604 % over a shorter run (CPU-seconds 191–194 → 158–178) |+| output | 1439 frames, 148.8 MB | 1439 frames, 149.6 MB |++Per pair (before → after, 1-minute load at the start): 44.9 → 26.8 s (57 → 80), 35.8 → 35.5 s+(61 → 67), 33.7 → 29.4 s (120 → 148); the second pair ran into a load spike during the "after"+run (67 → 120). The wall time now follows the CPU render alone. The same export with the+built-in encoder is untouched: the same bytes before and after (MD5 `12f18e96…`, 86 and 91 s at+load 54–182).++| Export ▸ GPU Rendering on, Hardware encoding on | before (3 runs) | **after** (3 runs) |+|---|---|---|+| wall, median | 22.2 s (18.4 / 22.2 / 23.6) | **18.7 s** (18.5 / 18.7 / 19.7) |+| encode thread: convert | 6.3–11.8 s (4–8 ms a frame: the pool is freer) | **0.46–0.47 s** |+| wait for the render | 7.5–9.1 s | 14–15 s |+| CPU, mean (process) | 241–289 % | **216–219 %** |+| GPU, mean (`ioreg` device utilization) | 12–14 % | 12–14 % |++Load 87–137 throughout, steadier than the CPU-render rows. Here too the export is now bound by+the render (GPU plan + read-back + the CPU's 8-bit conversion), which #2119 and #1284 address.++Output check: both hardware files decode with ffmpeg without a complaint, 1439 frames, video+60.018 s / audio 60.032 s like the software export, BT.709 limited range tagged. Against the+planar path's output, VideoToolbox's own conversion gives the same luma within encoder noise+(Y 46.8 dB; the planar path against the software export is 46.1 dB) and a lower chroma PSNR on+this hard-edged synthetic pattern (Cb 38.8, Cr 35.9 dB; bias 0.1–0.2 codes, 2.8 % of chroma+samples more than 4 codes apart, all at the colour-bar and checkerboard edges): VideoToolbox+filters chroma where our conversion box-averages 2×2, not a shift (a one-sample shift of either+path scores 24–29 dB) and not a range or matrix difference. On smooth content (a 5 s+`mandelbrot` 1080p over a 4K `gradients` picture-in-picture) the two paths are 49.9 / 47.0 /+47.3 dB apart, encoder noise. `rgba_pictures_encode_like_the_cpu_conversion` holds both paths to+> 30 dB against our conversion in every plane and < 2 codes apart on average (measured 52 / 41 /+37 dB and 0.2 / 0.6 / 0.8 codes).++Sessions: under LLDB with auto-continuing breakpoints on `VTCompressionSessionCreate` and+`VTCompressionSessionInvalidate`, two consecutive exports in one `filmcraft-cli run` created 3+sessions (the HEVC availability probe and one per export) and invalidated every one (6 hits: ours+and the framework's at release); the test that exports at other frame rates, cancels an export+from another thread and exports again created 6 and invalidated 12.+ ## Results (CPU compositor: layer rectangles, an opaque bottom layer and recycled images, before → after) The CPU compositor is what export runs. In a profile of a 9:29 export it took about a third of the