Merge pull request #1106 from eliasx45/upstream/relink-moved-footage

# Conflicts:
#	docs/footage.md
This commit is contained in:
Brandon Thomas committed 2026-10-13 05:34:14 -04:00
commit 7a9213611e
6 files changed
+459 -5

No files matched your search

+19 -1
View File
@@ -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.
+2
View File
@@ -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());
+319
View File
@@ -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);
}
}
+55 -4
View File
@@ -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"]);
+56
View File
@@ -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);
}
+8
View File
@@ -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.