diff --git a/Cargo.lock b/Cargo.lock index ac4a03a06..501b4805c 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 11f398cf2..f4814d6c3 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 ec5b95c7d..5d8405c0d 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 6283613de..7b451726d 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 d1cfc4e1b..9e9a0a18f 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 05630dbe5..615d4fbd1 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 2bee4136d..7fd5456d3 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 af8eaca48..9e3fab589 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 8c1989701..b03086eb5 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