mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-13 15:09:03 +00:00
Merge pull request #1106 from eliasx45/upstream/relink-moved-footage
# Conflicts: # docs/footage.md
This commit is contained in:
commit
7a9213611e
6 files changed
+459
-5
No files matched your search
@@ -853,14 +853,32 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<u64> = 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.
|
||||
|
||||
@@ -53,6 +53,7 @@ pub mod puppet;
|
||||
mod query;
|
||||
mod quick_offset;
|
||||
mod relink;
|
||||
pub(crate) mod relink_moved;
|
||||
mod render_queue;
|
||||
pub(crate) mod rig3d;
|
||||
pub mod roto_cmds;
|
||||
@@ -197,6 +198,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(quick_offset::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(relink::specs());
|
||||
v.extend(relink_moved::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(import_project::specs());
|
||||
v.extend(timeline::specs());
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>) -> Option<Footage> {
|
||||
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<String> {
|
||||
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<Footage> {
|
||||
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<String> {
|
||||
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<Value> {
|
||||
if found.is_empty() {
|
||||
return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()}));
|
||||
}
|
||||
let ids: Vec<u64> = 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<Value> {
|
||||
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<Value> {
|
||||
let list: Vec<Value> = 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<CommandSpec> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ struct Job {
|
||||
enum Found {
|
||||
Missing(bool),
|
||||
Probed(Box<Footage>),
|
||||
/// Found again under the project's new folder (the project was moved with its footage).
|
||||
Moved(Box<Footage>),
|
||||
}
|
||||
|
||||
fn exists(s: &dyn crate::Services, path: &str) -> bool {
|
||||
@@ -52,6 +54,7 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, 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<Vec<ItemId>>, 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<Vec<ItemId>>, 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<Value> {
|
||||
@@ -223,6 +246,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<String> = 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"]);
|
||||
|
||||
@@ -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<u8> {
|
||||
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<String> = ["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::<Vec<_>>(), before.iter().map(|(_, p, _)| p.clone()).collect::<Vec<_>>());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
@@ -117,6 +117,14 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la
|
||||
masks, and the item stays in the project. The composition lasts as long as the footage, or as
|
||||
the longest layer that showed a still or solid. One undo step (`file.replaceWithPrecomp
|
||||
{"item"?}`).
|
||||
- **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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user