From bd473e0fd58f311d0a6253b9d88b75a241b65716 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 12:50:05 -0300 Subject: [PATCH 1/6] #1615: document EFFECTCRAFT_CPU_INFLIGHT in docs/preferences.md --- docs/preferences.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/preferences.md b/docs/preferences.md index 927b66788..b1ae0eda9 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -89,7 +89,11 @@ Commands (CLI, MCP, control channel): one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the cap turned off) and once frames have had to wait for memory. The render log names the count. - `EFFECTCRAFT_GPU_INFLIGHT` overrides it. + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one + frame per thread in flight, so their memory grows with the core count. Set + `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in + flight of every render, `effectcraft-cli render` included + ([#1615](https://github.com/storytold/effectcraft/issues/1615)). - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From aa4adf1c382f35474241afd6d2f80c8e7bdce5ca Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 14:18:54 -0300 Subject: [PATCH 2/6] #1615: note the frames the renderer measurement keeps in EFFECTCRAFT_CPU_INFLIGHT docs --- docs/preferences.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/preferences.md b/docs/preferences.md index b1ae0eda9..ce628cde1 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -93,7 +93,9 @@ Commands (CLI, MCP, control channel): frame per thread in flight, so their memory grows with the core count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in flight of every render, `effectcraft-cli render` included - ([#1615](https://github.com/storytold/effectcraft/issues/1615)). + ([#1615](https://github.com/storytold/effectcraft/issues/1615)). One exception: while a job + on GPU Acceleration times both compositors on its first frames, it renders them under the + cap but keeps them all (up to 12) until it has chosen. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From 5d1b6ce3f2e06b5d51cacba13c43e9da2d19dd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 10 Nov 2026 04:58:07 +0100 Subject: [PATCH 3/6] gpu: match CPU rounding in box blur rows Horizontal GPU box passes restarted the running sum every 256 pixels, while the CPU keeps one sum per row; use the full row width as the block so f32 rounding matches. The regression test requires exact equality on llvmpipe (CI); other adapters only get an absolute 2e-6 sanity bound. --- crates/gpu/src/effects.rs | 4 +++- crates/gpu/src/shaders/kernels.wgsl | 2 +- crates/gpu/src/tests.rs | 34 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index b33c15e99..fd5a97c38 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -274,7 +274,9 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { - let block = if vertical { (4 * r).clamp(32, 256) } else { 256 } as u32; + // Vertical sums restart every `block` rows as the CPU's do; a row is one running sum, as + // on the CPU (restarting it rounds differently). + let block = if vertical { (4 * r).clamp(32, 256) as u32 } else { cur.width.max(1) }; let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) }; let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index bb4a5e63a..a123b70ca 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -270,7 +270,7 @@ fn line_get(horizontal: bool, line: i32, i: i32, n: i32, repeat: bool) -> vec4, horizontal: bool) { let dims = out_dims(); let r = i32(P.u[0].x); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 7b85bfc85..b0587b2e2 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -731,6 +731,40 @@ fn small_blurs_fall_back_to_the_cpu() { } } +/// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum +/// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path. +/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters (Metal measured +/// ~1e-6 either way) only get a 2e-6 sanity bound and do not catch the regression. Metal still +/// differs by ~1e-6 with matching blocks. +#[test] +fn box_blur_horizontal_rows_match_cpu_beyond_256() { + let Some(g) = gpu() else { return }; + let img = pattern(11, 320, 48); + let up = g.ctx.upload_image(&img).expect("upload"); + let tol = if on_llvmpipe() { 0.0 } else { 2e-6 }; + let check_case = |name: &str, want: Image, got: Image| { + assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size"); + let mut max_abs = 0.0f32; + for (a, b) in want.data.iter().zip(&got.data) { + for c in 0..4 { + max_abs = max_abs.max((a[c] - b[c]).abs()); + } + } + assert!(max_abs <= tol, "{name}: max abs channel diff {max_abs} exceeds {tol} on {}", g.ctx.name); + }; + for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] { + let mut e = crate::context::Enc::new(&g.ctx); + let rx = [r, r, r]; + let out = crate::effects::box_passes(&mut e, &up, &rx, &rx, false); + check_case(name, effectcraft_raster::box_blur(&img, r, r, 3, false), e.download(&out).expect("download")); + } + for (name, sigma) in [("gauss σ10", 10.0f64), ("gauss σ40", 40.0)] { + let mut e = crate::context::Enc::new(&g.ctx); + let out = crate::effects::gaussian_blur(&mut e, &up, sigma, sigma, false); + check_case(name, effectcraft_raster::gaussian_blur(&img, sigma, sigma, false), e.download(&out).expect("download")); + } +} + #[test] fn gpu_effects_match_cpu() { // Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939). From 513db8f42140fa4030e8fcf511b1c28a966f82d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 10 Nov 2026 09:33:06 +0100 Subject: [PATCH 4/6] gpu: assert box-blur row parity with diff(), exact only on llvmpipe Reuse the existing diff() helper so a non-finite pixel fails the test (f32::max ignored NaN). Keep exact equality on llvmpipe, which still fails without the row-block fix; other adapters use the usual Bpc32 tolerance, because a 2e-6 bound can fail on unmeasured hardware and does not catch the regression there. --- crates/gpu/src/tests.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b0587b2e2..1d2782262 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -733,24 +733,19 @@ fn small_blurs_fall_back_to_the_cpu() { /// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum /// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path. -/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters (Metal measured -/// ~1e-6 either way) only get a 2e-6 sanity bound and do not catch the regression. Metal still -/// differs by ~1e-6 with matching blocks. +/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters use the usual +/// Bpc32 tolerance because a tighter bound can fail on unmeasured hardware and does not catch +/// the regression (Metal still differs by ~1e-6 with or without the fix). #[test] fn box_blur_horizontal_rows_match_cpu_beyond_256() { let Some(g) = gpu() else { return }; let img = pattern(11, 320, 48); let up = g.ctx.upload_image(&img).expect("upload"); - let tol = if on_llvmpipe() { 0.0 } else { 2e-6 }; + let tol = if on_llvmpipe() { 0.0 } else { tolerance(BitDepth::Bpc32) }; let check_case = |name: &str, want: Image, got: Image| { assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size"); - let mut max_abs = 0.0f32; - for (a, b) in want.data.iter().zip(&got.data) { - for c in 0..4 { - max_abs = max_abs.max((a[c] - b[c]).abs()); - } - } - assert!(max_abs <= tol, "{name}: max abs channel diff {max_abs} exceeds {tol} on {}", g.ctx.name); + let d = diff(&want, &got, tol); + assert_eq!(d.over, 0, "{name}: max {} at {:?} on {}", d.max, d.worst, g.ctx.name); }; for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] { let mut e = crate::context::Enc::new(&g.ctx); From f35392cd3eabe7181a29f092b9e44b4a8fe5b709 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 11:22:09 +0530 Subject: [PATCH 5/6] #1615: say EFFECTCRAFT_CPU_INFLIGHT caps Render Queue renders and exports, not previews --- docs/preferences.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/preferences.md b/docs/preferences.md index e20084c5f..4e379909a 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -102,10 +102,10 @@ Commands (CLI, MCP, control channel): `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one frame per thread in flight, so their memory grows with the core count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in - flight of every render, `effectcraft-cli render` included - ([#1615](https://github.com/storytold/effectcraft/issues/1615)). One exception: while a job - on GPU Acceleration times both compositors on its first frames, it renders them under the - cap but keeps them all (up to 12) until it has chosen. + flight of every Render Queue render and export, `effectcraft-cli render` included + ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One + exception: while a job on GPU Acceleration times both compositors on its first frames, it + renders them under the cap but keeps them all (up to 12) until it has chosen. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles From 861c65ab26fb676894321fda9fefca7b0219325b Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 11:22:09 +0530 Subject: [PATCH 6/6] #3335: document EFFECTCRAFT_RQ_RENDERER and the Render Queue's per-job compositor choice architecture.md and web.md still said every job renders on the project's renderer (#414); since #3335 a GPU job times both compositors on its first frames and keeps the faster. --- docs/architecture.md | 9 ++++++--- docs/preferences.md | 6 +++++- docs/web.md | 6 +++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 195634eb1..f50c38f64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -340,10 +340,13 @@ whichever compositor measured faster for it (`render::auto::AutoPick`, kept by t per comp, output scale and path (readback or viewer display) it averages CPU and GPU frame times (warm-up on both sides, the fastest warm-up frame starting the average, single stalls capped), picks the GPU unless the CPU is clearly faster, and re-measures the other side every 48 frames. -Render Queue jobs don't use Auto: every frame of a job renders on the project's renderer (the -GPU compositor with GPU Acceleration), so runs of a render don't differ by timing. Light comps (a few small layers, which the CPU composites only within their bounds) then stay on -the CPU instead of paying for full-frame passes and a readback. A GPU frame walks the comp like +the CPU instead of paying for full-frame passes and a readback. Render Queue jobs don't use +Auto per frame: a job renders every frame on one compositor, never a mix (#3335). A GPU +Acceleration job with an accelerator times both on its first frames +(`export::pipeline::Cx::probe`, jobs of 12 frames or more) and keeps the faster, the CPU only +when clearly faster; `EFFECTCRAFT_RQ_RENDERER=gpu|cpu` fixes the choice, and readback-deferred +accelerators (the web) render on the project's renderer. A GPU frame walks the comp like `draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes, diff --git a/docs/preferences.md b/docs/preferences.md index 4e379909a..215ada597 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -105,7 +105,11 @@ Commands (CLI, MCP, control channel): flight of every Render Queue render and export, `effectcraft-cli render` included ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One exception: while a job on GPU Acceleration times both compositors on its first frames, it - renders them under the cap but keeps them all (up to 12) until it has chosen. + renders them under the cap but keeps them all (up to 12) until it has chosen. That timing + picks the compositor that a job of 12 frames or more renders every frame on (the render log + names it and why). `EFFECTCRAFT_RQ_RENDERER` set to `gpu` or `cpu` skips it and renders on + that compositor; `auto` (the default) measures. A queue item's later output modules reuse its + first one's choice, and Software Rendering projects always render on the CPU. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles diff --git a/docs/web.md b/docs/web.md index f94a687be..f4eb882a8 100644 --- a/docs/web.md +++ b/docs/web.md @@ -173,9 +173,9 @@ the same futures with `passes::block_on`; they never wait there). - **Render Queue**: `effectcraft_export::export_async` renders each frame in passes (frames with deferred readbacks render one by one instead of in parallel batches) and encodes as - before (`Exporter::export_async`). As on the desktop, a job renders every frame on the - project's renderer (the worker's GPU with GPU Acceleration), not on whichever - compositor measured faster, so runs don't differ by timing. + before (`Exporter::export_async`). Unlike the desktop, which times both compositors on a + job's first frames and keeps the faster, a job renders every frame on the project's renderer + (the worker's GPU with GPU Acceleration), so runs don't differ by timing. - **Analyses** (Warp Stabilizer, 3D Camera Tracker, Track Motion, mask and face tracking, Roto Brush freeze and propagation): their work functions are `async`; started with `wait` inside a job they are queued and awaited (`offload::run_or_queue`), and each input frame renders in