//! 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 Image + Send + Sync>, cache: Mutex>>, } impl FootageSource for Gen { fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { 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 = 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 = (0..96 * 64).map(|i| (36..56).contains(&(i % 96)) && (22..40).contains(&(i / 96))).collect(); let mut prev: Option = 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 = 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 = (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()); } #[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)>>); impl crate::offload::Offload for Manual { fn start(&self, req: crate::offload::WorkerRequest, inbox: Arc) -> 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![0.4, 0.72]); s.undo(); // Split Layers. let r = s.execute("layer.sceneEditDetection", json!({"mode": "split", "wait": true})).unwrap(); let ids: Vec = 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 { 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, fetched: Mutex>, actions: Mutex>, } 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, String> { self.tree.lock().unwrap().list(dir, only) } fn fetch(&self, paths: &[String], _: &Value) -> bool { // Opened folders' files arrive later. let pending: Vec = 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 { 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> { None } fn problem(&self, f: &Footage) -> Option { 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"]); }