mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 17:02:21 -06:00
* Content-Aware Fill: bit-pack holes, spill frames to disk, stream outputs (#2570 phases 2-4) * Content-Aware Fill phases 2-4: bit-pack holes, spill frames, stream outputs (clippy clean) * Address CodeRabbit review: spill safety, streaming correctness, perf, memory guard (#2570) * Address the remaining CodeRabbit review findings on Content-Aware Fill (#2570) A non-streaming fill returned empty frames, progress could jump backwards between the sink and the fill, the memory guard ignored the membrane frames still resident for the flow pass (and wasm's in-RAM copies), and the GPU fill stages ran outside an error scope, so an OOM retired the shared context instead of falling back to the CPU for that frame. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Content-Aware Fill: sample spilled frames without decoding them, check spill errors (#2570) Tracing sampled spilled frames by decoding whole frames under the shared cache lock, so parallel traces across neighbouring frames evicted each other and mostly paid a full-frame read while every other thread waited. Sample only the 2x2 bilinear support with positioned reads on an open handle instead, and report a failed spill read in the no-hole branch rather than streaming black frames as a successful fill. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Content-Aware Fill: spill membrane/propagated frames, unquantized input, surface scratch errors (#2570) Flow and propagation now write their intermediate frames to scratch spills beside the output instead of holding the whole range as float RGBA, and the input spill uses 32-bit floats whatever the project depth so the fill sees the same values as the old in-memory path. Spill reads/writes and scratch creation report through a shared error slot that run_plan surfaces instead of masking disk failures as a cancellation. Sampling uses positioned reads; the guard models the packed masks plus per-thread scratch. * Content-Aware Fill: report a membrane-spill write failure instead of cancelling (#2570) * Content-Aware Fill: include the flow fields in the memory guard (#2570) * Content-Aware Fill: scale the memory guard with workers and check scratch space (#2570) The guard's scratch term was a fixed 8 frames, which under-counted a wide machine with Lighting Correction by several times. It now models the sequential synthesis stage plus a few frames per worker (from available parallelism) and takes Lighting Correction into account. Native fills write three f32 copies of the range to scratch beside the output, so a range that cannot fit on that volume is now refused up front with the free space in the message, before any frame is rendered. Tests cover the worker/lighting budget, the scratch formula, a shared error slot between the input and secondary spills, an uncreatable spill folder, and the engine surfacing a spill failure as an error instead of a cancellation. * Content-Aware Fill: resolve a relative output folder for the scratch check (#2570) A bare relative output name has an empty parent, so the walk-up found no existing ancestor and skipped the free-space check. Resolve it against the process directory first, and cover that in the test. * Do not reformat the GPU fill tests in the spill branch (#2570) The file was already not rustfmt-clean upstream, so the formatter only added noise to a file this PR no longer needs to touch: phase 1's call-site changes are already on the base branch. * Content-Aware Fill: measure peak RSS and correct the per-worker scratch model (#2570) The ignored measure_peak_rss_of_a_fill test fills a spilled 640x360 range and reports VmHWM. On this 18-worker machine 90 frames peak at 340-470 MB across runs (35 MB with RAYON_NUM_THREADS=1) and 600 frames at 582 MB, so the scratch slope is ~7 frames a worker, not the 2 the first guard revision assumed. Scaling the summary down showed a clean linear slope at 8 and 4 workers, and Lighting Correction stayed below the flow stage's peak. Budget 8 frames a worker (9 with Lighting Correction), just above the measured top of the range, and adjust the guard test to the real numbers. At 600 frames the measured peak is 582 MB against ~11.1 GB for the old five-copies model, a 19x reduction. * Contributors: credit Madhav Agarwal in the About window Adds the self-chosen real and display name for @gmrmad1. The generated contributors.json picks it up once the PR is merged. * Address CodeRabbit: size the guard from the rayon pool; migrate GPU tests (#2570) fill_workers used available_parallelism, but the parallel fill stages run on the rayon pool, which RAYON_NUM_THREADS can size larger. Use rayon::current_num_threads so the scratch budget follows the threads that actually run; wasm keeps rayon's single-thread fallback. The GPU inpaint tests still used the pre-spill FillInput API. The earlier 'do not reformat' revert dropped their API migration along with the formatting, leaving cargo test -p effectcraft-gpu uncompilable. Restore the Frames::Memory / HoleMask / sink migration and verify the 15 inpaint tests. * Fix gate: flatten the wasm prop lookup and keep the budget wrapper test-only (#2570) * Address CodeRabbit review: spill error check, global-pool preflight, docs (#2570) * Include RAM-backed spill in the preflight and guard streaming reads (#2570) * Resolve relative spill paths, prefer stacked mounts, guard the Edge Blend sink (#2570) * Make the fill spill anonymous and test the streaming claims (#2570) The spill was a named file in the output folder that only Drop removed, so an out-of-memory kill or a force-quit left a multi-gigabyte effectcraft-fill-* file in the user's project folder, and a failed set_len after keep() leaked it too. The spill is now an anonymous temporary file (tempfile_in), which the system reclaims when its last handle closes; every read and write is positioned on that one handle (pwrite/seek_write join the existing pread/seek_read), and the folder is kept only so the propagated frames spill beside the input. Also: - Edge Blend checks for a failed read before handing a frame to the sink, so a black fallback frame never streams. - The propagation workers stop as soon as the spill records an error. - HoleMask::get/set use get()/get_mut(), so a mask whose public words are shorter than len cannot panic. New tests cover the PR's headline claims: a spilled range fills bit-identically to the same frames in RAM (returned and streamed), the sink sees frames strictly in order, a truncated spill fails with a recorded error and streams nothing, a failure mid-stream stops before the next frame, propagation stops after a failure, the spill has no name in its folder, and HoleMask round-trips at word boundaries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: clean up a stopped fill and count two scratch copies (#2570) - A fill that fails or is cancelled after frames started streaming deletes the PNGs it wrote, and the output folder when the fill made it and it is left empty (OutputFiles, dropped after the spill so Windows can remove the folder). - After a successful fill, run_plan checks that every frame has a file, and apply_plan/add_fill_layer return an error on an empty range or file list (a job worker's reply) instead of indexing [0]. - scratch_need charges two f32 copies (32 B/px): flow_pairs drops the membrane spill before the propagated one is created, so the third copy never coexists. - The spill-creation error carries a stable "spill: cannot make the scratch file" marker; the panel test asserted on the OS wording, which on Windows is "Cannot create a file when that file already exists". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: a stopped fill leaves files it didn't create (#2570) A fill into a given outputDir that already holds an earlier sequence overwrites its frames as it goes (as before). The cleanup of a failed or stopped fill deleted every path it wrote, including those earlier frames, so a layer still using that sequence lost them. It now deletes only the files the fill created (CodeRabbit on #3079). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
771 lines
36 KiB
Rust
771 lines
36 KiB
Rust
//! M13.6 panels and tools through the commands: Progress (jobs), Media Browser, Metadata,
|
||
//! Lumetri Scopes, the Footage panel's Overlay / Ripple Insert edits, Content-Aware Fill, Scene
|
||
//! Edit Detection, Auto-trace and Align Video to Data.
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use effectcraft_color::Label;
|
||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
|
||
use effectcraft_raster::Image;
|
||
use effectcraft_raster::inpaint::psnr;
|
||
use effectcraft_render::FootageSource;
|
||
use effectcraft_time::{FrameRate, Tick};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
|
||
const FPS: u32 = 25;
|
||
|
||
/// Footage computed per frame.
|
||
struct Gen {
|
||
make: Box<dyn Fn(u32) -> Image + Send + Sync>,
|
||
cache: Mutex<HashMap<u32, Arc<Image>>>,
|
||
}
|
||
|
||
impl FootageSource for Gen {
|
||
fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||
let f = (t.seconds() * FPS as f64).round().max(0.0) as u32;
|
||
let mut c = self.cache.lock().unwrap();
|
||
Some(c.entry(f).or_insert_with(|| Arc::new((self.make)(f))).clone())
|
||
}
|
||
}
|
||
|
||
/// A comp `w × h`, `secs` long, with one footage layer drawn by `make` (returns session, comp,
|
||
/// layer, footage item).
|
||
fn setup(w: u32, h: u32, secs: f64, make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, ItemId, LayerId, ItemId) {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Panels", "width": w, "height": h, "frameRate": FPS, "duration": secs})).unwrap();
|
||
let cid = s.active_comp_id().unwrap();
|
||
let (lid, item) = s
|
||
.edit("clip", None, |p, _| {
|
||
let footage = Footage {
|
||
path: "synthetic.mov".into(),
|
||
kind: FootageKind::Video,
|
||
width: w,
|
||
height: h,
|
||
frame_rate: FrameRate::new(FPS as i64, 1),
|
||
duration: Tick::from_seconds_f64(secs),
|
||
has_video: true,
|
||
codec: "SYN".into(),
|
||
..Default::default()
|
||
};
|
||
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||
let comp = p.comp(cid).unwrap().clone();
|
||
let l = build::layer(p, &comp, "Clip", LayerSource::Footage { item }, (w, h), None);
|
||
let id = l.id;
|
||
p.comp_mut(cid).unwrap().layers.insert(0, l);
|
||
Ok((id, item))
|
||
})
|
||
.unwrap();
|
||
s.footage = Arc::new(Gen { make: Box::new(make), cache: Mutex::default() });
|
||
s.execute("layer.select", json!({"layers": [lid.0]})).unwrap();
|
||
(s, cid, lid, item)
|
||
}
|
||
|
||
fn tmp(name: &str) -> std::path::PathBuf {
|
||
let d = std::env::temp_dir().join(format!("ec-panels-{}-{name}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Progress
|
||
|
||
#[test]
|
||
fn progress_lists_and_cancels_jobs() {
|
||
let mut s = Session::default();
|
||
let r = s
|
||
.spawn_task("test", "Endless job", false, |ctl| {
|
||
let mut k = 0;
|
||
while ctl.progress(k % 100, 100) {
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
k += 1;
|
||
}
|
||
Err(crate::render_queue::CANCELLED.into())
|
||
})
|
||
.unwrap();
|
||
let id = r["job"].as_str().unwrap().to_string();
|
||
let listed = s.execute("jobs.list", json!({})).unwrap();
|
||
let running = listed["running"].as_array().unwrap();
|
||
assert_eq!(running.len(), 1);
|
||
assert_eq!(running[0]["id"], json!(id));
|
||
assert_eq!(running[0]["label"], json!("Endless job"));
|
||
assert_eq!(running[0]["cancellable"], json!(true));
|
||
assert_eq!(s.execute("jobs.cancel", json!({"job": id})).unwrap()["cancelled"], json!(true));
|
||
s.execute("jobs.wait", json!({})).unwrap();
|
||
assert!(s.jobs().is_empty());
|
||
assert_eq!(s.job_log.last().unwrap().status, "cancelled");
|
||
// A finished job applies its edit (one undo step) and reports its result.
|
||
let v = s
|
||
.spawn_task("test", "Quick job", true, |ctl| {
|
||
ctl.progress(1, 1);
|
||
let apply: crate::jobs::Apply = Box::new(|s: &mut Session| {
|
||
s.edit("Quick", None, |p, _| {
|
||
p.settings.comment = "done".into();
|
||
Ok(())
|
||
})?;
|
||
Ok(json!(42))
|
||
});
|
||
Ok(apply)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(v, json!(42));
|
||
assert_eq!(s.project.settings.comment, "done");
|
||
assert_eq!(s.job_log.last().unwrap().status, "done");
|
||
assert!(s.undo());
|
||
assert_eq!(s.project.settings.comment, "");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Media Browser & Metadata
|
||
|
||
#[test]
|
||
fn media_browser_and_metadata() {
|
||
let dir = tmp("browser");
|
||
std::fs::create_dir_all(dir.join("Footage")).unwrap();
|
||
std::fs::write(dir.join("shot.mov"), b"0123").unwrap();
|
||
std::fs::write(dir.join("data.csv"), "time,v\n0,1\n").unwrap();
|
||
std::fs::write(dir.join("readme.txt"), "x").unwrap();
|
||
let d = dir.to_string_lossy().to_string();
|
||
let mut s = Session::default();
|
||
let r = s.execute("mediaBrowser.go", json!({"path": d})).unwrap();
|
||
let names: Vec<&str> = r["entries"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
|
||
assert_eq!(names, ["Footage", "data.csv", "readme.txt", "shot.mov"]);
|
||
assert_eq!(s.state.media_browser.folder.as_deref(), Some(d.as_str()));
|
||
let r = s.execute("mediaBrowser.list", json!({"importableOnly": true})).unwrap();
|
||
assert_eq!(r["entries"].as_array().unwrap().len(), 3);
|
||
s.execute("mediaBrowser.addFavorite", json!({})).unwrap();
|
||
assert_eq!(s.state.media_browser.favorites, vec![d.clone()]);
|
||
s.execute("mediaBrowser.go", json!({"path": dir.join("Footage").to_string_lossy()})).unwrap();
|
||
s.execute("mediaBrowser.go", json!({"path": ".."})).unwrap();
|
||
assert_eq!(s.state.media_browser.folder.as_deref(), Some(d.as_str()));
|
||
s.execute("mediaBrowser.removeFavorite", json!({"path": d})).unwrap();
|
||
assert!(s.state.media_browser.favorites.is_empty());
|
||
// Import a data file from the browser; its metadata reads back.
|
||
let r = s.execute("mediaBrowser.import", json!({"paths": [dir.join("data.csv").to_string_lossy()]})).unwrap();
|
||
let id = r["items"][0].as_u64().unwrap();
|
||
let m = s.execute("item.metadata", json!({"item": id})).unwrap();
|
||
assert_eq!(m["item"]["type"], json!("Data"));
|
||
assert_eq!(m["item"]["codec"], json!("CSV"));
|
||
assert_eq!(m["item"]["dataRows"], json!(1));
|
||
assert!(m["item"]["modified"].as_str().unwrap().ends_with('Z'));
|
||
assert_eq!(m["project"]["footage"], json!(1));
|
||
// Editable comments: item (undoable) and project.
|
||
s.execute("project.setComment", json!({"items": [id], "comment": "telemetry"})).unwrap();
|
||
s.execute("project.setProjectComment", json!({"comment": "client cut"})).unwrap();
|
||
let m = s.execute("item.metadata", json!({"item": id})).unwrap();
|
||
assert_eq!(m["item"]["comment"], json!("telemetry"));
|
||
assert_eq!(m["project"]["comment"], json!("client cut"));
|
||
s.undo();
|
||
assert_eq!(s.project.settings.comment, "");
|
||
let fi = s.execute("mediaBrowser.fileInfo", json!({"path": dir.join("shot.mov").to_string_lossy()})).unwrap();
|
||
assert_eq!(fi["size"], json!(4));
|
||
assert_eq!(fi["kind"], json!("video"));
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Lumetri Scopes
|
||
|
||
#[test]
|
||
fn scopes_read_the_comp_frame() {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"width": 64, "height": 48, "frameRate": 25, "duration": 1})).unwrap();
|
||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||
let v = s.execute("scopes.analyze", json!({"scope": "vectorscopeYuv", "size": 101})).unwrap();
|
||
let st = effectcraft_raster::scopes::ColorStandard::Rec709;
|
||
let [_, cb, cr] = st.ycbcr([1.0, 0.0, 0.0]);
|
||
assert_eq!(v["peaks"][0], json!([((cb + 0.5) * 100.0).round() as u32, ((0.5 - cr) * 100.0).round() as u32]));
|
||
let h = s.execute("scopes.analyze", json!({"scope": "histogram", "standard": "rec601"})).unwrap();
|
||
let bars = &h["histogram"];
|
||
assert_eq!(bars[0][255], json!(64 * 48));
|
||
assert_eq!(bars[1][0], json!(64 * 48));
|
||
assert!((h["luma"]["mean"].as_f64().unwrap() - 0.299).abs() < 1e-3);
|
||
assert!(s.execute("scopes.analyze", json!({"scope": "bogus"})).is_err());
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Footage panel
|
||
|
||
#[test]
|
||
fn footage_panel_overlay_keeps_full_length_past_the_comp_end() {
|
||
let (mut s, cid, _clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||
let r = s.execute("footage.overlayEdit", json!({"item": item.0, "in": 0.0, "out": 4.0, "at": 6.0})).unwrap();
|
||
let ov = LayerId(r["layer"].as_u64().unwrap());
|
||
let l = s.project.comp(cid).unwrap().layer(ov).unwrap();
|
||
assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (6.0, 10.0));
|
||
}
|
||
|
||
#[test]
|
||
fn footage_panel_overlay_and_ripple_insert() {
|
||
let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||
assert!(
|
||
s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "footage"))
|
||
);
|
||
s.execute("footage.setTime", json!({"time": 1.0})).unwrap();
|
||
s.execute("footage.setIn", json!({})).unwrap();
|
||
s.execute("footage.setOut", json!({"frame": 49})).unwrap();
|
||
let info = s.execute("footage.info", json!({})).unwrap();
|
||
assert_eq!(info["in"], json!(1.0));
|
||
assert_eq!(info["out"], json!(2.0));
|
||
// Overlay Edit at 0.6 s: a 1 s layer on top showing source 1–2 s.
|
||
s.set_time(Tick::from_seconds_f64(0.6));
|
||
let r = s.execute("footage.overlayEdit", json!({})).unwrap();
|
||
let ov = LayerId(r["layer"].as_u64().unwrap());
|
||
let comp = s.project.comp(cid).unwrap();
|
||
assert_eq!(comp.layers[0].id, ov);
|
||
let l = comp.layer(ov).unwrap();
|
||
assert_eq!((l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()), (0.6, 1.6, -0.4));
|
||
assert_eq!(comp.layer(clip).unwrap().out_point.seconds(), 8.0, "overlay leaves other layers alone");
|
||
// Ripple Insert at 3 s: layers crossing 3 s are split, the rest moves back by 1 s.
|
||
s.set_time(Tick::from_seconds_f64(3.0));
|
||
let r = s.execute("footage.rippleInsertEdit", json!({})).unwrap();
|
||
let ins = LayerId(r["layer"].as_u64().unwrap());
|
||
let comp = s.project.comp(cid).unwrap();
|
||
assert_eq!(comp.layers.len(), 4, "overlay, insert, clip split in two");
|
||
let l = comp.layer(ins).unwrap();
|
||
assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (3.0, 4.0));
|
||
let head = comp.layer(clip).unwrap();
|
||
assert_eq!((head.in_point.seconds(), head.out_point.seconds()), (0.0, 3.0));
|
||
let tail = comp.layers.iter().find(|x| x.id != clip && x.id != ins && x.id != ov).unwrap();
|
||
assert_eq!((tail.in_point.seconds(), tail.out_point.seconds(), tail.start_time.seconds()), (4.0, 9.0, 1.0));
|
||
// Source time continuity: the tail shows source 3 s at comp 4 s.
|
||
assert_eq!(tail.layer_time(Tick::from_seconds_f64(4.0)).seconds(), 3.0);
|
||
// The overlay layer (in before 3 s, out before 3 s) didn't move.
|
||
assert_eq!(comp.layer(ov).unwrap().in_point.seconds(), 0.6);
|
||
// One undo step each.
|
||
assert!(s.undo());
|
||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 2);
|
||
assert!(s.execute("footage.setOut", json!({"time": 0.5})).is_ok());
|
||
assert_eq!(s.state.footage_panel.as_ref().unwrap().in_point, None, "an Out before the In clears the In");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Content-Aware Fill
|
||
|
||
fn bg(x: f32, y: f32) -> [f32; 4] {
|
||
let s = 0.5 + 0.3 * (x * std::f32::consts::TAU / 10.0).sin();
|
||
let n = effectcraft_raster::hash_noise(((x.floor() as i64).rem_euclid(20) / 4) as u32, ((y as u32) % 20) / 4, 3);
|
||
[s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]
|
||
}
|
||
|
||
/// Panning textured background with a red "object" walking through the masked region.
|
||
fn caf_frame(f: u32, with_object: bool) -> Image {
|
||
let (w, h) = (96u32, 64u32);
|
||
let mut img = Image::new(w, h);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let mut p = bg(x as f32 + 2.0 * f as f32, y as f32);
|
||
let ox = 40 + (f % 4);
|
||
if with_object && (ox..ox + 10).contains(&x) && (26..36).contains(&y) {
|
||
p = [0.9, 0.1, 0.1, 1.0];
|
||
}
|
||
img.set(x, y, p);
|
||
}
|
||
}
|
||
img
|
||
}
|
||
|
||
#[test]
|
||
fn content_aware_fill_removes_an_object() {
|
||
let (mut s, cid, clip, _) = setup(96, 64, 0.48, |f| caf_frame(f, true));
|
||
// Cut the object out with a Subtract mask; fill the work area (12 frames).
|
||
s.execute("layer.addMask", json!({"rect": [36.0, 22.0, 20.0, 18.0], "mode": "subtract"})).unwrap();
|
||
let out = tmp("caf");
|
||
s.execute("contentFill.set", json!({"method": "object", "range": "workArea", "alphaExpansion": 1})).unwrap();
|
||
let r = s.execute("contentFill.generate", json!({"outputDir": out.to_string_lossy(), "wait": true})).unwrap();
|
||
let fill = LayerId(r["layer"].as_u64().unwrap());
|
||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||
assert_eq!(files.len(), 12);
|
||
// The Fill layer sits right above the source with the source's timing.
|
||
let comp = s.project.comp(cid).unwrap();
|
||
let i = comp.layers.iter().position(|l| l.id == fill).unwrap();
|
||
assert_eq!(comp.layers[i + 1].id, clip);
|
||
let fl = comp.layer(fill).unwrap();
|
||
assert_eq!((fl.in_point, fl.out_point.seconds()), (Tick::ZERO, 0.48));
|
||
let LayerSource::Footage { item } = fl.source else { panic!("footage") };
|
||
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { panic!() };
|
||
assert_eq!((f.kind, f.sequence.len()), (FootageKind::Sequence, 12));
|
||
assert!(s.project.item(item).unwrap().name.starts_with("Fill 1"));
|
||
// The filled frames match the clean background inside the mask, and stay steady over time.
|
||
let region: Vec<bool> = (0..96 * 64).map(|i| (36..56).contains(&(i % 96)) && (22..40).contains(&(i / 96))).collect();
|
||
let mut prev: Option<Image> = None;
|
||
for (k, path) in files.iter().enumerate() {
|
||
let png = image::open(path).unwrap().to_rgba8();
|
||
let img = Image::from_rgba8(96, 64, png.as_raw());
|
||
let truth = caf_frame(k as u32, false);
|
||
let db = psnr(&img, &truth, Some(®ion));
|
||
assert!(db > 25.0, "frame {k}: {db:.1} dB");
|
||
// Outside the hole the frame is the source itself.
|
||
let outside: Vec<bool> = region.iter().map(|r| !r).collect();
|
||
assert!(psnr(&img, &caf_frame(k as u32, true), Some(&outside)) > 40.0);
|
||
if let Some(p) = &prev {
|
||
// Temporal consistency: the change in the hole follows the 2 px/frame pan (compare
|
||
// against the previous fill shifted).
|
||
let mut shifted = p.clone();
|
||
for y in 0..64 {
|
||
for x in 0..94 {
|
||
shifted.data[y * 96 + x] = p.data[y * 96 + x + 2];
|
||
}
|
||
}
|
||
let inner: Vec<bool> = (0..96 * 64).map(|i| (38..54).contains(&(i % 96)) && (24..38).contains(&(i / 96))).collect();
|
||
assert!(psnr(&img, &shifted, Some(&inner)) > 24.0, "frame {k} jumps");
|
||
}
|
||
prev = Some(img);
|
||
}
|
||
// One undo step removes the fill layer.
|
||
assert!(s.undo());
|
||
assert!(s.project.comp(cid).unwrap().layer(fill).is_none());
|
||
std::fs::remove_dir_all(&out).unwrap();
|
||
// Without a hole there is nothing to fill.
|
||
let (mut s2, _, _, _) = setup(32, 24, 0.2, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||
assert!(s2.execute("contentFill.generate", json!({"wait": true, "outputDir": tmp("caf2").to_string_lossy()})).is_err());
|
||
}
|
||
|
||
/// A scratch folder that cannot be created surfaces as an error, not a cancelled fill or a
|
||
/// black frame (#2570 review).
|
||
#[test]
|
||
fn content_aware_fill_reports_a_spill_failure() {
|
||
let (mut s, _, _, _) = setup(48, 32, 0.2, |f| {
|
||
let mut img = Image::new(48, 32);
|
||
for y in 0..32 {
|
||
for x in 0..48 {
|
||
img.set(x, y, bg(x as f32 + f as f32, y as f32));
|
||
}
|
||
}
|
||
img
|
||
});
|
||
s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap();
|
||
let dir = tmp("cafspill");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let file = dir.join("not-a-folder");
|
||
std::fs::write(&file, b"x").unwrap();
|
||
// The fill wants to create its spill here; the parent is a file.
|
||
let out = file.join("fills");
|
||
let e = s.execute("contentFill.generate", json!({"method": "object", "outputDir": out.to_string_lossy(), "wait": true})).unwrap_err();
|
||
// The OS wording differs ("Not a directory" on Unix, "Cannot create a file when that file
|
||
// already exists" on Windows), so look for the fill's own marker.
|
||
let msg = e.to_string();
|
||
assert!(msg.contains("spill: cannot make the scratch file"), "not a spill error: {msg}");
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
/// A fill plan that comes back without files or frames (a job worker that wrote none) is an
|
||
/// error, not a crash on the first file (#2570 review).
|
||
#[test]
|
||
fn content_aware_fill_without_files_is_an_error() {
|
||
let (mut s, cid, lid, _) = setup(48, 32, 0.2, |_| Image::filled(48, 32, [0.5, 0.5, 0.5, 1.0]));
|
||
let plan = crate::commands::content_fill::FillPlan {
|
||
comp: cid,
|
||
layer: lid,
|
||
name: "Clip".into(),
|
||
width: 48,
|
||
height: 32,
|
||
times: vec![Tick::ZERO],
|
||
method: "object".into(),
|
||
lighting: 0.0,
|
||
expand: 0,
|
||
reference: None,
|
||
dir: "Clip_Fill_1".into(),
|
||
};
|
||
assert!(crate::commands::content_fill::apply_plan(&mut s, &plan, vec![]).is_err());
|
||
let empty = crate::commands::content_fill::FillPlan { times: vec![], ..plan };
|
||
assert!(crate::commands::content_fill::apply_plan(&mut s, &empty, vec!["Clip_Fill_1/fill_00000.png".into()]).is_err());
|
||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1, "no fill layer was added");
|
||
}
|
||
|
||
#[test]
|
||
fn content_aware_fill_runs_in_the_background() {
|
||
let (mut s, _, _, _) = setup(48, 32, 0.2, |f| {
|
||
let mut img = Image::new(48, 32);
|
||
for y in 0..32 {
|
||
for x in 0..48 {
|
||
img.set(x, y, bg(x as f32 + f as f32, y as f32));
|
||
}
|
||
}
|
||
img
|
||
});
|
||
s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap();
|
||
let out = tmp("cafbg");
|
||
let r = s.execute("contentFill.generate", json!({"method": "edgeBlend", "outputDir": out.to_string_lossy()})).unwrap();
|
||
assert!(r["job"].as_str().unwrap().starts_with("task:"));
|
||
assert_eq!(s.jobs().len(), 1);
|
||
assert_eq!(s.jobs()[0].kind, "contentFill");
|
||
s.wait_jobs();
|
||
let rec = s.job_log.last().unwrap();
|
||
assert_eq!(rec.status, "done", "{}", rec.message);
|
||
assert_eq!(rec.result["frames"], json!(5));
|
||
std::fs::remove_dir_all(&out).unwrap();
|
||
}
|
||
|
||
/// The browser: Content-Aware Fill goes to a job worker (its files written through the
|
||
/// worker's services), and the page adds the fill layer when the plan comes back.
|
||
#[test]
|
||
fn content_aware_fill_runs_in_a_job_worker() {
|
||
#[derive(Default)]
|
||
struct Manual(std::sync::Mutex<Vec<(crate::offload::WorkerRequest, Arc<crate::offload::Inbox>)>>);
|
||
impl crate::offload::Offload for Manual {
|
||
fn start(&self, req: crate::offload::WorkerRequest, inbox: Arc<crate::offload::Inbox>) -> std::result::Result<(), String> {
|
||
self.0.lock().unwrap().push((req, inbox));
|
||
Ok(())
|
||
}
|
||
fn cancel(&self, _: u64) {}
|
||
}
|
||
let (mut s, cid, clip, _) = setup(48, 32, 0.2, |f| {
|
||
let mut img = Image::new(48, 32);
|
||
for y in 0..32 {
|
||
for x in 0..48 {
|
||
img.set(x, y, bg(x as f32 + f as f32, y as f32));
|
||
}
|
||
}
|
||
img
|
||
});
|
||
let off = Arc::new(Manual::default());
|
||
s.offload = Some(off.clone());
|
||
s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap();
|
||
let out = tmp("cafworker");
|
||
let r = s.execute("contentFill.generate", json!({"method": "edgeBlend", "outputDir": out.to_string_lossy()})).unwrap();
|
||
assert_eq!((r["job"].as_str(), r["worker"].as_bool()), (Some("contentFill"), Some(true)), "{r}");
|
||
assert!(s.jobs().iter().any(|j| j.id == "contentFill"));
|
||
let (req, inbox) = off.0.lock().unwrap().pop().unwrap();
|
||
let back: crate::offload::WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||
// The worker: the same footage, the default (file system) services.
|
||
let mut w = Session { footage: s.footage.clone(), ..Default::default() };
|
||
let post: crate::offload::Post = std::rc::Rc::new(move |r| inbox.push(r));
|
||
crate::offload::run_request(&mut w, back, &post);
|
||
s.poll_offload();
|
||
let comp = s.project.comp(cid).unwrap().clone();
|
||
let i = comp.layers.iter().position(|l| l.id == clip).unwrap();
|
||
assert!(i > 0, "a fill layer above the source");
|
||
let fill = &comp.layers[i - 1];
|
||
assert!(fill.name.starts_with("Fill 1"), "{}", fill.name);
|
||
let LayerSource::Footage { item } = fill.source else { panic!("footage") };
|
||
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { panic!() };
|
||
assert_eq!(f.sequence.len(), 5);
|
||
assert!(f.sequence.iter().all(|p| std::path::Path::new(p).exists()));
|
||
assert!(s.jobs().is_empty());
|
||
// One undo step.
|
||
assert!(s.undo());
|
||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), comp.layers.len() - 1);
|
||
std::fs::remove_dir_all(&out).unwrap();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Scene Edit Detection
|
||
|
||
fn shot_frame(f: u32) -> Image {
|
||
// Shots: frames 0–9, 10–17, 18–24.
|
||
let (kind, k) = if f < 10 {
|
||
(0, f)
|
||
} else if f < 18 {
|
||
(1, f - 10)
|
||
} else {
|
||
(2, f - 18)
|
||
};
|
||
let mut img = Image::new(80, 48);
|
||
for y in 0..48 {
|
||
for x in 0..80 {
|
||
let fx = x as f32 + 2.0 * k as f32;
|
||
let v = match kind {
|
||
0 => 0.5 + 0.4 * (fx * 0.15).sin() * (y as f32 * 0.11).cos(),
|
||
1 => {
|
||
if ((fx / 10.0).floor() as i32 + y as i32 / 10) % 2 == 0 {
|
||
0.85
|
||
} else {
|
||
0.15
|
||
}
|
||
}
|
||
_ => effectcraft_raster::hash_noise((fx.floor() as u32) / 4, y / 4, 5),
|
||
};
|
||
let c = match kind {
|
||
0 => [v, v * 0.6, 0.2],
|
||
1 => [0.1, v * 0.8, v],
|
||
_ => [v * 0.9, v * 0.9, 0.5 + v * 0.4],
|
||
};
|
||
img.set(x, y, [c[0], c[1], c[2], 1.0]);
|
||
}
|
||
}
|
||
img
|
||
}
|
||
|
||
#[test]
|
||
fn scene_edit_detection_finds_cuts() {
|
||
let (mut s, cid, clip, _) = setup(80, 48, 1.0, shot_frame);
|
||
// Markers.
|
||
let r = s.execute("layer.sceneEditDetection", json!({"mode": "markers", "wait": true})).unwrap();
|
||
assert_eq!(r["cuts"], json!([0.4, 0.72]));
|
||
let l = s.project.comp(cid).unwrap().layer(clip).unwrap();
|
||
assert_eq!(l.markers.iter().map(|m| m.time.seconds()).collect::<Vec<_>>(), vec![0.4, 0.72]);
|
||
s.undo();
|
||
// Split Layers.
|
||
let r = s.execute("layer.sceneEditDetection", json!({"mode": "split", "wait": true})).unwrap();
|
||
let ids: Vec<u64> = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||
assert_eq!(ids.len(), 3);
|
||
let comp = s.project.comp(cid).unwrap();
|
||
let spans: Vec<(f64, f64)> = ids.iter().map(|i| comp.layer(LayerId(*i)).unwrap()).map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect();
|
||
assert_eq!(spans, vec![(0.0, 0.4), (0.4, 0.72), (0.72, 1.0)]);
|
||
s.undo();
|
||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1);
|
||
// Split and Precompose: three precomp layers, one undo step.
|
||
let r = s.execute("layer.sceneEditDetection", json!({"mode": "splitPrecompose", "wait": true})).unwrap();
|
||
let comp = s.project.comp(cid).unwrap();
|
||
assert_eq!(comp.layers.len(), 3);
|
||
for v in r["layers"].as_array().unwrap() {
|
||
let l = comp.layer(LayerId(v.as_u64().unwrap())).unwrap();
|
||
assert!(matches!(l.source, LayerSource::Comp { .. }));
|
||
}
|
||
assert_eq!(s.history.undo.last().unwrap().0, "Scene Edit Detection");
|
||
s.undo();
|
||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Auto-trace
|
||
|
||
fn disc_frame(f: u32) -> Image {
|
||
let mut img = Image::new(80, 60);
|
||
let (cx, cy) = (30.0 + 2.0 * f as f64, 30.0);
|
||
for y in 0..60 {
|
||
for x in 0..80 {
|
||
let d = ((x as f64 + 0.5 - cx).powi(2) + (y as f64 + 0.5 - cy).powi(2)).sqrt();
|
||
// Anti-aliased disc of radius 15 plus a 12 × 12 square, on transparency.
|
||
let a = (15.5 - d).clamp(0.0, 1.0) as f32;
|
||
let sq = (60..72).contains(&x) && (8..20).contains(&y);
|
||
let a = if sq { 1.0 } else { a };
|
||
img.set(x, y, [a, a, a, a]);
|
||
}
|
||
}
|
||
img
|
||
}
|
||
|
||
fn iou_with(path_set: &[effectcraft_keyframe::ShapePath], truth: &Image) -> f64 {
|
||
let paths: Vec<_> = path_set.iter().map(effectcraft_path::to_kurbo).collect();
|
||
let cov = effectcraft_path::fill_coverage(&paths, &effectcraft_geom::Mat3::IDENTITY, truth.width, truth.height, effectcraft_path::FillRule::EvenOdd);
|
||
let (mut i, mut u) = (0.0, 0.0);
|
||
for (c, p) in cov.data.iter().zip(&truth.data) {
|
||
let (a, b) = (*c >= 0.5, p[3] >= 0.5);
|
||
i += (a && b) as u8 as f64;
|
||
u += (a || b) as u8 as f64;
|
||
}
|
||
i / u
|
||
}
|
||
|
||
fn mask_paths(s: &Session, cid: ItemId, lid: LayerId, lt: Tick) -> Vec<effectcraft_keyframe::ShapePath> {
|
||
let l = s.project.comp(cid).unwrap().layer(lid).unwrap();
|
||
l.masks()
|
||
.unwrap()
|
||
.groups()
|
||
.filter(|g| g.get("opacity").unwrap().value_at(lt).as_f64() > 0.0)
|
||
.map(|g| match g.get("path").unwrap().value_at(lt) {
|
||
effectcraft_project::Value::Path(p) => p,
|
||
_ => panic!("path"),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn auto_trace_matches_shapes() {
|
||
let (mut s, cid, clip, _) = setup(80, 60, 0.4, disc_frame);
|
||
let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "channel": "alpha", "tolerance": 0.5, "cornerRoundness": 30})).unwrap();
|
||
assert_eq!(r["masks"], json!(2));
|
||
let iou = iou_with(&mask_paths(&s, cid, clip, Tick::ZERO), &disc_frame(0));
|
||
assert!(iou > 0.95, "IoU {iou}");
|
||
s.undo();
|
||
// Work area onto a new layer: one key per frame following the moving disc.
|
||
let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "timeSpan": "workArea", "applyToNewLayer": true, "minimumArea": 20})).unwrap();
|
||
let nl = LayerId(r["layer"].as_u64().unwrap());
|
||
assert_ne!(nl, clip);
|
||
assert_eq!(r["frames"], json!(10));
|
||
for f in [0u32, 5, 9] {
|
||
let t = FrameRate::new(FPS as i64, 1).tick_of(f as i64);
|
||
let iou = iou_with(&mask_paths(&s, cid, nl, t), &disc_frame(f));
|
||
assert!(iou > 0.93, "frame {f}: IoU {iou}");
|
||
}
|
||
// Luminance with Invert traces the background instead (one outline with holes).
|
||
s.undo();
|
||
let r = s.execute("layer.autoTrace", json!({"layer": clip.0, "channel": "luminance", "invert": true, "threshold": 50})).unwrap();
|
||
assert!(r["masks"].as_u64().unwrap() >= 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Align Video to Data
|
||
|
||
#[test]
|
||
fn align_video_to_data() {
|
||
let (mut s, cid, clip, _) = setup(16, 16, 20.0, |_| Image::filled(16, 16, [0.2, 0.2, 0.2, 1.0]));
|
||
let add_data = |s: &mut Session, name: &str, ext: &str, text: &str| -> u64 {
|
||
s.edit("data", None, |p, _| {
|
||
let f =
|
||
Footage { path: format!("{name}.{ext}"), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text.into()), ..Default::default() };
|
||
Ok(p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f)).0)
|
||
})
|
||
.unwrap()
|
||
};
|
||
let gps = add_data(&mut s, "gps", "csv", "lat,lon,timestamp\n1,2,2026-05-01T10:00:00Z\n1,2,2026-05-01T10:00:01Z\n");
|
||
// The video started 5 s after the first sample: it moves 5 s later.
|
||
let r = s.execute("layer.alignVideoToData", json!({"data": gps, "videoStart": "2026-05-01T10:00:05Z"})).unwrap();
|
||
assert_eq!(r["startTime"], json!(5.0));
|
||
let l = s.project.comp(cid).unwrap().layer(clip).unwrap();
|
||
assert_eq!((l.start_time.seconds(), l.in_point.seconds()), (5.0, 5.0));
|
||
s.undo();
|
||
// Data placed at 2 s, timestamps as times of day, video start as timecode.
|
||
let log = add_data(&mut s, "log", "json", r#"{"samples": [{"t": "09:59:58.0", "v": 1}, {"t": "09:59:59.0", "v": 2}]}"#);
|
||
let r = s.execute("layer.alignVideoToData", json!({"data": "log", "videoStart": "10:00:00:00", "dataStart": 2.0})).unwrap();
|
||
assert_eq!(r["key"], json!("t"));
|
||
assert_eq!(r["startTime"], json!(4.0));
|
||
// A video that started before the data moves earlier (negative start).
|
||
let r = s.execute("layer.alignVideoToData", json!({"data": log, "videoStart": "2026-05-01T09:59:57Z"})).unwrap();
|
||
assert_eq!(r["startTime"], json!(-1.0));
|
||
// Errors: unknown key, no data.
|
||
assert!(s.execute("layer.alignVideoToData", json!({"data": gps, "key": "nope", "videoStart": 0})).is_err());
|
||
let _: Value = Value::Null;
|
||
}
|
||
|
||
#[test]
|
||
fn panels_commands_are_wired() {
|
||
// The Window menu opens real panels now, and the former stubs are enabled.
|
||
let menus = crate::menus::entries();
|
||
for panel in ["lumetriScopes", "footage", "mediaBrowser", "metadata", "progress", "contentAwareFill"] {
|
||
assert!(menus.iter().any(|(_, e)| e.command == "window.panel" && e.params["panel"] == panel), "{panel}");
|
||
}
|
||
assert!(crate::find_command("window.unavailablePanel").is_none());
|
||
let (s, _, _, _) = setup(8, 8, 1.0, |_| Image::filled(8, 8, [0.0; 4]));
|
||
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.alignVideoToData", "layer.newContentAwareFill", "contentFill.generate"] {
|
||
assert!(s.is_enabled(id), "{id}");
|
||
}
|
||
}
|
||
|
||
/// A browser like the web app's: a virtual tree whose files are read on demand.
|
||
#[derive(Default)]
|
||
struct VirtualBrowser {
|
||
tree: Mutex<crate::media_browser::VirtualTree>,
|
||
fetched: Mutex<Vec<String>>,
|
||
actions: Mutex<Vec<String>>,
|
||
}
|
||
|
||
impl crate::media_browser::Browser for VirtualBrowser {
|
||
fn home(&self) -> String {
|
||
"/Browser Storage".into()
|
||
}
|
||
fn places(&self) -> Vec<(String, String)> {
|
||
vec![("Browser Storage".into(), self.home()), ("Shoot".into(), "/Folders/Shoot".into())]
|
||
}
|
||
fn list(&self, dir: &str, only: bool) -> Result<Vec<crate::media_browser::Entry>, String> {
|
||
self.tree.lock().unwrap().list(dir, only)
|
||
}
|
||
fn fetch(&self, paths: &[String], _: &Value) -> bool {
|
||
// Opened folders' files arrive later.
|
||
let pending: Vec<String> = paths.iter().filter(|p| p.starts_with("/Folders/")).cloned().collect();
|
||
self.fetched.lock().unwrap().extend(pending.iter().cloned());
|
||
pending.is_empty()
|
||
}
|
||
fn actions(&self) -> Vec<(String, String)> {
|
||
vec![("openFolder".into(), "Open Folder…".into())]
|
||
}
|
||
fn action(&self, id: &str) -> Result<Value, String> {
|
||
self.actions.lock().unwrap().push(id.into());
|
||
Ok(json!({"pending": true}))
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn media_browser_over_a_virtual_tree() {
|
||
let b = Arc::new(VirtualBrowser::default());
|
||
{
|
||
let mut t = b.tree.lock().unwrap();
|
||
t.add_dir("/Browser Storage");
|
||
t.add_file("/Folders/Shoot/a.csv", "/Folders/Shoot/a.csv", 12, Some(1));
|
||
}
|
||
let mut s = Session { browser: Some(b.clone()), ..Default::default() };
|
||
let r = s.execute("mediaBrowser.list", json!({})).unwrap();
|
||
assert_eq!(r["path"], "/Browser Storage");
|
||
assert_eq!(r["places"][1]["path"], "/Folders/Shoot");
|
||
assert_eq!(r["actions"][0]["id"], "openFolder");
|
||
let r = s.execute("mediaBrowser.go", json!({"path": "/Folders/Shoot"})).unwrap();
|
||
assert_eq!(r["entries"][0]["name"], "a.csv");
|
||
assert_eq!(r["parent"], "/Folders");
|
||
assert!(s.execute("mediaBrowser.go", json!({"path": "/Nope"})).is_err());
|
||
// Importing a file that is not read yet: pending (the host imports it once it arrives).
|
||
let r = s.execute("mediaBrowser.import", json!({"paths": ["/Folders/Shoot/a.csv"]})).unwrap();
|
||
assert_eq!(r["pending"], true);
|
||
assert_eq!(*b.fetched.lock().unwrap(), ["/Folders/Shoot/a.csv"]);
|
||
assert!(s.project.items.values().all(|i| !matches!(i.kind, ItemKind::Footage(_))));
|
||
let r = s.execute("mediaBrowser.action", json!({"action": "openFolder"})).unwrap();
|
||
assert_eq!(r["pending"], true);
|
||
assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]);
|
||
assert!(s.execute("mediaBrowser.action", json!({})).is_err());
|
||
}
|
||
|
||
/// Footage the source can't decode is reported with its reason by `project.summary` and
|
||
/// `footage.info`, so an agent can tell the user why the picture is black.
|
||
#[test]
|
||
fn footage_that_cannot_be_decoded_reports_why() {
|
||
struct Broken;
|
||
impl FootageSource for Broken {
|
||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||
None
|
||
}
|
||
fn problem(&self, f: &Footage) -> Option<String> {
|
||
Some(format!("{} isn't supported by the built-in decoder", f.codec))
|
||
}
|
||
}
|
||
let mut s = Session { footage: Arc::new(Broken), ..Default::default() };
|
||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap();
|
||
let f = Footage {
|
||
kind: FootageKind::Video,
|
||
path: "a.mp4".into(),
|
||
codec: "H.264 (High 4:2:2)".into(),
|
||
width: 64,
|
||
height: 36,
|
||
has_video: true,
|
||
..Default::default()
|
||
};
|
||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(f));
|
||
let sum = s.execute("project.summary", json!({})).unwrap();
|
||
let it = sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(item.0)).unwrap();
|
||
assert_eq!(it["problem"], json!("H.264 (High 4:2:2) isn't supported by the built-in decoder"));
|
||
assert!(sum["items"].as_array().unwrap().iter().filter(|i| i["id"] != json!(item.0)).all(|i| i.get("problem").is_none()), "only footage reports one");
|
||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
|
||
}
|
||
|
||
/// A proxy in use that can't be decoded leaves its item without a picture although the item's
|
||
/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the
|
||
/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off.
|
||
#[test]
|
||
fn a_proxy_in_use_that_cannot_be_decoded_reports_why() {
|
||
struct BrokenProxies;
|
||
impl FootageSource for BrokenProxies {
|
||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||
None
|
||
}
|
||
fn problem(&self, f: &Footage) -> Option<String> {
|
||
f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec))
|
||
}
|
||
}
|
||
let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() };
|
||
let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||
let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() };
|
||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still));
|
||
let proxy = Footage {
|
||
kind: FootageKind::Video,
|
||
path: "plate.avi".into(),
|
||
codec: "MPEG-4 Part 2".into(),
|
||
width: 32,
|
||
height: 18,
|
||
has_video: true,
|
||
..Default::default()
|
||
};
|
||
let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder");
|
||
let problem = |s: &mut Session, id: u64| {
|
||
let sum = s.execute("project.summary", json!({})).unwrap();
|
||
sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned()
|
||
};
|
||
for id in [item.0, comp] {
|
||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||
p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true }));
|
||
assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}");
|
||
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false;
|
||
assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine");
|
||
}
|
||
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true;
|
||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want);
|
||
}
|