From 1ccf7cb91066b81a91ff9e1d0c0e311bcf124a9f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 21:34:48 +0700 Subject: [PATCH 1/2] Export: Render Queue jobs keep a layer cache, so static layers render once (#413) Cause: pipeline::Cx::render_at built the job's Renderer without a layer cache, so every layer of every frame rendered from scratch (source, masks, effects, styles), and the GPU compositor, whose uploads are keyed by the cached buffers, uploaded every layer again on every frame. Fix: each job (Cx) owns a LayerCache, the cache type the viewer uses, with its default budget (1 GiB, the default Settings > Memory & CPU layer cache), shared by the job's parallel frames and dropped with the job. With deferred GPU readbacks it holds the accelerator's miss gate, as the job workers' caches do. Cx::renderer builds the job's renderer (options, accelerator, cache) for render_at and the test. Tests: pipeline::tests::layers_render_once_per_job exports ten frames of a solid animated only in Transform: the cache misses of the first frame are the only ones (it fails without the cache: no layer is cached), and every frame equals the frame rendered without the cache. Diagnosis by @pat-the-wizard. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/lib.rs | 3 +- crates/export/src/pipeline.rs | 76 ++++++++++++++++++++++++++++++++--- docs/architecture.md | 3 +- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 69cdc84b..ec5b95c7 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -21,7 +21,8 @@ //! render log (Errors Only / Plus Settings / Plus Per Frame Info) is written next to the output. //! //! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to -//! the encoder in order. Progress is reported after each batch through a callback that can cancel +//! the encoder in order. They share the job's layer cache, as viewer frames share the viewer's: +//! static and transform-only layers render once per job. Progress is reported after each batch through a callback that can cancel //! the job by returning `false`. Codecs and the MP4/MOV muxer are FilmCraft's pure-Rust crates //! (plan ADR 0001); nothing outside this crate touches them. //! diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 3680ffcd..6283613d 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -9,17 +9,21 @@ use effectcraft_project::render_queue::{ }; use effectcraft_project::{Comp, Project}; use effectcraft_raster::Image; -use effectcraft_render::{RenderOpts, Renderer}; +use effectcraft_render::{LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; use web_time::Instant; use crate::{Job, RenderQuality, out}; -/// A job being exported: the job, the project with the Render Settings overrides applied, and -/// per-frame timing for the render log. +/// 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> { pub job: &'a Job<'a>, pub project: Project, + /// Processed layer buffers shared by the job's frames, as the viewer's layer cache: static + /// and transform-only layers render once per job (the GPU compositor then uploads them + /// once). Dropped with the job. + cache: LayerCache, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -69,14 +73,19 @@ impl<'a> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); - Cx { job, project, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } + let cache = LayerCache::default(); + // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. + cache.set_gate(job.accel.and_then(|a| a.miss_gate())); + Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } } pub fn comp(&self) -> Option<&Comp> { self.project.comp(self.job.comp) } - async fn render_at(&self, t: Tick) -> Image { + /// The renderer of the job's frames: the Render Settings' options, the job's accelerator + /// and layer cache. + fn renderer(&self) -> Renderer<'_> { let s = self.job.settings; let opts = RenderOpts { scale: s.resolution.clamp(0.01, 4.0), @@ -94,8 +103,13 @@ impl<'a> Cx<'a> { let mut r = Renderer::new(&self.project, self.job.footage, opts); r.expr = self.job.expr; r.accel = self.job.accel; + r.cache = Some(&self.cache); + r + } + + async fn render_at(&self, t: Tick) -> Image { // With deferred GPU readbacks (a browser worker) the frame renders in passes. - effectcraft_render::passes::comp_frame(&r, self.job.comp, t).await + effectcraft_render::passes::comp_frame(&self.renderer(), self.job.comp, t).await } /// Whether frames render in passes (an accelerator with deferred readbacks). @@ -350,6 +364,56 @@ fn premultiplied8(img: &Image) -> Vec { mod tests { use super::*; + #[test] + fn layers_render_once_per_job() { + // #413: a job rendered every layer of every frame from scratch (no layer cache). + use effectcraft_color::Label; + use effectcraft_keyframe::{Keyframe, Value}; + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + use effectcraft_project::{ItemKind, LayerSource, Solid, build}; + use effectcraft_render::NoFootage; + use effectcraft_time::FrameRate; + // A solid animated only in Transform: its pixels render once, then move. + let mut p = Project::default(); + let mut comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 16, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (16, 16), None); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([8.0, 24.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([56.0, 24.0, 0.0]))]; + comp.layers.push(l); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + let cx = Cx::new(&job); + let comp = cx.comp().unwrap(); + cx.frame(comp, 0); + let renders = cx.cache.stats().misses; + assert!(renders > 0); + for i in 1..10 { + let img = cx.frame(comp, i); + // The frames are the ones rendered without a cache. + let t = settings.frame_time(comp, i); + let mut uncached = cx.renderer(); + uncached.cache = None; + assert_eq!(img.data, uncached.comp_frame(cid, t).data, "frame {i}"); + } + let stats = cx.cache.stats(); + assert_eq!(stats.misses, renders, "the solid rendered on the first frame only"); + assert!(stats.hits >= 9, "{stats:?}"); + } + #[test] fn interleave_takes_alternate_lines() { let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]); diff --git a/docs/architecture.md b/docs/architecture.md index c8c8a4e5..2bee4136 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,7 +161,8 @@ collapsed 3D precomps (drawn flattened), morph targets, extruded strokes. A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of its evaluated inputs, excluding the transform. Static and transform-only layers render once; -editing one layer re-renders only that layer. Effects that read the clock directly are declared in +editing one layer re-renders only that layer. Each Render Queue job keeps its own layer cache +for its frames. Effects that read the clock directly are declared in `effects::TIME_DEPENDENT`, and a test checks every registered effect against that list. The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by project From 8c4a3cbfe0456362352b5ed2ecadcba517511cf2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 21:59:27 +0700 Subject: [PATCH 2/2] Export: a Render Queue job renders every frame on the project's renderer (#414) Cause: pipeline::Cx rendered with Backend::Auto, which picks the CPU or the GPU compositor for each top-level frame from measured frame times (render::AutoPick: warm-up on both sides, a re-probe every 48 frames). The two compositors differ slightly, so frames came out differently depending on which side the timings favoured in that run, and two GPU exports of the same frames differed. Fix: the job renders every frame on the project's renderer, as After Effects renders a job with one renderer: Backend::Gpu with Mercury GPU Acceleration (frames the GPU declines still render on the CPU, as they do in every GPU render), Backend::Cpu with Mercury Software Only. The viewer and previews keep Auto. Docs (architecture, parity, web) say so. Tests: render_settings::a_job_renders_on_one_compositor exports ten frames with a stand-in GPU compositor that keeps Auto history and draws solid green: with Mercury GPU Acceleration every frame is green and two runs are identical (with Auto the warm-up sent frames to the CPU: it fails), with Software Only the stand-in is never asked. The web worker GPU job test's comment no longer describes Auto. Diagnosis by @pat-the-wizard. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/export/Cargo.toml | 1 + crates/export/src/lib.rs | 3 +- crates/export/src/pipeline.rs | 8 ++- crates/export/tests/render_settings.rs | 80 +++++++++++++++++++++++++- crates/ui-egui/tests/ui_web_job_gpu.rs | 4 +- docs/architecture.md | 2 + docs/parity.md | 3 +- docs/web.md | 6 +- 9 files changed, 96 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac4a03a0..501b4805 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1252,6 +1252,7 @@ version = "0.6.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", + "effectcraft-effects", "effectcraft-hevcenc", "effectcraft-keyframe", "effectcraft-media", diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 11f398cf..f4814d6c 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -31,6 +31,7 @@ log = { workspace = true } [dev-dependencies] effectcraft-media = { workspace = true } +effectcraft-effects = { workspace = true } effectcraft-color = { workspace = true } filmcraft-opus = { workspace = true } filmcraft-vp9 = { workspace = true } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index ec5b95c7..5d8405c0 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -75,7 +75,8 @@ pub struct Job<'a> { pub project: &'a Project, pub footage: &'a dyn FootageSource, pub expr: Option<&'a dyn ExprHost>, - /// GPU compositor: used when the project's renderer is Mercury GPU Acceleration. + /// GPU compositor: used for every frame of the job when the project's renderer is Mercury + /// GPU Acceleration (frames it declines render on the CPU). pub accel: Option<&'a dyn effectcraft_render::Accelerator>, pub comp: ItemId, pub settings: &'a RenderSettings, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 6283613d..7b451726 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -9,7 +9,7 @@ use effectcraft_project::render_queue::{ }; use effectcraft_project::{Comp, Project}; use effectcraft_raster::Image; -use effectcraft_render::{LayerCache, RenderOpts, Renderer}; +use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; use web_time::Instant; @@ -87,6 +87,10 @@ impl<'a> Cx<'a> { /// and layer cache. fn renderer(&self) -> Renderer<'_> { let s = self.job.settings; + // The whole job renders on the project's renderer (Mercury GPU Acceleration or Software + // Only), not on whichever compositor Auto timed faster for each frame: the CPU and GPU + // compositors differ slightly, so a per-frame choice made renders differ run to run. + let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { scale: s.resolution.clamp(0.01, 4.0), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, @@ -94,7 +98,7 @@ impl<'a> Cx<'a> { draft: s.quality == RenderQuality::Draft, // Output renders always look through the comp's active camera. view: None, - backend: effectcraft_render::Backend::Auto, + backend, roi: None, nested_switches: self.job.nested_switches, draft_shadows: true, diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/render_settings.rs index d1cfc4e1..9e9a0a18 100644 --- a/crates/export/tests/render_settings.rs +++ b/crates/export/tests/render_settings.rs @@ -1,16 +1,18 @@ //! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches //! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only //! and premultiplied output, the solo and guide-layer overrides, the render log, storage -//! overflow and PCM audio formats. +//! overflow, PCM audio formats and the compositor a job renders on. +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use effectcraft_color::Label; +use effectcraft_effects::Buf; use effectcraft_export::render_queue::*; use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export}; use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; -use effectcraft_render::NoFootage; +use effectcraft_render::{Accelerator, AutoPick, FxStep, Image, NoFootage, Renderer}; use effectcraft_time::{FrameRate, Tick}; const W: u32 = 64; @@ -43,6 +45,20 @@ fn custom(start: f64, end: f64) -> RenderSettings { } fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result, Files) { + run_on(p, cid, s, om, path, options, None, cancel) +} + +/// [`run_with`] with an accelerator. +fn run_on( + p: &Project, + cid: ItemId, + s: &RenderSettings, + om: &OutputModule, + path: &str, + options: JobOptions, + accel: Option<&dyn Accelerator>, + cancel: bool, +) -> (Result, Files) { let files: Files = Default::default(); let f = files.clone(); let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); @@ -50,7 +66,7 @@ fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, pat project: p, footage: &NoFootage, expr: None, - accel: None, + accel, comp: cid, settings: s, output: om, @@ -402,3 +418,61 @@ fn cancelled_export_removes_only_files_it_opened() { } std::fs::remove_dir_all(dir).unwrap(); } + +/// A GPU compositor stand-in that renders every frame solid green (the CPU draws the bar and the +/// block on black) and keeps Auto's timing history. +#[derive(Default)] +struct GreenAccel { + pick: AutoPick, + frames: AtomicUsize, +} + +impl Accelerator for GreenAccel { + fn name(&self) -> String { + "green".into() + } + fn comp_frame(&self, r: &Renderer, comp: ItemId, _t: Tick) -> Option { + self.frames.fetch_add(1, Ordering::SeqCst); + let c = r.project.comp(comp)?; + Some(Image::filled(c.width, c.height, [0.0, 1.0, 0.0, 1.0])) + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[FxStep], _buf: &Buf, _levels: Option) -> Option { + None + } + fn auto_pick(&self) -> Option<&AutoPick> { + Some(&self.pick) + } +} + +#[test] +fn a_job_renders_on_one_compositor() { + // #414: Auto chose the CPU or the GPU for each frame from measured frame times, so GPU + // renders of the same frames differed run to run. A job renders every frame on the + // project's renderer. + let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let om = OutputModule::for_format(OutputFormat::PngSequence); + let s = custom(0.0, 0.4); + let render = |p: &Project, accel: &GreenAccel, path: &str| { + let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), Some(accel), false); + r.expect("export"); + (0..10).map(|i| png(&files, i)).collect::>() + }; + // Mercury GPU Acceleration: every frame on the GPU, the same in every run. + p.settings.gpu_acceleration = true; + let gpu = GreenAccel::default(); + let a = render(&p, &gpu, "/g/a_[#####].png"); + assert_eq!(gpu.frames.load(Ordering::SeqCst), 10); + for (i, f) in a.iter().enumerate() { + assert!(f.pixels().all(|px| px.0 == [0, 255, 0, 255]), "frame {i} rendered on the CPU"); + } + assert_eq!(a, render(&p, &gpu, "/g/b_[#####].png")); + // Mercury Software Only: every frame on the CPU. + p.settings.gpu_acceleration = false; + let cpu = GreenAccel::default(); + let c = render(&p, &cpu, "/g/c_[#####].png"); + assert_eq!(cpu.frames.load(Ordering::SeqCst), 0); + assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block"); +} diff --git a/crates/ui-egui/tests/ui_web_job_gpu.rs b/crates/ui-egui/tests/ui_web_job_gpu.rs index 05630dbe..615d4fbd 100644 --- a/crates/ui-egui/tests/ui_web_job_gpu.rs +++ b/crates/ui-egui/tests/ui_web_job_gpu.rs @@ -82,8 +82,8 @@ fn render_queue_jobs_render_on_the_worker_gpu() { let done = |r: &[WorkerReply]| r.iter().any(|r| matches!(r, WorkerReply::Item { update } if format!("{update:?}").contains("Done"))); assert!(done(&cpu_replies) && done(&gpu_replies), "{gpu_replies:?}"); assert!(matches!(gpu_replies.last(), Some(WorkerReply::Done))); - // Backend Auto (what renders use) warms up both sides, GPU first: the GPU frames rendered - // in passes on the device (their chains, then the frame read back). + // Every frame renders on the project's renderer, the GPU: in passes on the device (their + // chains, then the frame read back). let frames = gpu_files.len() as u64; assert_eq!(frames, 6); assert!(after.0 - before.0 >= 6, "passes {before:?} → {after:?}"); diff --git a/docs/architecture.md b/docs/architecture.md index 2bee4136..7fd5456d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -236,6 +236,8 @@ 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 Mercury 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 `draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's diff --git a/docs/parity.md b/docs/parity.md index af8eaca4..9e3fab58 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -289,7 +289,8 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). footprint is now cached and uploaded once). - **Auto picks per comp**: `Backend::Auto` (Mercury GPU Acceleration) times each comp on both compositors (`render::AutoPick`: per comp, scale and path; warm-up, capped stalls, re-probe - every 48 frames) and renders on the faster one, in renders and in the viewer. A light comp + every 48 frames) and renders on the faster one, in the viewer and previews (a Render Queue + job renders every frame on the project's renderer, so its runs don't differ by timing). A light comp stays on the CPU, which composites only the layers' bounds: Lower Third Auto 0.8 ms/frame (CPU warm 1.7, GPU warm 4.1, which is mostly the 32 MB readback). - **GPU ports (EFF-5)**: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, diff --git a/docs/web.md b/docs/web.md index 8c198970..b03086eb 100644 --- a/docs/web.md +++ b/docs/web.md @@ -171,9 +171,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`). Renders use Backend Auto, as on the desktop: per comp - the worker warms up both sides (GPU first) and then renders on the faster one, each frame's - time measured across all its passes. + before (`Exporter::export_async`). As on the desktop, a job renders every frame on the + project's renderer (the worker's GPU with Mercury GPU Acceleration), not on whichever + compositor measured faster, 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