From d8bca3cdd9b42077549131690e15c4e50b3c1a05 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:40:42 +0100 Subject: [PATCH] Overlap movie encoding with rendering Movie exports (H.264, HEVC, AV1, ProRes, uncompressed) rendered a batch of frames on every core, then encoded it frame by frame while the render threads waited. Each batch now encodes on a pool shared by all exports while the next batch renders (Cx::frames_beside). With deferred GPU readbacks, or where no thread can be started (wasm without threads), the two still take turns. A panicking encoder becomes an export error. H.264 frames are converted to 4:2:0 on the render threads, per frame, instead of on the encoder's critical path. The conversion reads 8-bit levels from a table and rounds without a libm call; both give the same values as before (tested against the previous formula). Demo comp (1920x1080, 300 frames) to H.264 on a 16-thread Ryzen, export wall time -6% (median of 7 interleaved runs); output byte-identical. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/encode.rs | 193 +++++++++++++++++++++++++++++----- crates/export/src/pipeline.rs | 91 ++++++++++++++++ 2 files changed, 260 insertions(+), 24 deletions(-) diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index e4a45561d..73dabd990 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -26,9 +26,38 @@ pub(crate) struct Packet { pub(crate) cto: i32, } -pub(crate) trait VideoEncoder { +/// A frame for the movie encoder: 8-bit RGBA, or 8-bit 4:2:0 planes already converted on the +/// render threads (for encoders whose [`VideoEncoder::takes_yuv420`] is true). +pub(crate) enum Pixels { + Rgba(Vec), + Yuv420(Yuv420), +} + +/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`]). +pub(crate) struct Yuv420 { + y: Vec, + u: Vec, + v: Vec, +} + +impl Yuv420 { + fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Yuv420 { + let (mut y, mut u, mut v) = (vec![], vec![], vec![]); + rgba_to_yuv420(rgba, w, h, &mut y, &mut u, &mut v); + Yuv420 { y, u, v } + } +} + +pub(crate) trait VideoEncoder: Send { fn sample_entry(&self) -> SampleEntry; fn encode(&mut self, rgba: &[u8], index: u64) -> Result>; + /// Whether frames should come as [`Pixels::Yuv420`] (converted while other frames render). + fn takes_yuv420(&self) -> bool { + false + } + fn encode_yuv420(&mut self, _frame: &Yuv420, _index: u64) -> Result> { + Err(ExportError::Unsupported("this encoder takes RGBA frames".into())) + } fn flush(&mut self) -> Result>; /// Edit-list media start (B-frame delay) in the track timescale. fn media_start(&self) -> Option { @@ -73,9 +102,18 @@ impl VideoEncoder for H264 { SampleEntry::avc(cfg, self.w as u16, self.h as u16) } fn encode(&mut self, rgba: &[u8], index: u64) -> Result> { - rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v); + let mut planes = Yuv420 { y: std::mem::take(&mut self.y), u: std::mem::take(&mut self.u), v: std::mem::take(&mut self.v) }; + rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut planes.y, &mut planes.u, &mut planes.v); + let r = self.encode_yuv420(&planes, index); + (self.y, self.u, self.v) = (planes.y, planes.u, planes.v); + r + } + fn takes_yuv420(&self) -> bool { + true + } + fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result> { let cw = (self.w as usize).div_ceil(2); - let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w as usize, uv_stride: cw }; + let frame = filmcraft_h264enc::YuvFrame { y: &p.y, u: &p.u, v: &p.v, y_stride: self.w as usize, uv_stride: cw }; let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?; Ok(self.packets(ps)) } @@ -88,6 +126,25 @@ impl VideoEncoder for H264 { } } +/// `v / 255` for every 8-bit `v` (the same values as dividing). +pub(crate) const UNIT8: [f32; 256] = { + let mut t = [0.0; 256]; + let mut i = 0; + while i < 256 { + t[i] = i as f32 / 255.0; + i += 1; + } + t +}; + +/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and +/// the fraction are exact there, so this is `f32::round` without its libm call. +#[inline(always)] +pub(crate) fn round_pos(v: f32) -> f32 { + let t = v as u32 as f32; + if v - t >= 0.5 { t + 1.0 } else { t } +} + /// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored). pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec, u: &mut Vec, v: &mut Vec) { let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); @@ -95,14 +152,13 @@ pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec, u u.resize(cw * ch, 0); v.resize(cw * ch, 0); y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| { - let rows = yr.len() / w; let mut acc = vec![(0f32, 0f32, 0f32); cw]; - for dy in 0..rows { + for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() { let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4]; - for x in 0..w { - let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0); + for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() { + let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]); let l = 0.2126 * r + 0.7152 * g + 0.0722 * b; - yr[dy * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8; + *yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8; let a = &mut acc[x / 2]; a.0 += (b - l) / 1.8556; a.1 += (r - l) / 1.5748; @@ -110,8 +166,8 @@ pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec, u } } for (cx, a) in acc.iter().enumerate() { - ur[cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8; - vr[cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8; + ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8; + vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8; } }); } @@ -265,15 +321,34 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St }; let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 }; let batch = batch_size(); - let render = |_: u64, img: effectcraft_raster::Image| -> Vec { job.pixels(&img, comp, channels, w, h) }; + let yuv = venc.takes_yuv420(); + let render = |_: u64, img: effectcraft_raster::Image| -> Pixels { + let px = job.pixels(&img, comp, channels, w, h); + if yuv { Pixels::Yuv420(Yuv420::from_rgba(&px, w as usize, h as usize)) } else { Pixels::Rgba(px) } + }; + let encode = |venc: &mut Box, px: &Pixels, index: u64| match px { + Pixels::Rgba(rgba) => venc.encode(rgba, index), + Pixels::Yuv420(planes) => venc.encode_yuv420(planes, index), + }; + + let beside = crate::pipeline::Beside::shared(); + let total = st.total; // Encode the first batch before creating tracks: encoders finalise their config on frame 1. - let first_end = batch.min(st.total); - let first: Vec> = job.frames(comp, (0..first_end).collect(), render).await; - let mut pending = Vec::new(); - for (k, px) in first.iter().enumerate() { - pending.extend(venc.encode(px, k as u64)?); - } + // Each batch encodes while the next one renders. + let first_end = batch.min(total); + let first: Vec = job.frames(comp, (0..first_end).collect(), render).await; + let next_end = (first_end + batch).min(total); + let (pending, mut ready) = job + .frames_beside(beside, comp, (first_end..next_end).collect(), render, || -> Result> { + let mut pending = Vec::new(); + for (k, px) in first.iter().enumerate() { + pending.extend(encode(&mut venc, px, k as u64)?); + } + Ok(pending) + }) + .await?; + let pending = pending?; drop(first); st.advance(first_end)?; @@ -351,19 +426,25 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St } Ok(()) }; - let total = st.total; let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } }; write_video(&mut mux, pending)?; write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?; let mut i = first_end; while i < total { let end = (i + batch).min(total); - let frames: Vec> = job.frames(comp, (i..end).collect(), render).await; - for (k, px) in frames.iter().enumerate() { - let ps = venc.encode(px, i + k as u64)?; - write_video(&mut mux, ps)?; - } - write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?; + let next_end = (end + batch).min(total); + let frames = std::mem::take(&mut ready); + let (done, next) = job + .frames_beside(beside, comp, (end..next_end).collect(), render, || -> Result<()> { + for (k, px) in frames.iter().enumerate() { + let ps = encode(&mut venc, px, i + k as u64)?; + write_video(&mut mux, ps)?; + } + write_audio_until(&mut mux, frame_end_time(end), &mut cursor) + }) + .await?; + done?; + ready = next; st.advance(end - i)?; i = end; } @@ -384,6 +465,70 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St mod tests { use super::*; + /// The conversion as first written (per-pixel division and `f32::round`). + fn yuv420_reference(rgba: &[u8], w: usize, h: usize) -> (Vec, Vec, Vec) { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let (mut y, mut u, mut v) = (vec![0u8; w * h], vec![0u8; cw * ch], vec![0u8; cw * ch]); + for cy in 0..ch { + let mut acc = vec![(0f32, 0f32, 0f32); cw]; + for row in cy * 2..(cy * 2 + 2).min(h) { + for x in 0..w { + let s = &rgba[(row * w + x) * 4..]; + let (r, g, b) = (s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0); + let l = 0.2126 * r + 0.7152 * g + 0.0722 * b; + y[row * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8; + let a = &mut acc[x / 2]; + a.0 += (b - l) / 1.8556; + a.1 += (r - l) / 1.5748; + a.2 += 1.0; + } + } + for (cx, a) in acc.iter().enumerate() { + u[cy * cw + cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8; + v[cy * cw + cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8; + } + } + (y, u, v) + } + + #[test] + fn round_pos_is_round() { + for i in 0..2_000_000u32 { + let v = i as f32 * 0.000_25; + assert_eq!(round_pos(v).to_bits(), v.round().to_bits(), "{v}"); + let n = f32::from_bits(v.to_bits().wrapping_sub(1)).max(0.0); + assert_eq!(round_pos(n).to_bits(), n.round().to_bits(), "{n}"); + } + for k in 0..1024u32 { + for v in [k as f32 + 0.5, f32::from_bits((k as f32 + 0.5).to_bits() - 1), f32::from_bits((k as f32 + 0.5).to_bits() + 1)] { + assert_eq!(round_pos(v), v.round(), "{v}"); + } + } + for (i, u) in UNIT8.iter().enumerate() { + assert_eq!(u.to_bits(), (i as f32 / 255.0).to_bits()); + } + } + + #[test] + fn yuv420_matches_the_reference() { + // Every 8-bit level in every channel, odd sizes, and pseudo-random pixels. + for (w, h) in [(1, 1), (2, 2), (3, 5), (17, 9), (64, 33)] { + let mut rgba = vec![0u8; w * h * 4]; + let mut seed = (w * 31 + h) as u32; + for (i, b) in rgba.iter_mut().enumerate() { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + *b = if i % 7 == 0 { (i / 4 % 256) as u8 } else { (seed >> 24) as u8 }; + } + let (mut y, mut u, mut v) = (vec![], vec![], vec![]); + rgba_to_yuv420(&rgba, w, h, &mut y, &mut u, &mut v); + assert_eq!((y, u, v), yuv420_reference(&rgba, w, h), "{w}x{h}"); + } + let grey: Vec = (0..=255u8).flat_map(|g| [g, g, g, 255, g, 255 - g, g / 2, 255]).collect(); + let (mut y, mut u, mut v) = (vec![], vec![], vec![]); + rgba_to_yuv420(&grey, 512, 1, &mut y, &mut u, &mut v); + assert_eq!((y, u, v), yuv420_reference(&grey, 512, 1)); + } + #[test] fn yuv_of_primaries() { let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255]; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 9fd131474..4b24a4bd9 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -91,6 +91,24 @@ pub(crate) fn map_frames_while( out } +/// Threads that encode one batch of frames while the next renders ([`Cx::frames_beside`]). +/// Their own pool: the encoder's parallel steps (colour conversion) would otherwise queue behind +/// the render's work in the global pool. `None` where threads can't be started (a browser +/// without them): encoding and rendering then take turns. +pub(crate) struct Beside(Option); + +impl Beside { + fn new() -> Beside { + Beside(rayon::ThreadPoolBuilder::new().thread_name(|i| format!("export-encode-{i}")).build().ok()) + } + + /// The process's encode threads, started by the first movie export and shared by all. + pub fn shared() -> &'static Beside { + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED.get_or_init(Beside::new) + } +} + /// A job being exported: the job, the project with the Render Settings overrides applied, the /// job's layer cache and per-frame timing for the render log. pub(crate) struct Cx<'a> { @@ -214,6 +232,40 @@ impl<'a> Cx<'a> { map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) } + /// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the + /// render threads don't wait for the (largely sequential) encoder of the previous batch. + /// With deferred readbacks, or where no encode thread could be started, `encode` runs first. + pub async fn frames_beside( + &self, + beside: &Beside, + comp: &Comp, + ks: Vec, + f: impl Fn(u64, Image) -> T + Sync + Send, + encode: impl FnOnce() -> A + Send, + ) -> crate::Result<(A, Vec)> { + match &beside.0 { + Some(pool) if !self.deferred() => { + let mut done = None; + let frames = pool.in_place_scope(|s| { + s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode)))); + effectcraft_render::passes::block_on(self.frames(comp, ks, f)) + }); + match done { + Some(Ok(a)) => Ok((a, frames)), + Some(Err(panic)) => { + let why = panic.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| panic.downcast_ref::().cloned()).unwrap_or_default(); + Err(crate::ExportError::Encode(format!("the encoder stopped unexpectedly: {why}"))) + } + None => Err(crate::ExportError::Encode("the encoder stopped unexpectedly".into())), + } + } + _ => { + let a = encode(); + Ok((a, self.frames(comp, ks, f).await)) + } + } + } + /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every /// frame in flight holds its own working textures, so a whole batch rendered at once can /// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the @@ -706,6 +758,45 @@ mod tests { assert!(stats.hits >= 9, "{stats:?}"); } + #[test] + fn frames_render_beside_the_encoder() { + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + use effectcraft_render::NoFootage; + use effectcraft_time::FrameRate; + let mut p = Project::default(); + let comp = Comp::new(32, 16, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::H264)); + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &output, + path: "/c/out.mp4", + sink: None, + nested_switches: true, + options: Default::default(), + }; + let cx = Cx::new(&job); + let comp = cx.comp().unwrap(); + use effectcraft_render::passes::block_on; + let want = block_on(cx.frames(comp, (0..6).collect(), |k, img| (k, img.width, img.height))); + // On threads of their own, and taking turns (no threads): the same frames, in order. + for beside in [Beside::shared(), &Beside(None)] { + let (a, frames) = block_on(cx.frames_beside(beside, comp, (0..6).collect(), |k, img| (k, img.width, img.height), || 7)).unwrap(); + assert_eq!((a, &frames), (7, &want)); + } + // A panicking encoder fails the export instead of taking the render thread down. + let r = block_on(cx.frames_beside(Beside::shared(), comp, vec![0], |k, _| k, || -> u32 { panic!("encoder bug") })); + match r { + Err(crate::ExportError::Encode(e)) => assert!(e.contains("encoder bug"), "{e}"), + other => panic!("{:?}", other.map(|(a, f)| (a, f.len()))), + } + } + #[test] fn interleave_takes_alternate_lines() { let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);