From 816ca95800c68ae2ab1b78f036f96f1556f30660 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 17:43:58 +0000 Subject: [PATCH] Footage: follow stored footage paths only on this computer or the project's share Footage paths stored in a project are resolved through craft-docpath against the project file when it is opened or imported: relative paths are taken from the project's folder, and an item whose path is on another computer, names a device or is a non-file URL is marked missing without being looked at. The footage check, the changed-on-disk scan, Reload Footage, Relink, Edit Original and Reveal in Finder resolve the path again before touching the file, and the Project panel, Metadata panel and waveforms read no file details of missing footage. Lottie image assets are resolved against the Lottie file; one on another computer is skipped with a warning. Files a 3D model names are read only when local or on the model's own share. Reveal in Finder refuses a folder that would open as an app or other bundle. Files imported or relinked in this session are opened as given. --- Cargo.lock | 7 + Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/commands/align_data.rs | 2 +- crates/engine/src/commands/edit.rs | 4 +- crates/engine/src/commands/file.rs | 38 ++--- crates/engine/src/commands/file_more.rs | 7 +- crates/engine/src/commands/import_project.rs | 54 +++---- crates/engine/src/commands/layer_menu.rs | 73 ++++++++- crates/engine/src/commands/relink.rs | 9 +- crates/engine/src/commands/relink_moved.rs | 13 +- crates/engine/src/doc_path.rs | 158 +++++++++++++++++++ crates/engine/src/doc_path/tests.rs | 153 ++++++++++++++++++ crates/engine/src/footage_check.rs | 30 ++-- crates/engine/src/footage_reload.rs | 9 +- crates/engine/src/lib.rs | 5 + crates/engine/src/media_browser.rs | 2 +- crates/engine/src/offload.rs | 6 +- crates/engine/src/tests_import_project.rs | 2 +- crates/lottie/Cargo.toml | 1 + crates/lottie/src/import.rs | 24 ++- crates/lottie/src/tests.rs | 23 +++ crates/media/Cargo.toml | 1 + crates/media/src/pool.rs | 27 +++- crates/ui-egui/src/panels/project.rs | 4 +- crates/ui-egui/src/panels/waveform.rs | 2 +- 26 files changed, 557 insertions(+), 99 deletions(-) create mode 100644 crates/engine/src/doc_path.rs create mode 100644 crates/engine/src/doc_path/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 3b70881e..1ca52eba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,6 +968,10 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1245,6 +1249,7 @@ dependencies = [ name = "effectcraft-engine" version = "0.10.0" dependencies = [ + "craft-docpath", "effectcraft-color", "effectcraft-effects", "effectcraft-geom", @@ -1418,6 +1423,7 @@ dependencies = [ name = "effectcraft-lottie" version = "0.10.0" dependencies = [ + "craft-docpath", "effectcraft-color", "effectcraft-effects", "effectcraft-keyframe", @@ -1435,6 +1441,7 @@ dependencies = [ name = "effectcraft-media" version = "0.10.0" dependencies = [ + "craft-docpath", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", diff --git a/Cargo.toml b/Cargo.toml index 2eba7988..479421ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 68e761ab..0e26cca5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } effectcraft-time = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-color = { workspace = true } diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs index 1f18de5f..097bddc2 100644 --- a/crates/engine/src/commands/align_data.rs +++ b/crates/engine/src/commands/align_data.rs @@ -182,7 +182,7 @@ fn run(s: &mut Session, p: &Value) -> Result { let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) { Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?, None => { - let (created, _, _) = crate::media_browser::file_dates(&footage.path); + let (created, _, _) = if footage.missing { (None, None, None) } else { crate::media_browser::file_dates(&footage.path) }; Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true } } }; diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 42a2baea..422728ea 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -828,7 +828,9 @@ pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> { } fn edit_original(s: &mut Session, _: &Value) -> Result { - let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?; + let stored = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?; + let path = s.stored_path(&stored).map_err(|why| super::bad("edit.editOriginal", crate::doc_path::refusal(&stored, why)))?; + let path = path.to_string_lossy(); openable_original(&path).map_err(|e| super::bad("edit.editOriginal", e))?; let url = super::layer_menu::file_url(&path); s.events.push(crate::Event::OpenUrl(url.clone())); diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 501d1548..760c1f22 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -18,7 +18,10 @@ fn new_project(s: &mut Session, _: &Value) -> Result { if s.prefs.project.use_template && !tpl.is_empty() { let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?; - s.replace_project(Project::from_json(&text)?, None); + let mut proj = Project::from_json(&text)?; + crate::doc_path::resolve_footage(&mut proj, Some(&tpl)); + s.replace_project(proj, None); + s.stored_paths.add(&s.project, Some(&tpl)); s.update_sentinel(); return Ok(json!({"template": tpl})); } @@ -45,8 +48,12 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; let (mut proj, repaired) = Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; - resolve_relative_footage(&mut proj, path, s.services.as_ref()); + // Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or + // written by hand) is found next to the project file, as After Effects looks for footage + // relative to the project (#652). + let refused = crate::doc_path::resolve_footage(&mut proj, Some(path)); s.replace_project(proj, Some(path.to_string())); + s.stored_paths.add(&s.project, Some(path)); s.restore_view(&effectcraft_project::saved_view(&text)); s.note_project_path(path); // Values past the limits (a corrupt or hostile file) were brought within them (#788). @@ -56,6 +63,10 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let more = if more > 0 { format!(" and {more} more") } else { String::new() }; s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}.")); } + if let Some((_, first, why)) = refused.first() { + let n = refused.len(); + s.toast(format!("{n} footage item{} marked missing: {}.", if n == 1 { "" } else { "s" }, crate::doc_path::refusal(first, *why))); + } // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. let saved_by = effectcraft_project::saved_by(&text); if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) { @@ -72,28 +83,6 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired})) } -/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written -/// by hand) is found next to the project file, as After Effects looks for footage relative to -/// the project. It used to be looked up from the working directory: the CLI started in the -/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher, -/// in another folder) showed and rendered nothing (#652). A path that exists from the working -/// directory is kept. -fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) { - use std::path::Path; - let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return }; - let beside = |f: &str| dir.join(f).to_string_lossy().to_string(); - for item in p.items.values_mut() { - let ItemKind::Footage(f) = &mut item.kind else { continue }; - if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) { - continue; - } - f.path = beside(&f.path); - for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) { - *frame = beside(frame); - } - } -} - /// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. @@ -459,6 +448,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec Result { // 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) => { + Some(ItemKind::Footage(previous)) if previous.missing || !s.stored_paths.exists(s.services.as_ref(), &previous.path) => { super::relink_moved::moved_with(s, item, &previous.path, &path) } _ => vec![], }; let relinked: Vec = moved.iter().map(|(id, _)| id.0).collect(); + for nf in std::iter::once(&f).chain(moved.iter().map(|(_, nf)| nf)) { + s.stored_paths.allow(nf); + } s.edit("Replace Footage", None, |proj, _| { let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?; it.name = name; @@ -946,7 +949,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { Some(ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(), _ => return Err(bad("file.revealInFinder", "the item is not a file")), }; - let url = super::layer_menu::folder_url(&path); + let url = super::layer_menu::reveal_url(s, &path).map_err(|e| bad("file.revealInFinder", e))?; s.events.push(crate::Event::OpenUrl(url.clone())); Ok(json!({"url": url})) } diff --git a/crates/engine/src/commands/import_project.rs b/crates/engine/src/commands/import_project.rs index f0474ee8..af37771a 100644 --- a/crates/engine/src/commands/import_project.rs +++ b/crates/engine/src/commands/import_project.rs @@ -27,40 +27,24 @@ fn read_project(s: &Session, path: &str) -> Result<(Project, Option)> { Ok((project, effectcraft_project::saved_by(&text))) } -/// Footage paths relative to the imported project's folder become absolute when the file is -/// there; returns the footage paths that are missing. -fn resolve_footage(s: &Session, project: &mut Project, dir: &std::path::Path) -> Vec { +/// Footage paths stored in the imported project are resolved against its file +/// ([`crate::doc_path::resolve_footage`]): relative ones become paths beside it, and items whose +/// path is not followed are marked missing. Returns the footage paths that are missing, as stored. +fn resolve_footage(s: &Session, project: &mut Project, path: &str) -> Vec { + let stored: std::collections::HashMap = + project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.id, f.path.clone())) } else { None }).collect(); + let refused = crate::doc_path::resolve_footage(project, Some(path)); let services = s.services.clone(); - let resolve = |raw: &mut String| -> bool { - if raw.is_empty() || services.exists(raw) { - return true; - } - if std::path::Path::new(raw.as_str()).is_absolute() { - return false; - } - let joined = dir.join(raw.as_str()).to_string_lossy().to_string(); - if services.exists(&joined) { - *raw = joined; - true - } else { - false - } - }; - let mut missing = vec![]; - for it in project.items.values_mut() { - if let ItemKind::Footage(f) = &mut it.kind { - if f.sequence.is_empty() { - if !resolve(&mut f.path) { - missing.push(f.path.clone()); - } - } else { - let mut all = true; - for frame in &mut f.sequence { - all &= resolve(frame); - } - if !all { - missing.push(f.path.clone()); - } + let exists = |p: &String| p.is_empty() || services.exists(p); + let mut missing: Vec = refused.iter().map(|(_, p, _)| p.clone()).collect(); + for it in project.items.values() { + if let ItemKind::Footage(f) = &it.kind { + if refused.iter().any(|(id, _, _)| *id == it.id) { + continue; + } + let present = if f.sequence.is_empty() { exists(&f.path) } else { f.sequence.iter().all(exists) }; + if !present { + missing.push(stored.get(&it.id).cloned().unwrap_or_else(|| f.path.clone())); } } } @@ -79,8 +63,8 @@ fn import_project(s: &mut Session, p: &Value) -> Result { let (mut sub, saved_by) = read_project(s, &path)?; crate::upgrade_effects(&mut sub); let file = std::path::Path::new(&path); - let dir = file.parent().map(|d| d.to_path_buf()).unwrap_or_default(); - let missing = resolve_footage(s, &mut sub, &dir); + let missing = resolve_footage(s, &mut sub, &path); + s.stored_paths.add(&sub, Some(&path)); // The imported project's own render queue stays behind (as for templates). let base = s.project.next_id; if sub.next_id.checked_add(base).is_none() { diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 83a1510a..9cac8041 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -677,6 +677,54 @@ pub(crate) fn folder_url(path: &str) -> String { file_url(&dir) } +/// Extensions of folders that macOS opens as an app, plug-in or other bundle rather than showing +/// their contents. +const BUNDLE_EXTENSIONS: &[&str] = &[ + "app", + "appex", + "bundle", + "component", + "dext", + "driver", + "framework", + "kext", + "mdimporter", + "mpkg", + "osax", + "pkg", + "plugin", + "prefpane", + "qlgenerator", + "saver", + "service", + "systemextension", + "vst", + "vst3", + "wdgt", + "workflow", + "xpc", +]; + +/// Whether the system file manager would open folder `dir` as something other than a folder: a +/// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension. +fn opens_as_bundle(dir: &std::path::Path) -> bool { + let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str()) +} + +/// The folder of the footage file `stored` (a path stored in the project) as a `file://` URL for +/// Reveal in Finder. A path that is not followed ([`crate::doc_path`]), or a folder that is not a +/// plain folder after following links, is refused. +pub(crate) fn reveal_url(s: &Session, stored: &str) -> std::result::Result { + let path = s.stored_path(stored).map_err(|why| crate::doc_path::refusal(stored, why))?; + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| format!("{stored} is not in a folder"))?; + let real = std::fs::canonicalize(dir).map_err(|e| format!("cannot show the folder of {stored}: {e}"))?; + if !real.is_dir() || opens_as_bundle(dir) || opens_as_bundle(&real) { + return Err(format!("the folder of {stored} is not a plain folder")); + } + Ok(file_url(&dir.to_string_lossy())) +} + /// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash /// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces, /// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file. @@ -700,7 +748,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { Some(effectcraft_project::ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(), _ => return Err(bad("layer.revealInFinder", "the layer's source is not a file")), }; - let url = folder_url(&path); + let url = reveal_url(s, &path).map_err(|e| bad("layer.revealInFinder", e))?; s.events.push(crate::Event::OpenUrl(url.clone())); Ok(json!({"url": url})) } @@ -892,4 +940,27 @@ mod tests { fn folder_url_names_the_containing_folder() { assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips"); } + + /// Reveal in Finder shows plain folders only: a bundle the system would open as an app, or a + /// link to one, is refused. + #[test] + fn reveal_refuses_folders_that_open_as_apps() { + let d = std::env::temp_dir().join(format!("ec-reveal-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + for f in ["Clips", "Tool.app", "Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}"] { + std::fs::create_dir_all(d.join(f)).unwrap(); + } + let s = crate::Session::default(); + let reveal = |folder: &str| super::reveal_url(&s, &d.join(folder).join("a.mov").to_string_lossy()); + assert_eq!(reveal("Clips"), Ok(super::file_url(&d.join("Clips").to_string_lossy()))); + assert!(reveal("Tool.app").is_err()); + assert!(reveal("Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}").is_err()); + assert!(reveal("Gone").is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("Tool.app"), d.join("Footage")).unwrap(); + assert!(reveal("Footage").is_err(), "a link to a bundle"); + } + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/engine/src/commands/relink.rs b/crates/engine/src/commands/relink.rs index 1c095dda..2d7c2ea1 100644 --- a/crates/engine/src/commands/relink.rs +++ b/crates/engine/src/commands/relink.rs @@ -81,12 +81,13 @@ fn run(s: &mut Session, p: &Value) -> Result { } let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?; let services = s.services.clone(); + let stored = s.stored_paths.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())), + ItemKind::Footage(f) if !f.path.is_empty() && !stored.exists(services.as_ref(), &f.path) => Some((item.id, f.clone())), _ => None, }) .collect(); @@ -149,7 +150,8 @@ fn run(s: &mut Session, p: &Value) -> Result { // 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) + || stored.exists(services.as_ref(), &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")); @@ -157,6 +159,9 @@ fn run(s: &mut Session, p: &Value) -> Result { } let matched = updates.len(); if !dry_run && !updates.is_empty() { + for (_, _, new) in &updates { + s.stored_paths.allow(new); + } s.edit("Relink Missing Footage", None, |project, _| { for (id, _, new) in updates { if let Some(item) = project.item_mut(id) { diff --git a/crates/engine/src/commands/relink_moved.rs b/crates/engine/src/commands/relink_moved.rs index ef64a423..ecc531bc 100644 --- a/crates/engine/src/commands/relink_moved.rs +++ b/crates/engine/src/commands/relink_moved.rs @@ -91,8 +91,8 @@ fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option) -> Optio Some(nf) } -fn present(s: &dyn Services, f: &Footage) -> bool { - !f.path.is_empty() && s.exists(&f.path) +fn present(s: &dyn Services, stored: &crate::doc_path::StoredPaths, f: &Footage) -> bool { + stored.exists(s, &f.path) } /// Footage items whose file is gone (flagged by the footage check, or not there now). @@ -101,7 +101,7 @@ fn missing_items(s: &Session) -> Vec<(ItemId, Footage)> { .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)) => { + ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), &s.stored_paths, f)) => { Some((i.id, f.clone())) } _ => None, @@ -146,6 +146,9 @@ fn apply(s: &mut Session, label: &str, found: Vec<(ItemId, Footage)>) -> Result< return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()})); } let ids: Vec = found.iter().map(|(id, _)| id.0).collect(); + for (_, nf) in &found { + s.stored_paths.allow(nf); + } s.edit(label, None, |proj: &mut Project, _| { for (id, nf) in found { if let Some(it) = proj.item_mut(id) { @@ -175,7 +178,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &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_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), &s.stored_paths, nf)); let same_folder = || { let dir = new_dir.as_deref()?; let dir_parts = parts(dir); @@ -183,7 +186,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s 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 + present(services.as_ref(), &s.stored_paths, nf) && parts(&nf.path).len() == dir_parts.len() + 1 }) }; if let Some(nf) = same_place.or_else(same_folder) { diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 00000000..c7416a8c --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,158 @@ +//! Footage paths stored in a document (the project file, an imported project, the New Project +//! template) reach the file system only through [`resolve`]: a relative path is taken from the +//! document's folder, and only local files, or files on the network share the document itself is +//! on, are followed (see `craft_docpath`). +//! +//! Loading a document resolves its footage paths once ([`resolve_footage`]): followed paths are +//! replaced by the resolved path, and an item with a refused path is marked missing, so the media +//! layer never reads it. The session then remembers which paths came from which document +//! ([`StoredPaths`]); the footage check, the changed-on-disk scan, Reload Footage, Relink, Edit +//! Original and Reveal in Finder follow a path only through it. Paths the user imports or relinks +//! in this session are followed as given. + +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub use craft_docpath::Refused; +use effectcraft_project::{Footage, ItemId, ItemKind, Project}; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is not a local file, or is not on the project's own share. + Refused(Refused), + /// The path is relative and the project has no folder to take it from. + NoProjectFolder, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NoProjectFolder => f.write_str("the path is relative and the project has not been saved"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to open for `stored`, a footage path or `file:` URL stored in the project saved at +/// `project` (`None` for a project without a file), or why it is not followed. A relative +/// `project` is taken from the working directory, where it was opened from. +pub fn resolve(stored: &str, project: Option<&str>) -> Result { + let project = project.filter(|p| !p.is_empty()).map(|p| { + let p = Path::new(p); + std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()) + }); + let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // project does not choose. + if path.is_relative() { + return Err(NotFollowed::NoProjectFolder); + } + Ok(path) +} + +/// The paths of `f` (its file and every frame of an image sequence) resolved against the document +/// saved at `document`, or why one of them is not followed. +fn footage_paths(f: &Footage, document: Option<&str>) -> Result<(String, Vec), (String, NotFollowed)> { + let one = |q: &String| -> Result { + if q.is_empty() { + return Ok(String::new()); + } + resolve(q, document).map(|p| p.to_string_lossy().into_owned()).map_err(|why| (q.clone(), why)) + }; + Ok((one(&f.path)?, f.sequence.iter().map(one).collect::>()?)) +} + +fn paths_of(f: &Footage) -> impl Iterator { + std::iter::once(&f.path).chain(&f.sequence).filter(|q| !q.is_empty()) +} + +/// Footage paths that came from a document, with the path of that document (`None`: one without +/// a file). Any other path was chosen in this session and is followed as given. +#[derive(Clone, Debug, Default)] +pub struct StoredPaths(Arc>>); + +impl StoredPaths { + /// Record the footage paths of `p`, loaded from the document at `document`. A path already + /// recorded keeps the document it came from first. + pub(crate) fn add(&mut self, p: &Project, document: Option<&str>) { + let map = Arc::make_mut(&mut self.0); + for item in p.items.values() { + if let ItemKind::Footage(f) = &item.kind { + for q in paths_of(f) { + map.entry(q.clone()).or_insert_with(|| document.map(str::to_string)); + } + } + } + } + + /// The user chose the files of `f` in this session (import, relink, Replace Footage): follow + /// them as given from now on. + pub(crate) fn allow(&mut self, f: &Footage) { + if paths_of(f).any(|q| self.0.contains_key(q)) { + let map = Arc::make_mut(&mut self.0); + for q in paths_of(f) { + map.remove(q); + } + } + } + + /// The file to open for footage path `path`. + pub fn follow(&self, path: &str) -> Result { + match self.0.get(path) { + Some(document) => resolve(path, document.as_deref()), + None => Ok(PathBuf::from(path)), + } + } + + /// Whether the file at footage path `path` exists. A path that is not followed is not looked + /// for. + pub(crate) fn exists(&self, services: &dyn crate::Services, path: &str) -> bool { + !path.is_empty() && self.follow(path).is_ok_and(|p| p.to_str().is_some_and(|p| services.exists(p))) + } + + /// Whether every file of `f` is followed, or the first that is not and why. + pub(crate) fn check(&self, f: &Footage) -> Result<(), (String, NotFollowed)> { + paths_of(f).try_for_each(|q| self.follow(q).map(|_| ()).map_err(|why| (q.clone(), why))) + } +} + +/// Resolve the footage paths stored in `p`, the project saved at `project`: followed paths are +/// replaced by the file they resolve to, and an item with a path that is not followed is marked +/// missing. Returns those items with the refused path and why. +pub(crate) fn resolve_footage(p: &mut Project, project: Option<&str>) -> Vec<(ItemId, String, NotFollowed)> { + let mut refused = vec![]; + for item in p.items.values_mut() { + let ItemKind::Footage(f) = &mut item.kind else { continue }; + match footage_paths(f, project) { + Ok((path, sequence)) => { + f.path = path; + f.sequence = sequence; + } + Err((path, why)) => { + f.missing = true; + refused.push((item.id, path, why)); + } + } + } + refused +} + +/// The message for footage whose stored path is not followed. +pub(crate) fn refusal(stored: &str, why: NotFollowed) -> String { + format!("the footage file {stored} is not opened: {why}") +} + +impl crate::Session { + /// The file to open for footage path `stored` in this session's project. + pub fn stored_path(&self, stored: &str) -> Result { + self.stored_paths.follow(stored) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs new file mode 100644 index 00000000..b8dcbc19 --- /dev/null +++ b/crates/engine/src/doc_path/tests.rs @@ -0,0 +1,153 @@ +use std::sync::{Arc, Mutex}; + +use effectcraft_project::{Footage, ItemId, ItemKind}; +use serde_json::json; + +use crate::{Event, Services, Session}; + +/// File access that records every path it is asked about. +#[derive(Default)] +struct Recording(Mutex>); + +impl Recording { + fn note(&self, path: &str) { + self.0.lock().unwrap().push(path.to_string()); + } +} + +impl Services for Recording { + fn read_file(&self, path: &str) -> std::io::Result> { + self.note(path); + std::fs::read(path) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + std::fs::write(path, data) + } + fn exists(&self, path: &str) -> bool { + self.note(path); + std::path::Path::new(path).exists() + } + fn stamp(&self, path: &str) -> Option<(u64, u128)> { + self.note(path); + let meta = std::fs::metadata(path).ok()?; + Some((meta.len(), meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos())) + } +} + +/// Probes every file as a still. +struct Stills; + +impl crate::Importer for Stills { + fn probe(&self, path: &str) -> Result { + if !std::path::Path::new(path).is_file() { + return Err(format!("{path}: no such file")); + } + Ok(Footage { path: path.into(), kind: effectcraft_project::FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } +} + +fn footage_id(s: &Session, name: &str) -> ItemId { + s.project.items.values().find(|i| i.name == name).unwrap().id +} + +fn footage<'a>(s: &'a Session, name: &str) -> &'a Footage { + match &s.project.item(footage_id(s, name)).unwrap().kind { + ItemKind::Footage(f) => f, + _ => panic!("{name} is not footage"), + } +} + +/// Footage paths to other computers stored in a project are never looked at: not on open, by +/// the footage check, by the changed-on-disk scan, by Edit Original or by Reveal in Finder. A +/// relative path is taken from the project's folder, and a file the user imports in this session +/// is used as given until the project is saved and opened again. +#[test] +fn footage_on_other_computers_is_never_looked_at() { + let root = std::env::temp_dir().join(format!("ec-doc-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("job"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(root.join("media")).unwrap(); + std::fs::write(dir.join("clip.png"), b"x").unwrap(); + std::fs::write(root.join("media").join("far.png"), b"x").unwrap(); + let mut stored = vec![("unc", r"\\192.0.2.1\share\x.png".to_string()), ("url", "file://192.0.2.1/s/x.png".to_string()), ("beside", "clip.png".to_string())]; + // Linux reads `//tmp/…/far.png` as the local file: a sink that skipped the check would find it. + if cfg!(unix) { + stored.push(("slashes", format!("/{}", root.join("media").join("far.png").display()))); + } + let mut s = Session::default(); + for (name, path) in &stored { + let f = Footage { path: path.clone(), width: 64, height: 48, has_video: true, ..Default::default() }; + Arc::make_mut(&mut s.project).add_item(name, crate::color::Label::None, None, ItemKind::Footage(f)); + } + let project = dir.join("p.ecproj"); + s.execute("file.saveAs", json!({"path": project.to_string_lossy()})).unwrap(); + + let rec = Arc::new(Recording::default()); + let mut o = Session { services: rec.clone(), ..Default::default() }; + o.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap(); + for (name, _) in &stored { + assert_eq!(footage(&o, name).missing, *name != "beside", "{name}"); + } + assert_eq!(std::path::Path::new(&footage(&o, "beside").path), dir.join("clip.png")); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"].as_u64(), Some(stored.len() as u64 - 1), "{r}"); + o.execute("footage.reloadChanged", json!({})).unwrap(); + o.drain_events(); + for (name, _) in &stored { + o.state.project_selection = vec![footage_id(&o, name)]; + let opened = o.execute("edit.editOriginal", json!({})); + let revealed = o.execute("file.revealInFinder", json!({})); + assert_eq!((opened.is_ok(), revealed.is_ok()), (*name == "beside", *name == "beside"), "{name}: {opened:?} {revealed:?}"); + } + let urls: Vec = o.drain_events().into_iter().filter_map(|e| if let Event::OpenUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 2, "{urls:?}"); + + let seen = rec.0.lock().unwrap().clone(); + assert!(seen.iter().any(|p| std::path::Path::new(p) == dir.join("clip.png")), "{seen:?}"); + // Only files in the temporary folder (the project's folder, where moved footage is looked for). + let local = root.to_string_lossy().into_owned(); + for p in &seen { + assert!(p.starts_with(&local), "looked at {p}"); + } + + // Chosen in this session, the same file is followed as given (as a file on a NAS would be)… + #[cfg(unix)] + { + let far = stored.iter().find(|(n, _)| *n == "slashes").unwrap().1.clone(); + o.importer = Some(Arc::new(Stills)); + o.execute("file.import", json!({"paths": [far.clone()]})).unwrap(); + let imported = |s: &Session| -> Vec { + s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path == far)).map(|i| i.id).collect() + }; + let id = *imported(&o).iter().find(|id| matches!(&o.project.item(**id).unwrap().kind, ItemKind::Footage(f) if !f.missing)).unwrap(); + o.state.project_selection = vec![id]; + o.execute("edit.editOriginal", json!({})).unwrap(); + o.execute("file.revealInFinder", json!({})).unwrap(); + assert!(o.execute("footage.check", json!({"wait": true, "items": [id.0]})).unwrap()["missing"] == 0); + // …and is a stored path again once the project is saved and opened. + o.execute("file.save", json!({})).unwrap(); + let mut q = Session::default(); + q.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap(); + let ids = imported(&q); + assert_eq!(ids.len(), 2); + for id in ids { + assert!(matches!(&q.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing)); + q.state.project_selection = vec![id]; + assert!(q.execute("edit.editOriginal", json!({})).is_err()); + assert!(q.execute("file.revealInFinder", json!({})).is_err()); + } + } + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn stored_paths_resolve_against_the_project() { + let doc = std::env::temp_dir().join("art").join("intro.ecproj"); + let doc = doc.to_str().unwrap(); + assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov"))); + assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder)); + assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network))); + #[cfg(windows)] + assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok()); +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index adfb1768..6bb803f0 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -31,10 +31,6 @@ enum Found { Moved(Box), } -fn exists(s: &dyn crate::Services, path: &str) -> bool { - !path.is_empty() && s.exists(path) -} - /// Start the check (all footage, or `items`). `wait` runs it to completion first (always on /// wasm32); the result is then `{checked, missing, probed}`. pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate::Result { @@ -54,6 +50,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: let services = s.services.clone(); let importer = s.importer.clone(); let rate = s.sequence_rate(); + let stored = s.stored_paths.clone(); 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| { @@ -64,13 +61,17 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: return Err(crate::render_queue::CANCELLED.into()); } let f = &j.footage; - let present = if f.sequence.is_empty() { - exists(services.as_ref(), &f.path) - } else { - // A sequence is there while any of its frames is (missing frames render as - // placeholders, as in After Effects). - exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p)) - }; + // A path that is not followed is never looked for (the item stays missing). + let followed = stored.check(f).is_ok(); + let exists = |p: &str| stored.exists(services.as_ref(), p); + let present = followed + && if f.sequence.is_empty() { + exists(&f.path) + } else { + // A sequence is there while any of its frames is (missing frames render as + // placeholders, as in After Effects). + exists(&f.path) || f.sequence.iter().any(|p| exists(p)) + }; // Moved with the project: the same place under the project's folder. if !present && let Some(dir) = &project_dir @@ -233,9 +234,10 @@ mod tests { let mut paths: Vec = o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); paths.sort(); - // Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed - // separators; `Path` equality compares components, so both spellings are the same file. - let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + // `Path` equality compares components, so `/` and `\` spell the same file on Windows. + let abs = crate::doc_path::resolve("/nonexistent/ec/abs.png", path.to_str()).unwrap(); + let gone = dir.join("media").join("ec-relative-gone.png"); + let mut expected = [abs.to_str().unwrap(), clip.to_str().unwrap(), gone.to_str().unwrap()]; expected.sort(); assert_eq!(paths.len(), expected.len(), "{paths:?}"); for (got, want) in paths.iter().zip(expected) { diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index ca51069a..fa12b27a 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -32,12 +32,13 @@ pub type Scan = Arc)>>>>; /// The files footage items read: each item's file and every frame of an image sequence. Image /// sequences count only with Automatically Reload Footage set to All Footage (After Effects' /// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up). +/// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched. fn watched(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { if let ItemKind::Footage(f) = &it.kind { - if !f.sequence.is_empty() && !sequences { + if (!f.sequence.is_empty() && !sequences) || s.stored_paths.check(f).is_err() { continue; } if !f.path.is_empty() { @@ -136,8 +137,10 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi let mut failed = BTreeSet::new(); for i in ids { if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { - // Probed the way it imports by default, so a sequence is found as one. - let nf = match s.probe_footage(&f.path) { + // Probed the way it imports by default, so a sequence is found as one. A path that + // is not followed is not read. + let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path)); + let nf = match probed { Ok(p) => refreshed(s, f, p), Err(_) if undoable => Footage { missing: true, ..f.clone() }, Err(_) => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 96e09b57..a5b016b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod camera_track; pub mod commands; pub mod config; pub mod demo; +pub mod doc_path; pub mod footage_check; pub mod footage_reload; pub mod guard; @@ -508,6 +509,8 @@ pub struct Session { /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when /// they want it. pub check_footage_on_open: bool, + /// Footage paths that came from a document, with that document ([`doc_path`]). + pub stored_paths: doc_path::StoredPaths, /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, @@ -608,6 +611,7 @@ impl Default for Session { next_task_id: 1, learn: None, check_footage_on_open: false, + stored_paths: Default::default(), footage_stamps: Default::default(), footage_scan: None, script_calls: ScriptCalls::default(), @@ -1128,6 +1132,7 @@ impl Session { job.wait(); } self.render_job = None; + self.stored_paths = Default::default(); self.stop_track(); self.track_job = None; self.stop_mask_track(); diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index b394ad93..d22875f1 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -306,7 +306,7 @@ fn kind_name(k: FootageKind) -> &'static str { /// Metadata of footage (the fields the Metadata panel lists). pub fn footage_metadata(f: &Footage) -> Value { - let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)); + let (created, modified, size) = if f.missing { (None, None, None) } else { file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)) }; json!({ "path": f.path, "kind": kind_name(f.kind), diff --git a/crates/engine/src/offload.rs b/crates/engine/src/offload.rs index bd1fec8a..ad2c0cde 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -310,13 +310,15 @@ pub fn next_id() -> u64 { NEXT_ID.fetch_add(1, Ordering::Relaxed) } -/// Footage files a project reads (paths, sequence frames). +/// Footage files a project reads (paths, sequence frames; missing items read none). pub fn footage_files(p: &Project) -> Vec { let mut v: Vec = p .items .values() .filter_map(|i| match &i.kind { - ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::>()), + ItemKind::Footage(f) if !f.path.is_empty() && !f.missing => { + Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::>()) + } _ => None, }) .flatten() diff --git a/crates/engine/src/tests_import_project.rs b/crates/engine/src/tests_import_project.rs index 6aa52b41..cbc161c9 100644 --- a/crates/engine/src/tests_import_project.rs +++ b/crates/engine/src/tests_import_project.rs @@ -113,7 +113,7 @@ fn import_project_merges_with_fresh_ids_and_one_undo() { // A relative footage path resolves next to the imported project; a missing one is reported. let data = t.project.items.values().find(|i| i.name == "data.json").unwrap(); let ItemKind::Footage(f) = &data.kind else { panic!("footage") }; - assert_eq!(std::path::Path::new(&f.path), dir.join("data.json")); + assert_eq!(std::fs::canonicalize(&f.path).unwrap(), std::fs::canonicalize(dir.join("data.json")).unwrap()); assert_eq!(r["missingMedia"], json!(["/offline/gone.mov"])); // One undo step removes all of it; redo brings it back. diff --git a/crates/lottie/Cargo.toml b/crates/lottie/Cargo.toml index ec71de40..a9f64b3b 100644 --- a/crates/lottie/Cargo.toml +++ b/crates/lottie/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } effectcraft-time = { workspace = true } effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 0fb1bfeb..7730017d 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -88,6 +88,21 @@ impl Ctx<'_> { } } +/// The file an external image asset (`u` + `p`) names, taken from `base_dir`, the folder of the +/// Lottie file. Only local files, or files on the network share the Lottie file is on, are +/// imported (see `craft_docpath`). +fn external_image(stored: &str, base_dir: &str) -> Result { + let doc = (!base_dir.is_empty()).then(|| { + let doc = std::path::Path::new(base_dir).join("lottie.json"); + std::path::absolute(&doc).unwrap_or(doc) + }); + match craft_docpath::resolve(stored, doc.as_deref()) { + Ok(path) if path.is_absolute() => Ok(path.to_string_lossy().into_owned()), + Ok(_) => Err(format!("{stored} is relative and the Lottie file's folder is not known")), + Err(why) => Err(format!("{stored}: {why}")), + } +} + /// Import a Lottie document (JSON or a stored `.lottie` archive) into `project` as a new /// composition. `store` saves embedded images and returns the file path to use; /// `base_dir` resolves external image references. @@ -145,8 +160,13 @@ pub fn import( (cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() }) } else { let u = s(a, "u").unwrap_or(""); - let rel = format!("{u}{p}"); - Some(if rel.starts_with('/') || cx.base_dir.is_empty() { rel } else { format!("{}/{rel}", cx.base_dir.trim_end_matches('/')) }) + match external_image(&format!("{u}{p}"), cx.base_dir) { + Ok(path) => Some(path), + Err(why) => { + cx.im.warn(format!("image asset `{id}` is not imported: {why}")); + continue; + } + } }; let Some(path) = path else { cx.im.warn(format!("image asset `{id}` could not be stored")); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 19aac0f9..e819c023 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -806,3 +806,26 @@ fn extreme_enum_numbers_do_not_overflow() { let mut q = Project::default(); import(&mut q, doc.to_string().as_bytes(), "x", "", &mut |_| None).unwrap(); } + +/// External image assets are taken from the Lottie file's folder; one on another computer is not +/// imported. +#[test] +fn external_images_are_local_or_skipped() { + let base = std::env::temp_dir().join("ec-lottie-anim"); + let mut assets = vec![ + serde_json::json!({"id": "near", "u": "images/", "p": "a.png", "w": 1, "h": 1}), + serde_json::json!({"id": "unc", "u": "", "p": r"\\192.0.2.1\share\x.png", "w": 1, "h": 1}), + serde_json::json!({"id": "url", "u": "file://192.0.2.1/s/", "p": "x.png", "w": 1, "h": 1}), + ]; + // `u` + `p` is `//tmp/…/a.png`, which Linux reads as a local file. + if cfg!(unix) { + assets.push(serde_json::json!({"id": "slashes", "u": "/", "p": base.join("a.png").to_string_lossy(), "w": 1, "h": 1})); + } + let doc = serde_json::json!({"w": 10, "h": 10, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": []}); + let mut q = Project::default(); + let r = import(&mut q, doc.to_string().as_bytes(), "x", &base.to_string_lossy(), &mut |_| None).unwrap(); + let paths: Vec = q.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); + assert_eq!(paths.len(), 1, "{paths:?}"); + assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png")); + assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index bb47dccb..0ef01d83 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } effectcraft-time = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 98c70710..9a8ec5c2 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -557,9 +557,9 @@ impl Inner { return m.clone(); } let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| { - let dir = std::path::Path::new(path).parent().map(|d| d.to_path_buf()).unwrap_or_default(); + // Files the model names are read only when local or on the model's own network share. let resolve = |uri: &str| -> Option> { - let p = dir.join(uri); + let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?; self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec()) }; effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string()) @@ -1140,4 +1140,27 @@ mod tests { assert_eq!(px(&f, FloatCurve::Srgb), srgb); assert_eq!(px(&f, FloatCurve::Gamma24), g24); } + + /// A model's material library is read beside it; one named by a path to another computer + /// is not read. + #[test] + fn model_files_on_other_computers_are_not_read() { + let dir = std::env::temp_dir().join(format!("ec-model-files-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("red.mtl"), "newmtl red\nKd 1 0 0\n").unwrap(); + let pool = MediaPool::new(); + let red = |name: &str, mtl: &str| { + let path = dir.join(name); + std::fs::write(&path, format!("mtllib {mtl}\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl red\nf 1 2 3\n")).unwrap(); + let model = pool.inner.model(&path.to_string_lossy()).unwrap(); + model.materials.iter().any(|m| m.base_color[..3] == [1.0, 0.0, 0.0]) + }; + assert!(red("beside.obj", "red.mtl")); + assert!(!red("unc.obj", r"\\192.0.2.1\share\red.mtl")); + assert!(!red("url.obj", "file://192.0.2.1/s/red.mtl")); + // Linux reads `//dir/red.mtl` as the local file. + #[cfg(unix)] + assert!(!red("slashes.obj", &format!("/{}", dir.join("red.mtl").display()))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 76309a5d..c4e9894c 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -57,10 +57,10 @@ pub fn fmt_size(bytes: u64) -> String { } } -/// Footage file size on disk (comps, solids and folders have none). +/// Footage file size on disk (comps, solids, folders and missing footage have none). fn file_size(it: &Item) -> Option { match &it.kind { - ItemKind::Footage(f) => std::fs::metadata(&f.path).ok().map(|m| m.len()), + ItemKind::Footage(f) if !f.missing => std::fs::metadata(&f.path).ok().map(|m| m.len()), _ => None, } } diff --git a/crates/ui-egui/src/panels/waveform.rs b/crates/ui-egui/src/panels/waveform.rs index 6fc42ed3..4e355f95 100644 --- a/crates/ui-egui/src/panels/waveform.rs +++ b/crates/ui-egui/src/panels/waveform.rs @@ -54,7 +54,7 @@ pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Optio .name("ec-waveform".into()) .spawn(move || { use effectcraft_engine::media_cache; - let file = cache.and_then(|d| media_cache::peaks_path(&d, &f.path, BINS_PER_SEC)); + let file = cache.filter(|_| !f.missing).and_then(|d| media_cache::peaks_path(&d, &f.path, BINS_PER_SEC)); let peaks = match file.as_deref().and_then(media_cache::load_peaks) { Some(p) => p, None => {