refactor(ui-egui): move src/frames.rs tests to src/frames/tests.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent 7204aeaa69
commit 10ee0d51ab
2 changed files with 929 additions and 930 deletions
+1 -930
View File
@@ -1467,933 +1467,4 @@ impl Queue {
}
#[cfg(test)]
mod tests {
use super::*;
struct ProbeDisk {
keys: Mutex<HashSet<u128>>,
probes: std::sync::atomic::AtomicUsize,
}
impl RemoteFrames for ProbeDisk {
fn slots(&self) -> usize {
0
}
fn disk(&self) -> bool {
true
}
fn disk_contains(&self, key: u128) -> bool {
self.probes.fetch_add(1, Ordering::Relaxed);
self.keys.lock().unwrap().contains(&key)
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, RenderOpts, Arc<ProbeDisk>, u128) {
let mut session = effectcraft_engine::Session::default();
session.execute("comp.new", serde_json::json!({"width": 32, "height": 18, "duration": 1})).unwrap();
let comp = session.active_comp_id().unwrap();
let opts = RenderOpts::default();
let content = 1234;
let mut base = key(1, 0, opts_hash(&opts));
base.revision = 7;
base.comp = comp.0;
let disk = Arc::new(ProbeDisk { keys: Mutex::new(HashSet::new()), probes: 0.into() });
disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts))));
let mut frames = Frames::default();
frames.set_remote(Some(disk.clone()));
frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content);
let source = RenderSource {
project: session.project.clone(),
footage: session.footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
(frames, source, base, opts, disk, content)
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() {
use std::sync::mpsc;
use std::time::{Duration, Instant};
struct Disk(u128);
impl RemoteFrames for Disk {
fn slots(&self) -> usize {
1
}
fn disk(&self) -> bool {
true
}
fn disk_contains(&self, key: u128) -> bool {
self.0 == key
}
fn start(&self, _: RemoteJob, done: RemoteDone) {
done(Err("test has no renderer".into()));
}
}
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", serde_json::json!({"width":32,"height":18,"duration":1})).unwrap();
let cid = s.active_comp_id().unwrap();
let opts = RenderOpts::default();
let stored = disk_cache::frame_key(disk_cache::comp_content_key(&s.project, cid), 2, 1000, opts_hash(&opts));
let mut frames = Frames { pool: Some(rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap()), ..Default::default() };
frames.set_remote(Some(Arc::new(Disk(stored))));
let (entered, started) = mpsc::channel();
let (release, wait) = mpsc::channel();
frames.pool.as_ref().unwrap().spawn(move || {
entered.send(()).unwrap();
wait.recv_timeout(Duration::from_secs(5)).unwrap();
});
started.recv_timeout(Duration::from_secs(3)).unwrap();
let src = RenderSource {
project: s.project.clone(),
footage: s.footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let mut base = key(1, 0, 0);
base.comp = cid.0;
for revision in 1..50 {
base.revision = revision;
assert!(frames.disk_frames(&src, &base, 0..30, &opts).is_empty());
}
assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked");
assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1);
release.send(()).unwrap();
let until = Instant::now() + Duration::from_secs(3);
while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) {
assert!(Instant::now() < until);
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(frames.disk_frames(&src, &base, 0..30, &opts), vec![2], "the blue indicator must return once its key is ready");
assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed");
}
#[test]
fn disk_indicator_range_tracks_live_membership() {
let (frames, src, base, opts, disk, _) = disk_fixture(&[10, 12]);
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 3);
disk.keys.lock().unwrap().insert(disk_cache::frame_key(1234, 11, base.scale, base.view ^ opts_hash(&opts)));
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 11, 12]);
insert(&frames, FrameKey { frame: 11, ..base });
disk.probes.store(0, Ordering::Relaxed);
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
disk.keys.lock().unwrap().remove(&disk_cache::frame_key(1234, 12, base.scale, base.view ^ opts_hash(&opts)));
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10]);
let mut revised = base;
revised.revision += 1;
frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678);
assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty());
let changed = FrameKey { scale: base.scale + 1, ..base };
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
let changed = FrameKey { view: base.view + 1, ..base };
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
assert!(frames.disk_frames(&src, &base, 10..13, &RenderOpts { draft: true, ..opts }).is_empty());
}
#[test]
fn disk_indicator_range_is_bounded() {
let (frames, src, base, opts, disk, _) = disk_fixture(&[0, 1, 99_999]);
assert_eq!(frames.disk_frames(&src, &base, -5..2, &opts), vec![0, 1]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
disk.probes.store(0, Ordering::Relaxed);
assert_eq!(frames.disk_frames(&src, &base, 99_999..i64::MAX, &opts), vec![99_999]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 1);
disk.probes.store(0, Ordering::Relaxed);
assert!(frames.disk_frames(&src, &base, 2..2, &opts).is_empty());
// Written as a struct literal: `3..2` trips clippy::reversed_empty_ranges.
let reversed = std::ops::Range { start: 3, end: 2 };
assert!(frames.disk_frames(&src, &base, reversed, &opts).is_empty());
assert_eq!(disk.probes.load(Ordering::Relaxed), 0);
}
/// Frames larger than the texture limit are averaged down by a whole factor (#201: an
/// 11000×2200 comp at Full resolution against an 8192 limit).
#[test]
fn frames_over_the_texture_limit_are_averaged_down() {
assert_eq!(fitted_size([11000, 2200], 8192), [5500, 1100]);
assert_eq!(fitted_size([8192, 100], 8192), [8192, 100]);
assert_eq!(fitted_size([30000, 30000], 16384), [15000, 15000]);
assert_eq!(fitted_size([0, 0], 0), [0, 0]);
// 5×2 (three columns of white, then black) into 2 px: 3×2 blocks (the last one 2×2).
let w = egui::Color32::WHITE;
let b = egui::Color32::from_rgba_premultiplied(0, 0, 0, 255);
let img = egui::ColorImage::new([5, 2], vec![w, w, w, b, b, w, w, w, b, b]);
let out = fit_texture(img.clone(), 2);
assert_eq!(out.size, [2, 1]);
assert_eq!(out.pixels, vec![w, b]);
assert_eq!(fit_texture(img.clone(), 5).pixels, img.pixels);
let mixed = fit_texture(egui::ColorImage::new([2, 1], vec![w, b]), 1);
assert_eq!(mixed.pixels, vec![egui::Color32::from_rgba_premultiplied(128, 128, 128, 255)]);
}
#[test]
fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live() {
let frames = Frames::default();
let worker = frames.worker();
let first = key(1, 0, 0);
insert(&frames, first);
frames.retire_gpu();
assert!(worker.gpu_retired.load(Ordering::Acquire), "already queued workers share retirement");
assert!(frames.is_cached(&first), "healthy materialized CPU frames survive");
let next = key(1, 1, 0);
frames.inflight.lock().unwrap().insert(next);
frames.queue.lock().unwrap().running.insert(next);
worker.finish_key(next, false, img(), web_time::Instant::now());
assert!(frames.is_cached(&next), "CPU retry can still publish");
assert_eq!(frames.inflight(), 0);
assert!(frames.queue.lock().unwrap().running.is_empty());
}
fn key(content: u64, frame: i64, opts: u64) -> FrameKey {
FrameKey { revision: content, content, comp: 1, frame, scale: 1000, view: 0, opts }
}
fn img() -> FrameImage {
FrameImage::Cpu(Arc::new(egui::ColorImage::new([2, 2], vec![egui::Color32::BLACK; 4])))
}
fn insert(f: &Frames, k: FrameKey) {
f.cache.lock().unwrap().insert(k, img());
}
/// A frame whose render panics (a GPU out of memory used to, on every frame thread) is
/// released, not left "in progress" forever, so the viewer can ask for it again (#106).
#[test]
fn a_frame_whose_render_panics_is_released() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct Explodes(AtomicUsize);
impl FootageSource for Explodes {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
self.0.fetch_add(1, Ordering::SeqCst);
panic!("a decoder bug");
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage { kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() };
let item = Arc::make_mut(&mut s.project).add_item("clip.png", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Explodes::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let key = FrameKey { revision: s.revision, content: f.content_of(&s.project, s.revision, cid), ..key(0, 0, 0) };
let key = FrameKey { comp: cid.0, ..key };
f.request(&src, key, cid, Tick::ZERO, RenderOpts::default());
for _ in 0..1000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(footage.0.load(Ordering::SeqCst) > 0, "the render reached the footage");
assert_eq!(f.inflight(), 0, "released");
assert!(!f.is_cached(&key));
}
/// #647: a pool thread waiting inside a render (on a parallel loop) never starts another
/// frame render on top of it. Dragging a layer queues a frame every UI frame; renders used to
/// nest on every thread, each holding its buffers, until memory ran out.
#[test]
fn a_waiting_render_never_starts_another_on_its_thread() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use effectcraft_engine::time::FrameRate;
use serde_json::json;
use std::cell::Cell;
use std::sync::atomic::AtomicUsize;
thread_local! {
static DEPTH: Cell<usize> = const { Cell::new(0) };
}
/// A decoder that works in parallel, as the renderer does: its thread joins the loop and
/// may wait for the parts other threads took.
#[derive(Default)]
struct Parallel {
calls: AtomicUsize,
deepest: AtomicUsize,
}
impl FootageSource for Parallel {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let d = DEPTH.get() + 1;
DEPTH.set(d);
self.deepest.fetch_max(d, Ordering::SeqCst);
self.calls.fetch_add(1, Ordering::SeqCst);
(0..64).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)));
DEPTH.set(d - 1);
Some(Arc::new(Image::new(4, 4)))
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage {
kind: FootageKind::Video,
width: 4,
height: 4,
has_video: true,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(10.0),
..Default::default()
};
let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Parallel::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let content = f.content_of(&s.project, s.revision, cid);
let frames = 60;
for frame in 0..frames {
let key = FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 };
f.request(&src, key, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default());
}
for _ in 0..4000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(f.inflight(), 0, "every queued frame rendered");
assert!(footage.calls.load(Ordering::SeqCst) >= frames as usize);
assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread");
}
/// #329: scrubbing a long movie, the viewer frame of every step the pointer passed started
/// rendering and seeked the movie's decoder in turn; the frame under the pointer waited for
/// all of them. A newer viewer frame of another time calls off the one rendering, which is
/// not cached (its footage may be missing).
#[test]
fn a_newer_viewer_frame_calls_off_the_one_rendering() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use effectcraft_engine::time::FrameRate;
use serde_json::json;
use std::cell::RefCell;
thread_local! {
static CANCEL: RefCell<Option<Arc<AtomicBool>>> = const { RefCell::new(None) };
}
/// A movie whose first frames take a long seek, which a called-off read gives up.
#[derive(Default)]
struct Seeking {
seeking: AtomicBool,
called_off: AtomicBool,
}
impl FootageSource for Seeking {
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
let prev = CANCEL.replace(Some(cancel.clone()));
f();
CANCEL.set(prev);
}
fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<Image>> {
if t < Tick::from_seconds_f64(0.25) {
self.seeking.store(true, Ordering::SeqCst);
for _ in 0..1000 {
if CANCEL.with_borrow(|c| c.as_ref().is_some_and(|c| c.load(Ordering::SeqCst))) {
self.called_off.store(true, Ordering::SeqCst);
return None;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
Some(Arc::new(Image::new(4, 4)))
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage {
kind: FootageKind::Video,
width: 4,
height: 4,
has_video: true,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(10.0),
..Default::default()
};
let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Seeking::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let content = f.content_of(&s.project, s.revision, cid);
let key = |frame| FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 };
f.request_urgent(&src, key(0), cid, Tick::ZERO, RenderOpts::default());
for _ in 0..1000 {
if footage.seeking.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(footage.seeking.load(Ordering::SeqCst), "the first frame's render reached the footage");
f.request_urgent(&src, key(150), cid, FrameRate::FPS_30.tick_of(150), RenderOpts::default());
for _ in 0..1000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(footage.called_off.load(Ordering::SeqCst), "the superseded frame's seek ran on");
assert!(f.is_cached(&key(150)), "the newest frame rendered");
assert!(!f.is_cached(&key(0)), "a called-off frame is not cached");
assert_eq!(f.inflight(), 0, "released");
}
/// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer
/// frames still waiting (they would all render first), but not prefetch frames or another
/// comp's viewer frame.
#[test]
fn a_newer_viewer_frame_drops_the_ones_still_waiting() {
/// Starts nothing, so every request stays queued.
struct Idle;
impl RemoteFrames for Idle {
fn slots(&self) -> usize {
0
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
let src = RenderSource {
project: Arc::default(),
footage: Arc::new(effectcraft_engine::render::NoFootage),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let request = |k: FrameKey, urgent: bool| f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), urgent);
let other = FrameKey { comp: 2, ..key(1, 0, 0) };
request(other, true);
request(key(1, 20, 0), false);
for frame in 0..10 {
request(key(1, frame, 0), true);
}
let mut queued: Vec<(u64, i64, bool)> = f.queue.lock().unwrap().jobs.iter().map(|j| (j.key.comp, j.key.frame, j.urgent)).collect();
queued.sort();
assert_eq!(queued, [(1, 9, true), (1, 20, false), (2, 0, false)]);
assert_eq!(f.inflight(), 3, "dropped frames aren't in flight: the viewer can ask again");
}
/// Each time the GPU runs out of memory, half as many GPU frames are kept, down to a floor.
#[test]
fn running_out_of_video_memory_halves_the_gpu_frame_budget() {
let f = Frames::default();
let mut c = f.cache.lock().unwrap();
assert_eq!(c.gpu_budget, GPU_BUDGET);
c.gpu_out_of_memory();
assert_eq!(c.gpu_budget, GPU_BUDGET / 2);
for _ in 0..20 {
c.gpu_out_of_memory();
}
assert_eq!(c.gpu_budget, MIN_GPU_BUDGET);
}
#[test]
fn least_recently_shown_frames_go_first() {
let f = Frames::default();
f.set_budget(3 * 16);
for i in 0..3 {
insert(&f, key(1, i, 0));
}
// Frame 0 is shown again, so frame 1 is now the oldest.
assert!(f.get(&key(1, 0, 0)).is_some());
insert(&f, key(1, 3, 0));
assert!(f.is_cached(&key(1, 0, 0)) && !f.is_cached(&key(1, 1, 0)));
assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0)));
}
fn playhead(cur: i64, looping: bool) -> Playhead {
Playhead { series: key(1, 0, 0), cur, start: 0, end: 99, step: 1, looping }
}
/// #595: a playing preview's cache is full. The frames just ahead of the playhead were
/// rendered before the ones just shown, so plain LRU evicted them first and the preview
/// played into a gap. Frames the preview no longer reaches go first, then the ones it
/// reaches last.
#[test]
fn a_full_cache_keeps_the_frames_just_ahead_of_the_playhead() {
let f = Frames::default();
f.set_budget(4 * 16);
// Prefetch rendered 11..=13, then 10 was shown (the most recently used).
for i in [11, 12, 13, 10] {
insert(&f, key(1, i, 0));
}
let cached = |f: &Frames| f.cached_frames(&key(1, 0, 0));
f.set_playhead(Some(playhead(10, false)));
// The next frame rendered is the one the preview reaches last: the frames just ahead stay.
insert(&f, key(1, 14, 0));
assert_eq!(cached(&f), [10, 11, 12, 13]);
// Without looping, a frame behind the playhead goes before any frame ahead of it.
f.set_playhead(Some(playhead(12, false)));
insert(&f, key(1, 14, 0));
assert_eq!(cached(&f), [10, 12, 13, 14]);
// Looping, the frame just behind is the one reached last.
f.set_playhead(Some(playhead(13, true)));
insert(&f, key(1, 0, 0));
assert_eq!(cached(&f), [0, 10, 13, 14]);
// Stopped: least recently shown first again.
f.set_playhead(None);
insert(&f, key(1, 50, 0));
assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0)));
}
/// A frame source that queues jobs and never renders them: the tests check the queue.
struct Idle;
impl RemoteFrames for Idle {
fn slots(&self) -> usize {
0
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
fn idle_source() -> RenderSource {
RenderSource {
project: Arc::default(),
footage: Arc::new(effectcraft_engine::render::NoFootage),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
}
}
/// #595: prefetch renders the frames the preview reaches first, whatever order they were
/// asked for in (a frame evicted and asked for again waited behind every frame further
/// ahead); queued frames the preview no longer reaches are dropped and not left in flight.
#[test]
fn prefetch_renders_the_frames_the_playhead_reaches_first() {
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
let src = idle_source();
for frame in [40, 30, 20, 5, 12, 11] {
f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default());
}
f.request(&src, FrameKey { comp: 2, ..key(1, 1, 0) }, ItemId(2), Tick::ZERO, RenderOpts::default());
f.set_playhead(Some(playhead(10, false)));
let mut order = vec![];
loop {
let mut q = f.queue.lock().unwrap();
let Some(i) = q.best() else { break };
let j = q.jobs.swap_remove(i);
order.push((j.key.comp, j.key.frame));
}
assert_eq!(order, [(1, 11), (1, 12), (1, 20), (1, 30), (1, 40), (2, 1)]);
assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight");
}
/// The cache is full. A zoom (or preview-resolution) change left the frames of the old
/// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back
/// at went before the ones of the abandoned zoom. The other size's frames go first, then the
/// order is as before.
#[test]
fn a_size_change_evicts_frames_of_other_sizes_first() {
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
f.set_budget(4 * 16);
let at = |frame, scale| FrameKey { scale, ..key(1, frame, 0) };
// 100% (frames 0..=1) is cached, then 50% (2..=3): the 100% frames are the oldest.
for (frame, scale) in [(0, 1000), (1, 1000), (2, 500), (3, 500)] {
insert(&f, at(frame, scale));
}
// A passive viewer at 50% asks for a frame: that doesn't change which size is kept.
f.request(&idle_source(), at(9, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
// Back at 100%: the viewer asks for frame 4.
f.request_urgent(&idle_source(), at(4, 1000), ItemId(1), Tick::ZERO, RenderOpts::default());
f.request(&idle_source(), at(10, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
insert(&f, at(4, 1000));
assert!(
f.is_cached(&at(0, 1000)) && f.is_cached(&at(1, 1000)) && !f.is_cached(&at(2, 500)) && f.is_cached(&at(3, 500)),
"the first frame of the old size goes, not the cached ones of the current size"
);
insert(&f, at(5, 1000));
assert!(!f.is_cached(&at(3, 500)), "then the last frame of the old size");
assert_eq!(f.cached_frames(&at(0, 1000)), [0, 1, 4, 5]);
// No other size left: least recently shown first again.
insert(&f, at(6, 1000));
assert_eq!(f.cached_frames(&at(0, 1000)), [1, 4, 5, 6]);
}
#[test]
fn frames_are_keyed_by_content_and_series_are_kept_apart() {
let f = Frames::default();
insert(&f, key(1, 0, 0));
insert(&f, key(2, 0, 0));
insert(&f, key(2, 1, 7));
// Other content or render options (Draft, shadows…) are another series.
assert_eq!(f.cached_frames(&key(2, 0, 0)), vec![0]);
assert_eq!(f.cached_frames(&key(2, 0, 7)), vec![1]);
// A later revision with the same content finds the frame (an edit elsewhere, an undo).
assert!(f.is_cached(&FrameKey { revision: 99, ..key(1, 0, 0) }));
assert!(!f.is_cached(&key(3, 0, 0)));
}
#[test]
fn live_edits_present_the_newest_completed_revision_without_going_backwards() {
let f = Frames::default();
let shown = key(1, 0, 0);
let newest = key(3, 0, 0);
let wanted = key(5, 0, 0);
insert(&f, shown);
insert(&f, newest);
insert(&f, key(2, 0, 0)); // Older worker finishes after the newer one.
let displayed = f.get_for_viewer(&wanted, Some(&shown)).unwrap().0;
assert_eq!(displayed, newest);
assert!(f.get_for_viewer(&wanted, Some(&displayed)).is_none(), "never replay an older completion");
insert(&f, wanted);
assert_eq!(f.get_for_viewer(&wanted, Some(&displayed)).unwrap().0, wanted, "settle on the exact final edit");
// Undo reuses the content of an earlier revision immediately.
let undo = FrameKey { revision: 6, ..shown };
let reused = f.get_for_viewer(&undo, Some(&wanted)).unwrap().0;
assert_eq!(reused.content, shown.content);
assert_eq!(reused.revision, undo.revision);
}
#[test]
fn live_edits_do_not_substitute_other_times_views_or_render_settings() {
let f = Frames::default();
let shown = key(1, 0, 0);
let wanted = key(3, 0, 0);
let done = key(2, 0, 0);
for mismatch in [
FrameKey { comp: 9, ..done },
FrameKey { frame: 1, ..done },
FrameKey { scale: 500, ..done },
FrameKey { view: 1, ..done },
FrameKey { opts: 1, ..done },
FrameKey { revision: 4, ..done },
] {
f.clear();
insert(&f, mismatch);
assert!(f.get_for_viewer(&wanted, Some(&shown)).is_none(), "wrong candidate: {mismatch:?}");
}
f.clear();
insert(&f, done);
assert!(f.get_for_viewer(&wanted, None).is_none(), "no stale content on opening a viewer");
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { comp: 9, ..shown })).is_none());
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { frame: 1, ..shown })).is_none());
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { view: 1, ..shown })).is_none());
}
#[test]
fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let b = ItemId(s.execute("comp.new", json!({"name": "B", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let f = Frames::default();
let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c);
let (a0, b0) = (at(&s, a), at(&s, b));
// Edit B: A's content (and frames) stay, B's change.
s.execute("layer.newSolid", json!({"comp": b.0, "color": "#ff0000"})).unwrap();
assert_eq!(at(&s, a), a0);
assert_ne!(at(&s, b), b0);
// Undo: B is back to the content its old frames were rendered for.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(at(&s, b), b0);
}
#[test]
fn a_long_drag_keeps_a_bounded_number_of_states() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let f = Frames::default();
let mut last = None;
for i in 0..200 {
s.execute("comp.settings", json!({"width": 8 + i, "merge": "drag"})).unwrap();
let k = FrameKey { content: f.content_of(&s.project, s.revision, a), ..key(0, 0, 0) };
insert(&f, k);
last = Some(k);
}
// The snapshots and the frames kept stay bounded (a dropped identity's frames go with it,
// so a later state that reuses its addresses can't be shown an old frame).
assert!(f.keepers.lock().unwrap().1.len() <= MAX_KEEPERS);
assert!(f.cache.lock().unwrap().map.len() <= MAX_KEEPERS);
assert!(f.is_cached(&last.unwrap()), "the current state's frame stays");
}
#[test]
fn an_identity_keeps_its_comps_so_an_edit_in_place_still_changes_it() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
// A project nothing else holds (no undo history): make_mut would edit the comp in place,
// at the same address, if the identity hadn't kept a snapshot holding it.
let mut p = Arc::new((*s.project).clone());
drop(s);
let f = Frames::default();
let before = f.content_of(&p, 1, a);
let comp = Arc::make_mut(&mut p).comp_mut(a).unwrap();
comp.width = 16;
assert_ne!(f.content_of(&p, 2, a), before);
}
/// Audio, Lock and Shy (and Hide Shy Layers) don't change pixels: toggling them, in the comp
/// or a comp it nests, keeps its frames, also when toggled back (#103).
#[test]
fn audio_lock_and_shy_switches_keep_the_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer");
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let nested = id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer");
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
let switch = |s: &mut Session, comp: ItemId, layer: u64, sw: &str, v: bool| {
s.execute("layer.setSwitch", json!({"comp": comp.0, "layers": [layer], "switch": sw, "value": v})).unwrap();
};
for (comp, layer, sw) in [(outer, nested, "audio"), (outer, nested, "lock"), (outer, nested, "shy"), (inner, solid, "audio"), (inner, solid, "shy")] {
// Audio off; Lock and Shy on.
switch(&mut s, comp, layer, sw, sw != "audio");
assert_eq!(at(&s), o0, "{sw} on in {comp:?}");
}
s.execute("comp.setSwitch", json!({"comp": outer.0, "switch": "hideShy", "value": true})).unwrap();
assert_eq!(at(&s), o0, "Hide Shy Layers");
switch(&mut s, outer, nested, "audio", true);
switch(&mut s, outer, nested, "lock", false);
assert_eq!(at(&s), o0, "toggled back");
// Switches that change pixels still do.
switch(&mut s, inner, solid, "video", false);
let o1 = at(&s);
assert_ne!(o1, o0, "Video");
switch(&mut s, outer, nested, "audio", false);
assert_eq!(at(&s), o1, "a mute after a real edit keeps that edit's frames");
switch(&mut s, inner, solid, "video", true);
assert_ne!(at(&s), o1);
}
/// A cut or trim recaches only the frames of the layers it touched (#1641), and none are
/// kept when the change reaches other layers (here a child of the trimmed layer).
#[test]
fn an_edit_keeps_the_frames_outside_the_layers_it_changed() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Edit", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
let a = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
let b = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [a], "in": 0, "out": 4})).unwrap();
s.execute("layer.timing", json!({"layers": [b], "in": 4, "out": 10})).unwrap();
let (fr, out) = s.project.comp(comp).map(|c| (c.frame_rate, c.layers.iter().find(|l| l.id.0 == a).unwrap().out_point)).unwrap();
let frames: Vec<i64> = (0..fr.frame_at(Tick::from_seconds_f64(10.0))).collect();
let f = Frames::default();
let cache_all = |s: &Session| {
let c = f.content_of(&s.project, s.revision, comp);
frames.iter().for_each(|&n| insert(&f, key(c, n, 0)));
c
};
let cached = |c: u64| frames.iter().filter(|&&n| f.is_cached(&key(c, n, 0))).copied().collect::<Vec<_>>();
let before = cache_all(&s);
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert_ne!(after, before);
let kept: Vec<i64> = (fr.frame_at(out) + 2..frames.len() as i64).collect();
assert_eq!(cached(after), kept, "the cut layer's frames (and one either side) render again; the rest stay");
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
let parented = cache_all(&s);
s.execute("layer.timing", json!({"layers": [a], "out": 1})).unwrap();
assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), Vec::<i64>::new(), "the trimmed layer's child moves everywhere");
assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo");
}
/// Every frame an edit keeps renders as a fresh render of the edited project does: Solo
/// hides the other layers at every frame, and a camera reaches the frames whose shutter
/// samples it (#1641 review).
#[test]
fn the_frames_an_edit_keeps_match_a_fresh_render() {
use effectcraft_engine::Session;
use effectcraft_engine::render::NoFootage;
use serde_json::json;
// Cache every frame of `comp`, run `edit`, check the frames kept against fresh renders.
fn kept(s: &mut Session, comp: ItemId, edit: serde_json::Value) -> Vec<i64> {
let (fr, n) = s.project.comp(comp).map(|c| (c.frame_rate, c.frame_rate.frame_at(c.duration))).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..n).for_each(|i| insert(&f, key(before, i, 0)));
let old = s.project.clone();
s.execute(edit["cmd"].as_str().unwrap(), edit["params"].clone()).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
let kept: Vec<i64> = (0..n).filter(|&i| f.is_cached(&key(after, i, 0))).collect();
let render = |p, i| Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(comp, fr.tick_of(i));
for &i in &kept {
assert_eq!(render(&old, i), render(&s.project, i), "kept frame {i} draws other pixels now");
}
kept
}
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let mut s = Session::default();
// Solo on a 2–3 s layer hides the 10 s underlay everywhere; turning it off shows it again.
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Solo", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap();
let short = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [short], "in": 2, "out": 3})).unwrap();
let solo = |on: bool| json!({"cmd": "layer.setSwitch", "params": {"layers": [short], "switch": "solo", "value": on}});
assert_eq!(kept(&mut s, comp, solo(true)), Vec::<i64>::new());
assert_eq!(kept(&mut s, comp, solo(false)), Vec::<i64>::new());
// A 2–3 s camera over a 4 s motion-blurred 3D layer, shutter 720° at phase 360°: a frame
// samples 1 to 3 frames ahead, so moving the camera reaches the 3 frames before it.
let comp = ItemId(id(
s.execute("comp.new", json!({"name": "Cam", "width": 16, "height": 16, "duration": 4, "frameRate": 30, "shutterAngle": 720, "shutterPhase": 360}))
.unwrap(),
"comp",
));
let plane = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
for sw in ["threeD", "motionBlur"] {
s.execute("layer.setSwitch", json!({"layers": [plane], "switch": sw, "value": true})).unwrap();
}
let cam = id(s.execute("layer.newCamera", json!({"comp": comp.0})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [cam], "in": 2, "out": 3})).unwrap();
let moved =
kept(&mut s, comp, json!({"cmd": "prop.set", "params": {"comp": comp.0, "layer": cam, "path": "transform/position", "value": [40, 8, -30]}}));
assert!(moved.contains(&0) && moved.contains(&100), "frames far from the camera stay: {moved:?}");
assert!(!(56..=91).any(|f| moved.contains(&f)), "frames whose shutter samples the camera render again: {moved:?}");
}
#[test]
fn short_solid_additions_and_removals_keep_unaffected_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let comp = ItemId(s.execute("comp.new", json!({"width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap());
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let f = Frames::default();
let cache_all = |s: &Session| {
let content = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(content, n, 0)));
};
let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0));
// 2–3 s, a quarter frame of the default shutter and a frame of rounding either side.
let kept: Vec<i64> = (0..=57).chain(92..600).collect();
cache_all(&s);
let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
assert_eq!(cached(&s), kept, "adding a source invalidates only frames 58..=91");
cache_all(&s);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "undoing the addition restores every frame");
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "redoing the addition restores every frame");
s.execute("edit.clear", json!({"layers": [solid]})).unwrap();
assert_eq!(cached(&s), kept, "removing its layer invalidates the same span");
}
#[test]
fn adding_a_source_does_not_hide_changes_to_existing_dependencies() {
use effectcraft_engine::Session;
use serde_json::json;
for (command, params) in [
("layer.settings", json!({"comp": "Outer", "layer": "Underlay", "color": "#0000ff"})),
("prop.set", json!({"comp": "Inner", "layer": "Inner solid", "path": "transform/opacity", "value": 40})),
] {
let mut s = Session::default();
let comp = ItemId(
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap(),
);
s.execute("layer.newSolid", json!({"name": "Underlay", "color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 20})).unwrap();
s.execute("layer.newSolid", json!({"name": "Inner solid", "color": "#00ff00"})).unwrap();
s.execute("comp.open", json!({"comp": "Outer"})).unwrap();
s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(before, n, 0)));
s.execute(command, params).unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert!(f.cached_frames(&key(after, 0, 0)).is_empty(), "{command} changes pixels outside the added layer");
}
}
#[test]
fn markers_keep_the_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer");
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap();
assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames");
}
#[test]
fn markers_count_when_an_expression_can_read_them() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer");
s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap();
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap();
assert_ne!(at(&s), o0, "an expression can read the marker, so the frames change");
}
}
mod tests;
+928
View File
@@ -0,0 +1,928 @@
use super::*;
struct ProbeDisk {
keys: Mutex<HashSet<u128>>,
probes: std::sync::atomic::AtomicUsize,
}
impl RemoteFrames for ProbeDisk {
fn slots(&self) -> usize {
0
}
fn disk(&self) -> bool {
true
}
fn disk_contains(&self, key: u128) -> bool {
self.probes.fetch_add(1, Ordering::Relaxed);
self.keys.lock().unwrap().contains(&key)
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, RenderOpts, Arc<ProbeDisk>, u128) {
let mut session = effectcraft_engine::Session::default();
session.execute("comp.new", serde_json::json!({"width": 32, "height": 18, "duration": 1})).unwrap();
let comp = session.active_comp_id().unwrap();
let opts = RenderOpts::default();
let content = 1234;
let mut base = key(1, 0, opts_hash(&opts));
base.revision = 7;
base.comp = comp.0;
let disk = Arc::new(ProbeDisk { keys: Mutex::new(HashSet::new()), probes: 0.into() });
disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts))));
let mut frames = Frames::default();
frames.set_remote(Some(disk.clone()));
frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content);
let source = RenderSource {
project: session.project.clone(),
footage: session.footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
(frames, source, base, opts, disk, content)
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() {
use std::sync::mpsc;
use std::time::{Duration, Instant};
struct Disk(u128);
impl RemoteFrames for Disk {
fn slots(&self) -> usize {
1
}
fn disk(&self) -> bool {
true
}
fn disk_contains(&self, key: u128) -> bool {
self.0 == key
}
fn start(&self, _: RemoteJob, done: RemoteDone) {
done(Err("test has no renderer".into()));
}
}
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", serde_json::json!({"width":32,"height":18,"duration":1})).unwrap();
let cid = s.active_comp_id().unwrap();
let opts = RenderOpts::default();
let stored = disk_cache::frame_key(disk_cache::comp_content_key(&s.project, cid), 2, 1000, opts_hash(&opts));
let mut frames = Frames { pool: Some(rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap()), ..Default::default() };
frames.set_remote(Some(Arc::new(Disk(stored))));
let (entered, started) = mpsc::channel();
let (release, wait) = mpsc::channel();
frames.pool.as_ref().unwrap().spawn(move || {
entered.send(()).unwrap();
wait.recv_timeout(Duration::from_secs(5)).unwrap();
});
started.recv_timeout(Duration::from_secs(3)).unwrap();
let src = RenderSource {
project: s.project.clone(),
footage: s.footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let mut base = key(1, 0, 0);
base.comp = cid.0;
for revision in 1..50 {
base.revision = revision;
assert!(frames.disk_frames(&src, &base, 0..30, &opts).is_empty());
}
assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked");
assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1);
release.send(()).unwrap();
let until = Instant::now() + Duration::from_secs(3);
while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) {
assert!(Instant::now() < until);
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(frames.disk_frames(&src, &base, 0..30, &opts), vec![2], "the blue indicator must return once its key is ready");
assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed");
}
#[test]
fn disk_indicator_range_tracks_live_membership() {
let (frames, src, base, opts, disk, _) = disk_fixture(&[10, 12]);
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 3);
disk.keys.lock().unwrap().insert(disk_cache::frame_key(1234, 11, base.scale, base.view ^ opts_hash(&opts)));
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 11, 12]);
insert(&frames, FrameKey { frame: 11, ..base });
disk.probes.store(0, Ordering::Relaxed);
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
disk.keys.lock().unwrap().remove(&disk_cache::frame_key(1234, 12, base.scale, base.view ^ opts_hash(&opts)));
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10]);
let mut revised = base;
revised.revision += 1;
frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678);
assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty());
let changed = FrameKey { scale: base.scale + 1, ..base };
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
let changed = FrameKey { view: base.view + 1, ..base };
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
assert!(frames.disk_frames(&src, &base, 10..13, &RenderOpts { draft: true, ..opts }).is_empty());
}
#[test]
fn disk_indicator_range_is_bounded() {
let (frames, src, base, opts, disk, _) = disk_fixture(&[0, 1, 99_999]);
assert_eq!(frames.disk_frames(&src, &base, -5..2, &opts), vec![0, 1]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
disk.probes.store(0, Ordering::Relaxed);
assert_eq!(frames.disk_frames(&src, &base, 99_999..i64::MAX, &opts), vec![99_999]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 1);
disk.probes.store(0, Ordering::Relaxed);
assert!(frames.disk_frames(&src, &base, 2..2, &opts).is_empty());
// Written as a struct literal: `3..2` trips clippy::reversed_empty_ranges.
let reversed = std::ops::Range { start: 3, end: 2 };
assert!(frames.disk_frames(&src, &base, reversed, &opts).is_empty());
assert_eq!(disk.probes.load(Ordering::Relaxed), 0);
}
/// Frames larger than the texture limit are averaged down by a whole factor (#201: an
/// 11000×2200 comp at Full resolution against an 8192 limit).
#[test]
fn frames_over_the_texture_limit_are_averaged_down() {
assert_eq!(fitted_size([11000, 2200], 8192), [5500, 1100]);
assert_eq!(fitted_size([8192, 100], 8192), [8192, 100]);
assert_eq!(fitted_size([30000, 30000], 16384), [15000, 15000]);
assert_eq!(fitted_size([0, 0], 0), [0, 0]);
// 5×2 (three columns of white, then black) into 2 px: 3×2 blocks (the last one 2×2).
let w = egui::Color32::WHITE;
let b = egui::Color32::from_rgba_premultiplied(0, 0, 0, 255);
let img = egui::ColorImage::new([5, 2], vec![w, w, w, b, b, w, w, w, b, b]);
let out = fit_texture(img.clone(), 2);
assert_eq!(out.size, [2, 1]);
assert_eq!(out.pixels, vec![w, b]);
assert_eq!(fit_texture(img.clone(), 5).pixels, img.pixels);
let mixed = fit_texture(egui::ColorImage::new([2, 1], vec![w, b]), 1);
assert_eq!(mixed.pixels, vec![egui::Color32::from_rgba_premultiplied(128, 128, 128, 255)]);
}
#[test]
fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live() {
let frames = Frames::default();
let worker = frames.worker();
let first = key(1, 0, 0);
insert(&frames, first);
frames.retire_gpu();
assert!(worker.gpu_retired.load(Ordering::Acquire), "already queued workers share retirement");
assert!(frames.is_cached(&first), "healthy materialized CPU frames survive");
let next = key(1, 1, 0);
frames.inflight.lock().unwrap().insert(next);
frames.queue.lock().unwrap().running.insert(next);
worker.finish_key(next, false, img(), web_time::Instant::now());
assert!(frames.is_cached(&next), "CPU retry can still publish");
assert_eq!(frames.inflight(), 0);
assert!(frames.queue.lock().unwrap().running.is_empty());
}
fn key(content: u64, frame: i64, opts: u64) -> FrameKey {
FrameKey { revision: content, content, comp: 1, frame, scale: 1000, view: 0, opts }
}
fn img() -> FrameImage {
FrameImage::Cpu(Arc::new(egui::ColorImage::new([2, 2], vec![egui::Color32::BLACK; 4])))
}
fn insert(f: &Frames, k: FrameKey) {
f.cache.lock().unwrap().insert(k, img());
}
/// A frame whose render panics (a GPU out of memory used to, on every frame thread) is
/// released, not left "in progress" forever, so the viewer can ask for it again (#106).
#[test]
fn a_frame_whose_render_panics_is_released() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct Explodes(AtomicUsize);
impl FootageSource for Explodes {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
self.0.fetch_add(1, Ordering::SeqCst);
panic!("a decoder bug");
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage { kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() };
let item = Arc::make_mut(&mut s.project).add_item("clip.png", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Explodes::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let key = FrameKey { revision: s.revision, content: f.content_of(&s.project, s.revision, cid), ..key(0, 0, 0) };
let key = FrameKey { comp: cid.0, ..key };
f.request(&src, key, cid, Tick::ZERO, RenderOpts::default());
for _ in 0..1000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(footage.0.load(Ordering::SeqCst) > 0, "the render reached the footage");
assert_eq!(f.inflight(), 0, "released");
assert!(!f.is_cached(&key));
}
/// #647: a pool thread waiting inside a render (on a parallel loop) never starts another
/// frame render on top of it. Dragging a layer queues a frame every UI frame; renders used to
/// nest on every thread, each holding its buffers, until memory ran out.
#[test]
fn a_waiting_render_never_starts_another_on_its_thread() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use effectcraft_engine::time::FrameRate;
use serde_json::json;
use std::cell::Cell;
use std::sync::atomic::AtomicUsize;
thread_local! {
static DEPTH: Cell<usize> = const { Cell::new(0) };
}
/// A decoder that works in parallel, as the renderer does: its thread joins the loop and
/// may wait for the parts other threads took.
#[derive(Default)]
struct Parallel {
calls: AtomicUsize,
deepest: AtomicUsize,
}
impl FootageSource for Parallel {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let d = DEPTH.get() + 1;
DEPTH.set(d);
self.deepest.fetch_max(d, Ordering::SeqCst);
self.calls.fetch_add(1, Ordering::SeqCst);
(0..64).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)));
DEPTH.set(d - 1);
Some(Arc::new(Image::new(4, 4)))
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage {
kind: FootageKind::Video,
width: 4,
height: 4,
has_video: true,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(10.0),
..Default::default()
};
let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Parallel::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let content = f.content_of(&s.project, s.revision, cid);
let frames = 60;
for frame in 0..frames {
let key = FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 };
f.request(&src, key, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default());
}
for _ in 0..4000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(f.inflight(), 0, "every queued frame rendered");
assert!(footage.calls.load(Ordering::SeqCst) >= frames as usize);
assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread");
}
/// #329: scrubbing a long movie, the viewer frame of every step the pointer passed started
/// rendering and seeked the movie's decoder in turn; the frame under the pointer waited for
/// all of them. A newer viewer frame of another time calls off the one rendering, which is
/// not cached (its footage may be missing).
#[test]
fn a_newer_viewer_frame_calls_off_the_one_rendering() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use effectcraft_engine::time::FrameRate;
use serde_json::json;
use std::cell::RefCell;
thread_local! {
static CANCEL: RefCell<Option<Arc<AtomicBool>>> = const { RefCell::new(None) };
}
/// A movie whose first frames take a long seek, which a called-off read gives up.
#[derive(Default)]
struct Seeking {
seeking: AtomicBool,
called_off: AtomicBool,
}
impl FootageSource for Seeking {
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
let prev = CANCEL.replace(Some(cancel.clone()));
f();
CANCEL.set(prev);
}
fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<Image>> {
if t < Tick::from_seconds_f64(0.25) {
self.seeking.store(true, Ordering::SeqCst);
for _ in 0..1000 {
if CANCEL.with_borrow(|c| c.as_ref().is_some_and(|c| c.load(Ordering::SeqCst))) {
self.called_off.store(true, Ordering::SeqCst);
return None;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
Some(Arc::new(Image::new(4, 4)))
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage {
kind: FootageKind::Video,
width: 4,
height: 4,
has_video: true,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(10.0),
..Default::default()
};
let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Seeking::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let content = f.content_of(&s.project, s.revision, cid);
let key = |frame| FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 };
f.request_urgent(&src, key(0), cid, Tick::ZERO, RenderOpts::default());
for _ in 0..1000 {
if footage.seeking.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(footage.seeking.load(Ordering::SeqCst), "the first frame's render reached the footage");
f.request_urgent(&src, key(150), cid, FrameRate::FPS_30.tick_of(150), RenderOpts::default());
for _ in 0..1000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(footage.called_off.load(Ordering::SeqCst), "the superseded frame's seek ran on");
assert!(f.is_cached(&key(150)), "the newest frame rendered");
assert!(!f.is_cached(&key(0)), "a called-off frame is not cached");
assert_eq!(f.inflight(), 0, "released");
}
/// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer
/// frames still waiting (they would all render first), but not prefetch frames or another
/// comp's viewer frame.
#[test]
fn a_newer_viewer_frame_drops_the_ones_still_waiting() {
/// Starts nothing, so every request stays queued.
struct Idle;
impl RemoteFrames for Idle {
fn slots(&self) -> usize {
0
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
let src = RenderSource {
project: Arc::default(),
footage: Arc::new(effectcraft_engine::render::NoFootage),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let request = |k: FrameKey, urgent: bool| f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), urgent);
let other = FrameKey { comp: 2, ..key(1, 0, 0) };
request(other, true);
request(key(1, 20, 0), false);
for frame in 0..10 {
request(key(1, frame, 0), true);
}
let mut queued: Vec<(u64, i64, bool)> = f.queue.lock().unwrap().jobs.iter().map(|j| (j.key.comp, j.key.frame, j.urgent)).collect();
queued.sort();
assert_eq!(queued, [(1, 9, true), (1, 20, false), (2, 0, false)]);
assert_eq!(f.inflight(), 3, "dropped frames aren't in flight: the viewer can ask again");
}
/// Each time the GPU runs out of memory, half as many GPU frames are kept, down to a floor.
#[test]
fn running_out_of_video_memory_halves_the_gpu_frame_budget() {
let f = Frames::default();
let mut c = f.cache.lock().unwrap();
assert_eq!(c.gpu_budget, GPU_BUDGET);
c.gpu_out_of_memory();
assert_eq!(c.gpu_budget, GPU_BUDGET / 2);
for _ in 0..20 {
c.gpu_out_of_memory();
}
assert_eq!(c.gpu_budget, MIN_GPU_BUDGET);
}
#[test]
fn least_recently_shown_frames_go_first() {
let f = Frames::default();
f.set_budget(3 * 16);
for i in 0..3 {
insert(&f, key(1, i, 0));
}
// Frame 0 is shown again, so frame 1 is now the oldest.
assert!(f.get(&key(1, 0, 0)).is_some());
insert(&f, key(1, 3, 0));
assert!(f.is_cached(&key(1, 0, 0)) && !f.is_cached(&key(1, 1, 0)));
assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0)));
}
fn playhead(cur: i64, looping: bool) -> Playhead {
Playhead { series: key(1, 0, 0), cur, start: 0, end: 99, step: 1, looping }
}
/// #595: a playing preview's cache is full. The frames just ahead of the playhead were
/// rendered before the ones just shown, so plain LRU evicted them first and the preview
/// played into a gap. Frames the preview no longer reaches go first, then the ones it
/// reaches last.
#[test]
fn a_full_cache_keeps_the_frames_just_ahead_of_the_playhead() {
let f = Frames::default();
f.set_budget(4 * 16);
// Prefetch rendered 11..=13, then 10 was shown (the most recently used).
for i in [11, 12, 13, 10] {
insert(&f, key(1, i, 0));
}
let cached = |f: &Frames| f.cached_frames(&key(1, 0, 0));
f.set_playhead(Some(playhead(10, false)));
// The next frame rendered is the one the preview reaches last: the frames just ahead stay.
insert(&f, key(1, 14, 0));
assert_eq!(cached(&f), [10, 11, 12, 13]);
// Without looping, a frame behind the playhead goes before any frame ahead of it.
f.set_playhead(Some(playhead(12, false)));
insert(&f, key(1, 14, 0));
assert_eq!(cached(&f), [10, 12, 13, 14]);
// Looping, the frame just behind is the one reached last.
f.set_playhead(Some(playhead(13, true)));
insert(&f, key(1, 0, 0));
assert_eq!(cached(&f), [0, 10, 13, 14]);
// Stopped: least recently shown first again.
f.set_playhead(None);
insert(&f, key(1, 50, 0));
assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0)));
}
/// A frame source that queues jobs and never renders them: the tests check the queue.
struct Idle;
impl RemoteFrames for Idle {
fn slots(&self) -> usize {
0
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
fn idle_source() -> RenderSource {
RenderSource {
project: Arc::default(),
footage: Arc::new(effectcraft_engine::render::NoFootage),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
}
}
/// #595: prefetch renders the frames the preview reaches first, whatever order they were
/// asked for in (a frame evicted and asked for again waited behind every frame further
/// ahead); queued frames the preview no longer reaches are dropped and not left in flight.
#[test]
fn prefetch_renders_the_frames_the_playhead_reaches_first() {
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
let src = idle_source();
for frame in [40, 30, 20, 5, 12, 11] {
f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default());
}
f.request(&src, FrameKey { comp: 2, ..key(1, 1, 0) }, ItemId(2), Tick::ZERO, RenderOpts::default());
f.set_playhead(Some(playhead(10, false)));
let mut order = vec![];
loop {
let mut q = f.queue.lock().unwrap();
let Some(i) = q.best() else { break };
let j = q.jobs.swap_remove(i);
order.push((j.key.comp, j.key.frame));
}
assert_eq!(order, [(1, 11), (1, 12), (1, 20), (1, 30), (1, 40), (2, 1)]);
assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight");
}
/// The cache is full. A zoom (or preview-resolution) change left the frames of the old
/// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back
/// at went before the ones of the abandoned zoom. The other size's frames go first, then the
/// order is as before.
#[test]
fn a_size_change_evicts_frames_of_other_sizes_first() {
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
f.set_budget(4 * 16);
let at = |frame, scale| FrameKey { scale, ..key(1, frame, 0) };
// 100% (frames 0..=1) is cached, then 50% (2..=3): the 100% frames are the oldest.
for (frame, scale) in [(0, 1000), (1, 1000), (2, 500), (3, 500)] {
insert(&f, at(frame, scale));
}
// A passive viewer at 50% asks for a frame: that doesn't change which size is kept.
f.request(&idle_source(), at(9, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
// Back at 100%: the viewer asks for frame 4.
f.request_urgent(&idle_source(), at(4, 1000), ItemId(1), Tick::ZERO, RenderOpts::default());
f.request(&idle_source(), at(10, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
insert(&f, at(4, 1000));
assert!(
f.is_cached(&at(0, 1000)) && f.is_cached(&at(1, 1000)) && !f.is_cached(&at(2, 500)) && f.is_cached(&at(3, 500)),
"the first frame of the old size goes, not the cached ones of the current size"
);
insert(&f, at(5, 1000));
assert!(!f.is_cached(&at(3, 500)), "then the last frame of the old size");
assert_eq!(f.cached_frames(&at(0, 1000)), [0, 1, 4, 5]);
// No other size left: least recently shown first again.
insert(&f, at(6, 1000));
assert_eq!(f.cached_frames(&at(0, 1000)), [1, 4, 5, 6]);
}
#[test]
fn frames_are_keyed_by_content_and_series_are_kept_apart() {
let f = Frames::default();
insert(&f, key(1, 0, 0));
insert(&f, key(2, 0, 0));
insert(&f, key(2, 1, 7));
// Other content or render options (Draft, shadows…) are another series.
assert_eq!(f.cached_frames(&key(2, 0, 0)), vec![0]);
assert_eq!(f.cached_frames(&key(2, 0, 7)), vec![1]);
// A later revision with the same content finds the frame (an edit elsewhere, an undo).
assert!(f.is_cached(&FrameKey { revision: 99, ..key(1, 0, 0) }));
assert!(!f.is_cached(&key(3, 0, 0)));
}
#[test]
fn live_edits_present_the_newest_completed_revision_without_going_backwards() {
let f = Frames::default();
let shown = key(1, 0, 0);
let newest = key(3, 0, 0);
let wanted = key(5, 0, 0);
insert(&f, shown);
insert(&f, newest);
insert(&f, key(2, 0, 0)); // Older worker finishes after the newer one.
let displayed = f.get_for_viewer(&wanted, Some(&shown)).unwrap().0;
assert_eq!(displayed, newest);
assert!(f.get_for_viewer(&wanted, Some(&displayed)).is_none(), "never replay an older completion");
insert(&f, wanted);
assert_eq!(f.get_for_viewer(&wanted, Some(&displayed)).unwrap().0, wanted, "settle on the exact final edit");
// Undo reuses the content of an earlier revision immediately.
let undo = FrameKey { revision: 6, ..shown };
let reused = f.get_for_viewer(&undo, Some(&wanted)).unwrap().0;
assert_eq!(reused.content, shown.content);
assert_eq!(reused.revision, undo.revision);
}
#[test]
fn live_edits_do_not_substitute_other_times_views_or_render_settings() {
let f = Frames::default();
let shown = key(1, 0, 0);
let wanted = key(3, 0, 0);
let done = key(2, 0, 0);
for mismatch in [
FrameKey { comp: 9, ..done },
FrameKey { frame: 1, ..done },
FrameKey { scale: 500, ..done },
FrameKey { view: 1, ..done },
FrameKey { opts: 1, ..done },
FrameKey { revision: 4, ..done },
] {
f.clear();
insert(&f, mismatch);
assert!(f.get_for_viewer(&wanted, Some(&shown)).is_none(), "wrong candidate: {mismatch:?}");
}
f.clear();
insert(&f, done);
assert!(f.get_for_viewer(&wanted, None).is_none(), "no stale content on opening a viewer");
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { comp: 9, ..shown })).is_none());
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { frame: 1, ..shown })).is_none());
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { view: 1, ..shown })).is_none());
}
#[test]
fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let b = ItemId(s.execute("comp.new", json!({"name": "B", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let f = Frames::default();
let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c);
let (a0, b0) = (at(&s, a), at(&s, b));
// Edit B: A's content (and frames) stay, B's change.
s.execute("layer.newSolid", json!({"comp": b.0, "color": "#ff0000"})).unwrap();
assert_eq!(at(&s, a), a0);
assert_ne!(at(&s, b), b0);
// Undo: B is back to the content its old frames were rendered for.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(at(&s, b), b0);
}
#[test]
fn a_long_drag_keeps_a_bounded_number_of_states() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let f = Frames::default();
let mut last = None;
for i in 0..200 {
s.execute("comp.settings", json!({"width": 8 + i, "merge": "drag"})).unwrap();
let k = FrameKey { content: f.content_of(&s.project, s.revision, a), ..key(0, 0, 0) };
insert(&f, k);
last = Some(k);
}
// The snapshots and the frames kept stay bounded (a dropped identity's frames go with it,
// so a later state that reuses its addresses can't be shown an old frame).
assert!(f.keepers.lock().unwrap().1.len() <= MAX_KEEPERS);
assert!(f.cache.lock().unwrap().map.len() <= MAX_KEEPERS);
assert!(f.is_cached(&last.unwrap()), "the current state's frame stays");
}
#[test]
fn an_identity_keeps_its_comps_so_an_edit_in_place_still_changes_it() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
// A project nothing else holds (no undo history): make_mut would edit the comp in place,
// at the same address, if the identity hadn't kept a snapshot holding it.
let mut p = Arc::new((*s.project).clone());
drop(s);
let f = Frames::default();
let before = f.content_of(&p, 1, a);
let comp = Arc::make_mut(&mut p).comp_mut(a).unwrap();
comp.width = 16;
assert_ne!(f.content_of(&p, 2, a), before);
}
/// Audio, Lock and Shy (and Hide Shy Layers) don't change pixels: toggling them, in the comp
/// or a comp it nests, keeps its frames, also when toggled back (#103).
#[test]
fn audio_lock_and_shy_switches_keep_the_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer");
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let nested = id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer");
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
let switch = |s: &mut Session, comp: ItemId, layer: u64, sw: &str, v: bool| {
s.execute("layer.setSwitch", json!({"comp": comp.0, "layers": [layer], "switch": sw, "value": v})).unwrap();
};
for (comp, layer, sw) in [(outer, nested, "audio"), (outer, nested, "lock"), (outer, nested, "shy"), (inner, solid, "audio"), (inner, solid, "shy")] {
// Audio off; Lock and Shy on.
switch(&mut s, comp, layer, sw, sw != "audio");
assert_eq!(at(&s), o0, "{sw} on in {comp:?}");
}
s.execute("comp.setSwitch", json!({"comp": outer.0, "switch": "hideShy", "value": true})).unwrap();
assert_eq!(at(&s), o0, "Hide Shy Layers");
switch(&mut s, outer, nested, "audio", true);
switch(&mut s, outer, nested, "lock", false);
assert_eq!(at(&s), o0, "toggled back");
// Switches that change pixels still do.
switch(&mut s, inner, solid, "video", false);
let o1 = at(&s);
assert_ne!(o1, o0, "Video");
switch(&mut s, outer, nested, "audio", false);
assert_eq!(at(&s), o1, "a mute after a real edit keeps that edit's frames");
switch(&mut s, inner, solid, "video", true);
assert_ne!(at(&s), o1);
}
/// A cut or trim recaches only the frames of the layers it touched (#1641), and none are
/// kept when the change reaches other layers (here a child of the trimmed layer).
#[test]
fn an_edit_keeps_the_frames_outside_the_layers_it_changed() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Edit", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
let a = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
let b = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [a], "in": 0, "out": 4})).unwrap();
s.execute("layer.timing", json!({"layers": [b], "in": 4, "out": 10})).unwrap();
let (fr, out) = s.project.comp(comp).map(|c| (c.frame_rate, c.layers.iter().find(|l| l.id.0 == a).unwrap().out_point)).unwrap();
let frames: Vec<i64> = (0..fr.frame_at(Tick::from_seconds_f64(10.0))).collect();
let f = Frames::default();
let cache_all = |s: &Session| {
let c = f.content_of(&s.project, s.revision, comp);
frames.iter().for_each(|&n| insert(&f, key(c, n, 0)));
c
};
let cached = |c: u64| frames.iter().filter(|&&n| f.is_cached(&key(c, n, 0))).copied().collect::<Vec<_>>();
let before = cache_all(&s);
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert_ne!(after, before);
let kept: Vec<i64> = (fr.frame_at(out) + 2..frames.len() as i64).collect();
assert_eq!(cached(after), kept, "the cut layer's frames (and one either side) render again; the rest stay");
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
let parented = cache_all(&s);
s.execute("layer.timing", json!({"layers": [a], "out": 1})).unwrap();
assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), Vec::<i64>::new(), "the trimmed layer's child moves everywhere");
assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo");
}
/// Every frame an edit keeps renders as a fresh render of the edited project does: Solo
/// hides the other layers at every frame, and a camera reaches the frames whose shutter
/// samples it (#1641 review).
#[test]
fn the_frames_an_edit_keeps_match_a_fresh_render() {
use effectcraft_engine::Session;
use effectcraft_engine::render::NoFootage;
use serde_json::json;
// Cache every frame of `comp`, run `edit`, check the frames kept against fresh renders.
fn kept(s: &mut Session, comp: ItemId, edit: serde_json::Value) -> Vec<i64> {
let (fr, n) = s.project.comp(comp).map(|c| (c.frame_rate, c.frame_rate.frame_at(c.duration))).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..n).for_each(|i| insert(&f, key(before, i, 0)));
let old = s.project.clone();
s.execute(edit["cmd"].as_str().unwrap(), edit["params"].clone()).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
let kept: Vec<i64> = (0..n).filter(|&i| f.is_cached(&key(after, i, 0))).collect();
let render = |p, i| Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(comp, fr.tick_of(i));
for &i in &kept {
assert_eq!(render(&old, i), render(&s.project, i), "kept frame {i} draws other pixels now");
}
kept
}
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let mut s = Session::default();
// Solo on a 2–3 s layer hides the 10 s underlay everywhere; turning it off shows it again.
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Solo", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap();
let short = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [short], "in": 2, "out": 3})).unwrap();
let solo = |on: bool| json!({"cmd": "layer.setSwitch", "params": {"layers": [short], "switch": "solo", "value": on}});
assert_eq!(kept(&mut s, comp, solo(true)), Vec::<i64>::new());
assert_eq!(kept(&mut s, comp, solo(false)), Vec::<i64>::new());
// A 2–3 s camera over a 4 s motion-blurred 3D layer, shutter 720° at phase 360°: a frame
// samples 1 to 3 frames ahead, so moving the camera reaches the 3 frames before it.
let comp = ItemId(id(
s.execute("comp.new", json!({"name": "Cam", "width": 16, "height": 16, "duration": 4, "frameRate": 30, "shutterAngle": 720, "shutterPhase": 360}))
.unwrap(),
"comp",
));
let plane = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
for sw in ["threeD", "motionBlur"] {
s.execute("layer.setSwitch", json!({"layers": [plane], "switch": sw, "value": true})).unwrap();
}
let cam = id(s.execute("layer.newCamera", json!({"comp": comp.0})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [cam], "in": 2, "out": 3})).unwrap();
let moved =
kept(&mut s, comp, json!({"cmd": "prop.set", "params": {"comp": comp.0, "layer": cam, "path": "transform/position", "value": [40, 8, -30]}}));
assert!(moved.contains(&0) && moved.contains(&100), "frames far from the camera stay: {moved:?}");
assert!(!(56..=91).any(|f| moved.contains(&f)), "frames whose shutter samples the camera render again: {moved:?}");
}
#[test]
fn short_solid_additions_and_removals_keep_unaffected_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let comp = ItemId(s.execute("comp.new", json!({"width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap());
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let f = Frames::default();
let cache_all = |s: &Session| {
let content = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(content, n, 0)));
};
let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0));
// 2–3 s, a quarter frame of the default shutter and a frame of rounding either side.
let kept: Vec<i64> = (0..=57).chain(92..600).collect();
cache_all(&s);
let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
assert_eq!(cached(&s), kept, "adding a source invalidates only frames 58..=91");
cache_all(&s);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "undoing the addition restores every frame");
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "redoing the addition restores every frame");
s.execute("edit.clear", json!({"layers": [solid]})).unwrap();
assert_eq!(cached(&s), kept, "removing its layer invalidates the same span");
}
#[test]
fn adding_a_source_does_not_hide_changes_to_existing_dependencies() {
use effectcraft_engine::Session;
use serde_json::json;
for (command, params) in [
("layer.settings", json!({"comp": "Outer", "layer": "Underlay", "color": "#0000ff"})),
("prop.set", json!({"comp": "Inner", "layer": "Inner solid", "path": "transform/opacity", "value": 40})),
] {
let mut s = Session::default();
let comp = ItemId(
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap(),
);
s.execute("layer.newSolid", json!({"name": "Underlay", "color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 20})).unwrap();
s.execute("layer.newSolid", json!({"name": "Inner solid", "color": "#00ff00"})).unwrap();
s.execute("comp.open", json!({"comp": "Outer"})).unwrap();
s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(before, n, 0)));
s.execute(command, params).unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert!(f.cached_frames(&key(after, 0, 0)).is_empty(), "{command} changes pixels outside the added layer");
}
}
#[test]
fn markers_keep_the_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer");
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap();
assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames");
}
#[test]
fn markers_count_when_an_expression_can_read_them() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer");
s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap();
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap();
assert_ne!(at(&s), o0, "an expression can read the marker, so the frames change");
}