Files
effectcraft/crates/export/src/pipeline.rs
T
BFlat 07d1de1764 Merge pull request #2534 from klosixgaming-arch/fix/2022-render-banding
export: dither 16/32 bpc renders on the way to 8 bits, and let Render Settings Color Depth set the render's depth (#2022)
2026-10-30 05:09:48 +07:00

1081 lines
48 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 by default: one, as the export scheduler's single GPU worker
/// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV
/// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one,
/// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU.
/// Not sized from free VRAM: the GPU backend reports no free or total device memory on every
/// platform, so a portable estimate is not available here.
pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1;
/// Overrides [`GPU_INFLIGHT_DEFAULT`]: 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
/// the default (with a warning when a value was given) when it is 0, negative, not a number or
/// too large.
pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
let Some(v) = var else { return GPU_INFLIGHT_DEFAULT };
match v.trim().parse::<usize>() {
Ok(n) if n >= 1 => n,
_ => {
log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: using {GPU_INFLIGHT_DEFAULT}");
GPU_INFLIGHT_DEFAULT
}
}
}
/// 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). 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.
pub overflowed: Mutex<Vec<String>>,
written: Mutex<Vec<String>>,
}
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 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)
}
/// 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: 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,
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.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(&self.renderer(), 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;
}
// 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());
map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f)
}
/// [`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, or a Software Only project.
fn gpu_cap(&self) -> Option<usize> {
(self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()))
}
/// 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.
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
effectcraft_render::passes::block_on(self.frame_async(comp, i))
}
/// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes).
pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image {
let t0 = Instant::now();
let img = match self.job.settings.sample(comp, i) {
FrameSample::Progressive(t) => self.render_at(t).await,
FrameSample::Fields { first, second, upper_first } => {
let a = self.render_at(first).await;
if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
}
};
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`].
pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> 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();
let planes = r.active_accel()?.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;
}
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.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
map_frames_while(
ks,
self.inflight(),
gpu_live,
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|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));
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;
}
let (w, h) = om.frame_size(img.width, img.height, 1.0);
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")),
}
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)
}
}
/// 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");
}
#[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_the_default() {
assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT);
assert_eq!(gpu_inflight(Some("3")), 3);
assert_eq!(gpu_inflight(Some(" 5 ")), 5);
for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] {
assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}");
}
}
#[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 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}");
}
}
#[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);
}
}