diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 423aaea2..98fe0cb4 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -48,6 +48,7 @@ mod prop_groups; mod proxy; pub mod puppet; mod query; +mod relink; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; @@ -169,6 +170,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(key_labels::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); + v.extend(relink::specs()); v.extend(lottie::specs()); v.extend(timeline::specs()); v.extend(comp_more::specs()); diff --git a/crates/engine/src/commands/relink.rs b/crates/engine/src/commands/relink.rs new file mode 100644 index 00000000..53e2c019 --- /dev/null +++ b/crates/engine/src/commands/relink.rs @@ -0,0 +1,414 @@ +//! Recover moved media without replacing interpretation, sequence ranges or layer timing. + +use std::collections::BTreeMap; +use std::path::Path; + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, str_p}; +use crate::{Result, Session, cmd}; + +const COMMAND: &str = "file.relinkFootage"; +const ENTRY_LIMIT: usize = 100_000; + +fn enabled(_: &Session) -> std::result::Result<(), String> { + if cfg!(target_arch = "wasm32") { Err("folder relinking requires the desktop filesystem".into()) } else { Ok(()) } +} + +fn files(folder: &Path, ctl: &crate::jobs::TaskCtl) -> std::result::Result>, String> { + let mut pending = vec![folder.to_path_buf()]; + let mut found: BTreeMap> = BTreeMap::new(); + let mut count = 0usize; + while let Some(dir) = pending.pop() { + ctl.message(dir.display().to_string()); + let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in entries { + if ctl.cancelled() { + return Err(crate::render_queue::CANCELLED.into()); + } + count = count.saturating_add(1); + if count > ENTRY_LIMIT { + return Err(format!("search exceeds {ENTRY_LIMIT} entries; choose a smaller folder")); + } + let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; + let ty = entry.file_type().map_err(|e| format!("{}: {e}", entry.path().display()))?; + // Do not follow symlinks: no cycles or traversal outside the chosen tree. + if ty.is_dir() { + pending.push(entry.path()); + } else if ty.is_file() + && let (Some(name), Some(path)) = (entry.file_name().to_str(), entry.path().to_str()) + { + found.entry(name.to_string()).or_default().push(path.to_string()); + } + } + } + for paths in found.values_mut() { + paths.sort(); + } + Ok(found) +} + +fn compatible(old: &Footage, new: &Footage) -> bool { + let kind = if old.kind == FootageKind::Sequence { FootageKind::Still } else { old.kind }; + kind == new.kind + && old.width == new.width + && old.height == new.height + && old.has_video == new.has_video + && old.has_audio == new.has_audio + && (old.kind == FootageKind::Audio || (old.width > 0 && old.height > 0)) + && (old.codec.is_empty() || old.codec == new.codec) + && (!matches!(old.kind, FootageKind::Video | FootageKind::Audio) + || (old.native_rate.unwrap_or(old.frame_rate) == new.frame_rate && old.frame_rate.frame_at(old.duration) == new.frame_rate.frame_at(new.duration))) +} + +fn sequence_at(old: &Footage, path: &str) -> Option> { + let parent = Path::new(path).parent()?; + old.sequence + .iter() + .map(|file| { + let mapped = parent.join(Path::new(file).file_name()?); + std::fs::symlink_metadata(&mapped).is_ok_and(|m| m.is_file()).then(|| mapped.to_str().map(str::to_string)).flatten() + }) + .collect() +} + +fn run(s: &mut Session, p: &Value) -> Result { + let folder = str_p(p, "folder").filter(|f| !f.trim().is_empty()).ok_or_else(|| bad(COMMAND, "missing `folder`"))?; + let folder = std::fs::canonicalize(folder).map_err(|e| bad(COMMAND, format!("{folder}: {e}")))?; + if !folder.is_dir() { + return Err(bad(COMMAND, "folder must be a directory")); + } + let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?; + let services = s.services.clone(); + let missing: Vec<(ItemId, Footage)> = s + .project + .items + .values() + .filter_map(|item| match &item.kind { + ItemKind::Footage(f) if !f.path.is_empty() && !services.exists(&f.path) => Some((item.id, f.clone())), + _ => None, + }) + .collect(); + let dry_run = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + s.spawn_task("relink", "Relinking missing footage", wait, move |ctl| { + let index = files(&folder, ctl)?; + let mut rows = vec![]; + let mut updates = vec![]; + for (n, (id, old)) in missing.iter().enumerate() { + if !ctl.progress(n as u64, missing.len() as u64) { + return Err(crate::render_queue::CANCELLED.into()); + } + let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio) + && old.layer.is_none() && old.page == 0; + let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or(""); + let paths = index.get(name).cloned().unwrap_or_default(); + let mut candidates = vec![]; + let mut accepted = vec![]; + for path in paths { + if ctl.cancelled() { + return Err(crate::render_queue::CANCELLED.into()); + } + let mut reason = "source type requires manual replacement".to_string(); + let mut confidence = "low"; + if supported { + match importer.probe(&path) { + Ok(f) if compatible(old, &f) => { + if old.kind != FootageKind::Sequence || (!old.sequence.is_empty() && sequence_at(old, &path).is_some()) { + confidence = "high"; + reason = "exact filename and compatible source metadata".into(); + accepted.push(path.clone()); + } else { + reason = "sequence is incomplete".into(); + } + } + Ok(_) => reason = "source metadata differs".into(), + Err(e) => reason = format!("cannot decode candidate: {e}"), + } + } + if ctl.cancelled() { + return Err(crate::render_queue::CANCELLED.into()); + } + candidates.push(json!({"path": path, "confidence": confidence, "reason": reason})); + } + let status = if !supported { "unsupported" } else if candidates.is_empty() { "notFound" } else if candidates.len() > 1 { "ambiguous" } else if accepted.len() == 1 { "matched" } else { "incompatible" }; + if status == "matched" && let Some(path) = accepted.first() { + let mut new = old.clone(); + new.path = path.clone(); + if old.kind == FootageKind::Sequence { + new.sequence = sequence_at(old, path).ok_or_else(|| "sequence changed during search; run the search again".to_string())?; + } + new.missing = false; + updates.push((*id, old.clone(), new)); + } + rows.push(json!({"item": id.0, "original": old.path, "status": status, "candidates": candidates})); + } + ctl.progress(missing.len() as u64, missing.len() as u64); + let apply: crate::jobs::Apply = Box::new(move |s| { + // A background search must not overwrite edits made while it was running. + for (id, old, new) in &updates { + if s.project.item(*id).is_none_or(|i| !matches!(&i.kind, ItemKind::Footage(f) if f == old)) + || services.exists(&old.path) || !services.exists(&new.path) + || (new.kind == FootageKind::Sequence && new.sequence.iter().any(|p| !services.exists(p))) + { + return Err(bad(COMMAND, "footage changed during search; run the search again")); + } + } + let matched = updates.len(); + if !dry_run && !updates.is_empty() { + s.edit("Relink Missing Footage", None, |project, _| { + for (id, _, new) in updates { + if let Some(item) = project.item_mut(id) { + item.kind = ItemKind::Footage(new); + } + } + Ok(()) + })?; + } + let unresolved = rows.len().saturating_sub(matched); + let toast = format!("{} {matched} high-confidence match(es); {unresolved} unresolved. Duplicate filenames are left for Replace Footage.", if dry_run { "Found" } else { "Relinked" }); + Ok(json!({"dryRun": dry_run, "matched": matched, "relinked": if dry_run { 0 } else { matched }, "unresolved": unresolved, "items": rows, "toast": toast})) + }); + Ok(apply) + }) +} + +pub fn specs() -> Vec { + vec![cmd!( + "file.relinkFootage", + "Relink Missing Footage...", + ["File", "Dependencies"], + None, + "{folder: string (recursive desktop search, no symlinks; max 100000 entries), dryRun?: bool (report only), wait?: bool (return results inline; otherwise jobs.list/jobs.wait). Relinks only unique exact filenames with compatible metadata; preserves interpretation and sequence ranges. Reports high/low confidence and ambiguous, incompatible, unsupported or notFound items.", + enabled, + run + )] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> std::result::Result { + if std::fs::read(path).map_err(|e| e.to_string())? != b"valid" { + return Err("invalid media".into()); + } + Ok(Footage { path: path.into(), width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) + } + } + + struct Fixture(std::path::PathBuf); + impl Fixture { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!("ec-relink-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + std::fs::create_dir(&dir).unwrap(); + Self(std::fs::canonicalize(dir).unwrap()) + } + fn file(&self, name: &str, bytes: &[u8]) -> String { + let path = self.0.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + path.to_str().unwrap().into() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + fn setup(dir: &Fixture, name: &str) -> (Session, ItemId, Footage) { + let mut s = Session { importer: Some(Arc::new(Probe)), ..Default::default() }; + let path = dir.file(&format!("old/{name}"), b"valid"); + let id = s.execute("file.import", json!({"paths": [path], "sequence": false})).unwrap()["items"][0].as_u64().unwrap(); + let id = ItemId(id); + let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind else { panic!("footage") }; + f.invert_alpha = true; + f.loop_count = 3; + f.start_timecode = Some(1001); + let old = f.clone(); + std::fs::remove_file(&old.path).unwrap(); + (s, id, old) + } + + fn search(s: &mut Session, dir: &Fixture, dry: bool) -> Value { + s.execute(COMMAND, json!({"folder": dir.0.join("new"), "wait": true, "dryRun": dry})).unwrap() + } + + fn get(s: &Session, id: ItemId) -> Footage { + let ItemKind::Footage(f) = &s.project.item(id).unwrap().kind else { panic!("footage") }; + f.clone() + } + + #[test] + fn recursive_relink_preserves_interpretation_and_supports_undo_redo() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + let new = dir.file("new/deep/shots/plate.png", b"valid"); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": id.0})).unwrap(); + let key = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap(); + let ctx = crate::render::eval::EvalCtx::new(&s.project, cid, c, crate::time::Tick::ZERO); + crate::render::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 0).unwrap() + }; + let original_key = key(&s); + let revision = s.revision; + let before = s.history.undo.len(); + let report = search(&mut s, &dir, true); + assert_eq!(report["items"][0]["candidates"][0]["confidence"], "high"); + assert_eq!(report["relinked"], 0); + assert_eq!(get(&s, id), old); + assert_eq!(s.history.undo.len(), before); + assert_eq!(search(&mut s, &dir, false)["relinked"], 1); + let expected = Footage { path: new, missing: false, ..old.clone() }; + assert_eq!(get(&s, id), expected); + assert!(s.revision > revision); + assert_ne!(key(&s), original_key, "restored footage cannot reuse the missing source's rendered layer"); + assert_eq!(s.history.undo.len(), before + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(get(&s, id), old); + assert_eq!(key(&s), original_key); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(get(&s, id), expected); + } + + #[test] + fn duplicates_and_invalid_media_are_reported_without_edits() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + dir.file("new/a/plate.png", b"valid"); + let b = dir.file("new/b/plate.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "ambiguous"); + assert_eq!(get(&s, id), old); + std::fs::remove_file(b).unwrap(); + dir.file("new/a/plate.png", b"corrupt"); + let r = search(&mut s, &dir, false); + assert_eq!(r["items"][0]["status"], "incompatible"); + assert_eq!(r["items"][0]["candidates"][0]["confidence"], "low"); + assert_eq!(get(&s, id), old); + std::fs::remove_file(dir.0.join("new/a/plate.png")).unwrap(); + assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "notFound"); + } + + #[test] + fn sequence_relink_preserves_picked_range_and_requires_every_saved_frame() { + let dir = Fixture::new(); + let (mut s, id, mut old) = setup(&dir, "shot_0002.png"); + old.kind = FootageKind::Sequence; + old.sequence = vec![old.path.clone(), dir.0.join("old/shot_0004.png").to_str().unwrap().into()]; + Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind = ItemKind::Footage(old.clone()); + dir.file("new/shot_0002.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["relinked"], 0); + dir.file("new/shot_0004.png", b"valid"); + dir.file("new/shot_0001.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["relinked"], 1); + let f = get(&s, id); + assert_eq!(f.sequence.len(), 2); + assert!(f.sequence[0].ends_with("shot_0002.png")); + assert!(f.sequence[1].ends_with("shot_0004.png")); + assert_eq!(f.start_timecode, old.start_timecode); + } + + #[test] + fn invalid_folder_errors_and_existing_media_is_untouched() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + assert!(s.execute(COMMAND, json!({"folder": dir.0.join("absent"), "wait": true})).is_err()); + assert!(s.execute(COMMAND, json!({"wait": true})).is_err()); + dir.file("old/plate.png", b"valid"); + dir.file("new/plate.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["relinked"], 0); + assert_eq!(get(&s, id), old); + } + + struct BlockingProbe { + started: std::sync::mpsc::Sender<()>, + release: std::sync::Mutex>, + calls: Arc, + } + impl crate::Importer for BlockingProbe { + fn probe(&self, path: &str) -> std::result::Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.started.send(()).map_err(|e| e.to_string())?; + self.release.lock().map_err(|e| e.to_string())?.recv_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?; + Probe.probe(path) + } + } + + #[test] + fn cancellation_between_candidate_probes_leaves_project_untouched() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + dir.file("new/a/plate.png", b"valid"); + dir.file("new/b/plate.png", b"valid"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + s.importer = Some(Arc::new(BlockingProbe { started: started_tx, release: std::sync::Mutex::new(release_rx), calls: calls.clone() })); + let r = s.execute(COMMAND, json!({"folder": dir.0.join("new")})).unwrap(); + started_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!(s.cancel_job(r["job"].as_str().unwrap())); + release_tx.send(()).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1); + assert_eq!(get(&s, id), old); + assert_eq!(s.job_log.last().unwrap().status, "cancelled"); + } + + #[test] + fn background_result_does_not_overwrite_interpretation_edited_during_search() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + dir.file("new/plate.png", b"valid"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + s.importer = Some(Arc::new(BlockingProbe { started: started_tx, release: std::sync::Mutex::new(release_rx), calls: Arc::default() })); + s.execute(COMMAND, json!({"folder": dir.0.join("new")})).unwrap(); + started_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + s.execute("file.interpretFootage", json!({"items": [id.0], "invertAlpha": false})).unwrap(); + release_tx.send(()).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(get(&s, id).path, old.path); + assert!(!get(&s, id).invert_alpha); + assert_eq!(s.job_log.last().unwrap().status, "failed"); + } + + #[test] + fn movie_confidence_checks_native_frame_count_and_rate() { + let new = Footage { + kind: FootageKind::Video, + width: 64, + height: 32, + has_video: true, + frame_rate: crate::time::FrameRate::FPS_30, + duration: crate::time::Tick::from_seconds_f64(1.0), + ..Default::default() + }; + let mut old = new.clone(); + old.native_rate = Some(old.frame_rate); + old.frame_rate = crate::time::FrameRate::FPS_24; + old.duration = old.frame_rate.tick_of(30); + assert!(compatible(&old, &new), "conformed playback preserves native frame count"); + assert!(!compatible(&old, &Footage { duration: crate::time::Tick::from_seconds_f64(2.0), ..new.clone() })); + assert!(!compatible(&old, &Footage { width: 128, ..new })); + } + + #[cfg(unix)] + #[test] + fn symlink_cycles_and_external_files_are_skipped() { + let dir = Fixture::new(); + let (mut s, _, _) = setup(&dir, "plate.png"); + let outside = dir.file("outside/plate.png", b"valid"); + std::fs::create_dir(dir.0.join("new")).unwrap(); + std::os::unix::fs::symlink(dir.0.join("new"), dir.0.join("new/cycle")).unwrap(); + std::os::unix::fs::symlink(outside, dir.0.join("new/plate.png")).unwrap(); + assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "notFound"); + } +} diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index c5830828..e98e29f6 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -553,6 +553,7 @@ File Find Missing Effects | file.findMissing {"what":"effects"} Find Missing Fonts | file.findMissing {"what":"fonts"} Find Missing Footage | file.findMissing {"what":"footage"} + Relink Missing Footage... | file.relinkFootage Watch Folder... | file.watchFolder --- Scripts diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 435782f6..e69bcb91 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1125,6 +1125,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu /// Commands that need a file or folder path: ask the host's file dialog when `params` lacks one. fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option> { enum Ask { + Folder, Import, OpenProject, Save(&'static str), @@ -1151,6 +1152,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["jsx", "js"])), "effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])), "file.replaceFootage" => ("path", Ask::Import), + "file.relinkFootage" => ("folder", Ask::Folder), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), "file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")), "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), @@ -1169,6 +1171,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option = match ask { + Ask::Folder => { + let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) }; + f().map(Value::from) + } Ask::Import => { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; let paths = f(&[ diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index 3d252d90..142a4fa3 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -120,3 +120,87 @@ fn enter_chooses_the_highlighted_entry() { } assert!(!shown(&h, "Quick Apply"), "and closed the menu"); } + +#[test] +fn relink_menu_uses_engine_availability_and_folder_picker_cancel_is_a_noop() { + use std::cell::Cell; + use std::rc::Rc; + + let mut app = EffectcraftApp::new(Session::default()); + let menu = effectcraft_ui_egui::menus::menu_items(&app); + let entry = menu.iter().find(|e| e.id == "file.relinkFootage").unwrap(); + assert_eq!(entry.path, ["File", "Dependencies"]); + assert_eq!(entry.enabled, app.session.is_enabled("file.relinkFootage")); + assert_eq!(entry.enabled, !cfg!(target_arch = "wasm32")); + let picked = Rc::new(Cell::new(false)); + let flag = picked.clone(); + app.hooks.pick_folder = Some(Box::new(move || { + flag.set(true); + None + })); + let revision = app.session.revision; + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.relinkFootage", json!({})).unwrap(); + assert!(picked.get()); + assert!(result.is_null()); + assert_eq!(app.session.revision, revision); + assert!(app.session.history.undo.is_empty()); +} + +#[test] +fn relink_is_reachable_through_the_dependencies_menu() { + use std::cell::Cell; + use std::rc::Rc; + + let mut h = harness(); + let picked = Rc::new(Cell::new(false)); + let flag = picked.clone(); + h.state_mut().hooks.pick_folder = Some(Box::new(move || { + flag.set(true); + None + })); + let revision = h.state().session.revision; + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + let dependencies = h.query_by_label_contains("Dependencies").unwrap().rect(); + h.input_mut().events.push(Event::PointerMoved(dependencies.center())); + h.run_steps(8); + let relink = h.query_by_label_contains("Relink Missing Footage").unwrap().rect(); + if let Some(path) = std::env::var_os("EFFECTCRAFT_RELINK_MENU_SNAPSHOT") { + h.render().unwrap().save(path).unwrap(); + } + click_at(&mut h, relink.center()); + assert!(picked.get()); + assert_eq!(h.state().session.revision, revision); +} + +#[test] +fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { + use effectcraft_engine::project::{ItemId, ItemKind}; + use effectcraft_engine::render::RenderOpts; + use effectcraft_engine::time::Tick; + + let dir = std::env::temp_dir().join(format!("ec-ui-relink-{}", std::process::id())); + std::fs::create_dir_all(dir.join("old")).unwrap(); + std::fs::create_dir_all(dir.join("new/deep")).unwrap(); + let old = dir.join("old/plate.png"); + image::RgbaImage::from_pixel(32, 32, image::Rgba([230, 40, 80, 255])).save(&old).unwrap(); + let mut s = effectcraft_host::session(); + let item = s.execute("file.import", json!({"paths": [old], "sequence": false})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Relink", "width": 32, "height": 32, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let expected = s.render(cid, Tick::ZERO, RenderOpts::default()); + let moved = dir.join("new/deep/plate.png"); + std::fs::rename(&old, &moved).unwrap(); + s.execute("footage.check", json!({"wait": true})).unwrap(); + let ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") }; + assert!(f.missing); + let missing_content = effectcraft_ui_egui::frames::comp_content(&s.project, cid); + let missing = s.render(cid, Tick::ZERO, RenderOpts::default()); + assert_ne!(missing.data, expected.data); + let r = s.execute("file.relinkFootage", json!({"folder": dir.join("new"), "wait": true})).unwrap(); + assert_eq!(r["relinked"], 1); + assert_ne!(effectcraft_ui_egui::frames::comp_content(&s.project, cid), missing_content); + assert_eq!(s.render(cid, Tick::ZERO, RenderOpts::default()).data, expected.data); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/footage.md b/docs/footage.md index 9a933f03..784a75ad 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -93,3 +93,22 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la "sequence": false}` for one frame). - **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder (a render still in progress) and keeps its interpretation. + +**File ▸ Dependencies ▸ Relink Missing Footage…** searches a chosen folder and its subfolders +for missing source files on the desktop. It relinks unique exact filenames whose decoded type, +dimensions, codec and audio/video flags agree with the saved footage (movies and audio also +require matching native frame rate and frame count). The Progress panel reports completion; +the result includes each candidate's high or low confidence and reason. Confidence is a metadata +heuristic, not proof of identical contents. Duplicate filenames remain unresolved even if only +one decodes successfully; use Replace Footage to choose them manually. + +Relinking preserves item names, interpretation, layer timing and the saved sequence range, and +the entire operation is one undo step. Sequences require every saved frame in the new folder; +extra frames do not extend the range. Existing files are untouched. Layer-specific/page-specific +footage, models and embedded data are left unresolved. Symlinks are skipped, unreadable folders +fail the search without edits, and searches are limited to 100,000 entries. Browser folder +relinking is unavailable. + +Agents: `file.relinkFootage {"folder": "/moved/shots", "dryRun": true, "wait": true}` returns +a report without edits. Omit `dryRun` to apply unique high-confidence matches. Without `wait`, +the command returns a cancellable job; `jobs.wait` / `jobs.list` expose its report.