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 1/3] 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]); From db41fa7980a4a2eaf7a1b4e13fea862adf5b78fb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:40:43 +0100 Subject: [PATCH 2/3] Skip transparent pixels and quantise while compositing Much of a layer buffer is often transparent (a stroked ring, a glow-padded text layer, a precomp canvas), yet every destination pixel sampled it bilinearly, and 8/16 bpc comps then re-quantised the layer's whole rotated bounding box. - Bilinear warps skip 16x16 source tiles whose pixels (and a one-pixel apron) all have zero alpha: their samples are transparent and were never drawn. - The fast affine warp reports the rectangle it changed and, given the comp's levels, quantises each pixel as it blends it, so draw_comp has nothing left to re-quantise for that layer. quantize_px moves to effectcraft-raster for this. - quantize_rect skips runs of four all-zero (+0.0) pixels, which quantise to themselves. - to_rgba8 / to_rgba8_over convert row by row instead of per 4-byte item. Every pixel stays bit-identical: tests compare the warp against the reference path, fused quantising against quantising after blending, and the zero-run skip against per-pixel quantising. With the previous commit, demo comp to H.264 (`cargo xtask bench --scenario demo --format h264 --frames 300`, 11 interleaved runs on a 16-thread Ryzen): wall 9.26 s -> 6.97 s median (-24.8%), 8.55 s -> 6.55 s min (-23.3%); CPU time -23%. Output byte-identical. Co-Authored-By: Claude Opus 5.5 --- crates/raster/src/lib.rs | 43 ++++- crates/raster/src/warp.rs | 372 +++++++++++++++++++++++++++++------- crates/render/src/color.rs | 68 ++++--- crates/render/src/layers.rs | 56 +++++- crates/render/src/lib.rs | 2 +- 5 files changed, 430 insertions(+), 111 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index dce8dda81..9dfd06822 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -18,10 +18,32 @@ use rayon::prelude::*; pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur}; pub use channels3d::AuxChannels; -pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; +pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_changed, composite_warp_reference, resample}; pub type Px = [f32; 4]; +/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): +/// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer +/// conversion rounds like `round()` and vectorises. +#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN +#[inline(always)] +pub fn quantize_px(p: &mut Px, levels: f32, inv: f32) { + let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; + let hi = [a, a, a, a]; + for c in 0..3 { + p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; + } + p[3] = a; +} + +/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's +/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm +/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there). +#[inline(always)] +fn round_half_up(v: f32) -> f32 { + if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 } +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct Image { pub width: u32, @@ -199,26 +221,33 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - let mut out = vec![0u8; self.data.len() * 4]; - out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| { + self.map_rgba8(|o, p| { let a = p[3].clamp(0.0, 1.0); let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; for c in 0..3 { o[c] = ((p[c] * inv).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; } o[3] = (a * 255.0 + 0.5) as u8; - }); - out + }) } /// Composite over an opaque background colour and return opaque 8-bit RGBA. pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec { - let mut out = vec![0u8; self.data.len() * 4]; - out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| { + self.map_rgba8(|o, p| { let k = 1.0 - p[3].clamp(0.0, 1.0); for c in 0..3 { o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; } o[3] = 255; + }) + } + /// 8-bit RGBA with each pixel written by `f`, row-parallel. + fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec { + let mut out = vec![0u8; self.data.len() * 4]; + let w = self.width.max(1) as usize; + out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| { + for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) { + f(o, p); + } }); out } diff --git a/crates/raster/src/warp.rs b/crates/raster/src/warp.rs index 8b45761e4..c16480865 100644 --- a/crates/raster/src/warp.rs +++ b/crates/raster/src/warp.rs @@ -57,7 +57,15 @@ fn half(src: &Image) -> Image { /// bilinear without bounds checks, integer-translation copies, inlined Normal blending); it /// matches [`composite_warp_reference`] to float rounding. pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { - composite_warp_impl(dst, src, m, opts, true, None); + composite_warp_impl(dst, src, m, opts, true, None, None); +} + +/// [`composite_warp`], also returning the destination pixels it changed when it knows them: +/// `Some([x0, y0, x1, y1])` (half-open, empty when nothing was drawn) on the fast affine path, +/// `None` otherwise. With `quantize` (an 8/16 bpc destination's levels), the fast path also +/// quantises each pixel it blends ([`crate::quantize_px`]): exactly when this returns `Some`. +pub fn composite_warp_changed(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, quantize: Option) -> Option<[u32; 4]> { + composite_warp_impl(dst, src, m, opts, true, None, quantize) } /// `dst += warp(src) · weight` (plain sum, no blending): motion-blur sub-samples accumulate @@ -65,7 +73,7 @@ pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Sampling, weight: f32) { let opts = WarpOpts { sampling, ..Default::default() }; if m.is_affine() && m.inverse().is_some_and(|i| i.is_affine()) { - composite_warp_impl(dst, src, m, &opts, true, Some(weight)); + composite_warp_impl(dst, src, m, &opts, true, Some(weight), None); return; } let mut tmp = Image::new(dst.width, dst.height); @@ -79,7 +87,7 @@ pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Samplin /// The straightforward per-pixel implementation (reference for the fast path's tests). pub fn composite_warp_reference(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { - composite_warp_impl(dst, src, m, opts, false, None); + composite_warp_impl(dst, src, m, opts, false, None, None); } /// Bilinear blend of four taps (same operation order as [`Image::sample_bilinear`]). @@ -94,9 +102,49 @@ fn bilerp(a: Px, b: Px, c: Px, d: Px, tx: f32, ty: f32) -> Px { o } -/// Bilinear sample with an unchecked interior fast path. +/// Side of the square source tiles of [`ClearTiles`]. +const TILE: usize = 16; + +/// Which `TILE`×`TILE` source tiles are fully transparent, the one-pixel apron to their right +/// and below included: the four taps of a bilinear sample whose top-left tap lies in such a +/// tile all have zero alpha, so the sample is transparent and draws nothing. +struct ClearTiles { + cols: usize, + clear: Vec, +} + +impl ClearTiles { + fn new(img: &Image) -> ClearTiles { + let (w, h) = (img.width as usize, img.height as usize); + let (cols, rows) = (w.div_ceil(TILE), h.div_ceil(TILE)); + let clear = (0..rows) + .into_par_iter() + .flat_map_iter(|ty| { + let mut row_clear = vec![true; cols]; + for y in ty * TILE..(ty * TILE + TILE + 1).min(h) { + let row = &img.data[y * w..(y + 1) * w]; + for (tx, c) in row_clear.iter_mut().enumerate() { + if *c { + *c = row[tx * TILE..(tx * TILE + TILE + 1).min(w)].iter().all(|p| p[3] == 0.0); + } + } + } + row_clear + }) + .collect(); + ClearTiles { cols, clear } + } + + #[inline(always)] + fn is_clear(&self, x: usize, y: usize) -> bool { + self.clear.get((y / TILE) * self.cols + x / TILE).copied().unwrap_or(false) + } +} + +/// Bilinear sample with an unchecked interior fast path; `None` when every interior tap is in a +/// [`ClearTiles`] tile (the sample would have zero alpha). #[inline(always)] -fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px { +fn sample_bilinear_fast(img: &Image, clear: &ClearTiles, x: f64, y: f64) -> Option { let fx = x - 0.5; let fy = y - 0.5; let x0 = fx.floor(); @@ -104,14 +152,17 @@ fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px { let (xi, yi) = (x0 as i64, y0 as i64); let (w, h) = (img.width as i64, img.height as i64); if xi >= 0 && yi >= 0 && xi + 1 < w && yi + 1 < h { + if clear.is_clear(xi as usize, yi as usize) { + return None; + } let tx = (fx - x0) as f32; let ty = (fy - y0) as f32; let i = yi as usize * w as usize + xi as usize; let row0 = &img.data[i..i + 2]; let row1 = &img.data[i + w as usize..i + w as usize + 2]; - return bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty); + return Some(bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty)); } - img.sample_bilinear(x, y) + Some(img.sample_bilinear(x, y)) } /// Index range `[lo, hi)` of `i` in `0..n` for which `p0 + i·s` may lie in `[min, max]` @@ -127,7 +178,33 @@ fn span(p0: f64, s: f64, min: f64, max: f64, n: usize) -> (usize, usize) { if hi <= lo { (0, 0) } else { (lo as usize, hi as usize) } } -/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. +/// Columns `[a, b)` of one destination row that a warp changed (`a == b`: none). +#[derive(Clone, Copy)] +struct RowSpan { + a: usize, + b: usize, +} + +impl RowSpan { + const NONE: RowSpan = RowSpan { a: usize::MAX, b: 0 }; + #[inline(always)] + fn add(&mut self, x: usize) { + self.a = self.a.min(x); + self.b = x + 1; + } +} + +/// Union of changed pixels as `[x0, y0, x1, y1]` (half-open; `x0 >= x1` when empty). +fn union(r: [usize; 4], q: [usize; 4]) -> [usize; 4] { + match (r[0] < r[2], q[0] < q[2]) { + (false, _) => q, + (_, false) => r, + _ => [r[0].min(q[0]), r[1].min(q[1]), r[2].max(q[2]), r[3].max(q[3])], + } +} + +/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. Returns +/// the rectangle of the pixels it blended. #[allow(clippy::too_many_arguments)] fn affine_rows( dst: &mut Image, @@ -135,12 +212,14 @@ fn affine_rows( inv: &Mat3, sampling: Sampling, op: f32, + quantize: Option, x0: usize, x1: usize, y0: usize, y1: usize, blend: impl Fn(Px, Px, u32, u32) -> Px + Sync, -) { +) -> [usize; 4] { + let levels = quantize.map(|l| (l, 1.0 / l)); let w = dst.width as usize; let m = inv.0; let (iw, ih) = (img.width as f64, img.height as f64); @@ -148,71 +227,104 @@ fn affine_rows( // Pure integer translation: every sample lands on a pixel centre, so all filters return the // source pixel itself (bicubic still clamps negatives, as in the reference). let int_tx = m[0][0] == 1.0 && m[1][1] == 1.0 && m[0][1] == 0.0 && m[1][0] == 0.0 && m[0][2].fract() == 0.0 && m[1][2].fract() == 0.0; - dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { - let fy = y as f64 + 0.5; - let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]); - let n = x1 - x0; - let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n); - let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n); - let (lo, hi) = (ax.max(ay), bx.min(by)); - if lo >= hi { - return; - } - if int_tx { - let sy = y as i64 + m[1][2] as i64; - if sy < 0 || sy >= img.height as i64 { - return; + // Bilinear samples skip the transparent parts of the source (most of a thin shape's or a + // glow-padded layer's buffer) without reading them. + let clear = (sampling == Sampling::Bilinear && !int_tx).then(|| ClearTiles::new(img)); + let empty = [usize::MAX, usize::MAX, 0, 0]; + dst.data + .par_chunks_mut(w) + .enumerate() + .skip(y0) + .take(y1 - y0) + .map(|(y, row)| { + let mut changed = RowSpan::NONE; + let fy = y as f64 + 0.5; + let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]); + let n = x1 - x0; + let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n); + let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n); + let (lo, hi) = (ax.max(ay), bx.min(by)); + if lo >= hi { + return empty; } - let dx = m[0][2] as i64; - let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize]; - let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize; - let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize; - for x in xa..xb.max(xa) { - let mut s = srow[(x as i64 + dx) as usize]; - if sampling == Sampling::Bicubic { - s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)]; + if int_tx { + let sy = y as i64 + m[1][2] as i64; + if sy < 0 || sy >= img.height as i64 { + return empty; } - if s[3] <= 0.0 { - continue; - } - if op < 1.0 { - for c in s.iter_mut() { - *c *= op; + let dx = m[0][2] as i64; + let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize]; + let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize; + let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize; + for x in xa..xb.max(xa) { + let mut s = srow[(x as i64 + dx) as usize]; + if sampling == Sampling::Bicubic { + s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)]; } + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + if let Some((l, inv)) = levels { + crate::quantize_px(&mut row[x], l, inv); + } + changed.add(x); } - row[x] = blend(row[x], s, x as u32, y as u32); - } - return; - } - for i in lo..hi { - let x = x0 + i; - let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64); - if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 { - continue; - } - let mut s = match sampling { - Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64), - Sampling::Bilinear => sample_bilinear_fast(img, p.x, p.y), - Sampling::Bicubic => img.sample_bicubic(p.x, p.y), - }; - if s[3] <= 0.0 { - continue; - } - if op < 1.0 { - for c in s.iter_mut() { - *c *= op; + } else { + for i in lo..hi { + let x = x0 + i; + let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64); + if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 { + continue; + } + let mut s = match (sampling, &clear) { + (Sampling::Nearest, _) => img.get(p.x.floor() as i64, p.y.floor() as i64), + (Sampling::Bilinear, Some(clear)) => match sample_bilinear_fast(img, clear, p.x, p.y) { + Some(s) => s, + None => continue, + }, + (Sampling::Bilinear, None) => img.sample_bilinear(p.x, p.y), + (Sampling::Bicubic, _) => img.sample_bicubic(p.x, p.y), + }; + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + if let Some((l, inv)) = levels { + crate::quantize_px(&mut row[x], l, inv); + } + changed.add(x); } } - row[x] = blend(row[x], s, x as u32, y as u32); - } - }); + if changed.a < changed.b { [changed.a, y, changed.b, y + 1] } else { empty } + }) + .reduce(|| empty, union) } -fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, allow_fast: bool, accumulate: Option) { +fn composite_warp_impl( + dst: &mut Image, + src: &Image, + m: &Mat3, + opts: &WarpOpts, + allow_fast: bool, + accumulate: Option, + quantize: Option, +) -> Option<[u32; 4]> { + const NOTHING: Option<[u32; 4]> = Some([0; 4]); if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 { - return; + return NOTHING; } - let Some(inv) = m.inverse() else { return }; + let Some(inv) = m.inverse() else { return NOTHING }; // Minification: pick a pre-filtered level so sampling doesn't alias. let scale = m.mean_scale(); let mut level_img; @@ -239,7 +351,7 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, area = area.intersect(&c); } if area.is_empty() { - return; + return NOTHING; } let (x0, x1) = (area.x0.max(0.0) as usize, area.x1.min(dst.width as f64) as usize); let (y0, y1) = (area.y0.max(0.0) as usize, area.y1.min(dst.height as f64) as usize); @@ -247,25 +359,28 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, let affine = inv.is_affine(); let mode = opts.mode; let op = opts.opacity; + let changed = |r: [usize; 4]| Some(r.map(|v| v.min(u32::MAX as usize) as u32)); if let Some(k) = accumulate && affine { - affine_rows(dst, img, &inv, opts.sampling, 1.0, x0, x1, y0, y1, |d, s, _, _| [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]); - return; + let r = affine_rows(dst, img, &inv, opts.sampling, 1.0, None, x0, x1, y0, y1, |d, s, _, _| { + [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k] + }); + return changed(r); } if allow_fast && affine && !mode.is_stencil() { - match mode { - BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| { + let r = match mode { + BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| { let k = 1.0 - s[3]; [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] }), BlendMode::Dissolve | BlendMode::DancingDissolve => { let seed = opts.seed; - affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) + affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) } - _ => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), - } - return; + _ => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), + }; + return changed(r); } dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { let fy = y as f64 + 0.5; @@ -312,6 +427,7 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, } } } + None } /// Resize with bilinear filtering (box pre-filter when shrinking). @@ -394,6 +510,116 @@ mod tests { } } + /// A mostly transparent source: a thin ring with a few stray pixels (one with negative zero alpha). + fn sparse_image(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, w.min(h) as f32 * 0.4); + for y in 0..h { + for x in 0..w { + let d = ((x as f32 + 0.5 - cx).hypot(y as f32 + 0.5 - cy) - r).abs(); + if d < 1.5 { + let a = 1.0 - d / 1.5; + img.set(x, y, [0.2 * a, 0.6 * a, a, a]); + } + } + } + img.set(3, 2, [0.5, 0.5, 0.5, 0.5]); + img.set(w - 1, h - 1, [1.0; 4]); + img.set(w / 2, 1, [0.0, 0.0, 0.0, -0.0]); + img + } + + #[test] + fn clear_tiles_hold_only_transparent_taps() { + let img = sparse_image(150, 97); + let clear = ClearTiles::new(&img); + let mut skipped = 0; + for y in 0..img.height as usize - 1 { + for x in 0..img.width as usize - 1 { + if clear.is_clear(x, y) { + skipped += 1; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + assert_eq!(img.get(x as i64 + dx, y as i64 + dy)[3], 0.0, "tap ({}, {}) of a clear tile", x as i64 + dx, y as i64 + dy); + } + } + } + } + assert!(skipped > 100 * 70, "the ring's empty middle is skipped: {skipped}"); + } + + /// The changed rectangle holds every pixel a warp changed, and the skipped transparent + /// samples leave the result as the reference path draws it. + #[test] + fn changed_rect_covers_the_changed_pixels() { + let src = sparse_image(90, 70); + let mats = [ + Mat3::translate(vec2(7.0, 3.0)), + Mat3::translate(vec2(10.25, 4.5)), + Mat3::translate(vec2(60.0, 50.0)) * Mat3::rotate_deg(33.0) * Mat3::scale(vec2(0.9, 1.2)) * Mat3::translate(vec2(-45.0, -35.0)), + Mat3::translate(vec2(-30.0, -20.0)), + Mat3::scale(vec2(0.3, 0.3)), + ]; + for (mi, m) in mats.iter().enumerate() { + for mode in [BlendMode::Normal, BlendMode::Add, BlendMode::Dissolve] { + let opts = WarpOpts { mode, seed: 3, ..Default::default() }; + let before = test_image(120, 100, 4); + let mut a = before.clone(); + let r = composite_warp_changed(&mut a, &src, m, &opts, None); + let Some([x0, y0, x1, y1]) = r else { panic!("mat {mi}: the fast path tracks what it changed") }; + for (i, (p, q)) in a.data.iter().zip(&before.data).enumerate() { + let (x, y) = (i as u32 % a.width, i as u32 / a.width); + if p != q { + assert!(x >= x0 && x < x1 && y >= y0 && y < y1, "mat {mi} {mode:?}: ({x}, {y}) changed outside {r:?}"); + } + } + let mut b = before.clone(); + composite_warp_reference(&mut b, &src, m, &opts); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "mat {mi} {mode:?} px {i}: {p:?} vs {q:?}"); + } + } + } + } + let mut dst = Image::new(10, 10); + assert_eq!(composite_warp_changed(&mut dst, &Image::new(4, 4), &Mat3::IDENTITY, &WarpOpts::default(), None), Some([u32::MAX, u32::MAX, 0, 0])); + let stencil = WarpOpts { mode: BlendMode::StencilAlpha, ..Default::default() }; + assert_eq!(composite_warp_changed(&mut dst, &src, &Mat3::IDENTITY, &stencil, Some(255.0)), None, "stencils change the whole destination"); + } + + /// Quantising while blending equals blending, then quantising the changed rectangle of an + /// already quantised destination (what an 8/16 bpc comp did after every layer), bit for bit. + #[test] + fn quantize_while_blending_matches_quantizing_after() { + let src = sparse_image(90, 70); + let mats = [Mat3::translate(vec2(7.0, 3.0)), Mat3::translate(vec2(60.0, 50.0)) * Mat3::rotate_deg(33.0) * Mat3::translate(vec2(-45.0, -35.0))]; + for m in &mats { + for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Dissolve] { + for (levels, opacity) in [(255.0f32, 1.0), (32_768.0, 0.7)] { + let mut before = test_image(120, 100, 8); + for p in &mut before.data { + crate::quantize_px(p, levels, 1.0 / levels); + } + let opts = WarpOpts { mode, opacity, seed: 5, ..Default::default() }; + let mut after = before.clone(); + let Some([x0, y0, x1, y1]) = composite_warp_changed(&mut after, &src, m, &opts, None) else { panic!("fast path") }; + for y in y0..y1 { + for x in x0..x1 { + let i = after.idx(x, y); + crate::quantize_px(&mut after.data[i], levels, 1.0 / levels); + } + } + let mut fused = before.clone(); + let r = composite_warp_changed(&mut fused, &src, m, &opts, Some(levels)); + assert_eq!(r, Some([x0, y0, x1, y1])); + for (i, (p, q)) in fused.data.iter().zip(&after.data).enumerate() { + assert_eq!(p.map(f32::to_bits), q.map(f32::to_bits), "{mode:?} {levels} px {i}"); + } + } + } + } + } + /// Accumulating sub-samples equals warping each into its own image and summing. #[test] fn accumulate_matches_per_sample_images() { diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index abfa791e8..d27526553 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -21,7 +21,7 @@ use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; -use effectcraft_raster::Image; +use effectcraft_raster::{Image, quantize_px}; use rayon::prelude::*; /// The colour pipeline of a project (see the module docs). @@ -218,27 +218,6 @@ pub fn quantize(img: &mut Image, levels: f32) { quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels); } -/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the -/// clamp, so the integer conversion rounds like `round()` and vectorises. -#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN -#[inline(always)] -fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) { - let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; - let hi = [a, a, a, a]; - for c in 0..3 { - p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; - } - p[3] = a; -} - -/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's -/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm -/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there). -#[inline(always)] -fn round_half_up(v: f32) -> f32 { - if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 } -} - /// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel. fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { if r.x0 >= r.x1 || r.y0 >= r.y1 { @@ -248,7 +227,17 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { let w = img.width as usize; let (x0, x1) = (r.x0 as usize, r.x1 as usize); img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| { - for p in &mut row[x0..x1] { + // Runs of all-zero pixels (+0.0 in every channel) quantise to themselves: skip them a + // cache line at a time (sparse shape, precomp and glow buffers are mostly empty). + let (blocks, rest) = row[x0..x1].as_chunks_mut::<4>(); + for b in blocks { + if b.as_flattened().iter().fold(0, |a, v| a | v.to_bits()) != 0 { + for p in b { + quantize_px(p, levels, inv); + } + } + } + for p in rest { quantize_px(p, levels, inv); } }); @@ -307,6 +296,39 @@ pub fn output_hdr(img: &mut Image, to_linear: Option, mode: HdrMode, #[cfg(test)] mod tests { + use super::*; + + /// Skipping all-zero runs leaves every pixel as quantising it one by one does, bit for bit + /// (negative zeros and lone pixels in empty runs included). + #[test] + fn quantize_skips_only_zero_runs() { + let mut img = Image::new(23, 7); + for (i, p) in img.data.iter_mut().enumerate() { + *p = match i % 11 { + 0 => [0.3, -0.2, 0.71, 0.9], + 3 => [-0.0, 0.0, 0.0, 0.0], + 5 => [0.0, 0.0, 0.0, -0.0], + 7 => [1.7, 0.4, f32::NAN, 0.5], + _ => [0.0; 4], + }; + } + for levels in [255.0f32, 32_768.0] { + let mut want = img.clone(); + for p in &mut want.data { + quantize_px(p, levels, 1.0 / levels); + } + for r in [PxRect { x0: 0, y0: 0, x1: 23, y1: 7 }, PxRect { x0: 3, y0: 1, x1: 20, y1: 6 }] { + let mut got = img.clone(); + quantize_rect(&mut got, r, levels); + for (i, (g, p)) in got.data.iter().zip(&img.data).enumerate() { + let (x, y) = (i as u32 % 23, i as u32 / 23); + let w = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { want.data[i] } else { *p }; + assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "{levels} {r:?} px ({x}, {y})"); + } + } + } + } + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: /// 0..=levels, exact halves included. #[test] diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index 15476a66f..c24c37ff8 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -342,11 +342,26 @@ impl<'a> Renderer<'a> { /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) { + self.place_changed(ctx, layer, buf, target, mode, opacity, None); + } + + /// [`Self::place`], returning the target pixels it changed when the warp tracked them (then + /// also quantised to `quantize` levels; see [`composite_warp_changed`]). + #[allow(clippy::too_many_arguments)] + fn place_changed( + &self, + ctx: &EvalCtx, + layer: &Layer, + buf: &Buf, + target: &mut Image, + mode: BlendMode, + opacity: f32, + quantize: Option, + ) -> Option<[u32; 4]> { let pl = self.placement(ctx, layer, buf); let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None }; if let [m] = pl.matrices.as_slice() { - composite_warp(target, &buf.img, m, &opts); - return; + return composite_warp_changed(target, &buf.img, m, &opts, quantize); } // Sub-samples sum straight into one buffer (each warp is row-parallel). let mut acc = Image::new(target.width, target.height); @@ -355,6 +370,7 @@ impl<'a> Renderer<'a> { effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k); } target.blend_from(&acc, mode, opacity, pl.seed); + None } /// Draw a 2D layer into `canvas`; returns the region it may have changed. @@ -377,8 +393,14 @@ impl<'a> Renderer<'a> { let st = self.styled_to_blend(st); let t1 = web_time::Instant::now(); let changed = if st.plain { - self.composite_layer(ctx, layer, &st.body, canvas, opacity); - self.footprint(ctx, layer, &st.body, opacity, blank) + // Pixels the warp tracks are quantised as it blends them (nothing left to do); the + // footprint is still needed when it doesn't, or when no pixel needs quantising. + let footprint = self.footprint(ctx, layer, &st.body, opacity, blank); + let quantize = if footprint == Region::Empty { None } else { self.pipe.levels }; + match self.composite_layer(ctx, layer, &st.body, canvas, opacity, quantize) { + Some(_) if quantize.is_some() => Region::Empty, + drawn => narrow(footprint, drawn), + } } else { self.composite_styled(ctx, layer, &st, canvas, opacity); Region::Full @@ -432,16 +454,18 @@ impl<'a> Renderer<'a> { }); } - fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) { + /// Composite a plain layer; returns the canvas pixels it changed when they are known (then + /// also quantised to `quantize` levels). + fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option) -> Option<[u32; 4]> { let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if !matte && !layer.preserve_transparency { - self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity); - return; + return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize); } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0); self.composite_iso(ctx, layer, iso, canvas, opacity); + None } /// Apply the track matte / preserve transparency to an isolated layer render, then blend. @@ -556,3 +580,21 @@ pub struct Placement { /// Dissolve noise seed. pub seed: u32, } + +/// The part of `footprint` a warp actually changed (`drawn`, see [`composite_warp_changed`]): +/// pixels it left alone are still quantised. +fn narrow(footprint: Region, drawn: Option<[u32; 4]>) -> Region { + let Some([x0, y0, x1, y1]) = drawn else { return footprint }; + if x0 >= x1 || y0 >= y1 { + return Region::Empty; + } + let r = color::PxRect { x0, y0, x1, y1 }; + match footprint { + Region::Empty => Region::Empty, + Region::Full => Region::Rect(r), + Region::Rect(f) => { + let q = color::PxRect { x0: r.x0.max(f.x0), y0: r.y0.max(f.y0), x1: r.x1.min(f.x1), y1: r.y1.min(f.y1) }; + if q.x0 >= q.x1 || q.y0 >= q.y1 { Region::Empty } else { Region::Rect(q) } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 439f92cb5..0552a44d4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -32,7 +32,7 @@ use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Pa use effectcraft_geom::{Mat3, Mat4, vec2}; use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; pub use effectcraft_raster::Image; -use effectcraft_raster::{WarpOpts, composite_warp}; +use effectcraft_raster::{WarpOpts, composite_warp_changed}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size}; use rayon::prelude::*; From 94399e62ff1a2266521b86e1546f7e5b7b328541 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:40:43 +0100 Subject: [PATCH 3/3] Pin movie export bytes H.264 and ProRes exports of a 60-frame comp (several render batches, a rotating stroked ring, a fading solid at a fractional position, a Screen-mode shape, 8 bpc) must keep the exact bytes main wrote before the two previous commits. Co-Authored-By: Claude Opus 5.5 --- crates/export/tests/it/main.rs | 1 + crates/export/tests/it/movie_bytes.rs | 104 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 crates/export/tests/it/movie_bytes.rs diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 0f89a641d..9cdc7a6c9 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -4,6 +4,7 @@ mod audio_sample_positions; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod mov_audio_sample_positions; +mod movie_bytes; mod ms_rounded_footage; #[cfg(not(target_arch = "wasm32"))] mod native_audio_sample_positions; diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs new file mode 100644 index 000000000..5f1f8eb6b --- /dev/null +++ b/crates/export/tests/it/movie_bytes.rs @@ -0,0 +1,104 @@ +//! Movie exports pinned byte for byte: a comp longer than one render batch, with a rotating +//! stroked ring (mostly transparent, bilinear), a fading half-transparent solid at a fractional +//! position and a Screen-mode shape, in 8 bpc. The comp is at most four macroblock rows high, +//! so the H.264 encoder writes one slice per frame on any number of cores. The hashes are those of the files written before +//! frames rendered while the previous batch encoded, before transparent source tiles were +//! skipped and before blended pixels were quantised as they were drawn: those changes must not +//! move a single byte. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, Sink, export}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +fn fnv1a(data: &[u8]) -> u64 { + data.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)) +} + +fn project() -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(160, 64, FrameRate::new(30, 1), Tick::from_seconds_f64(2.0)); + comp.background = [0.05, 0.1, 0.3]; + let t = Tick::from_seconds_f64; + + let sid = p.add_item("Half", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.5, 0.1], width: 70, height: 40, pixel_aspect: 1.0 })); + let mut solid = build::layer(&mut p, &comp, "Half", LayerSource::Solid { item: sid }, (70, 40), None); + if let Some(pr) = solid.props.prop_mut("transform/position") { + pr.value = Value::Vec3([60.25, 36.5, 0.0]); + } + if let Some(pr) = solid.props.prop_mut("transform/opacity") { + pr.keys = vec![Keyframe::new(t(0.0), Value::Scalar(20.0)), Keyframe::new(t(2.0), Value::Scalar(70.0))]; + } + + let mut ring = build::layer(&mut p, &comp, "Ring", LayerSource::Shape, (160, 64), None); + let mut box_ = build::layer(&mut p, &comp, "Box", LayerSource::Shape, (160, 64), None); + let mut next = p.next_id; + { + let mut ids = Ids(&mut next); + let e = build::shape_ellipse(&mut ids, [56.0, 56.0], [0.0, 0.0]); + let s = build::shape_stroke(&mut ids, [0.3, 0.8, 1.0, 1.0], 3.0); + let g = build::shape_group(&mut ids, "Ring", vec![e, s]); + if let Some(c) = ring.props.sub_mut("contents") { + c.children.push(g.into()); + } + let r = build::shape_rect(&mut ids, [30.0, 20.0], [25.0, -10.0], 4.0); + let f = build::shape_fill(&mut ids, [0.2, 1.0, 0.4, 0.8]); + let g = build::shape_group(&mut ids, "Box", vec![r, f]); + if let Some(c) = box_.props.sub_mut("contents") { + c.children.push(g.into()); + } + } + p.next_id = next; + if let Some(pr) = ring.props.prop_mut("transform/rotation") { + pr.keys = vec![Keyframe::new(t(0.0), Value::Scalar(0.0)), Keyframe::new(t(2.0), Value::Scalar(130.0))]; + } + if let Some(pr) = box_.props.prop_mut("transform/position") { + pr.keys = vec![Keyframe::new(t(0.0), Value::Vec3([70.0, 30.0, 0.0])), Keyframe::new(t(2.0), Value::Vec3([95.5, 38.25, 0.0]))]; + } + box_.blend_mode = BlendMode::Screen; + + comp.layers = vec![box_, ring, solid]; + let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +fn export_bytes(format: OutputFormat, name: &str) -> Vec { + let (p, cid) = project(); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let om = OutputModule::for_format(format); + let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let file = out.clone(); + let sink: &Sink = &move |_: &str, data: Vec| *file.lock().unwrap() = data; + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: name, + sink: Some(sink), + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!(r.frames, 60); + std::mem::take(&mut *out.lock().unwrap()) +} + +#[test] +fn h264_movie_bytes_are_pinned() { + let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); + assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4"); +} + +#[test] +fn prores_movie_bytes_are_pinned() { + let data = export_bytes(OutputFormat::ProRes, "/out/pinned.mov"); + assert_eq!((data.len(), fnv1a(&data)), (270_094, 8_038_709_743_204_075_800), "ProRes MOV"); +}