mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge branch 'worktree-agent-afea1146abae6192c'
# Conflicts: # apps/effectcraft-cli/Cargo.toml # apps/effectcraft-cli/src/main.rs # crates/render/src/lib.rs
This commit is contained in:
commit
5077aafffc
15 files changed
+1267
-85
No files matched your search
Generated
+34
@@ -875,6 +875,16 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
|
||||
|
||||
[[package]]
|
||||
name = "cpu-time"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
@@ -1068,11 +1078,13 @@ dependencies = [
|
||||
name = "effectcraft-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"image",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -5085,6 +5097,22 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5094,6 +5122,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
@@ -19,3 +19,5 @@ effectcraft-automation = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
cpu-time = "1"
|
||||
@@ -13,6 +13,7 @@
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] render timings
|
||||
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
@@ -40,12 +41,16 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
|
||||
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off] | --queue
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache
|
||||
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
|
||||
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json
|
||||
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
|
||||
|
||||
/// Options that take a value.
|
||||
const VALUED: &[&str] = &[
|
||||
"--n",
|
||||
"--play",
|
||||
"--params",
|
||||
"--project",
|
||||
"--bridge",
|
||||
@@ -231,6 +236,7 @@ fn emit(v: &Value, json_out: bool) {
|
||||
fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
match cmd {
|
||||
"render" => render(args, json_out)?,
|
||||
"bench" => bench_cmd(args)?,
|
||||
"info" => {
|
||||
let mut b = backend(args, true)?;
|
||||
let p = tool(&mut b, "get_project", json!({}))?;
|
||||
@@ -530,3 +536,131 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bench`: time one frame N times without the layer cache (per-layer and per-effect breakdown),
|
||||
/// and optionally `--play N` consecutive frames with and without the cache.
|
||||
fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
let mut s = effectcraft_host::session();
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})),
|
||||
None => s.execute("file.openDemoProject", json!({})),
|
||||
}
|
||||
.map_err(|e| Failure::Error(e.to_string()))?;
|
||||
if let Some(c) = args.opt("--comp") {
|
||||
s.execute("comp.open", json!({"comp": c})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
}
|
||||
let cid = s.active_comp_id().ok_or_else(|| Failure::Error("no composition".into()))?;
|
||||
let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0));
|
||||
let opts = RenderOpts { scale: args.num("--scale")?.unwrap_or(1.0), ..Default::default() };
|
||||
bench(&s, cid, t, opts.clone(), args.num("--n")?.unwrap_or(10.0).max(1.0) as usize);
|
||||
if let Some(n) = args.num("--play")? {
|
||||
bench_play(&s, cid, t, opts, n.max(1.0) as usize);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- benchmark helpers
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_render::{LayerCache, LayerTiming, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
fn median(v: &mut [f64]) -> f64 {
|
||||
v.sort_by(f64::total_cmp);
|
||||
if v.is_empty() { 0.0 } else { v[v.len() / 2] }
|
||||
}
|
||||
|
||||
fn min(v: &[f64]) -> f64 {
|
||||
v.iter().copied().fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// CPU time of the whole process (all threads) in ms: a load-independent measure of work.
|
||||
fn cpu_ms() -> f64 {
|
||||
cpu_time::ProcessTime::try_now().map(|t| t.as_duration().as_secs_f64() * 1e3).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// One frame: (wall ms, CPU ms, per-layer timings).
|
||||
fn render_timed(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, cache: Option<&LayerCache>) -> (f64, f64, Vec<LayerTiming>) {
|
||||
let prof = std::sync::Mutex::new(Vec::<LayerTiming>::new());
|
||||
let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts);
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = cache;
|
||||
r.profile = Some(&prof);
|
||||
let c0 = cpu_ms();
|
||||
let t0 = std::time::Instant::now();
|
||||
let img = r.comp_frame(cid, t);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let cpu = cpu_ms() - c0;
|
||||
std::hint::black_box(&img);
|
||||
(ms, cpu, prof.into_inner().unwrap_or_default())
|
||||
}
|
||||
|
||||
type LayerRow = (usize, String, Vec<f64>, Vec<f64>, Vec<(String, Vec<f64>)>);
|
||||
|
||||
/// `frame --bench N`: render the same frame N times without the layer cache (the full cost of a
|
||||
/// frame) and print min/median frame time plus min per-layer and per-effect times.
|
||||
fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
||||
let mut totals = Vec::with_capacity(n);
|
||||
let mut cpus = Vec::with_capacity(n);
|
||||
let mut per_layer: Vec<LayerRow> = Vec::new();
|
||||
for _ in 0..n {
|
||||
let (ms, cpu, prof) = render_timed(s, cid, t, opts, None);
|
||||
totals.push(ms);
|
||||
cpus.push(cpu);
|
||||
for (i, lt) in prof.into_iter().enumerate() {
|
||||
if per_layer.len() <= i {
|
||||
per_layer.push((lt.depth, lt.layer.clone(), vec![], vec![], vec![]));
|
||||
}
|
||||
let e = &mut per_layer[i];
|
||||
e.2.push(lt.process_ms);
|
||||
e.3.push(lt.composite_ms);
|
||||
for (j, (id, ms)) in lt.effects.into_iter().enumerate() {
|
||||
if e.4.len() <= j {
|
||||
e.4.push((id, vec![]));
|
||||
}
|
||||
e.4[j].1.push(ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
|
||||
opts.scale,
|
||||
min(&totals),
|
||||
median(&mut totals),
|
||||
min(&cpus)
|
||||
);
|
||||
for (d, name, p, c, fx) in per_layer {
|
||||
eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
|
||||
for (id, v) in fx {
|
||||
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `frame --bench-play N`: render N consecutive frames from `t` as playback does, without and
|
||||
/// with the layer cache, and print per-frame times.
|
||||
fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
||||
let Some(comp) = s.project.comp(cid) else { return };
|
||||
let f0 = comp.frame_rate.frame_at(t);
|
||||
let cache = LayerCache::default();
|
||||
for (label, c) in [("no cache", None), ("layer cache", Some(&cache))] {
|
||||
let runs: Vec<(f64, f64)> = (0..n as i64)
|
||||
.map(|i| {
|
||||
let (ms, cpu, _) = render_timed(s, cid, comp.frame_rate.tick_of(f0 + i), opts, c);
|
||||
(ms, cpu)
|
||||
})
|
||||
.collect();
|
||||
let mut v: Vec<f64> = runs.iter().map(|r| r.0).collect();
|
||||
let mean = v.iter().sum::<f64>() / n as f64;
|
||||
let cpu = runs.iter().map(|r| r.1).sum::<f64>() / n as f64;
|
||||
eprintln!(
|
||||
"bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms",
|
||||
opts.scale,
|
||||
median(&mut v),
|
||||
min(&v)
|
||||
);
|
||||
}
|
||||
let st = cache.stats();
|
||||
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
|
||||
}
|
||||
@@ -222,6 +222,26 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
|
||||
g
|
||||
}
|
||||
|
||||
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
|
||||
/// parameters). The renderer's layer cache folds the layer time into the key for these.
|
||||
/// The `time_dependence_is_declared` test checks the list against every registered effect.
|
||||
pub const TIME_DEPENDENT: &[&str] = &[
|
||||
"ec.distort.wavewarp",
|
||||
"ec.distort.ripple",
|
||||
"ec.generate.radiowaves",
|
||||
"ec.stylize.scatter",
|
||||
"ec.stylize.strobe",
|
||||
"ec.noise.noise",
|
||||
"ec.noise.addgrain",
|
||||
"ec.noise.noisealpha",
|
||||
"ec.noise.noisehlsauto",
|
||||
];
|
||||
|
||||
/// See [`TIME_DEPENDENT`].
|
||||
pub fn is_time_dependent(id: &str) -> bool {
|
||||
TIME_DEPENDENT.contains(&id)
|
||||
}
|
||||
|
||||
/// Run an effect on a buffer.
|
||||
pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
|
||||
(spec.render)(ctx, buf)
|
||||
@@ -265,4 +285,34 @@ mod tests {
|
||||
assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Effects that read the clock must be listed in `TIME_DEPENDENT`, or the layer cache would
|
||||
/// serve stale pixels. Render every effect at two times with default parameters (plus
|
||||
/// "random…" switches turned on) and require identical output from unlisted ones.
|
||||
#[test]
|
||||
fn time_dependence_is_declared() {
|
||||
let mut img = Image::new(40, 30);
|
||||
for y in 0..30 {
|
||||
for x in 0..40 {
|
||||
let a = if (6..34).contains(&x) && (5..25).contains(&y) { 1.0 } else { 0.3 };
|
||||
img.set(x, y, [((x * 7 + y * 3) % 11) as f32 / 11.0 * a, y as f32 / 30.0 * a, 0.4 * a, a]);
|
||||
}
|
||||
}
|
||||
for s in registry() {
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in params.values.iter_mut() {
|
||||
if k.to_lowercase().contains("random") && matches!(v, Value::Bool(_)) {
|
||||
*v = Value::Bool(true);
|
||||
}
|
||||
}
|
||||
let run = |t: f64| {
|
||||
let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false };
|
||||
apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
};
|
||||
let (a, b) = (run(0.0), run(1.37));
|
||||
if a != b {
|
||||
assert!(is_time_dependent(s.id), "{} depends on time but is not in TIME_DEPENDENT", s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Comp, ItemId, Layer, LayerId, Project, Uid};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{ExprHost, FootageSource, NoFootage, RenderOpts, Renderer};
|
||||
use effectcraft_render::{ExprHost, FootageSource, LayerCache, NoFootage, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -141,6 +141,8 @@ pub struct Session {
|
||||
pub events: Vec<Event>,
|
||||
/// Commands executed: (id, params).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
|
||||
pub layer_cache: Arc<LayerCache>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -160,6 +162,7 @@ impl Default for Session {
|
||||
render_job: None,
|
||||
events: vec![],
|
||||
journal: vec![],
|
||||
layer_cache: Arc::new(LayerCache::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +322,7 @@ impl Session {
|
||||
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
|
||||
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.layer_cache);
|
||||
r.comp_frame(comp, t)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,35 @@ fn demo_opens_and_renders() {
|
||||
assert!(lit > 100_000, "{lit}");
|
||||
}
|
||||
|
||||
/// The session's layer cache never changes pixels: scrubbing the demo (including back and
|
||||
/// forth, and across an edit made through a command) matches cache-less renders exactly.
|
||||
#[test]
|
||||
fn demo_layer_cache_is_transparent() {
|
||||
let mut s = demo();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let opts = effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() };
|
||||
let uncached = |s: &Session, t: f64| {
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, s.footage.as_ref(), opts);
|
||||
r.expr = s.expr.as_deref();
|
||||
r.comp_frame(cid, effectcraft_time::Tick::from_seconds_f64(t))
|
||||
};
|
||||
let check = |s: &Session, t: f64| {
|
||||
let a = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), opts);
|
||||
let b = uncached(s, t);
|
||||
assert!(a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|c| (p[c] - q[c]).abs() < 1e-6)), "t={t}");
|
||||
};
|
||||
for t in [0.5, 1.0, 3.0, 3.0334, 6.0, 3.0, 0.5] {
|
||||
check(&s, t);
|
||||
}
|
||||
assert!(s.layer_cache.stats().hits > 0);
|
||||
// Edit the title's glow through the command layer, then scrub again.
|
||||
let title = s.project.comp(cid).unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap();
|
||||
s.execute("prop.set", json!({"layer": title, "path": "effects/#1/radius", "value": 5.0})).unwrap();
|
||||
for t in [3.0, 0.5] {
|
||||
check(&s, t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id_and_runs_or_reports() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
|
||||
+142
-41
@@ -1,20 +1,23 @@
|
||||
//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional
|
||||
//! and radial (spin/zoom) blurs.
|
||||
//!
|
||||
//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over
|
||||
//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two
|
||||
//! buffers so a Gaussian allocates two images regardless of the number of passes.
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{Image, Px};
|
||||
|
||||
/// One horizontal box pass of radius `r` (window 2r+1) over each row; `repeat` = edge pixels
|
||||
/// extend, otherwise transparent.
|
||||
fn box_h(img: &Image, r: usize, repeat: bool) -> Image {
|
||||
/// One horizontal box pass of radius `r` (window 2r+1) over each row of `src` (width `w`) into
|
||||
/// `out`; `repeat` = edge pixels extend, otherwise transparent.
|
||||
fn box_h_into(src: &[Px], out: &mut [Px], w: usize, r: usize, repeat: bool) {
|
||||
if r == 0 {
|
||||
return img.clone();
|
||||
out.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let w = img.width as usize;
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
out.data.par_chunks_mut(w).zip(img.data.par_chunks(w)).for_each(|(o, row)| {
|
||||
out.par_chunks_mut(w).zip(src.par_chunks(w)).for_each(|(o, row)| {
|
||||
let get = |i: isize| -> Px {
|
||||
if i < 0 {
|
||||
if repeat { row[0] } else { [0.0; 4] }
|
||||
@@ -40,18 +43,89 @@ fn box_h(img: &Image, r: usize, repeat: bool) -> Image {
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn transpose(img: &Image) -> Image {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let mut out = Image::new(img.height, img.width);
|
||||
out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| {
|
||||
for y in 0..h {
|
||||
col[y] = img.data[y * w + x];
|
||||
/// One vertical box pass (same semantics as [`box_h_into`] along columns).
|
||||
fn box_v_into(src: &[Px], out: &mut [Px], w: usize, h: usize, r: usize, repeat: bool) {
|
||||
if r == 0 {
|
||||
out.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
let block = (4 * r).clamp(32, 256);
|
||||
let row = |i: isize| -> Option<&[Px]> {
|
||||
if i < 0 {
|
||||
repeat.then(|| &src[0..w])
|
||||
} else if i as usize >= h {
|
||||
repeat.then(|| &src[(h - 1) * w..h * w])
|
||||
} else {
|
||||
Some(&src[i as usize * w..(i as usize + 1) * w])
|
||||
}
|
||||
};
|
||||
out.par_chunks_mut(w * block).enumerate().for_each(|(bi, chunk)| {
|
||||
let ya = (bi * block) as isize;
|
||||
let mut acc = vec![[0.0f32; 4]; w];
|
||||
for i in ya - r as isize..=ya + r as isize {
|
||||
if let Some(rw) = row(i) {
|
||||
for (a, p) in acc.iter_mut().zip(rw) {
|
||||
for c in 0..4 {
|
||||
a[c] += p[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (yy, o) in chunk.chunks_mut(w).enumerate() {
|
||||
let y = ya + yy as isize;
|
||||
for (o, a) in o.iter_mut().zip(&acc) {
|
||||
*o = a.map(|v| v * norm);
|
||||
}
|
||||
match (row(y + r as isize + 1), row(y - r as isize)) {
|
||||
(Some(add), Some(sub)) => {
|
||||
for ((a, p), q) in acc.iter_mut().zip(add).zip(sub) {
|
||||
for c in 0..4 {
|
||||
a[c] += p[c] - q[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(add), None) => {
|
||||
for (a, p) in acc.iter_mut().zip(add) {
|
||||
for c in 0..4 {
|
||||
a[c] += p[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(sub)) => {
|
||||
for (a, q) in acc.iter_mut().zip(sub) {
|
||||
for c in 0..4 {
|
||||
a[c] -= q[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Run horizontal passes with radii `rx` then vertical passes with radii `ry`.
|
||||
fn box_passes(img: &Image, rx: &[usize], ry: &[usize], repeat: bool) -> Image {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let passes: Vec<(bool, usize)> = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0).collect();
|
||||
if passes.is_empty() || w == 0 || h == 0 {
|
||||
return img.clone();
|
||||
}
|
||||
let mut a = Image::new(img.width, img.height);
|
||||
let mut b = Image::new(img.width, img.height);
|
||||
for (i, &(vertical, r)) in passes.iter().enumerate() {
|
||||
let src: &[Px] = if i == 0 { &img.data } else { &a.data };
|
||||
if vertical {
|
||||
box_v_into(src, &mut b.data, w, h, r, repeat);
|
||||
} else {
|
||||
box_h_into(src, &mut b.data, w, r, repeat);
|
||||
}
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Box radii for `n` passes approximating a Gaussian of `sigma` (Kovesi / "boxes for Gauss").
|
||||
@@ -72,36 +146,15 @@ fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
|
||||
|
||||
/// Separable box blur with given radii, `iterations` passes.
|
||||
pub fn box_blur(img: &Image, rx: usize, ry: usize, iterations: usize, repeat: bool) -> Image {
|
||||
let mut cur = img.clone();
|
||||
for _ in 0..iterations.max(1) {
|
||||
cur = box_h(&cur, rx, repeat);
|
||||
}
|
||||
if ry > 0 {
|
||||
let mut t = transpose(&cur);
|
||||
for _ in 0..iterations.max(1) {
|
||||
t = box_h(&t, ry, repeat);
|
||||
}
|
||||
cur = transpose(&t);
|
||||
}
|
||||
cur
|
||||
let n = iterations.max(1);
|
||||
box_passes(img, &vec![rx; n], &vec![ry; n], repeat)
|
||||
}
|
||||
|
||||
/// Gaussian blur with standard deviations in pixels (3 box passes per axis).
|
||||
pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image {
|
||||
let mut cur = img.clone();
|
||||
if sigma_x > 0.05 {
|
||||
for r in box_radii(sigma_x, 3) {
|
||||
cur = box_h(&cur, r, repeat);
|
||||
}
|
||||
}
|
||||
if sigma_y > 0.05 {
|
||||
let mut t = transpose(&cur);
|
||||
for r in box_radii(sigma_y, 3) {
|
||||
t = box_h(&t, r, repeat);
|
||||
}
|
||||
cur = transpose(&t);
|
||||
}
|
||||
cur
|
||||
let rx = if sigma_x > 0.05 { box_radii(sigma_x, 3) } else { vec![] };
|
||||
let ry = if sigma_y > 0.05 { box_radii(sigma_y, 3) } else { vec![] };
|
||||
box_passes(img, &rx, &ry, repeat)
|
||||
}
|
||||
|
||||
/// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from
|
||||
@@ -182,6 +235,54 @@ mod tests {
|
||||
assert!(b.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5));
|
||||
}
|
||||
|
||||
/// Direct (windowed sum) box filter along x or y: the definition the passes must match.
|
||||
fn box_ref(img: &Image, r: usize, vertical: bool, repeat: bool) -> Image {
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f64; 4];
|
||||
for k in -(r as i64)..=(r as i64) {
|
||||
let (sx, sy) = if vertical { (x, y + k) } else { (x + k, y) };
|
||||
let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) };
|
||||
for c in 0..4 {
|
||||
acc[c] += p[c] as f64;
|
||||
}
|
||||
}
|
||||
out.set(x as u32, y as u32, acc.map(|v| (v / (2 * r + 1) as f64) as f32));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_match_direct_box_filter() {
|
||||
let mut img = Image::new(53, 301);
|
||||
for y in 0..301 {
|
||||
for x in 0..53 {
|
||||
let v = crate::hash_noise(x, y, 3);
|
||||
img.set(x, y, [v * 0.5, v * 0.25, v, v]);
|
||||
}
|
||||
}
|
||||
for repeat in [false, true] {
|
||||
for (rx, ry) in [(0, 3), (2, 0), (5, 9), (40, 70), (1, 200)] {
|
||||
let fast = box_passes(&img, &[rx, rx + 1], &[ry, ry / 2], repeat);
|
||||
let mut slow = img.clone();
|
||||
for r in [rx, rx + 1] {
|
||||
slow = box_ref(&slow, r, false, repeat);
|
||||
}
|
||||
for r in [ry, ry / 2] {
|
||||
slow = box_ref(&slow, r, true, repeat);
|
||||
}
|
||||
for (i, (p, q)) in fast.data.iter().zip(&slow.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "r {rx},{ry} repeat {repeat} px {i}: {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radii_sane() {
|
||||
let r = box_radii(10.0, 3);
|
||||
|
||||
@@ -10,7 +10,7 @@ use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
|
||||
pub use warp::{Sampling, WarpOpts, composite_warp, resample};
|
||||
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
|
||||
|
||||
pub type Px = [f32; 4];
|
||||
|
||||
|
||||
+249
-1
@@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use effectcraft_geom::{Mat3, Rect, vec2};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{Image, hash_noise};
|
||||
use crate::{Image, Px, hash_noise};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Sampling {
|
||||
@@ -52,7 +52,163 @@ fn half(src: &Image) -> Image {
|
||||
}
|
||||
|
||||
/// Draw `src` transformed by `m` (src pixel space → dst pixel space) onto `dst` with blending.
|
||||
///
|
||||
/// Affine transforms with non-stencil modes take a fast path (per-row span clipping, interior
|
||||
/// bilinear without bounds checks, integer-translation copies, inlined Normal blending); it
|
||||
/// matches [`composite_warp_reference`] to float rounding.
|
||||
pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
||||
composite_warp_impl(dst, src, m, opts, true, None);
|
||||
}
|
||||
|
||||
/// `dst += warp(src) · weight` (plain sum, no blending): motion-blur sub-samples accumulate
|
||||
/// straight into one buffer instead of one full-size image per sample.
|
||||
pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Sampling, weight: f32) {
|
||||
let opts = WarpOpts { sampling, ..Default::default() };
|
||||
if m.is_affine() && m.inverse().is_some_and(|i| i.is_affine()) {
|
||||
composite_warp_impl(dst, src, m, &opts, true, Some(weight));
|
||||
return;
|
||||
}
|
||||
let mut tmp = Image::new(dst.width, dst.height);
|
||||
composite_warp(&mut tmp, src, m, &opts);
|
||||
dst.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(d, s)| {
|
||||
for c in 0..4 {
|
||||
d[c] += s[c] * weight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The straightforward per-pixel implementation (reference for the fast path's tests).
|
||||
pub fn composite_warp_reference(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
||||
composite_warp_impl(dst, src, m, opts, false, None);
|
||||
}
|
||||
|
||||
/// Bilinear blend of four taps (same operation order as [`Image::sample_bilinear`]).
|
||||
#[inline(always)]
|
||||
fn bilerp(a: Px, b: Px, c: Px, d: Px, tx: f32, ty: f32) -> Px {
|
||||
let mut o = [0.0; 4];
|
||||
for i in 0..4 {
|
||||
let top = a[i] + (b[i] - a[i]) * tx;
|
||||
let bot = c[i] + (d[i] - c[i]) * tx;
|
||||
o[i] = top + (bot - top) * ty;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Bilinear sample with an unchecked interior fast path.
|
||||
#[inline(always)]
|
||||
fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px {
|
||||
let fx = x - 0.5;
|
||||
let fy = y - 0.5;
|
||||
let x0 = fx.floor();
|
||||
let y0 = fy.floor();
|
||||
let (xi, yi) = (x0 as i64, y0 as i64);
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
if xi >= 0 && yi >= 0 && xi + 1 < w && yi + 1 < h {
|
||||
let tx = (fx - x0) as f32;
|
||||
let ty = (fy - y0) as f32;
|
||||
let i = yi as usize * w as usize + xi as usize;
|
||||
let row0 = &img.data[i..i + 2];
|
||||
let row1 = &img.data[i + w as usize..i + w as usize + 2];
|
||||
return bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty);
|
||||
}
|
||||
img.sample_bilinear(x, y)
|
||||
}
|
||||
|
||||
/// Index range `[lo, hi)` of `i` in `0..n` for which `p0 + i·s` may lie in `[min, max]`
|
||||
/// (conservative by one step on each side; callers still check per pixel).
|
||||
fn span(p0: f64, s: f64, min: f64, max: f64, n: usize) -> (usize, usize) {
|
||||
if s.abs() < 1e-12 {
|
||||
return if p0 >= min - 1e-9 && p0 <= max + 1e-9 { (0, n) } else { (0, 0) };
|
||||
}
|
||||
let (a, b) = ((min - p0) / s, (max - p0) / s);
|
||||
let (a, b) = if a < b { (a, b) } else { (b, a) };
|
||||
let lo = (a.floor() - 1.0).max(0.0);
|
||||
let hi = (b.ceil() + 2.0).min(n as f64);
|
||||
if hi <= lo { (0, 0) } else { (lo as usize, hi as usize) }
|
||||
}
|
||||
|
||||
/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn affine_rows(
|
||||
dst: &mut Image,
|
||||
img: &Image,
|
||||
inv: &Mat3,
|
||||
sampling: Sampling,
|
||||
op: f32,
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
blend: impl Fn(Px, Px, u32, u32) -> Px + Sync,
|
||||
) {
|
||||
let w = dst.width as usize;
|
||||
let m = inv.0;
|
||||
let (iw, ih) = (img.width as f64, img.height as f64);
|
||||
let step = vec2(m[0][0], m[1][0]);
|
||||
// Pure integer translation: every sample lands on a pixel centre, so all filters return the
|
||||
// source pixel itself (bicubic still clamps negatives, as in the reference).
|
||||
let int_tx = m[0][0] == 1.0 && m[1][1] == 1.0 && m[0][1] == 0.0 && m[1][0] == 0.0 && m[0][2].fract() == 0.0 && m[1][2].fract() == 0.0;
|
||||
dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| {
|
||||
let fy = y as f64 + 0.5;
|
||||
let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]);
|
||||
let n = x1 - x0;
|
||||
let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n);
|
||||
let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n);
|
||||
let (lo, hi) = (ax.max(ay), bx.min(by));
|
||||
if lo >= hi {
|
||||
return;
|
||||
}
|
||||
if int_tx {
|
||||
let sy = y as i64 + m[1][2] as i64;
|
||||
if sy < 0 || sy >= img.height as i64 {
|
||||
return;
|
||||
}
|
||||
let dx = m[0][2] as i64;
|
||||
let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize];
|
||||
let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize;
|
||||
let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize;
|
||||
for x in xa..xb.max(xa) {
|
||||
let mut s = srow[(x as i64 + dx) as usize];
|
||||
if sampling == Sampling::Bicubic {
|
||||
s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)];
|
||||
}
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if op < 1.0 {
|
||||
for c in s.iter_mut() {
|
||||
*c *= op;
|
||||
}
|
||||
}
|
||||
row[x] = blend(row[x], s, x as u32, y as u32);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in lo..hi {
|
||||
let x = x0 + i;
|
||||
let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64);
|
||||
if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 {
|
||||
continue;
|
||||
}
|
||||
let mut s = match sampling {
|
||||
Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64),
|
||||
Sampling::Bilinear => sample_bilinear_fast(img, p.x, p.y),
|
||||
Sampling::Bicubic => img.sample_bicubic(p.x, p.y),
|
||||
};
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if op < 1.0 {
|
||||
for c in s.iter_mut() {
|
||||
*c *= op;
|
||||
}
|
||||
}
|
||||
row[x] = blend(row[x], s, x as u32, y as u32);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, allow_fast: bool, accumulate: Option<f32>) {
|
||||
if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
@@ -91,6 +247,26 @@ pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
|
||||
let affine = inv.is_affine();
|
||||
let mode = opts.mode;
|
||||
let op = opts.opacity;
|
||||
if let Some(k) = accumulate
|
||||
&& affine
|
||||
{
|
||||
affine_rows(dst, img, &inv, opts.sampling, 1.0, x0, x1, y0, y1, |d, s, _, _| [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]);
|
||||
return;
|
||||
}
|
||||
if allow_fast && affine && !mode.is_stencil() {
|
||||
match mode {
|
||||
BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| {
|
||||
let k = 1.0 - s[3];
|
||||
[s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k]
|
||||
}),
|
||||
BlendMode::Dissolve | BlendMode::DancingDissolve => {
|
||||
let seed = opts.seed;
|
||||
affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed)))
|
||||
}
|
||||
_ => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)),
|
||||
}
|
||||
return;
|
||||
}
|
||||
dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| {
|
||||
let fy = y as f64 + 0.5;
|
||||
let mut sp = inv.apply(vec2(x0 as f64 + 0.5, fy));
|
||||
@@ -172,6 +348,78 @@ mod tests {
|
||||
assert_eq!(dst.get(3, 2), [1.0; 4]);
|
||||
}
|
||||
|
||||
fn test_image(w: u32, h: u32, seed: u32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let a = if (x + y) % 7 == 0 { 0.0 } else { hash_noise(x, y, seed) };
|
||||
img.set(x, y, [hash_noise(x, y, seed + 1) * a, hash_noise(x, y, seed + 2) * a, hash_noise(x, y, seed + 3) * a, a]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// The fast affine path equals the reference per-pixel path (modes, filters, transforms).
|
||||
#[test]
|
||||
fn fast_path_matches_reference() {
|
||||
let src = test_image(37, 23, 5);
|
||||
let mats = [
|
||||
Mat3::IDENTITY,
|
||||
Mat3::translate(vec2(5.0, -3.0)),
|
||||
Mat3::translate(vec2(-40.0, 2.0)),
|
||||
Mat3::translate(vec2(3.25, 7.5)),
|
||||
Mat3::translate(vec2(20.0, 10.0)) * Mat3::rotate_deg(23.0) * Mat3::scale(vec2(1.3, 0.8)),
|
||||
Mat3::translate(vec2(30.0, 5.0)) * Mat3::rotate_deg(-120.0) * Mat3::scale(vec2(0.7, 0.7)),
|
||||
Mat3::translate(vec2(10.0, 10.0)) * Mat3::scale(vec2(-1.0, 1.0)),
|
||||
Mat3::scale(vec2(0.3, 0.3)),
|
||||
];
|
||||
let modes = [BlendMode::Normal, BlendMode::Add, BlendMode::Multiply, BlendMode::Screen, BlendMode::Dissolve, BlendMode::Overlay];
|
||||
for (mi, m) in mats.iter().enumerate() {
|
||||
for mode in modes {
|
||||
for sampling in [Sampling::Nearest, Sampling::Bilinear, Sampling::Bicubic] {
|
||||
for opacity in [1.0, 0.6] {
|
||||
let opts = WarpOpts { sampling, opacity, mode, seed: 9, clip: None };
|
||||
let mut a = test_image(48, 40, 77);
|
||||
let mut b = a.clone();
|
||||
composite_warp(&mut a, &src, m, &opts);
|
||||
composite_warp_reference(&mut b, &src, m, &opts);
|
||||
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "mat {mi} {mode:?} {sampling:?} op {opacity} px {i}: {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulating sub-samples equals warping each into its own image and summing.
|
||||
#[test]
|
||||
fn accumulate_matches_per_sample_images() {
|
||||
let src = test_image(30, 20, 11);
|
||||
let persp = Mat3::square_to_quad([vec2(5.0, 4.0), vec2(40.0, 2.0), vec2(44.0, 30.0), vec2(2.0, 35.0)]) * Mat3::scale(vec2(1.0 / 30.0, 1.0 / 20.0));
|
||||
let mats = [Mat3::translate(vec2(3.0, 4.0)), Mat3::translate(vec2(5.5, 4.25)) * Mat3::rotate_deg(12.0), persp];
|
||||
let k = 1.0 / mats.len() as f32;
|
||||
let mut fast = Image::new(48, 40);
|
||||
let mut slow = Image::new(48, 40);
|
||||
for m in &mats {
|
||||
accumulate_warp(&mut fast, &src, m, Sampling::Bilinear, k);
|
||||
let mut img = Image::new(48, 40);
|
||||
composite_warp_reference(&mut img, &src, m, &WarpOpts::default());
|
||||
for (a, b) in slow.data.iter_mut().zip(&img.data) {
|
||||
for c in 0..4 {
|
||||
a[c] += b[c] * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (p, q) in fast.data.iter().zip(&slow.data) {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "{p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscale_preserves_average() {
|
||||
let src = Image::filled(64, 64, [0.25, 0.5, 0.75, 1.0]);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Layer render cache.
|
||||
//!
|
||||
//! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the
|
||||
//! layer's own non-transform properties, its source item and the render options — not on its
|
||||
//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed
|
||||
//! by a content hash of exactly those inputs, *evaluated at the frame time*, so:
|
||||
//!
|
||||
//! * a static layer (or one whose only animation is in Transform) renders once and is reused on
|
||||
//! every frame while scrubbing or playing;
|
||||
//! * editing one layer re-renders only that layer — every other layer's cached pixels stay valid
|
||||
//! because their keys do not change;
|
||||
//! * any change to a property value, keyframe, expression result, effect, mask, switch or source
|
||||
//! item changes the key, so stale pixels are never returned.
|
||||
//!
|
||||
//! Layers whose pixels depend on time in ways the property values do not capture (time-based
|
||||
//! effects such as Noise or Wave Warp, Wiggle Paths, footage, precomps) either fold the layer
|
||||
//! time into the key or are not cached.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
/// Thread-safe, memory-budgeted cache of processed layer buffers.
|
||||
pub struct LayerCache {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
buf: Arc<Buf>,
|
||||
bytes: usize,
|
||||
last_use: u64,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
map: HashMap<u64, Entry>,
|
||||
bytes: usize,
|
||||
budget: usize,
|
||||
clock: u64,
|
||||
hits: u64,
|
||||
misses: u64,
|
||||
}
|
||||
|
||||
/// Hit/miss counters and memory use (for perf readouts and tests).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CacheStats {
|
||||
pub entries: usize,
|
||||
pub bytes: usize,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
}
|
||||
|
||||
impl Default for LayerCache {
|
||||
fn default() -> Self {
|
||||
LayerCache::new(1 << 30)
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerCache {
|
||||
/// A cache holding at most `budget` bytes of pixels.
|
||||
pub fn new(budget: usize) -> LayerCache {
|
||||
LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0 }) }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: u64) -> Option<Arc<Buf>> {
|
||||
let mut g = self.inner.lock().ok()?;
|
||||
g.clock += 1;
|
||||
let now = g.clock;
|
||||
match g.map.get_mut(&key) {
|
||||
Some(e) => {
|
||||
e.last_use = now;
|
||||
let b = e.buf.clone();
|
||||
g.hits += 1;
|
||||
Some(b)
|
||||
}
|
||||
None => {
|
||||
g.misses += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&self, key: u64, buf: Arc<Buf>) {
|
||||
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
|
||||
let Ok(mut g) = self.inner.lock() else { return };
|
||||
if bytes > g.budget / 4 {
|
||||
return;
|
||||
}
|
||||
g.clock += 1;
|
||||
let now = g.clock;
|
||||
if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) {
|
||||
g.bytes -= old.bytes;
|
||||
}
|
||||
g.bytes += bytes;
|
||||
if g.bytes > g.budget {
|
||||
// Evict least-recently used entries down to 3/4 of the budget.
|
||||
let target = g.budget / 4 * 3;
|
||||
let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect();
|
||||
by_age.sort_unstable();
|
||||
for (_, k) in by_age {
|
||||
if g.bytes <= target {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = g.map.remove(&k) {
|
||||
g.bytes -= e.bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut g) = self.inner.lock() {
|
||||
g.map.clear();
|
||||
g.bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> CacheStats {
|
||||
self.inner.lock().map(|g| CacheStats { entries: g.map.len(), bytes: g.bytes, hits: g.hits, misses: g.misses }).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a style 64-bit hasher fed by `Debug` output (exact float formatting) and raw values.
|
||||
struct KeyHasher(u64);
|
||||
|
||||
impl Hasher for KeyHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.0 ^= b as u64;
|
||||
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Write for KeyHasher {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
Hasher::write(self, s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(h, "{v:?}");
|
||||
Hasher::write_u8(h, 0xff);
|
||||
}
|
||||
|
||||
/// Time-based layer content: does any part of the source/effects read the clock directly
|
||||
/// (rather than through property values)?
|
||||
fn time_dependent(layer: &Layer) -> bool {
|
||||
if layer.switches.effects
|
||||
&& let Some(fx) = layer.effects()
|
||||
{
|
||||
for g in fx.groups() {
|
||||
if let effectcraft_project::GroupKind::Effect { effect } = &g.kind
|
||||
&& g.enabled
|
||||
&& effectcraft_effects::is_time_dependent(effect)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(layer.source, LayerSource::Shape)
|
||||
&& let Some(c) = layer.props.sub("contents")
|
||||
{
|
||||
return group_has(c, "wiggle");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn group_has(g: &PropGroup, match_id: &str) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id),
|
||||
Node::Prop(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Feed every property of `g` that can change over time (keyframes or expression) as its value
|
||||
/// at the context time. The static structure is hashed separately.
|
||||
fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(p) => {
|
||||
if !p.keys.is_empty() || p.has_expression() {
|
||||
p.uid.hash(h);
|
||||
hash_debug(h, &ctx.value(layer, p));
|
||||
}
|
||||
}
|
||||
Node::Group(sub) => hash_values(h, ctx, layer, sub),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
|
||||
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
|
||||
/// layers…).
|
||||
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option<u64> {
|
||||
if layer.switches.adjustment {
|
||||
return None;
|
||||
}
|
||||
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
|
||||
match &layer.source {
|
||||
LayerSource::Solid { item } => {
|
||||
let it = ctx.project.item(*item)?;
|
||||
let ItemKind::Solid(s) = &it.kind else { return None };
|
||||
hash_debug(&mut h, s);
|
||||
}
|
||||
LayerSource::Text | LayerSource::Shape => {}
|
||||
_ => return None,
|
||||
}
|
||||
ctx.comp_id.hash(&mut h);
|
||||
ctx.comp.width.hash(&mut h);
|
||||
ctx.comp.height.hash(&mut h);
|
||||
scale.to_bits().hash(&mut h);
|
||||
draft.hash(&mut h);
|
||||
layer.id.hash(&mut h);
|
||||
hash_debug(&mut h, &layer.source);
|
||||
hash_debug(&mut h, &layer.switches);
|
||||
for c in &layer.props.children {
|
||||
if let Node::Group(g) = c
|
||||
&& g.match_id == "transform"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Static structure: values, keyframes, expressions, enabled flags, effect ids, modes.
|
||||
hash_debug(&mut h, c);
|
||||
if let Node::Group(g) = c {
|
||||
hash_values(&mut h, ctx, layer, g);
|
||||
}
|
||||
}
|
||||
if time_dependent(layer) {
|
||||
layer.layer_time(ctx.time).0.hash(&mut h);
|
||||
// Effects also see the layer's time mapping.
|
||||
layer.start_time.0.hash(&mut h);
|
||||
layer.stretch.to_bits().hash(&mut h);
|
||||
}
|
||||
Some(h.finish())
|
||||
}
|
||||
+79
-25
@@ -7,6 +7,7 @@
|
||||
//! Adjustment layers run their effects on everything below, limited by their own bounds/masks.
|
||||
|
||||
pub mod audio;
|
||||
pub mod cache;
|
||||
pub mod eval;
|
||||
pub mod masks;
|
||||
pub mod shapes;
|
||||
@@ -14,6 +15,7 @@ pub mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cache::{CacheStats, LayerCache};
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_effects::{Buf, EffectCtx, Params};
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
@@ -65,13 +67,32 @@ pub struct Renderer<'a> {
|
||||
pub footage: &'a dyn FootageSource,
|
||||
pub expr: Option<&'a dyn ExprHost>,
|
||||
pub opts: RenderOpts,
|
||||
/// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything.
|
||||
pub cache: Option<&'a LayerCache>,
|
||||
/// Optional per-layer timing sink (`frame --bench` / perf readouts).
|
||||
pub profile: Option<&'a std::sync::Mutex<Vec<LayerTiming>>>,
|
||||
/// Current nesting depth (precomp recursion guard).
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
/// Time spent on one layer of one frame (milliseconds).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayerTiming {
|
||||
pub depth: usize,
|
||||
pub layer: String,
|
||||
/// Source + masks + effects (cache lookup only, on a hit).
|
||||
pub process_ms: f64,
|
||||
/// Per effect: (effect id, ms). Empty on a cache hit.
|
||||
pub effects: Vec<(String, f64)>,
|
||||
/// Transform + matte + blend.
|
||||
pub composite_ms: f64,
|
||||
/// The processed buffer came from the layer cache.
|
||||
pub cached: bool,
|
||||
}
|
||||
|
||||
impl<'a> Renderer<'a> {
|
||||
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
|
||||
Renderer { project, footage, expr: None, opts, depth: 0 }
|
||||
Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 }
|
||||
}
|
||||
|
||||
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
|
||||
@@ -132,7 +153,11 @@ impl<'a> Renderer<'a> {
|
||||
p
|
||||
}
|
||||
|
||||
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool) -> Buf {
|
||||
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf {
|
||||
self.apply_effects_timed(ctx, layer, buf, adjustment, None)
|
||||
}
|
||||
|
||||
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, mut timing: Option<&mut Vec<(String, f64)>>) -> Buf {
|
||||
if !layer.switches.effects {
|
||||
return buf;
|
||||
}
|
||||
@@ -148,7 +173,11 @@ impl<'a> Renderer<'a> {
|
||||
let Some(spec) = effectcraft_effects::find(effect) else { continue };
|
||||
let params = self.effect_params(ctx, layer, g);
|
||||
let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
|
||||
let t0 = std::time::Instant::now();
|
||||
buf = effectcraft_effects::apply(spec, &ectx, buf);
|
||||
if let Some(v) = timing.as_deref_mut() {
|
||||
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
@@ -171,7 +200,7 @@ impl<'a> Renderer<'a> {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 };
|
||||
let sub = Renderer { depth: self.depth + 1, ..*self };
|
||||
let lt = layer.layer_time(ctx.time);
|
||||
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
|
||||
}
|
||||
@@ -199,10 +228,28 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
/// Fully processed layer buffer (source → masks → effects).
|
||||
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
|
||||
/// Served from the layer cache when the layer's content key matches.
|
||||
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
self.layer_buf_timed(ctx, layer, None)
|
||||
}
|
||||
|
||||
fn layer_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<Arc<Buf>> {
|
||||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
if let Some(t) = timing.as_deref_mut() {
|
||||
t.cached = true;
|
||||
}
|
||||
return Some(b);
|
||||
}
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
Some(self.apply_effects(ctx, layer, buf, false))
|
||||
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, timing.map(|t| &mut t.effects)));
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
|
||||
@@ -238,25 +285,14 @@ impl<'a> Renderer<'a> {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let angle = ctx.comp.shutter_angle / 360.0;
|
||||
let phase = ctx.comp.shutter_phase / 360.0;
|
||||
// Sub-samples sum straight into one buffer (each warp is row-parallel).
|
||||
let mut acc = Image::new(target.width, target.height);
|
||||
let accum: Vec<Image> = (0..samples)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let f = phase + angle * i as f64 / (samples - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
|
||||
let m = self.buf_matrix(&sub, layer, buf);
|
||||
let mut img = Image::new(target.width, target.height);
|
||||
composite_warp(&mut img, &buf.img, &m, &WarpOpts { mode: BlendMode::Normal, opacity: 1.0, ..opts });
|
||||
img
|
||||
})
|
||||
.collect();
|
||||
let k = 1.0 / samples as f32;
|
||||
for img in &accum {
|
||||
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, b)| {
|
||||
for c in 0..4 {
|
||||
a[c] += b[c] * k;
|
||||
}
|
||||
});
|
||||
for i in 0..samples {
|
||||
let f = phase + angle * i as f64 / (samples - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
|
||||
let m = self.buf_matrix(&sub, layer, buf);
|
||||
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, &m, opts.sampling, k);
|
||||
}
|
||||
target.blend_from(&acc, mode, opacity, layer.id.0 as u32);
|
||||
}
|
||||
@@ -270,16 +306,34 @@ impl<'a> Renderer<'a> {
|
||||
self.draw_adjustment(ctx, layer, canvas, opacity);
|
||||
return;
|
||||
}
|
||||
let Some(buf) = self.layer_buf(ctx, layer) else { return };
|
||||
let Some(prof) = self.profile else {
|
||||
if let Some(buf) = self.layer_buf(ctx, layer) {
|
||||
self.composite_layer(ctx, layer, &buf, canvas, opacity);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() };
|
||||
let t0 = std::time::Instant::now();
|
||||
let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return };
|
||||
let t1 = std::time::Instant::now();
|
||||
self.composite_layer(ctx, layer, &buf, canvas, opacity);
|
||||
timing.process_ms = (t1 - t0).as_secs_f64() * 1e3;
|
||||
timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3;
|
||||
if let Ok(mut v) = prof.lock() {
|
||||
v.push(timing);
|
||||
}
|
||||
}
|
||||
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
|
||||
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
|
||||
let preserve = layer.preserve_transparency;
|
||||
if matte.is_none() && !preserve {
|
||||
self.place(ctx, layer, &buf, canvas, layer.blend_mode, opacity);
|
||||
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
|
||||
return;
|
||||
}
|
||||
// Render in isolation, then matte / preserve transparency, then blend.
|
||||
let mut iso = Image::new(canvas.width, canvas.height);
|
||||
self.place(ctx, layer, &buf, &mut iso, BlendMode::Normal, 1.0);
|
||||
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
|
||||
if let Some((m, kind)) = matte {
|
||||
let mut mimg = Image::new(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
|
||||
@@ -160,3 +160,174 @@ fn effects_run_in_pipeline() {
|
||||
let c = img.get(100, 50);
|
||||
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ layer cache
|
||||
|
||||
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
|
||||
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
|
||||
r.cache = cache;
|
||||
r.comp_frame(cid, t)
|
||||
}
|
||||
|
||||
fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, params: &[(&str, Value)]) {
|
||||
let spec = effectcraft_effects::find(id).unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
|
||||
p.next_id = next;
|
||||
for (k, v) in params {
|
||||
g.prop_mut(k).unwrap().value = v.clone();
|
||||
}
|
||||
l.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
}
|
||||
|
||||
/// A comp with a static effected solid, an animated-trim shape, a moving text layer with a blur.
|
||||
fn cache_scene() -> (Project, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
|
||||
add_effect(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]);
|
||||
let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None);
|
||||
let mut next = p.next_id;
|
||||
{
|
||||
let mut ids = Ids(&mut next);
|
||||
let e = build::shape_ellipse(&mut ids, [60.0, 60.0], [0.0, 0.0]);
|
||||
let tr = build::shape_trim(&mut ids, 0.0, 0.0, 0.0);
|
||||
let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0);
|
||||
let g = build::shape_group(&mut ids, "Ring", vec![e, tr, s]);
|
||||
shape.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
}
|
||||
p.next_id = next;
|
||||
shape.props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Scalar(10.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
|
||||
let mut text = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
|
||||
text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() }));
|
||||
text.props.prop_mut("transform/position").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))];
|
||||
add_effect(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]);
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers = vec![text, shape, bg];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn assert_same(a: &crate::Image, b: &crate::Image, what: &str) {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height), "{what}");
|
||||
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-6, "{what}: pixel {i}: {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_frames_match_uncached() {
|
||||
let (p, cid) = cache_scene();
|
||||
let cache = crate::LayerCache::default();
|
||||
for f in [0.0, 0.25, 0.5, 0.5, 1.2, 1.5, 0.25] {
|
||||
let t = Tick::from_seconds_f64(f);
|
||||
assert_same(&render_cached(&p, cid, t, Some(&cache)), &render_cached(&p, cid, t, None), &format!("t={f}"));
|
||||
}
|
||||
// bg is static, text only moves (transform), the shape is static after 1 s: lots of reuse.
|
||||
let st = cache.stats();
|
||||
assert!(st.hits >= 10, "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_and_transform_only_layers_are_reused() {
|
||||
let (p, cid) = cache_scene();
|
||||
let cache = crate::LayerCache::default();
|
||||
render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache));
|
||||
let before = cache.stats();
|
||||
render_cached(&p, cid, Tick::from_seconds_f64(0.4), Some(&cache));
|
||||
let after = cache.stats();
|
||||
// bg (static) + text (only its position animates) hit; the shape's trim animates: miss.
|
||||
assert_eq!(after.hits - before.hits, 2, "{before:?} {after:?}");
|
||||
assert_eq!(after.misses - before.misses, 1, "{before:?} {after:?}");
|
||||
}
|
||||
|
||||
type Edit = Box<dyn Fn(&mut Project, ItemId)>;
|
||||
|
||||
/// Every kind of edit re-renders the affected layer: the cached render of the edited project
|
||||
/// equals a fresh uncached render, and differs from the unedited frame.
|
||||
#[test]
|
||||
fn edits_invalidate_cached_layers() {
|
||||
let (p, cid) = cache_scene();
|
||||
let t = Tick::from_seconds_f64(0.5);
|
||||
let edits: Vec<(&str, Edit)> = vec![
|
||||
(
|
||||
"effect param value",
|
||||
Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.prop_mut("effects/#1/white").unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0])),
|
||||
),
|
||||
(
|
||||
"effect param keyframes",
|
||||
Box::new(|p, cid| {
|
||||
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/blurriness").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(30.0))]
|
||||
}),
|
||||
),
|
||||
("effect disabled", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false)),
|
||||
("effects switch off", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[0].switches.effects = false)),
|
||||
(
|
||||
"effect added",
|
||||
Box::new(|p, cid| {
|
||||
let mut l = p.comp_mut(cid).unwrap().layers[2].clone();
|
||||
add_effect(p, &mut l, "ec.channel.invert", &[]);
|
||||
p.comp_mut(cid).unwrap().layers[2] = l;
|
||||
}),
|
||||
),
|
||||
(
|
||||
"shape keyframe moved",
|
||||
Box::new(|p, cid| {
|
||||
p.comp_mut(cid).unwrap().layers[1].props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys[1].time = Tick::from_seconds_f64(2.0)
|
||||
}),
|
||||
),
|
||||
(
|
||||
"text changed",
|
||||
Box::new(|p, cid| {
|
||||
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("text/sourceText").unwrap().value =
|
||||
Value::Text(Box::new(TextDoc { text: "XYZ".into(), size: 40.0, ..Default::default() }))
|
||||
}),
|
||||
),
|
||||
(
|
||||
"solid colour",
|
||||
Box::new(|p, cid| {
|
||||
let LayerSource::Solid { item } = p.comp(cid).unwrap().layers[2].source else { unreachable!() };
|
||||
if let ItemKind::Solid(s) = &mut p.item_mut(item).unwrap().kind {
|
||||
s.color = [0.9, 0.1, 0.1];
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false;
|
||||
}),
|
||||
),
|
||||
(
|
||||
"mask added",
|
||||
Box::new(|p, cid| {
|
||||
let mut next = p.next_id;
|
||||
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
p.comp_mut(cid).unwrap().layers[2].props.sub_mut("masks").unwrap().children.push(m.into());
|
||||
}),
|
||||
),
|
||||
("layer time shifted", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[1].start_time = Tick::from_seconds_f64(0.3))),
|
||||
];
|
||||
for (what, edit) in edits {
|
||||
let cache = crate::LayerCache::default();
|
||||
let orig = render_cached(&p, cid, t, Some(&cache));
|
||||
let mut q = p.clone();
|
||||
edit(&mut q, cid);
|
||||
let cached = render_cached(&q, cid, t, Some(&cache));
|
||||
let fresh = render_cached(&q, cid, t, None);
|
||||
assert_same(&cached, &fresh, what);
|
||||
assert!(orig.data.iter().zip(&fresh.data).any(|(a, b)| (a[0] - b[0]).abs() + (a[3] - b[3]).abs() > 1e-3), "{what}: edit had no visible effect");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_dependent_effects_rerender_every_frame() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
|
||||
add_effect(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let cache = crate::LayerCache::default();
|
||||
let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache));
|
||||
let b = render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache));
|
||||
assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2");
|
||||
assert!(a != b, "noise must animate");
|
||||
}
|
||||
@@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::project::{ItemId, Project};
|
||||
use effectcraft_engine::render::{ExprHost, FootageSource, RenderOpts, Renderer};
|
||||
use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use rayon::prelude::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct FrameKey {
|
||||
@@ -49,14 +50,18 @@ pub struct RenderSource {
|
||||
pub project: Arc<Project>,
|
||||
pub footage: Arc<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub layer_cache: Arc<LayerCache>,
|
||||
}
|
||||
|
||||
pub struct Frames {
|
||||
cache: Arc<Mutex<Cache>>,
|
||||
/// Keys queued or rendering.
|
||||
inflight: Arc<Mutex<HashSet<FrameKey>>>,
|
||||
/// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first).
|
||||
queue: Arc<Mutex<Queue>>,
|
||||
pool: rayon::ThreadPool,
|
||||
ctx: Option<egui::Context>,
|
||||
/// Last render time per frame (ms), for the Info panel / perf readout.
|
||||
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
|
||||
pub last_ms: Arc<Mutex<f64>>,
|
||||
}
|
||||
|
||||
@@ -66,6 +71,7 @@ impl Default for Frames {
|
||||
Frames {
|
||||
cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, budget: 3 << 30 })),
|
||||
inflight: Arc::new(Mutex::new(HashSet::new())),
|
||||
queue: Arc::new(Mutex::new(Queue::default())),
|
||||
pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"),
|
||||
ctx: None,
|
||||
last_ms: Arc::new(Mutex::new(0.0)),
|
||||
@@ -78,7 +84,7 @@ impl Default for Frames {
|
||||
pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorImage {
|
||||
let px: Vec<egui::Color32> = img
|
||||
.data
|
||||
.iter()
|
||||
.par_iter()
|
||||
.map(|p| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8;
|
||||
@@ -123,36 +129,92 @@ impl Frames {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a frame (no-op if cached or already rendering).
|
||||
/// Queue a prefetch frame (no-op if cached or already queued/rendering).
|
||||
pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) {
|
||||
self.request_with(src, key, comp, t, opts, false);
|
||||
}
|
||||
|
||||
/// Queue the frame the viewer is showing: it jumps ahead of every prefetch job.
|
||||
pub fn request_urgent(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) {
|
||||
self.request_with(src, key, comp, t, opts, true);
|
||||
}
|
||||
|
||||
/// An urgent (viewer) frame is queued or rendering.
|
||||
pub fn urgent_pending(&self) -> bool {
|
||||
self.queue.lock().map(|q| q.urgent_running > 0 || q.jobs.iter().any(|j| j.urgent)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) {
|
||||
if self.is_cached(&key) {
|
||||
return;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else { return };
|
||||
// Jobs for an older project revision can never be shown: drop them.
|
||||
let before = q.jobs.len();
|
||||
q.jobs.retain(|j| j.key.revision >= key.revision);
|
||||
let dropped = before - q.jobs.len();
|
||||
if dropped > 0
|
||||
&& let Ok(mut inf) = self.inflight.lock()
|
||||
{
|
||||
inf.retain(|k| k.revision >= key.revision || q.running.contains(k));
|
||||
}
|
||||
if urgent {
|
||||
// Only the newest viewer frame is urgent; earlier ones become prefetch.
|
||||
for j in q.jobs.iter_mut() {
|
||||
j.urgent = j.key == key;
|
||||
}
|
||||
}
|
||||
{
|
||||
let Ok(mut inf) = self.inflight.lock() else { return };
|
||||
if !inf.insert(key) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let src = src.clone();
|
||||
q.seq += 1;
|
||||
let seq = q.seq;
|
||||
q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq });
|
||||
drop(q);
|
||||
let queue = self.queue.clone();
|
||||
let cache = self.cache.clone();
|
||||
let inflight = self.inflight.clone();
|
||||
let ctx = self.ctx.clone();
|
||||
let last = self.last_ms.clone();
|
||||
// One spawn per job; each spawn renders whichever queued job matters most right now.
|
||||
self.pool.spawn(move || {
|
||||
let job = {
|
||||
let Ok(mut q) = queue.lock() else { return };
|
||||
// Urgent first (newest), then prefetch in request order.
|
||||
let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i);
|
||||
let Some(i) = best else { return };
|
||||
let job = q.jobs.swap_remove(i);
|
||||
q.running.insert(job.key);
|
||||
if job.urgent {
|
||||
q.urgent_running += 1;
|
||||
}
|
||||
job
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut r = Renderer::new(&src.project, src.footage.as_ref(), opts);
|
||||
r.expr = src.expr.as_deref();
|
||||
let img = r.comp_frame(comp, t);
|
||||
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
|
||||
r.expr = job.src.expr.as_deref();
|
||||
r.cache = Some(&job.src.layer_cache);
|
||||
let img = r.comp_frame(job.comp, job.t);
|
||||
let ci = Arc::new(to_color_image(&img));
|
||||
if let Ok(mut l) = last.lock() {
|
||||
if job.urgent
|
||||
&& let Ok(mut l) = last.lock()
|
||||
{
|
||||
*l = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
}
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(key, ci);
|
||||
c.insert(job.key, ci);
|
||||
}
|
||||
if let Ok(mut inf) = inflight.lock() {
|
||||
inf.remove(&key);
|
||||
inf.remove(&job.key);
|
||||
}
|
||||
if let Ok(mut q) = queue.lock() {
|
||||
q.running.remove(&job.key);
|
||||
if job.urgent {
|
||||
q.urgent_running -= 1;
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = ctx {
|
||||
ctx.request_repaint();
|
||||
@@ -160,3 +222,21 @@ impl Frames {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
key: FrameKey,
|
||||
src: RenderSource,
|
||||
comp: ItemId,
|
||||
t: Tick,
|
||||
opts: RenderOpts,
|
||||
urgent: bool,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
jobs: Vec<Job>,
|
||||
running: HashSet<FrameKey>,
|
||||
urgent_running: usize,
|
||||
seq: u64,
|
||||
}
|
||||
@@ -162,7 +162,12 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
pub fn render_source(&self) -> RenderSource {
|
||||
RenderSource { project: self.session.project.clone(), footage: self.session.footage.clone(), expr: self.session.expr.clone() }
|
||||
RenderSource {
|
||||
project: self.session.project.clone(),
|
||||
footage: self.session.footage.clone(),
|
||||
expr: self.session.expr.clone(),
|
||||
layer_cache: self.session.layer_cache.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The render scale used by the viewer right now.
|
||||
@@ -178,13 +183,26 @@ impl EffectcraftApp {
|
||||
FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32 }
|
||||
}
|
||||
|
||||
/// Request a frame render (no-op if cached/in flight).
|
||||
/// Request a prefetch frame render (no-op if cached/in flight).
|
||||
pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) {
|
||||
self.request_frame_with(comp, frame, scale, false);
|
||||
}
|
||||
|
||||
/// Request the frame on screen: rendered before any queued prefetch.
|
||||
pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) {
|
||||
self.request_frame_with(comp, frame, scale, true);
|
||||
}
|
||||
|
||||
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
|
||||
let Some(c) = self.session.project.comp(comp) else { return };
|
||||
let key = self.frame_key(comp, frame, scale);
|
||||
let t = c.frame_rate.tick_of(frame);
|
||||
let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview };
|
||||
self.frames.request(&self.render_source(), key, comp, t, opts);
|
||||
if urgent {
|
||||
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
|
||||
} else {
|
||||
self.frames.request(&self.render_source(), key, comp, t, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- playback
|
||||
@@ -226,7 +244,15 @@ impl EffectcraftApp {
|
||||
let (wa, wb) = (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration()));
|
||||
// Prefetch ahead.
|
||||
let cur = fr.frame_at(self.session.time());
|
||||
let ahead = (self.frames_parallelism() * 2).max(4) as i64;
|
||||
// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few
|
||||
// frames, and only once it is done, so prefetch never delays what is on screen.
|
||||
let ahead = if self.playback.playing {
|
||||
(self.frames_parallelism() * 2).max(4) as i64
|
||||
} else if self.frames.urgent_pending() {
|
||||
0
|
||||
} else {
|
||||
(self.frames_parallelism() / 2).max(2) as i64
|
||||
};
|
||||
let mut queued = self.frames.inflight();
|
||||
for k in 0..ahead * 3 {
|
||||
if queued >= ahead as usize {
|
||||
|
||||
@@ -184,11 +184,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Frame.
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let scale = app.viewer_scale(zoom, ppp);
|
||||
if !app.playback.playing {
|
||||
// Queue the frame on screen before any prefetch.
|
||||
app.request_frame_urgent(cid, comp.frame_rate.frame_at(app.session.time()), scale);
|
||||
}
|
||||
crate::tick_playback(app, &ctx, scale);
|
||||
let time = app.session.time();
|
||||
let frame = comp.frame_rate.frame_at(time);
|
||||
let key = app.frame_key(cid, frame, scale);
|
||||
app.request_frame(cid, frame, scale);
|
||||
app.request_frame_urgent(cid, frame, scale);
|
||||
if let Some(img) = app.frames.get(&key) {
|
||||
let stale = app.viewer_tex.as_ref().is_none_or(|(_, k)| *k != key);
|
||||
if stale {
|
||||
|
||||
Reference in new issue
Block a user