Files
effectcraft/crates/export/src/pipeline.rs
T

1702 lines
79 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The frame pipeline shared by every output format: Render Settings overrides (effects, solo,
//! guide layers, frame blending, motion blur), field rendering with 3:2 pulldown, colour depth,
//! the Output Module's crop and resize, alpha handling, storage overflow and the render log.
use std::sync::Mutex;
use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path};
use effectcraft_project::{BitDepth, Comp, Project};
use effectcraft_raster::Image;
use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use web_time::Instant;
use crate::{Job, RenderQuality, out};
/// GPU frames rendered at once until a frame has measured the device memory, and whenever it
/// can't be measured (#898, #879: measured on the #879 project at 1080p on an 8 GB card, 6.3 GB
/// peak with one frame, 7.0 to 7.8 GB with two, the whole card with three).
pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1;
/// The most GPU frames Auto renders at once (#3335: four and more measured no faster).
pub const GPU_INFLIGHT_MAX: usize = 3;
/// Overrides Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: a whole number of frames,
/// at least 1.
pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT";
/// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or
/// `None` (with a warning when a value was given) when it is unset, 0, negative, not a number
/// or too large.
pub(crate) fn gpu_inflight_env(var: Option<&str>) -> Option<usize> {
let v = var?;
match v.trim().parse::<usize>() {
Ok(n) if n >= 1 => Some(n),
_ => {
log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: ignored");
None
}
}
}
/// Auto's GPU frames in flight from the device memory after a frame (#3335): as many more
/// frames as the budget has room for beyond the allocator's peak (which holds one frame's
/// working memory) and what the caches may still grow by, 1 to [`GPU_INFLIGHT_MAX`]. Unknown
/// memory: 1.
pub fn auto_gpu_frames(m: Option<effectcraft_render::FrameMemory>) -> usize {
let Some(m) = m.filter(|m| m.budget > 0 && m.frame > 0 && m.peak > 0) else { return GPU_INFLIGHT_DEFAULT };
let more = m.budget.saturating_sub(m.peak).saturating_sub(m.reserve) / m.frame;
usize::try_from(more.saturating_add(1)).unwrap_or(GPU_INFLIGHT_MAX).clamp(1, GPU_INFLIGHT_MAX)
}
/// The GPU frames a job renders at once and why (for the render log): the environment's
/// override, else the setting (`pref`, `None` = Auto), else Auto from `measured` (`None`: no
/// GPU frame has rendered yet, so nothing is settled).
pub(crate) fn resolve_gpu_frames(env: Option<&str>, pref: Option<usize>, measured: Option<Option<effectcraft_render::FrameMemory>>) -> Option<(usize, String)> {
if let Some(n) = gpu_inflight_env(env) {
return Some((n, format!("set by {GPU_INFLIGHT_ENV}")));
}
if let Some(n) = pref.filter(|n| *n >= 1) {
return Some((n, "Settings".into()));
}
let m = measured?;
let n = auto_gpu_frames(m);
let mib = |b: u64| b >> 20;
Some((
n,
match m {
Some(m) => format!(
"Auto: {} MiB per frame, {} MiB peak and {} MiB cache reserve in a {} MiB budget",
mib(m.frame),
mib(m.peak),
mib(m.reserve),
mib(m.budget)
),
None => "Auto: the GPU's memory is not known".into(),
},
))
}
/// Overrides the Render Queue's measured choice of compositor for GPU projects with an
/// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Software Rendering) or
/// `auto` (measure, the default).
pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER";
/// Frames the GPU renders, timed, when a job measures which compositor is faster (after one
/// untimed warm-up frame each: layer cache misses, uploads, kernel builds).
const PROBE_FRAMES: usize = 2;
/// Frames the CPU renders at once, timed, in that measurement: its throughput comes from
/// rendering several frames in parallel, which two frames understate.
const PROBE_CPU_FRAMES: usize = 8;
/// Jobs shorter than this render on the project's compositor without measuring: the frames
/// the slower one renders for the measurement would cost more than they could save.
const PROBE_MIN_JOB: u64 = 12;
/// Bytes each of a job's two layer caches (one per compositor) may hold: together what the
/// job's single cache held before (`LayerCache::default()`).
pub const CACHE_BUDGET: usize = 1 << 29;
/// The faster compositor from measured milliseconds per frame (`None`: not measured, or the
/// GPU stopped). As the viewer's Auto ([`effectcraft_render::AutoPick::MARGIN`]), the CPU wins
/// only when clearly faster: ties and noise stay on the GPU.
pub fn pick_renderer(gpu_ms: Option<f64>, cpu_ms: Option<f64>) -> crate::JobRenderer {
match (gpu_ms, cpu_ms) {
(Some(g), Some(c)) if g.is_finite() && c.is_finite() && c < g * effectcraft_render::AutoPick::MARGIN => crate::JobRenderer::Cpu,
(None, Some(_)) => crate::JobRenderer::Cpu,
_ => crate::JobRenderer::Gpu,
}
}
/// How a job's compositor was chosen, for the render log.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Choice {
pub renderer: crate::JobRenderer,
/// Measured ms per frame on each (`None`: not measured).
pub gpu_ms: Option<f64>,
pub cpu_ms: Option<f64>,
pub why: &'static str,
}
/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders
/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working
/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI
/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames.
pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT";
/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or
/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a
/// number or too large.
pub(crate) fn cpu_inflight(var: Option<&str>) -> Option<usize> {
let v = var?;
if v.trim().is_empty() {
return None;
}
match v.trim().parse::<usize>() {
Ok(n) if n >= 1 => Some(n),
_ => {
log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped");
None
}
}
}
/// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for
/// the process's.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Inflight {
pub gpu: Option<usize>,
pub cpu: Option<usize>,
}
impl Inflight {
/// Frames rendered at once: the smaller cap while the GPU renders, and only the CPU cap once
/// it has stopped for good (the CPU fallback renders in parallel, #1511). `None`: uncapped.
pub fn renders(&self, gpu_live: bool) -> Option<usize> {
let gpu = self.gpu.filter(|_| gpu_live);
let n = match (gpu, self.cpu) {
(Some(g), Some(c)) => Some(g.min(c)),
(g, c) => g.or(c),
};
n.map(|n| n.max(1))
}
/// Frames per window, rendered (`renders` at a time) and then encoded in parallel: with a CPU
/// cap at most that many, so finished images held before encoding count against it too;
/// with a GPU cap alone, one per thread (the images are in RAM, not VRAM).
pub fn window(&self, renders: usize) -> usize {
self.cpu.map_or(renders.max(rayon::current_num_threads()), |c| c.max(1))
}
}
/// `render` then `encode` over the frames `ks`, results in `ks` order.
///
/// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as
/// before). With `cap` (GPU frames in flight), the frames go in windows of
/// `max(cap, threads)`: each window renders in consecutive chunks of `cap` (at most `cap`
/// renders at once), then encodes in parallel, so only the render is serialised and at most
/// one window of rendered images is held in memory. Chunks hold no lock or slot, so a failing
/// or panicking frame cannot leak one, and nested parallelism (or a single thread) cannot
/// deadlock.
#[cfg(test)]
pub(crate) fn map_frames<I: Send, T: Send>(
ks: Vec<u64>,
cap: Option<usize>,
render: impl Fn(u64) -> I + Sync + Send,
encode: impl Fn(u64, I) -> T + Sync + Send,
) -> Vec<T> {
map_frames_while(ks, Inflight { gpu: cap, cpu: None }, || true, render, encode)
}
/// [`map_frames`] with the caps of `caps`, the GPU cap holding only while `gpu_live()`: once it
/// turns false (the GPU stopped for good, so every frame falls back to the CPU), the remaining
/// frames render under the CPU cap alone, in parallel when there is none, instead of one chunk
/// of the GPU cap at a time. A CPU cap also bounds each window ([`Inflight::window`]).
pub(crate) fn map_frames_while<I: Send, T: Send>(
ks: Vec<u64>,
caps: Inflight,
gpu_live: impl Fn() -> bool,
render: impl Fn(u64) -> I + Sync + Send,
encode: impl Fn(u64, I) -> T + Sync + Send,
) -> Vec<T> {
use rayon::prelude::*;
if caps.renders(gpu_live()).is_none() {
return ks.into_par_iter().map(|k| encode(k, render(k))).collect();
}
let mut out = Vec::with_capacity(ks.len());
let mut rest = &ks[..];
while !rest.is_empty() {
let Some(n) = caps.renders(gpu_live()) else {
out.par_extend(rest.par_iter().map(|&k| encode(k, render(k))));
break;
};
let (win, tail) = rest.split_at(caps.window(n).min(rest.len()));
let mut imgs = Vec::with_capacity(win.len());
let mut i = 0;
while i < win.len() {
let end = caps.renders(gpu_live()).map_or(win.len(), |n| (i + n).min(win.len()));
imgs.par_extend(win[i..end].par_iter().map(|&k| render(k)));
i = end;
}
out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img)));
rest = tail;
}
out
}
/// Threads that encode one batch of frames while the next renders ([`Cx::frames_beside`]).
/// Their own pool: the encoder's parallel steps (colour conversion) would otherwise queue behind
/// the render's work in the global pool. `None` where threads can't be started (a browser
/// without them): encoding and rendering then take turns.
pub(crate) struct Beside(Option<rayon::ThreadPool>);
impl Beside {
fn new() -> Beside {
Beside(rayon::ThreadPoolBuilder::new().thread_name(|i| format!("export-encode-{i}")).build().ok())
}
/// The process's encode threads, started by the first movie export and shared by all.
pub fn shared() -> &'static Beside {
static SHARED: std::sync::OnceLock<Beside> = std::sync::OnceLock::new();
SHARED.get_or_init(Beside::new)
}
}
/// 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). One per compositor; the job's own (dropped with it) or a Render Queue run's.
caches: Caches<'a>,
/// The compositor the job renders on, once chosen ([`Cx::backend`]).
choice: Mutex<Option<Choice>>,
/// GPU frames rendered at once and why, once settled ([`Cx::gpu_inflight`]).
gpu_frames: Mutex<Option<(usize, String)>>,
/// A frame has rendered on the GPU compositor: its memory can be measured.
gpu_rendered: std::sync::atomic::AtomicBool,
/// (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.
pub overflowed: Mutex<Vec<String>>,
written: Mutex<Vec<String>>,
}
/// A job's layer caches: its own, or those of the Render Queue run it belongs to.
enum Caches<'a> {
Own { gpu: Box<LayerCache>, cpu: Box<LayerCache> },
Run(crate::JobCaches<'a>),
}
impl Caches<'_> {
fn of(&self, r: crate::JobRenderer) -> &LayerCache {
match (self, r) {
(Caches::Own { gpu, .. }, crate::JobRenderer::Gpu) => gpu,
(Caches::Own { cpu, .. }, crate::JobRenderer::Cpu) => cpu,
(Caches::Run(c), crate::JobRenderer::Gpu) => c.gpu,
(Caches::Run(c), crate::JobRenderer::Cpu) => c.cpu,
}
}
}
impl<'a> std::ops::Deref for Cx<'a> {
type Target = Job<'a>;
fn deref(&self) -> &Job<'a> {
self.job
}
}
/// The project as the Render Settings render it: Effects All On / All Off, Solo Switches All
/// Off, Frame Blending and Motion Blur "On for Checked Layers" (the composition switches on).
pub(crate) fn apply_overrides(p: &mut Project, job: &Job) {
let s = job.settings;
let ids: Vec<_> = p.comps().map(|(i, _)| *i).collect();
for id in ids {
let Some(c) = p.comp_mut(id) else { continue };
match s.frame_blending {
SwitchOverride::Current => {}
SwitchOverride::OnForChecked => c.enable_frame_blending = true,
SwitchOverride::OffForAll => c.enable_frame_blending = false,
}
match s.motion_blur_override() {
SwitchOverride::Current => {}
SwitchOverride::OnForChecked => c.enable_motion_blur = true,
SwitchOverride::OffForAll => c.enable_motion_blur = false,
}
for l in c.layers.iter_mut() {
match s.effects {
EffectsMode::Current => {}
EffectsMode::AllOn => l.switches.effects = true,
EffectsMode::AllOff => l.switches.effects = false,
}
if s.solo == CurrentOrOff::AllOff {
l.switches.solo = false;
}
}
}
}
impl<'a> Cx<'a> {
pub fn new(job: &'a Job<'a>) -> Cx<'a> {
let mut project = job.project.clone();
apply_overrides(&mut project, job);
// Render Settings ▸ Color Depth overrides the project's depth for the whole render (every
// layer and effect quantises at it), not just the finished frame (#2022).
project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
let caches = match job.options.caches {
Some(c) => Caches::Run(c),
None => Caches::Own { gpu: Box::new(LayerCache::new(CACHE_BUDGET)), cpu: Box::new(LayerCache::new(CACHE_BUDGET)) },
};
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
for r in [crate::JobRenderer::Gpu, crate::JobRenderer::Cpu] {
caches.of(r).set_gate(job.accel.and_then(|a| a.miss_gate()));
}
let cx = Cx {
job,
project,
caches,
choice: Mutex::new(None),
gpu_frames: Mutex::new(None),
gpu_rendered: Default::default(),
frame_times,
overflowed: Mutex::new(vec![]),
written: Mutex::new(vec![]),
};
if let Some(c) = cx.fixed_choice(std::env::var(RENDERER_ENV).ok().as_deref()) {
cx.decide(c);
}
cx
}
/// The project's compositor: the GPU's for GPU Acceleration with an accelerator.
fn project_renderer(&self) -> crate::JobRenderer {
if self.project.settings.gpu_acceleration && self.job.accel.is_some() { crate::JobRenderer::Gpu } else { crate::JobRenderer::Cpu }
}
/// The job's compositor when it is decided without measuring: the queue item's first
/// output module's, the project's when there is nothing to choose between (Software Only,
/// no accelerator, deferred readbacks), or `env` ([`RENDERER_ENV`]). `None`: measure.
fn fixed_choice(&self, env: Option<&str>) -> Option<Choice> {
let fixed = |renderer, why| Some(Choice { renderer, gpu_ms: None, cpu_ms: None, why });
if self.project_renderer() == crate::JobRenderer::Cpu {
return fixed(crate::JobRenderer::Cpu, "the project's renderer");
}
if self.deferred() {
return fixed(crate::JobRenderer::Gpu, "the project's renderer");
}
let shared = self.job.options.renderer.and_then(|s| *s.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let shared = match shared {
Some(Backend::Gpu) => Some(crate::JobRenderer::Gpu),
Some(Backend::Cpu) => Some(crate::JobRenderer::Cpu),
Some(Backend::Auto) | None => None,
};
if let Some(r) = shared {
return Some(Choice { renderer: r, gpu_ms: None, cpu_ms: None, why: "as the queue item's first output module" });
}
match env.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("gpu") => fixed(crate::JobRenderer::Gpu, "set by EFFECTCRAFT_RQ_RENDERER"),
Some("cpu") => fixed(crate::JobRenderer::Cpu, "set by EFFECTCRAFT_RQ_RENDERER"),
None | Some("" | "auto") => None,
Some(v) => {
log::warn!(target: "effectcraft_export", "{RENDERER_ENV}={v:?} is not gpu, cpu or auto: measuring");
None
}
}
}
/// Settle the job's compositor (once): remember it for the queue item's other output
/// modules and say so in the log.
fn decide(&self, c: Choice) {
let mut slot = self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if slot.is_some() {
return;
}
*slot = Some(c);
if let Some(shared) = self.job.options.renderer {
let mut shared = shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if shared.is_none_or(|b| b == Backend::Auto) {
*shared = Some(match c.renderer {
crate::JobRenderer::Gpu => Backend::Gpu,
crate::JobRenderer::Cpu => Backend::Cpu,
});
}
}
log::info!(target: "effectcraft_export", "{}: {}", self.job.options.label, describe_choice(&c));
}
/// The job's choice, once made.
pub(crate) fn choice(&self) -> Option<Choice> {
*self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The compositor frames render on: the choice, or the project's until it is made.
fn backend(&self) -> crate::JobRenderer {
self.choice().map_or_else(|| self.project_renderer(), |c| c.renderer)
}
pub fn comp(&self) -> Option<&Comp> {
self.project.comp(self.job.comp)
}
/// The renderer of the job's frames: the Render Settings' options, the job's accelerator
/// and layer cache.
#[cfg(test)]
fn renderer(&self) -> Renderer<'_> {
self.renderer_on(self.backend())
}
/// [`Cx::renderer`] on compositor `on`, with that compositor's layer cache.
fn renderer_on(&self, on: crate::JobRenderer) -> Renderer<'_> {
let s = self.job.settings;
// The whole job renders on one compositor (the project's, or the faster one measured on
// the first frames, #3335), 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 within a render.
let backend = match on {
crate::JobRenderer::Gpu => Backend::Gpu,
crate::JobRenderer::Cpu => Backend::Cpu,
};
let opts = RenderOpts {
scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)),
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
guides: s.guide_layers == CurrentOrOff::Current,
draft: s.quality == RenderQuality::Draft,
// Output renders always look through the comp's active camera.
view: None,
backend,
roi: None,
nested_switches: self.job.nested_switches,
draft_shadows: true,
// OpenEXR keeps over-range values: no HDR compand / tone mapping on its frames.
skip_hdr_step: self.job.output.format == effectcraft_project::render_queue::OutputFormat::ExrSequence,
proxy: s.proxy_use,
};
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.caches.of(on));
r
}
async fn render_at(&self, t: Tick, on: crate::JobRenderer) -> Image {
// With deferred GPU readbacks (a browser worker) the frame renders in passes.
effectcraft_render::passes::comp_frame(&self.renderer_on(on), self.job.comp, t).await
}
/// Whether frames render in passes (an accelerator with deferred readbacks).
pub fn deferred(&self) -> bool {
effectcraft_render::passes::is_deferred(self.job.accel)
}
/// Output frames `ks`, each mapped by `f` (encoding): rendered in parallel batches, or one
/// by one in passes when readbacks are deferred ([`Cx::deferred`]).
pub async fn frames<T: Send>(&self, comp: &Comp, ks: Vec<u64>, f: impl Fn(u64, Image) -> T + Sync + Send) -> Vec<T> {
if self.deferred() {
let mut out = Vec::with_capacity(ks.len());
for k in ks {
let img = self.frame_async(comp, k).await;
out.push(f(k, img));
}
return out;
}
self.render_frames(ks, |k, on| self.frame_on(comp, k, on), f)
}
/// `render` then `encode` over the frames `ks` on the job's compositor, results in `ks`
/// order. The first call of a job that still has to choose its compositor measures both on
/// its first frames ([`Cx::probe`]) and keeps the faster one's.
fn render_frames<I: Send, T: Send>(
&self,
ks: Vec<u64>,
render: impl Fn(u64, crate::JobRenderer) -> I + Sync + Send,
encode: impl Fn(u64, I) -> T + Sync + Send,
) -> Vec<T> {
use rayon::prelude::*;
let probed = self.probe(&ks, &render);
// The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the
// CPU, in parallel or under the CPU cap (#1615), which holds throughout.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
let on = self.backend();
if probed.is_empty() {
return map_frames_while(ks, self.inflight(), gpu_live, |k| render(k, on), encode);
}
let rest: Vec<u64> = ks.iter().copied().filter(|k| !probed.iter().any(|(p, _)| p == k)).collect();
let mut done: Vec<(u64, T)> = probed.into_par_iter().map(|(k, i)| (k, encode(k, i))).collect();
done.extend(rest.iter().copied().zip(map_frames_while(rest.clone(), self.inflight(), gpu_live, |k| render(k, on), &encode)));
// Back in `ks` order.
let mut out = Vec::with_capacity(ks.len());
for k in &ks {
if let Some(i) = done.iter().position(|(d, _)| d == k) {
out.push(done.swap_remove(i).1);
}
}
out
}
/// Choose the job's compositor by timing both on the first frames of `ks` (#3335), as the
/// viewer's Auto does per comp: frame 0 warms each up (layer cache misses, uploads, kernel
/// builds) untimed, then the GPU renders the next [`PROBE_FRAMES`] under its in-flight cap
/// and the CPU up to [`PROBE_CPU_FRAMES`] after them in parallel (its throughput). The CPU
/// may render more frames at once in the job than here, so the measurement leans towards
/// the GPU, as the margin of [`pick_renderer`] does: the CPU is chosen only when clearly
/// faster. Returns
/// the frames the faster one rendered (the slower one's are rendered again); empty when
/// the choice was made already or is made without measuring (a short job).
fn probe<I: Send>(&self, ks: &[u64], render: &(impl Fn(u64, crate::JobRenderer) -> I + Sync + Send)) -> Vec<(u64, I)> {
if self.choice().is_some() {
return vec![];
}
let total = self.comp().map_or(0, |c| self.job.settings.frame_count(c));
if total < PROBE_MIN_JOB {
self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" });
return vec![];
}
// Nothing will be emitted for a skipped batch, so a later batch can still measure.
if ks.is_empty() {
return vec![];
}
// A resumed sequence can have only one or two missing frames in its first batch.
// Settle before emitting them: measuring a later batch could otherwise switch to the
// CPU after GPU pixels have already been written, including across output modules.
let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2);
if n == 0 {
self.decide(Choice {
renderer: crate::JobRenderer::Gpu,
gpu_ms: None,
cpu_ms: None,
why: "the project's renderer (too few pending frames to measure)",
});
return vec![];
}
let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else {
return vec![];
};
let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len());
let cpu_cap = self.inflight().cpu;
let timed = |on: crate::JobRenderer, frames: &[u64]| {
let warm = render(first, on);
// The GPU's frames in flight settle with its warm-up frame (Auto measures it).
let caps = match on {
crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap },
crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap },
};
let t0 = Instant::now();
let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available());
let items = map_frames_while(frames.to_vec(), caps, gpu_live, |k| render(k, on), |k, i| (k, i));
let ms = t0.elapsed().as_secs_f64() * 1e3 / frames.len().max(1) as f64;
(std::iter::once((first, warm)).chain(items).collect::<Vec<_>>(), ms)
};
let log0 = logged();
let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks);
let log1 = logged();
let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks);
// A GPU that stopped meanwhile rendered its frames on the CPU: it is no choice.
let gpu_ms = self.job.accel.is_some_and(|a| a.available()).then_some(gpu_ms);
let renderer = pick_renderer(gpu_ms, Some(cpu_ms));
// The per-frame log keeps the kept frames' times only.
if let Some(ft) = &self.frame_times {
let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let (a, b) = if renderer == crate::JobRenderer::Gpu { (log1, v.len()) } else { (log0, log1) };
if a <= b && b <= v.len() {
v.drain(a..b);
}
}
self.decide(Choice { renderer, gpu_ms, cpu_ms: Some(cpu_ms), why: "measured on the first frames" });
if renderer == crate::JobRenderer::Gpu { gpu } else { cpu }
}
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
#[cfg(test)]
pub async fn frames_beside<T: Send, A: Send>(
&self,
beside: &Beside,
comp: &Comp,
ks: Vec<u64>,
f: impl Fn(u64, Image) -> T + Sync + Send,
encode: impl FnOnce() -> A + Send,
) -> crate::Result<(A, Vec<T>)> {
self.beside(beside, self.frames(comp, ks, f), encode).await
}
/// Run `encode` on `beside`'s threads while `frames` renders the next batch. With deferred
/// readbacks, or where no encode thread could be started, `encode` runs first.
pub async fn beside<T: Send, A: Send>(
&self,
beside: &Beside,
frames: impl std::future::Future<Output = Vec<T>>,
encode: impl FnOnce() -> A + Send,
) -> crate::Result<(A, Vec<T>)> {
match &beside.0 {
Some(pool) if !self.deferred() => {
let mut done = None;
let frames = pool.in_place_scope(|s| {
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
effectcraft_render::passes::block_on(frames)
});
match done {
Some(Ok(a)) => Ok((a, frames)),
Some(Err(panic)) => {
let why = panic.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| panic.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(crate::ExportError::Encode(format!("the encoder stopped unexpectedly: {why}")))
}
None => Err(crate::ExportError::Encode("the encoder stopped unexpectedly".into())),
}
}
_ => {
let a = encode();
Ok((a, frames.await))
}
}
}
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
/// frame in flight holds its own working textures, so a whole batch rendered at once can
/// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the
/// frames render on the CPU: no accelerator, a Software Only project, or a job that chose
/// the CPU's compositor (#3335), whose frames render in parallel.
fn gpu_cap(&self) -> Option<usize> {
(self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight())
}
/// GPU frames in flight ([`resolve_gpu_frames`]): [`GPU_INFLIGHT_DEFAULT`] until settled,
/// which Auto is after the job's first GPU frame.
fn gpu_inflight(&self) -> usize {
if let Some((n, _)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
return *n;
}
// Measured outside the lock (the allocator report walks every allocation).
let measured = self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed).then(|| self.job.accel.and_then(|a| a.frame_memory()));
let Some((n, why)) = resolve_gpu_frames(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref(), self.job.options.gpu_frames, measured) else {
return GPU_INFLIGHT_DEFAULT;
};
let mut slot = self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.get_or_insert_with(|| {
log::info!(target: "effectcraft_export", "{}: {n} GPU frame(s) at once ({why})", self.job.options.label);
(n, why)
})
.0
}
/// The GPU and CPU frames-in-flight caps every frame loop shares (#1615).
fn inflight(&self) -> Inflight {
Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) }
}
/// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized.
#[cfg(test)]
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
self.frame_on(comp, i, self.backend())
}
/// [`Cx::frame`] on compositor `on`.
fn frame_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image {
effectcraft_render::passes::block_on(self.frame_async_on(comp, i, on))
}
/// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes).
pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image {
self.frame_async_on(comp, i, self.backend()).await
}
async fn frame_async_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image {
let t0 = Instant::now();
let gpu = on == crate::JobRenderer::Gpu && self.job.accel.is_some();
let img = match self.job.settings.sample(comp, i) {
FrameSample::Progressive(t) => self.render_at(t, on).await,
FrameSample::Fields { first, second, upper_first } => {
let a = self.render_at(first, on).await;
if second == first { a } else { interleave(&a, &self.render_at(second, on).await, upper_first) }
}
};
if gpu {
self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
}
img
}
/// Output frame `i` as the H.264 export's 4:2:0 planes at `w`×`h`, rendered and converted
/// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for
/// frames that need nothing done after the render (progressive, at the project's depth, no
/// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`].
fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32, on: crate::JobRenderer) -> Option<Vec<u8>> {
if self.deferred() {
return None;
}
let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None };
// Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the
// accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't
// band (#2022). The project's depth already includes Render Settings ▸ Color Depth.
if self.project.settings.bit_depth != BitDepth::Bpc8 {
return None;
}
let om = self.job.output;
let scale = self.job.settings.effective_scale(comp);
let size = |n: u32| ((n as f64 * scale).round() as u32).max(1);
if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) {
return None;
}
let t0 = Instant::now();
let r = self.renderer_on(on);
let a = r.active_accel()?;
let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
if planes.len() != w as usize * h as usize + 2 * cw * ch {
return None;
}
// A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it.
a.frame_end();
self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
}
Some(planes)
}
/// Output frames `ks` for the H.264 encoder: the accelerator's planes
/// ([`Cx::yuv420_frame`]) where it can, otherwise `fallback` of the frame rendered as usual.
pub(crate) fn h264_frames<T: Send>(
&self,
comp: &Comp,
ks: Vec<u64>,
w: u32,
h: u32,
planes: impl Fn(Vec<u8>) -> T + Sync + Send,
fallback: impl Fn(u64, Image) -> T + Sync + Send,
) -> Vec<T> {
// As in `frames`: once the GPU has stopped for good, the rest render in parallel.
self.render_frames(
ks,
|k, on| self.yuv420_frame(comp, k, w, h, on).ok_or_else(|| self.frame_on(comp, k, on)),
|k, r| match r {
Ok(p) => planes(p),
Err(img) => fallback(k, img),
},
)
}
/// Output Module ▸ Crop, then Resize.
fn crop_resize(&self, img: Image) -> Image {
let om = self.job.output;
let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0));
// frame_size applies crop itself: use the original frame and the same scale as edges.
let (w, h) = om.frame_size(img.width, img.height, scale);
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
if !om.resize.enabled {
return img;
}
if (w, h) == (img.width, img.height) {
return img;
}
match om.resize.quality {
ResizeQuality::Low => effectcraft_raster::warp::resample(&img, w, h),
ResizeQuality::High => bicubic(&img, w, h),
}
}
/// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
/// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
/// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After
/// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients
/// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to
/// dither, so its output stays byte for byte what it was.
pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
let dither = self.project.settings.bit_depth != BitDepth::Bpc8;
self.pixels_with(img, comp, channels, w, h, dither)
}
/// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise).
pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec<u8> {
let px = match channels {
Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background),
Channels::Rgb => img.to_rgba8_over(comp.background),
Channels::Rgba => match (self.job.output.alpha_mode, dither) {
(AlphaMode::Straight, true) => img.to_rgba8_dithered(),
(AlphaMode::Straight, false) => img.to_rgba8(),
(AlphaMode::Premultiplied, _) => premultiplied8(img, dither),
},
Channels::Alpha => img
.data
.iter()
.flat_map(|p| {
let a = (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
[a, a, a, 255]
})
.collect(),
};
crate::fit(px, img.width, img.height, w, h)
}
/// Open a file and remember ownership for cancellation cleanup (including overflow paths).
pub fn create(&self, path: &str) -> crate::Result<out::Out<'_>> {
let file = out::create(self.sink, path)?;
if self.sink.is_none() {
self.written.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_owned());
}
Ok(file)
}
pub fn remove_cancelled_outputs(&self) -> crate::Result<()> {
let mut failure = None;
for path in self.written.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter() {
if let Err(e) = std::fs::remove_file(path)
&& e.kind() != std::io::ErrorKind::NotFound
{
failure.get_or_insert_with(|| crate::ExportError::Io(format!("cannot remove cancelled output {path}: {e}")));
}
}
match failure {
Some(error) => Err(error),
None => Ok(()),
}
}
/// Where a file goes: `path`, or (Use Storage Overflow) the first overflow folder with room
/// for `bytes` when the quota hook says the output volume is full.
pub fn place(&self, path: &str, bytes: u64) -> String {
let Some(q) = self.job.options.storage else { return path.to_string() };
if !self.job.settings.storage_overflow || q.has_room(path, bytes) {
return path.to_string();
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
for dir in &self.job.options.overflow {
let p = std::path::Path::new(dir).join(&name).to_string_lossy().to_string();
if q.has_room(&p, bytes) {
self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(p.clone());
return p;
}
}
path.to_string()
}
/// Write the render log next to the output, as the Log setting asks. Returns its path.
pub fn write_log(&self, result: &Result<crate::Report, crate::ExportError>, seconds: f64) -> Option<String> {
let level = self.job.options.log;
// Errors Only: a log for failures (a user stop is not an error).
if level == RenderLog::ErrorsOnly && matches!(result, Ok(_) | Err(crate::ExportError::Cancelled)) {
return None;
}
let comp = self.comp();
let path = log_path(self.job.path);
let mut s = String::new();
s.push_str("EffectCraft Render Log\n");
s.push_str(&format!("Item: {}\n", if self.job.options.label.is_empty() { "(render)" } else { &self.job.options.label }));
s.push_str(&format!("Output: {}\n", self.job.path));
match result {
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
}
if let Some(c) = self.choice() {
s.push_str(&format!("Renderer: {}\n", describe_choice(&c)));
}
if self.backend() == crate::JobRenderer::Gpu
&& let Some((n, why)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
{
s.push_str(&format!("GPU frames rendered at once: {n} ({why})\n"));
}
let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !over.is_empty() {
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
}
if level != RenderLog::ErrorsOnly {
s.push_str("\nRender Settings:\n");
for (k, v) in self.job.settings.describe(comp) {
s.push_str(&format!(" {k}: {v}\n"));
}
s.push_str("\nOutput Module:\n");
for (k, v) in self.job.output.describe() {
s.push_str(&format!(" {k}: {v}\n"));
}
}
if let (RenderLog::PlusPerFrameInfo, Some(ft), Some(c)) = (level, &self.frame_times, comp) {
let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
v.sort_by_key(|(i, _)| *i);
s.push_str("\nFrames:\n");
let f0 = self.job.settings.first_frame(c);
for (i, secs) in v {
let t = self.job.settings.frame_time(c, i);
s.push_str(&format!(" Frame {} ({:.3} s): rendered in {:.1} ms\n", f0 + i as i64, t.seconds(), secs * 1000.0));
}
}
let mut f = self.create(&path).ok()?;
std::io::Write::write_all(&mut f, s.as_bytes()).ok()?;
f.finish().ok()?;
Some(path)
}
}
/// The render log's account of a job's compositor: which, why, and what was measured.
pub(crate) fn describe_choice(c: &Choice) -> String {
let name = match c.renderer {
crate::JobRenderer::Gpu => "GPU Acceleration",
crate::JobRenderer::Cpu => "Software Rendering",
};
let ms = |v: Option<f64>| v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.1} ms/frame"));
if c.gpu_ms.is_none() && c.cpu_ms.is_none() {
format!("{name} ({})", c.why)
} else {
format!("{name} ({}: GPU {}, CPU {})", c.why, ms(c.gpu_ms), ms(c.cpu_ms))
}
}
/// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`.
pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image {
let mut out = first.clone();
let w = out.width as usize;
for y in 0..out.height as usize {
let from_first = (y % 2 == 0) == upper_first;
if !from_first && y < second.height as usize && second.width as usize == w {
out.data[y * w..(y + 1) * w].copy_from_slice(&second.data[y * w..(y + 1) * w]);
}
}
out
}
/// Remove `t`/`l`/`b`/`r` pixels from the edges (negative values add transparent pixels).
pub(crate) fn crop(img: &Image, t: i32, l: i32, b: i32, r: i32) -> Image {
let w = (img.width as i32 - l - r).max(1) as u32;
let h = (img.height as i32 - t - b).max(1) as u32;
let mut out = Image::new(w, h);
for y in 0..h as i32 {
let sy = y + t;
if sy < 0 || sy >= img.height as i32 {
continue;
}
for x in 0..w as i32 {
let sx = x + l;
if sx >= 0 && sx < img.width as i32 {
let o = out.idx(x as u32, y as u32);
out.data[o] = img.data[img.idx(sx as u32, sy as u32)];
}
}
}
out
}
/// Bicubic resize (Resize Quality: High).
fn bicubic(src: &Image, w: u32, h: u32) -> Image {
// Shrinking a lot: bilinear with its box pre-filter first, then bicubic for the last step.
if src.width > w * 2 && src.height > h * 2 {
let mid = effectcraft_raster::warp::resample(src, w * 2, h * 2);
return bicubic(&mid, w, h);
}
let mut out = Image::new(w, h);
let (sx, sy) = (src.width as f64 / w as f64, src.height as f64 / h as f64);
for y in 0..h {
for x in 0..w {
let o = out.idx(x, y);
// Clamp to the edge so borders stay opaque.
let fx = ((x as f64 + 0.5) * sx).clamp(0.5, src.width as f64 - 0.5);
let fy = ((y as f64 + 0.5) * sy).clamp(0.5, src.height as f64 - 0.5);
let mut p = src.sample_bicubic(fx, fy);
p[3] = p[3].min(1.0);
out.data[o] = p;
}
}
out
}
/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`.
fn premultiplied8(img: &Image, dither: bool) -> Vec<u8> {
let w = img.width.max(1) as usize;
let mut out = Vec::with_capacity(img.data.len() * 4);
for (i, p) in img.data.iter().enumerate() {
let a = p[3].clamp(0.0, 1.0);
let a8 = (a * 255.0 + 0.5) as u8;
let (x, y) = ((i % w) as u32, (i / w) as u32);
for c in 0..3 {
let v = p[c].clamp(0.0, a);
// Dither may round up past the rounded alpha; premultiplied colour never exceeds it.
out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 });
}
out.push(a8);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use rayon::prelude::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or
/// below the written alpha at every pixel.
#[test]
fn dithered_premultiplied_colour_never_exceeds_alpha() {
let v = 100.25 / 255.0;
let img = Image::filled(64, 64, [v, v, v, v]);
let px = premultiplied8(&img, true);
assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha");
assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100));
let plain = premultiplied8(&img, false);
assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100]));
}
/// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once.
fn run_capped(n: u64, cap: Option<usize>) -> (Vec<u64>, usize) {
use std::sync::atomic::{AtomicUsize, Ordering};
let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0));
let out = map_frames(
(0..n).collect(),
cap,
|k| {
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
max.fetch_max(c, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(2));
now.fetch_sub(1, Ordering::SeqCst);
k
},
|k, img| {
assert_eq!(k, img);
k * 10
},
);
(out, max.load(Ordering::SeqCst))
}
#[test]
fn frames_render_in_parallel_once_the_gpu_stops() {
// Every frame after the GPU stopped falls back to the CPU: the cap no longer applies.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0));
let out = pool.install(|| {
map_frames_while(
(0..24).collect(),
Inflight { gpu: Some(1), cpu: None },
|| live.load(Ordering::SeqCst),
|k| {
if k == 1 {
live.store(false, Ordering::SeqCst);
}
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
if k >= 2 {
max.fetch_max(c, Ordering::SeqCst);
}
std::thread::sleep(std::time::Duration::from_millis(20));
now.fetch_sub(1, Ordering::SeqCst);
k * 10
},
|_, v| v,
)
});
assert_eq!(out, (0..24).map(|k| k * 10).collect::<Vec<_>>());
assert!(max.load(Ordering::SeqCst) > 1, "frames after the GPU stopped ran one at a time");
}
#[test]
fn capped_frames_keep_their_order() {
let want: Vec<u64> = (0..40).map(|k| k * 10).collect();
for cap in [None, Some(1), Some(3), Some(7)] {
assert_eq!(run_capped(40, cap).0, want, "cap {cap:?}");
}
}
#[test]
fn gpu_frames_in_flight_respect_the_cap() {
// #879: every frame of a batch rendered on the GPU at once and exhausted VRAM.
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
pool.install(|| {
for cap in [1, 2, 3] {
let (out, max) = run_capped(30, Some(cap));
assert_eq!(out.len(), 30);
assert!(max <= cap, "cap {cap}: {max} frames ran at once");
}
});
}
#[test]
fn a_cap_above_the_frame_count_renders_them_all() {
let (out, max) = run_capped(2, Some(8));
assert_eq!(out, vec![0, 10]);
assert!(max <= 2);
assert!(run_capped(0, Some(3)).0.is_empty());
}
#[test]
fn cap_one_on_a_single_thread_does_not_deadlock() {
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let (out, max) = pool.install(|| run_capped(10, Some(1)));
assert_eq!(out.len(), 10);
assert_eq!(max, 1);
// Nested parallelism inside a frame (as the renderer has) on one thread.
let out = pool.install(|| map_frames((0..6).collect(), Some(2), |k| (0..4u64).into_par_iter().map(|i| i + k).sum::<u64>(), |_, v| v));
assert_eq!(out, (0..6).map(|k| 6 + 4 * k).collect::<Vec<_>>());
}
#[test]
fn a_failing_frame_does_not_hold_up_the_rest() {
let out = map_frames((0..9).collect(), Some(1), |k| if k == 3 { Err(k) } else { Ok(k) }, |_, r| r);
assert_eq!(out.iter().filter(|r| r.is_err()).count(), 1);
assert_eq!(out.len(), 9);
assert_eq!(out[8], Ok(8));
// A panicking frame propagates once and leaves no slot behind: the next batch runs.
let r = std::panic::catch_unwind(|| map_frames((0..6).collect(), Some(2), |k| if k == 2 { panic!("frame {k}") } else { k }, |_, k| k));
assert!(r.is_err());
assert_eq!(run_capped(6, Some(2)).0.len(), 6);
}
#[test]
fn encoding_is_not_capped_with_the_render() {
// Only the GPU render is serialised: a window of frames encodes in parallel.
use std::sync::atomic::{AtomicUsize, Ordering};
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0));
let out = pool.install(|| {
map_frames(
(0..8).collect(),
Some(1),
|k| k,
|k, _| {
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
max.fetch_max(c, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
now.fetch_sub(1, Ordering::SeqCst);
k
},
)
});
assert_eq!(out, (0..8).collect::<Vec<_>>());
assert!(max.load(Ordering::SeqCst) > 1, "encodes ran one at a time");
}
/// An accelerator that renders nothing itself (the tests only check whether frames are capped).
struct NoAccel;
impl effectcraft_render::Accelerator for NoAccel {
fn name(&self) -> String {
"none".into()
}
fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option<Image> {
None
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
}
#[test]
fn software_only_projects_are_not_capped() {
// Review of #879: an accelerator alone capped Software Only projects, which render on the CPU.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
use effectcraft_render::NoFootage;
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let accel = NoAccel;
for (gpu, want) in [(false, None), (true, Some(GPU_INFLIGHT_DEFAULT))] {
let mut p = Project::default();
p.settings.gpu_acceleration = gpu;
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: Some(&accel),
comp: effectcraft_project::ItemId(0),
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: Default::default(),
};
if std::env::var_os(GPU_INFLIGHT_ENV).is_none() {
assert_eq!(Cx::new(&job).gpu_cap(), want, "gpu acceleration {gpu}");
} else {
assert_eq!(Cx::new(&job).gpu_cap().is_some(), gpu);
}
}
let p = Project::default();
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: effectcraft_project::ItemId(0),
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: Default::default(),
};
assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator");
}
/// A resumed sequence can start with one or two missing frames. Once emitted, their
/// compositor must also be used by every later batch and the item's other output modules.
#[test]
fn a_small_first_batch_settles_the_renderer_before_emitting_frames() {
if std::env::var_os(RENDERER_ENV).is_some() {
return;
}
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let accel = NoAccel;
for n in [1, 2] {
let shared = Mutex::new(None);
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: cid,
settings: &settings,
output: &output,
path: "/r/f_[#####].png",
sink: None,
nested_switches: true,
options: crate::JobOptions { renderer: Some(&shared), ..Default::default() },
};
let cx = Cx::new(&job);
let empty = cx.render_frames(Vec::new(), |_, on| on, |_, on| on);
assert!(empty.is_empty());
assert_eq!(cx.choice(), None, "skipped batches do not choose");
let first = cx.render_frames((0..n).collect(), |_, on| on, |_, on| on);
assert_eq!(first, vec![crate::JobRenderer::Gpu; n as usize]);
assert_eq!(*shared.lock().unwrap(), Some(Backend::Gpu), "settled before any pixels are emitted");
let later = cx.render_frames((n..12).collect(), |_, on| on, |_, on| on);
assert!(later.iter().all(|on| *on == crate::JobRenderer::Gpu));
assert_eq!(Cx::new(&job).backend(), crate::JobRenderer::Gpu, "the next output module follows");
}
}
#[test]
fn a_job_that_chose_the_cpu_is_not_capped() {
// #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project
// that measured the CPU faster must not keep rendering one frame at a time.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let accel = NoAccel;
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: effectcraft_project::ItemId(0),
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: Default::default(),
};
let cx = Cx::new(&job);
if std::env::var_os(RENDERER_ENV).is_none() {
assert_eq!(cx.choice(), None, "a GPU project measures first");
assert!(cx.gpu_cap().is_some());
}
cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(117.0), cpu_ms: Some(68.0), why: "measured on the first frames" });
assert_eq!(cx.backend(), crate::JobRenderer::Cpu);
assert_eq!(cx.gpu_cap(), None);
assert_eq!(cx.renderer().active_accel().map(|a| a.name()), None, "CPU frames don't use the accelerator");
assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)");
}
#[test]
fn cpu_inflight_is_off_unless_a_positive_number() {
assert_eq!(cpu_inflight(None), None);
assert_eq!(cpu_inflight(Some("2")), Some(2));
assert_eq!(cpu_inflight(Some(" 4 ")), Some(4));
for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] {
assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}");
}
}
#[test]
fn cpu_cap_holds_and_combines_with_the_gpu_cap() {
// #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops.
let caps = |gpu, cpu| Inflight { gpu, cpu };
for live in [true, false] {
assert_eq!(caps(None, None).renders(live), None);
assert_eq!(caps(None, Some(2)).renders(live), Some(2));
}
assert_eq!(caps(Some(1), None).renders(true), Some(1));
assert_eq!(caps(Some(1), None).renders(false), None);
assert_eq!(caps(Some(3), Some(2)).renders(true), Some(2));
assert_eq!(caps(Some(1), Some(4)).renders(true), Some(1));
// Review of #1615: once the GPU stops, the CPU cap applies, not the GPU's 1.
assert_eq!(caps(Some(1), Some(4)).renders(false), Some(4));
// A CPU cap of 2 on 8 threads renders at most 2 frames at once.
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0));
let out = pool.install(|| map_frames_while((0..16).collect(), caps(None, Some(2)), || true, |k| track(&now, &max, k), |_, k| k));
assert_eq!(out, (0..16).collect::<Vec<_>>());
assert!(max.load(Ordering::SeqCst) <= 2, "{max:?} frames in flight");
}
/// Counts `k` in flight in `now`, records the most in `max`, and holds it for a moment.
fn track(now: &AtomicUsize, max: &AtomicUsize, k: u64) -> u64 {
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
max.fetch_max(c, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(5));
now.fetch_sub(1, Ordering::SeqCst);
k
}
#[test]
fn a_cpu_cap_bounds_the_images_waiting_to_encode() {
// Review of #1615: a window of one image per thread was rendered before any encoded, so
// a cap of 2 on 8 threads still held 8 finished images.
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
for gpu in [None, Some(1)] {
let (held, max) = (AtomicUsize::new(0), AtomicUsize::new(0));
let out = pool.install(|| {
map_frames_while(
(0..20).collect(),
Inflight { gpu, cpu: Some(2) },
|| true,
|k| {
let c = held.fetch_add(1, Ordering::SeqCst) + 1;
max.fetch_max(c, Ordering::SeqCst);
k
},
|k, img| {
held.fetch_sub(1, Ordering::SeqCst);
assert_eq!(k, img);
k
},
)
});
assert_eq!(out, (0..20).collect::<Vec<_>>());
assert!(max.load(Ordering::SeqCst) <= 2, "gpu {gpu:?}: {max:?} images held");
}
}
#[test]
fn the_cpu_cap_takes_over_once_the_gpu_stops() {
// Review of #1615: with both caps, frames after the GPU stopped rendered one at a time.
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0));
let out = pool.install(|| {
map_frames_while(
(0..40).collect(),
Inflight { gpu: Some(1), cpu: Some(4) },
|| live.load(Ordering::SeqCst),
|k| {
if k == 1 {
live.store(false, Ordering::SeqCst);
}
if k < 4 {
return k;
}
track(&now, &max, k)
},
|_, k| k,
)
});
assert_eq!(out, (0..40).collect::<Vec<_>>());
let max = max.load(Ordering::SeqCst);
assert!((2..=4).contains(&max), "{max} frames in flight after the GPU stopped");
}
#[test]
fn gpu_inflight_override_is_a_positive_number_or_ignored() {
assert_eq!(gpu_inflight_env(None), None);
assert_eq!(gpu_inflight_env(Some("3")), Some(3));
assert_eq!(gpu_inflight_env(Some(" 5 ")), Some(5));
for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] {
assert_eq!(gpu_inflight_env(Some(bad)), None, "{bad:?}");
}
}
#[test]
fn auto_gpu_frames_follow_the_memory_budget() {
// #3335: Auto renders as many GPU frames at once as the budget has room for, 1 to 3.
use effectcraft_render::FrameMemory;
const GIB: u64 = 1 << 30;
let m = |budget: u64, peak: u64, frame: u64, reserve: u64| Some(FrameMemory { budget, peak, frame, reserve });
// Unknown memory (no allocator report, no budget): one frame.
assert_eq!(auto_gpu_frames(None), 1);
assert_eq!(auto_gpu_frames(m(0, GIB, GIB / 2, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, 0, GIB / 2, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 0, 0)), 1);
// #879 on an 8 GB card: one frame peaked at 6.3 GB, over the 3 GiB budget: one.
assert_eq!(auto_gpu_frames(m(3 * GIB, 63 * GIB / 10, 5 * GIB, 0)), 1);
// Frames that had to wait for memory report the whole budget per frame: one.
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 3 * GIB, 0)), 1);
// Room for half a frame more, or room the upload cache may still take: one.
assert_eq!(auto_gpu_frames(m(2 * GIB, 3 * GIB / 2, GIB, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 3 * GIB / 2)), 2);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 2 * GIB)), 1);
// Room for one more frame: two; ample room: three, never more.
assert_eq!(auto_gpu_frames(m(3 * GIB, 2 * GIB, GIB, 0)), 2);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB / 2, GIB / 4, 0)), 3);
assert_eq!(auto_gpu_frames(m(u64::MAX, 1, 1, 0)), 3);
}
#[test]
fn gpu_frames_come_from_the_environment_the_setting_or_auto() {
use effectcraft_render::FrameMemory;
let ample = Some(Some(FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 1 << 28 }));
let n = |env, pref, measured| resolve_gpu_frames(env, pref, measured).map(|(n, _)| n);
// Auto: unsettled until a GPU frame rendered, then measured.
assert_eq!(n(None, None, None), None);
assert_eq!(n(None, None, Some(None)), Some(1));
assert_eq!(n(None, None, ample), Some(3));
// The setting's 1, 2 or 3, without measuring.
for k in 1..=3 {
assert_eq!(n(None, Some(k), None), Some(k));
assert_eq!(n(None, Some(k), ample), Some(k));
}
// The environment overrides both; a bad value is ignored.
assert_eq!(n(Some("2"), Some(1), None), Some(2));
assert_eq!(n(Some("4"), None, ample), Some(4));
assert_eq!(n(Some("zero"), Some(1), None), Some(1));
let (_, why) = resolve_gpu_frames(None, None, ample).unwrap();
assert_eq!(why, "Auto: 256 MiB per frame, 512 MiB peak and 256 MiB cache reserve in a 3072 MiB budget");
}
/// An accelerator reporting `memory` once asked (renders nothing itself).
struct MemAccel(effectcraft_render::FrameMemory);
impl effectcraft_render::Accelerator for MemAccel {
fn name(&self) -> String {
"mem".into()
}
fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option<Image> {
None
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
Some(self.0)
}
}
#[test]
fn auto_gpu_frames_settle_after_the_first_gpu_frame() {
// #3335: one GPU frame at a time until a frame measured the memory, then Auto's count
// for the rest of the job; the setting skips the measurement.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
if std::env::var_os(GPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() {
return;
}
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 0 });
for (pref, want) in [(None, 3), (Some(2), 2), (Some(1), 1)] {
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: cid,
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: crate::JobOptions { gpu_frames: pref, ..Default::default() },
};
let cx = Cx::new(&job);
if pref.is_none() {
assert_eq!(cx.gpu_cap(), Some(1), "before any GPU frame");
}
let c = cx.comp().unwrap();
cx.frame_on(c, 0, crate::JobRenderer::Gpu);
assert_eq!(cx.gpu_cap(), Some(want), "setting {pref:?}");
}
}
#[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.caches.of(cx.backend()).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.caches.of(cx.backend()).stats();
assert_eq!(stats.misses, renders, "the solid rendered on the first frame only");
assert!(stats.hits >= 9, "{stats:?}");
}
#[test]
fn frames_render_beside_the_encoder() {
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
use effectcraft_render::NoFootage;
use effectcraft_time::FrameRate;
let mut p = Project::default();
let comp = Comp::new(32, 16, FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::H264));
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &settings,
output: &output,
path: "/c/out.mp4",
sink: None,
nested_switches: true,
options: Default::default(),
};
let cx = Cx::new(&job);
let comp = cx.comp().unwrap();
use effectcraft_render::passes::block_on;
let want = block_on(cx.frames(comp, (0..6).collect(), |k, img| (k, img.width, img.height)));
// On threads of their own, and taking turns (no threads): the same frames, in order.
for beside in [Beside::shared(), &Beside(None)] {
let (a, frames) = block_on(cx.frames_beside(beside, comp, (0..6).collect(), |k, img| (k, img.width, img.height), || 7)).unwrap();
assert_eq!((a, &frames), (7, &want));
}
// A panicking encoder fails the export instead of taking the render thread down.
let r = block_on(cx.frames_beside(Beside::shared(), comp, vec![0], |k, _| k, || -> u32 { panic!("encoder bug") }));
match r {
Err(crate::ExportError::Encode(e)) => assert!(e.contains("encoder bug"), "{e}"),
other => panic!("{:?}", other.map(|(a, f)| (a, f.len()))),
}
}
#[test]
fn interleave_takes_alternate_lines() {
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
let b = Image::filled(3, 4, [0.0, 0.0, 1.0, 1.0]);
let up = interleave(&a, &b, true);
let lo = interleave(&a, &b, false);
for y in 0..4u32 {
assert_eq!(up.data[up.idx(1, y)][0], if y % 2 == 0 { 1.0 } else { 0.0 }, "upper first: line {y}");
assert_eq!(lo.data[lo.idx(1, y)][0], if y % 2 == 1 { 1.0 } else { 0.0 }, "lower first: line {y}");
}
}
/// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440.
#[test]
fn crop_resize_applies_margins_once() {
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
let output = OutputModule {
crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() },
resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() },
..Default::default()
};
assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440));
let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0);
assert_eq!((img.width, img.height), (1280, 1440));
assert_eq!(img.data.len(), 1280 * 1440);
}
/// Apply output-module geometry to a synthetic frame without rendering or writing files.
fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image {
use effectcraft_project::render_queue::RenderSettings;
let project = Project::default();
let settings = RenderSettings { resolution: scale, ..Default::default() };
let job = Job {
project: &project,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: None,
comp: effectcraft_project::ItemId(0),
settings: &settings,
output,
path: "unused.png",
sink: None,
nested_switches: true,
options: Default::default(),
};
Cx::new(&job).crop_resize(img)
}
/// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities.
#[test]
fn crop_resize_dimensions_match_the_output_module() {
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() };
let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() };
let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() };
for quality in [ResizeQuality::Low, ResizeQuality::High] {
let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality };
for (crop, resize, scale, expected) in [
(Crop::default(), Resize::default(), 1.0, (8, 6)),
(edges, Resize::default(), 1.0, (4, 6)),
(Crop::default(), resize(4), 1.0, (4, 3)),
(edges, resize(8), 1.0, (8, 12)),
(padding, resize(5), 1.0, (5, 4)),
(roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)),
(roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)),
(edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)),
] {
let output = OutputModule { crop, resize, ..Default::default() };
assert_eq!(output.frame_size(8, 6, scale), expected);
let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale);
assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}");
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
}
}
}
/// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes.
#[test]
fn crop_resize_zero_and_extreme_sizes_stay_bounded() {
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
for quality in [ResizeQuality::Low, ResizeQuality::High] {
for (size, crop, width, height, expected) in [
((8, 6), Crop::default(), 0, 0, (1, 1)),
((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)),
((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)),
((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)),
((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)),
] {
let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() };
let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0);
assert_eq!((img.width, img.height), expected);
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
assert!(img.data.iter().all(|p| *p == [0.0; 4]));
}
}
}
#[test]
fn crop_and_pad() {
let mut img = Image::new(4, 3);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [i as f32, 0.0, 0.0, 1.0];
}
let c = crop(&img, 1, 1, 0, 1);
assert_eq!((c.width, c.height), (2, 2));
assert_eq!(c.data[0][0], 5.0);
let p = crop(&img, -1, 0, 0, 0);
assert_eq!((p.width, p.height), (4, 4));
assert_eq!(p.data[0], [0.0; 4]);
assert_eq!(p.data[4][0], 0.0);
assert_eq!(p.data[5][0], 1.0);
}
}