mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06: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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user