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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 22:26:17 +07:00
co-authored by Claude Opus 5.5
parent 1ccf7cb910
commit 8c4a3cbfe0
9 changed files with 96 additions and 12 deletions
Generated
+1
View File
@@ -1252,6 +1252,7 @@ version = "0.6.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-hevcenc",
"effectcraft-keyframe",
"effectcraft-media",
+1
View File
@@ -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 }
+2 -1
View File
@@ -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,
+6 -2
View File
@@ -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,
+77 -3
View File
@@ -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<Report, String>, 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<Report, String>, Files) {
let files: Files = Default::default();
let f = files.clone();
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| 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<Image> {
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<f32>) -> Option<Buf> {
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::<Vec<_>>()
};
// 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");
}
+2 -2
View File
@@ -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:?}");
+2
View File
@@ -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
+2 -1
View File
@@ -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,
+3 -3
View File
@@ -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