collective

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

diff --git a/crates/platform/README.md b/crates/platform/README.md--- a/crates/platform/README.md+++ b/crates/platform/README.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.diff --git a/crates/platform/src/hardware_encode.rs b/crates/platform/src/hardware_encode.rs--- a/crates/platform/src/hardware_encode.rs+++ b/crates/platform/src/hardware_encode.rs@@ -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");+    }+}diff --git a/crates/platform/src/videotoolbox_encode.rs b/crates/platform/src/videotoolbox_encode.rs--- a/crates/platform/src/videotoolbox_encode.rs+++ b/crates/platform/src/videotoolbox_encode.rs@@ -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})"));         }diff --git a/crates/platform/tests/it/hardware_encode.rs b/crates/platform/tests/it/hardware_encode.rs--- a/crates/platform/tests/it/hardware_encode.rs+++ b/crates/platform/tests/it/hardware_encode.rs@@ -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();diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -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 bydiff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -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 |diff --git a/docs/performance.md b/docs/performance.md--- a/docs/performance.md+++ b/docs/performance.md@@ -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