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:
commit
609ef9cacb
|
|
@ -1252,6 +1252,7 @@ version = "0.6.0"
|
|||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-hevcenc",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-media",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue