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 69cdc84b..5d8405c0 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. //! @@ -74,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 3680ffcd..7b451726 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::{Backend, 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,15 +73,24 @@ 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; + // 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, @@ -85,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, @@ -94,8 +107,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 +368,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/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 c8c8a4e5..7fd5456d 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 @@ -235,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