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 => {