Merge pull request #429 from storytold/fix/413-414-export-cache

Export: Render Queue jobs keep a layer cache and render on one compositor (#413, #414)
This commit is contained in:
BFlat 2026-10-09 22:51:10 +07:00 committed by GitHub
commit 609ef9cacb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 169 additions and 19 deletions

1
Cargo.lock generated
View File

@ -1252,6 +1252,7 @@ version = "0.6.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-hevcenc",
"effectcraft-keyframe",
"effectcraft-media",

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 }

View File

@ -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,

View File

@ -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<Mutex<Vec<(u64, f64)>>>,
/// 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<u8> {
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]);

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");
}

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:?}");

View File

@ -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

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,

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