diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 9aecd8d6..ca170cc1 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -848,14 +848,32 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let name = src.name(); + // Replacing a missing file relinks the other missing files that moved with it (as After Effects + // does when you locate one). + let moved = match s.project.item(item).map(|it| &it.kind) { + Some(ItemKind::Footage(previous)) if previous.missing || !s.services.exists(&previous.path) => { + super::relink_moved::moved_with(s, item, &previous.path, &path) + } + _ => vec![], + }; + let relinked: Vec = moved.iter().map(|(id, _)| id.0).collect(); s.edit("Replace Footage", None, |proj, _| { let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?; it.name = name; it.kind = ItemKind::Footage(f); retarget_layers(proj, item, |_| LayerSource::Footage { item }); + for (id, nf) in moved { + if let Some(it) = proj.item_mut(id) { + it.kind = ItemKind::Footage(nf); + } + } Ok(()) })?; - Ok(Value::Null) + if relinked.is_empty() { + return Ok(Value::Null); + } + s.toast(format!("Also relinked {} other missing footage item(s)", relinked.len())); + Ok(json!({"relinked": relinked})) } /// Point every layer using `item` at a new source kind. diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 98fe0cb4..0a27d473 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -49,6 +49,7 @@ mod proxy; pub mod puppet; mod query; mod relink; +pub(crate) mod relink_moved; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; @@ -171,6 +172,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(relink::specs()); + v.extend(relink_moved::specs()); v.extend(lottie::specs()); v.extend(timeline::specs()); v.extend(comp_more::specs()); diff --git a/crates/engine/src/commands/relink_moved.rs b/crates/engine/src/commands/relink_moved.rs new file mode 100644 index 00000000..4f70dd6e --- /dev/null +++ b/crates/engine/src/commands/relink_moved.rs @@ -0,0 +1,319 @@ +//! Relinking footage that moved together (gaps.md G4; After Effects' Replace Footage on a missing +//! file, which then finds the other missing files in the same place). Searching a folder for +//! compatible files is `file.relinkFootage` ([`super::relink`]). +//! +//! - `footage.relink {item, path}`: point a footage item at `path`, keeping its interpretation, and +//! relink every other missing item that sits in the same place relative to it (the folder moved, +//! or a drive was renamed). One undo step. Replace Footage on a missing item does the same. +//! - `footage.missing`: the missing items. +//! - When a project opens from a folder it wasn't saved in, the footage check ([`moved_candidates`]) +//! tries each missing file at the same place under the project's new folder. +//! +//! Paths are compared by their parts, split on `/` and `\`, so a project made on Windows relinks on +//! macOS and the other way round. + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Project}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, str_p}; +use crate::{Result, Services, Session, cmd}; + +/// Trailing parts of a path tried under a moved project's folder. +const MOVED_PARTS: usize = 4; + +fn parts(path: &str) -> Vec<&str> { + path.split(['/', '\\']).filter(|p| !p.is_empty()).collect() +} + +/// The separator `path` uses (Windows paths keep theirs). +fn sep_of(path: &str) -> char { + if path.contains('\\') && !path.contains('/') { '\\' } else { '/' } +} + +fn file_name(path: &str) -> &str { + parts(path).last().copied().unwrap_or("") +} + +/// `prefix` and `rest` joined with `prefix`'s separator. +fn join(prefix: &str, rest: &[&str]) -> String { + let sep = sep_of(prefix); + let mut out = prefix.trim_end_matches(['/', '\\']).to_string(); + for p in rest { + out.push(sep); + out.push_str(p); + } + out +} + +/// What moved: `old` became `new`. The parts they end with in common are the same file's place +/// inside the moved folder; what comes before is the folder's old and new location. +/// `("/a/b/shots/x.mov", "/c/shots/x.mov")` → `(["a", "b"], "/c")`. +fn moved_root<'a>(old: &'a str, new: &str) -> Option<(Vec<&'a str>, String)> { + let (o, n) = (parts(old), parts(new)); + let common = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count(); + // At least the file name must match, else it's a different file (then only its folder moved). + let common = common.max(1).min(o.len()).min(n.len()); + let old_root: Vec<&str> = o.get(..o.len() - common)?.to_vec(); + // The new root keeps a leading `/` or a drive letter as written. + let new_parts = n.len() - common; + let mut new_root = String::new(); + if new.starts_with('/') { + new_root.push('/'); + } else if new.starts_with("\\\\") { + new_root.push_str("\\\\"); + } + let sep = sep_of(new); + for (i, p) in n.iter().take(new_parts).enumerate() { + if i > 0 { + new_root.push(sep); + } + new_root.push_str(p); + } + Some((old_root, new_root)) +} + +/// `path` under the moved root, when it was under the old one. +fn remap(path: &str, old_root: &[&str], new_root: &str) -> Option { + let p = parts(path); + if p.len() <= old_root.len() || p.get(..old_root.len())? != old_root { + return None; + } + Some(join(new_root, p.get(old_root.len()..)?)) +} + +/// The item's file and, for an image sequence, every frame, moved by `f`; `None` unless its main +/// file maps. +fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option) -> Option { + let mut nf = footage.clone(); + nf.path = f(&footage.path)?; + nf.sequence = footage.sequence.iter().map(|p| f(p).unwrap_or_else(|| p.clone())).collect(); + nf.missing = false; + Some(nf) +} + +fn present(s: &dyn Services, f: &Footage) -> bool { + !f.path.is_empty() && s.exists(&f.path) +} + +/// Footage items whose file is gone (flagged by the footage check, or not there now). +fn missing_items(s: &Session) -> Vec<(ItemId, Footage)> { + s.project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), f)) => { + Some((i.id, f.clone())) + } + _ => None, + }) + .collect() +} + +/// Where a file a moved project lost may be now: the same place under the project's new folder, +/// trying the longest tail of its old path first (`/old/proj/shots/a.mov` with the project now in +/// `/new/proj` → `/new/proj/old/proj/shots/a.mov`, …, `/new/proj/shots/a.mov`, `/new/proj/a.mov`). +pub(crate) fn moved_candidates(project_dir: &str, path: &str) -> Vec { + let p = parts(path); + (1..=MOVED_PARTS.min(p.len())).rev().filter_map(|k| p.get(p.len() - k..)).map(|tail| join(project_dir, tail)).collect() +} + +/// A missing item found again under a moved project's folder ([`moved_candidates`]), with its +/// sequence frames moved alongside. +pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage) -> Option { + moved_candidates(project_dir, &f.path).into_iter().filter(|c| services.exists(c)).find_map(|hit| { + let (old_root, new_root) = moved_root(&f.path, &hit)?; + moved_footage(f, |q| if q == f.path { Some(hit.clone()) } else { remap(q, &old_root, &new_root) }) + }) +} + +/// The folder a project file is in. +pub(crate) fn project_dir(project_path: &str) -> Option { + let p = parts(project_path); + if p.len() < 2 { + return None; + } + let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?); + if !project_path.starts_with('/') { + dir = dir.trim_start_matches(['/', '\\']).to_string(); + } + Some(dir) +} + +/// Relink `found` in one undo step and say how many. +fn apply(s: &mut Session, label: &str, found: Vec<(ItemId, Footage)>) -> Result { + if found.is_empty() { + return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()})); + } + let ids: Vec = found.iter().map(|(id, _)| id.0).collect(); + s.edit(label, None, |proj: &mut Project, _| { + for (id, nf) in found { + if let Some(it) = proj.item_mut(id) { + it.kind = ItemKind::Footage(nf); + } + } + Ok(()) + })?; + s.events.push(crate::Event::PurgeCaches); + let still = missing_items(s).len(); + s.toast(match still { + 0 => format!("Relinked {} footage item(s)", ids.len()), + n => format!("Relinked {} footage item(s); {n} still missing", ids.len()), + }); + Ok(json!({"relinked": ids, "stillMissing": still})) +} + +/// The other missing items that moved the way `item` did (`old_path` → `new_path`): the same place +/// under the moved folder, else the same name in the new file's folder. Used by `footage.relink` +/// and by Replace Footage on a missing item. +pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &str) -> Vec<(ItemId, Footage)> { + let Some((old_root, new_root)) = moved_root(old_path, new_path) else { return vec![] }; + let services = s.services.clone(); + let new_dir = project_dir(new_path); + let mut found = vec![]; + for (id, f) in missing_items(s) { + if id == item { + continue; + } + let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), nf)); + let same_folder = || { + let dir = new_dir.as_deref()?; + let dir_parts = parts(dir); + let old_dir_len = parts(&f.path).len().checked_sub(1)?; + let old_dir: Vec<&str> = parts(&f.path).get(..old_dir_len)?.to_vec(); + moved_footage(&f, |q| remap(q, &old_dir, dir).or_else(|| Some(join(dir, &[file_name(q)])))).filter(|nf| { + // Only frames in that one folder (a name match, not a guess deeper down). + present(services.as_ref(), nf) && parts(&nf.path).len() == dir_parts.len() + 1 + }) + }; + if let Some(nf) = same_place.or_else(same_folder) { + found.push((id, nf)); + } + } + found +} + +/// `footage.relink {item, path}` +fn relink(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "footage.relink"; + let path = str_p(p, "path").filter(|p| !p.trim().is_empty()).ok_or_else(|| bad(CMD, "missing `path` (the file's new location)"))?.to_string(); + let item = p.get("item").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(CMD, "missing `item` (a footage item id)"))?; + let Some(ItemKind::Footage(old)) = s.project.item(item).map(|i| &i.kind) else { return Err(bad(CMD, "`item` is not a footage item")) }; + let old = old.clone(); + if !s.services.exists(&path) { + return Err(bad(CMD, format!("there is no file at {path}"))); + } + let (old_root, new_root) = moved_root(&old.path, &path).ok_or_else(|| bad(CMD, "cannot compare the old and new paths"))?; + // The item itself: its file is `path`; its sequence frames move with it. + let main = + moved_footage(&old, |q| if q == old.path { Some(path.clone()) } else { remap(q, &old_root, &new_root) }).ok_or_else(|| bad(CMD, "cannot relink"))?; + let mut found = vec![(item, main)]; + found.extend(moved_with(s, item, &old.path, &path)); + apply(s, "Relink Footage", found) +} + +/// `footage.missing`: the missing footage items and where they were. +fn missing(s: &mut Session, _: &Value) -> Result { + let list: Vec = missing_items(s) + .into_iter() + .map(|(id, f)| json!({"item": id.0, "name": s.project.item(id).map(|i| i.name.clone()).unwrap_or_default(), "path": f.path})) + .collect(); + Ok(json!({"missing": list})) +} + +fn has_footage(s: &Session) -> std::result::Result<(), String> { + if s.project.items.values().any(|i| matches!(i.kind, ItemKind::Footage(_))) { Ok(()) } else { Err("the project has no footage".into()) } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "footage.relink", + "Relink Footage", + [], + None, + "{item, path} — point a footage item at its file's new location (keeps the interpretation) and relink the other missing files found in the same place", + has_footage, + relink + ), + crate::query!("footage.missing", "Missing Footage", "{} → {missing: [{item, name, path}]}", missing), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn moved_roots_and_remaps() { + let (old, new) = moved_root("/a/b/shots/x.mov", "/c/shots/x.mov").unwrap(); + assert_eq!((old, new.as_str()), (vec!["a", "b"], "/c")); + assert_eq!(remap("/a/b/shots/y.mov", &["a", "b"], "/c").as_deref(), Some("/c/shots/y.mov")); + assert_eq!(remap("/a/z/y.mov", &["a", "b"], "/c"), None); + // Windows to macOS. + let (old, new) = moved_root("D:\\Work\\Job\\audio\\m.wav", "/Volumes/Work/Job/audio/m.wav").unwrap(); + assert_eq!((old, new.as_str()), (vec!["D:"], "/Volumes")); + assert_eq!(remap("D:\\Work\\Job\\img\\a.png", &["D:"], "/Volumes").as_deref(), Some("/Volumes/Work/Job/img/a.png")); + // A different file name: only the folder moved. + let (old, new) = moved_root("/a/x.mov", "/b/y.mov").unwrap(); + assert_eq!((old, new.as_str()), (vec!["a"], "/b")); + assert_eq!( + moved_candidates("/new/p", "/old/p/shots/a.mov"), + ["/new/p/old/p/shots/a.mov", "/new/p/p/shots/a.mov", "/new/p/shots/a.mov", "/new/p/a.mov"] + ); + assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y")); + assert_eq!(project_dir("p.ecproj"), None); + // Hostile input doesn't panic. + for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] { + let _ = moved_root(a, b); + let _ = moved_candidates(b, a); + let _ = project_dir(a); + } + } + + fn temp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ec-relink-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn add(s: &mut Session, path: &str) -> ItemId { + let f = Footage { path: path.into(), width: 8, height: 8, has_video: true, missing: true, ..Default::default() }; + std::sync::Arc::make_mut(&mut s.project).add_item(file_name(path), crate::color::Label::None, None, ItemKind::Footage(f)) + } + + fn path_of(s: &Session, id: ItemId) -> String { + match s.project.item(id).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => f.path.clone(), + _ => String::new(), + } + } + + #[test] + fn relinking_one_file_finds_the_others_that_moved_with_it() { + let d = temp("one"); + let new = d.join("moved"); + std::fs::create_dir_all(new.join("shots")).unwrap(); + std::fs::create_dir_all(new.join("audio")).unwrap(); + for f in ["shots/a.mov", "shots/b.mov", "audio/m.wav"] { + std::fs::write(new.join(f), b"x").unwrap(); + } + let mut s = Session::default(); + let a = add(&mut s, "/gone/proj/shots/a.mov"); + let b = add(&mut s, "/gone/proj/shots/b.mov"); + let m = add(&mut s, "/gone/proj/audio/m.wav"); + let other = add(&mut s, "/elsewhere/c.mov"); + let target = new.join("shots/a.mov").to_string_lossy().to_string(); + let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap(); + assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}"); + assert_eq!(r["stillMissing"], 1); + assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy()); + assert_eq!(path_of(&s, other), "/elsewhere/c.mov"); + // One undo step brings every path back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(path_of(&s, b), "/gone/proj/shots/b.mov"); + // Errors: no such file, not footage. + assert!(s.execute("footage.relink", json!({"item": a.0, "path": "/nope/a.mov"})).is_err()); + assert!(s.execute("footage.relink", json!({"item": 999_999, "path": target})).is_err()); + let _ = std::fs::remove_dir_all(d); + } +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index f5db284e..1ff33a84 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -27,6 +27,8 @@ struct Job { enum Found { Missing(bool), Probed(Box), + /// Found again under the project's new folder (the project was moved with its footage). + Moved(Box), } fn exists(s: &dyn crate::Services, path: &str) -> bool { @@ -52,6 +54,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: let services = s.services.clone(); let importer = s.importer.clone(); let rate = s.sequence_rate(); + let project_dir = s.path.as_deref().and_then(crate::commands::relink_moved::project_dir); let n = jobs.len(); s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| { let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len()); @@ -68,6 +71,14 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: // placeholders, as in After Effects). exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p)) }; + // Moved with the project: the same place under the project's folder. + if !present + && let Some(dir) = &project_dir + && let Some(nf) = crate::commands::relink_moved::find_moved(services.as_ref(), dir, f) + { + found.push((j.id, Found::Moved(Box::new(nf)))); + continue; + } // Saved without metadata (a hand-written or converted project): probe it now. let needs_probe = present && f.width == 0 && f.height == 0 && f.kind != FootageKind::Audio; let probed = importer.as_ref().filter(|_| needs_probe).map(|imp| match f.kind { @@ -105,7 +116,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: /// not an edit (the same as After Effects marking footage missing at open). fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { let checked = found.len(); - let (mut missing, mut probed, mut changed) = (0, 0, false); + let (mut missing, mut probed, mut moved, mut changed) = (0, 0, 0, false); let p = std::sync::Arc::make_mut(&mut s.project); for (id, f) in found { let Some(ItemKind::Footage(cur)) = p.item_mut(id).map(|i| &mut i.kind) else { continue }; @@ -122,6 +133,11 @@ fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { *cur = *nf; changed = true; } + Found::Moved(nf) => { + moved += 1; + *cur = *nf; + changed = true; + } } } if changed { @@ -133,10 +149,17 @@ fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { } s.events.push(crate::Event::PurgeCaches); } - if missing > 0 { - s.toast(format!("{missing} footage item(s) missing (File ▸ Dependencies ▸ Find Missing Footage)")); + if moved > 0 { + s.toast(format!("Found {moved} footage item(s) in the project's new folder")); } - json!({"checked": checked, "missing": missing, "probed": probed}) + if missing > 0 { + s.toast(format!("{missing} footage item(s) missing (File ▸ Dependencies ▸ Relink Missing Footage in Folder…)")); + } + let mut out = json!({"checked": checked, "missing": missing, "probed": probed}); + if moved > 0 { + out["relinked"] = json!(moved); + } + out } pub(crate) fn command(s: &mut Session, p: &Value) -> crate::Result { @@ -217,6 +240,34 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn footage_moved_with_its_project_is_found_on_open() { + let d = std::env::temp_dir().join(format!("ec-footage-moved-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("Job/shots")).unwrap(); + std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap(); + // Saved on another machine: the footage sat next to the project in /Users/x/Job. + let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]); + let path = d.join("Job/p.ecproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!((r["relinked"].as_u64(), r["missing"].as_u64()), (Some(1), Some(1)), "{r}"); + let paths: Vec = o + .project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) => Some(f.path.clone()), + _ => None, + }) + .collect(); + assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}"); + assert!(!o.is_dirty(), "finding the files is not an edit"); + let _ = std::fs::remove_dir_all(d); + } + #[test] fn open_starts_the_check_when_asked() { let mut s = session_with(&["/nonexistent/ec/a.mov"]); diff --git a/crates/host/tests/relink.rs b/crates/host/tests/relink.rs new file mode 100644 index 00000000..5bb31b4e --- /dev/null +++ b/crates/host/tests/relink.rs @@ -0,0 +1,56 @@ +//! Relinking footage that moved (docs/footage.md "Replacing and reloading"): Replace Footage on a +//! missing item relinks the other missing items that moved with it, as in After Effects. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::project::ItemKind; +use serde_json::json; + +fn icon() -> Vec { + std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png")).unwrap() +} + +fn footage_paths(s: &effectcraft_engine::Session) -> Vec<(u64, String, bool)> { + let mut v: Vec<(u64, String, bool)> = s + .project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) => Some((i.id.0, f.path.clone(), f.missing)), + _ => None, + }) + .collect(); + v.sort(); + v +} + +#[test] +fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() { + let root: PathBuf = std::env::temp_dir().join(format!("ec-host-relink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let old = root.join("Job"); + std::fs::create_dir_all(old.join("stills")).unwrap(); + std::fs::write(old.join("stills/a.png"), icon()).unwrap(); + std::fs::write(old.join("stills/b.png"), icon()).unwrap(); + let mut s = effectcraft_host::session(); + let paths: Vec = ["stills/a.png", "stills/b.png"].iter().map(|p| old.join(p).to_string_lossy().to_string()).collect(); + s.execute("file.import", json!({"paths": paths, "sequence": false})).unwrap(); + let before = footage_paths(&s); + assert_eq!(before.len(), 2, "{before:?}"); + // The job folder moves; both files go missing. + let new = root.join("Archive/Job"); + std::fs::create_dir_all(root.join("Archive")).unwrap(); + std::fs::rename(&old, &new).unwrap(); + let r = s.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 2, "{r}"); + // Locating one finds the other. + let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0; + let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills/a.png").to_string_lossy()})).unwrap(); + assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}"); + let after = footage_paths(&s); + assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}"); + // One undo step puts both back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(footage_paths(&s).iter().map(|(_, p, _)| p.clone()).collect::>(), before.iter().map(|(_, p, _)| p.clone()).collect::>()); + let _ = std::fs::remove_dir_all(root); +} diff --git a/docs/footage.md b/docs/footage.md index 458c4c99..20ca8926 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -98,6 +98,14 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). +- **Relinking moved files.** `footage.relink {"item": id, "path": …}` points a missing item at + its file's new location, keeping its interpretation, and relinks every other missing item that + was in the same place relative to it (the whole folder moved, a drive was renamed, a project + made on Windows opened on macOS); File ▸ Replace Footage on a missing item does the same. One + undo step; `footage.missing` lists what is still missing. +- **A project moved with its footage** finds it again on open: the footage check that runs after + File ▸ Open tries each missing file at the same place under the project's new folder, without + marking the project modified. - **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.