Merge pull request #461 from vbasky/feature/recursive-footage-relink

This commit is contained in:
Claude committed 2026-10-10 12:50:18 +00:00
commit b5c6eb7d5e
6 files changed
+526

No files matched your search

+2
View File
@@ -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());
+414
View File
@@ -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<BTreeMap<String, Vec<String>>, String> {
let mut pending = vec![folder.to_path_buf()];
let mut found: BTreeMap<String, Vec<String>> = 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<Vec<String>> {
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<Value> {
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<CommandSpec> {
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<Footage, String> {
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<std::sync::mpsc::Receiver<()>>,
calls: Arc<std::sync::atomic::AtomicUsize>,
}
impl crate::Importer for BlockingProbe {
fn probe(&self, path: &str) -> std::result::Result<Footage, String> {
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");
}
}
+1
View File
@@ -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
+6
View File
@@ -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<Result<Value, String>> {
enum Ask {
Folder,
Import,
OpenProject,
Save(&'static str),
@@ -1151,6 +1152,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"file.installScript" | "file.installScriptUIPanel" => ("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<Res
}
let mut p = params.as_object().cloned().unwrap_or_default();
let picked: Option<Value> = 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(&[
+84
View File
@@ -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();
}
+19
View File
@@ -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.