mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #461 from vbasky/feature/recursive-footage-relink
This commit is contained in:
6 files changed
+526
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(&[
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user