mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge pull request #1748 from skylartaylor/import-and-open
Import templates and open originals within their own folders
This commit is contained in:
Generated
+8
@@ -968,6 +968,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1245,6 +1250,7 @@ dependencies = [
|
||||
name = "effectcraft-engine"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-geom",
|
||||
@@ -1418,6 +1424,7 @@ dependencies = [
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1435,6 +1442,7 @@ dependencies = [
|
||||
name = "effectcraft-media"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
|
||||
@@ -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 = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -182,7 +182,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -808,8 +808,30 @@ fn original_path(s: &Session) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Edit Original may hand `path` to the system's default app: a regular file (after
|
||||
/// following links) whose name, and the name it links to, have a media extension the footage
|
||||
/// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused.
|
||||
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
|
||||
let media = |p: &std::path::Path| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps")
|
||||
};
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
let real = std::fs::canonicalize(path).map_err(|e| format!("cannot open “{name}”: {e}"))?;
|
||||
if !real.is_file() {
|
||||
return Err(format!("“{name}” is not a file"));
|
||||
}
|
||||
if !media(std::path::Path::new(path)) || !media(&real) {
|
||||
return Err(format!("Edit Original opens media files only; “{name}” is not one"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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()));
|
||||
Ok(json!({"url": url}))
|
||||
@@ -952,20 +974,67 @@ fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
|
||||
#[test]
|
||||
fn edit_original_opens_an_encoded_file_url() {
|
||||
let clip = tmp("edit-original-url").join("take #1.mov");
|
||||
std::fs::write(&clip, b"").unwrap();
|
||||
let clip = clip.to_string_lossy().into_owned();
|
||||
let url = super::super::layer_menu::file_url(&clip);
|
||||
assert!(url.ends_with("/take%20%231.mov"), "{url}");
|
||||
let mut s = crate::Session::default();
|
||||
let id = s
|
||||
.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
|
||||
let f = effectcraft_project::Footage { path: clip.clone(), ..Default::default() };
|
||||
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.drain_events();
|
||||
let r = s.execute("edit.editOriginal", json!({})).unwrap();
|
||||
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
|
||||
assert_eq!(r["url"], url.as_str());
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == url)));
|
||||
}
|
||||
|
||||
/// Only regular media files reach the system opener.
|
||||
#[test]
|
||||
fn edit_original_opens_media_files_only() {
|
||||
let d = tmp("edit-original-kinds");
|
||||
for f in ["clip.mov", "Still.PNG", "run.bat", "app.desktop", "clip.mov.lnk"] {
|
||||
std::fs::write(d.join(f), b"").unwrap();
|
||||
}
|
||||
for f in ["Tool.app", "folder.mov"] {
|
||||
std::fs::create_dir_all(d.join(f)).unwrap();
|
||||
}
|
||||
let ok = |f: &str| super::openable_original(&d.join(f).to_string_lossy()).is_ok();
|
||||
assert!(ok("clip.mov") && ok("Still.PNG"));
|
||||
for f in ["run.bat", "app.desktop", "clip.mov.lnk", "Tool.app", "folder.mov", "missing.mov", ""] {
|
||||
assert!(!ok(f), "{f}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(d.join("app.desktop"), d.join("link.png")).unwrap();
|
||||
assert!(!ok("link.png"), "a media name linking to another kind of file");
|
||||
}
|
||||
// The command refuses without opening anything.
|
||||
let mut s = crate::Session::default();
|
||||
let path = d.join("run.bat").to_string_lossy().into_owned();
|
||||
let id = s
|
||||
.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path, ..Default::default() };
|
||||
Ok(p.add_item("bat", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.drain_events();
|
||||
assert!(s.execute("edit.editOriginal", json!({})).is_err());
|
||||
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +891,22 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(
|
||||
Ok((manifest, project, entries))
|
||||
}
|
||||
|
||||
/// Where a template's `media/<item>/<file>` entry is written: `<media_dir>/<item>/<file>`.
|
||||
/// `None` unless `<item>` is all digits and `<file>` is a plain file name (not empty, `.` or
|
||||
/// `..`, no separators, and on Windows no drive, stream or device name), so every entry lands
|
||||
/// in an item folder directly inside `media_dir`.
|
||||
fn template_media_path(media_dir: &std::path::Path, entry: &str) -> Option<std::path::PathBuf> {
|
||||
let (item, file) = entry.strip_prefix("media/")?.split_once('/')?;
|
||||
if item.is_empty() || !item.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
if !super::scripts::is_bare_name(file) || (cfg!(windows) && super::scripts::windows_unsafe_name(file)) {
|
||||
return None;
|
||||
}
|
||||
let out = media_dir.join(item).join(file);
|
||||
(out.parent().and_then(std::path::Path::parent) == Some(media_dir) && out.file_name() == Some(std::ffi::OsStr::new(file))).then_some(out)
|
||||
}
|
||||
|
||||
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.importTemplate";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
||||
@@ -905,8 +921,7 @@ fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let media_dir = dir.join(format!("{stem} Media"));
|
||||
let mut written = std::collections::HashMap::new();
|
||||
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
||||
let rel = k.trim_start_matches("media/");
|
||||
let out = media_dir.join(rel);
|
||||
let Some(out) = template_media_path(&media_dir, k) else { continue };
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<std::result:
|
||||
// media layer keys frames by path): a fresh import reads it again (#1070).
|
||||
for f in &found {
|
||||
s.forget_decoded(&f.footage);
|
||||
s.stored_paths.allow(&f.footage);
|
||||
}
|
||||
s.edit("Import", None, |proj, st| {
|
||||
let mut selection = made;
|
||||
|
||||
@@ -855,12 +855,15 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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<u64> = 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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
@@ -27,40 +27,24 @@ fn read_project(s: &Session, path: &str) -> Result<(Project, Option<String>)> {
|
||||
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<String> {
|
||||
/// 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<String> {
|
||||
let stored: std::collections::HashMap<ItemId, String> =
|
||||
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<String> = 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<Value> {
|
||||
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() {
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<Value> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,13 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -91,8 +91,8 @@ fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option<String>) -> 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<u64> = 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) {
|
||||
|
||||
@@ -132,7 +132,7 @@ fn is_script_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||||
fn is_bare_name(name: &str) -> bool {
|
||||
pub(crate) fn is_bare_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ fn is_bare_name(name: &str) -> bool {
|
||||
/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device
|
||||
/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
|
||||
/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`).
|
||||
fn windows_unsafe_name(name: &str) -> bool {
|
||||
pub(crate) fn windows_unsafe_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase();
|
||||
let port = |p: &str| {
|
||||
let mut n = stem.strip_prefix(p).unwrap_or_default().chars();
|
||||
|
||||
@@ -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<PathBuf, NotFollowed> {
|
||||
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>), (String, NotFollowed)> {
|
||||
let one = |q: &String| -> Result<String, (String, NotFollowed)> {
|
||||
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::<Result<_, _>>()?))
|
||||
}
|
||||
|
||||
fn paths_of(f: &Footage) -> impl Iterator<Item = &String> {
|
||||
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<HashMap<String, Option<String>>>);
|
||||
|
||||
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<PathBuf, NotFollowed> {
|
||||
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<PathBuf, NotFollowed> {
|
||||
self.stored_paths.follow(stored)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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<Vec<String>>);
|
||||
|
||||
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<Vec<u8>> {
|
||||
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<Footage, String> {
|
||||
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<String> = 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<ItemId> {
|
||||
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());
|
||||
}
|
||||
@@ -31,10 +31,6 @@ enum Found {
|
||||
Moved(Box<Footage>),
|
||||
}
|
||||
|
||||
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<Vec<ItemId>>, wait: bool) -> crate::Result<Value> {
|
||||
@@ -54,6 +50,7 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
|
||||
let services = s.services.clone();
|
||||
let importer = s.importer.clone();
|
||||
let rate = s.sequence_rate();
|
||||
let 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<Vec<ItemId>>, 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<String> =
|
||||
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) {
|
||||
|
||||
@@ -32,12 +32,13 @@ pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
|
||||
/// 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<String> {
|
||||
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(_) => {
|
||||
|
||||
@@ -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<String, footage_reload::Stamp>,
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String> {
|
||||
let mut v: Vec<String> = 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::<Vec<_>>()),
|
||||
ItemKind::Footage(f) if !f.path.is_empty() && !f.missing => {
|
||||
Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
|
||||
@@ -274,6 +274,55 @@ fn template_export_import_round_trip_with_overrides() {
|
||||
assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_import_writes_media_only_into_its_media_folder() {
|
||||
let root = out_dir("ectemplate-names");
|
||||
let dir = root.join("a").join("b");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (mut s, card, _solid, _ids) = card();
|
||||
let src = dir.join("Src.ectemplate");
|
||||
s.execute("essential.exportTemplate", json!({"comp": card.0, "path": src.to_string_lossy()})).unwrap();
|
||||
let mut entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&src).unwrap());
|
||||
std::fs::remove_file(&src).unwrap();
|
||||
let abs = root.join("abs").join("x").to_string_lossy().replace('\\', "/");
|
||||
for name in [
|
||||
"../x",
|
||||
"media/../../x",
|
||||
"media/1/../../../x",
|
||||
&format!("media/{abs}"),
|
||||
&format!("media/6{abs}"),
|
||||
"media/C:\\x",
|
||||
"media/7/C:\\x",
|
||||
"media/x/clip.png",
|
||||
"media/8/sub/clip.png",
|
||||
"media/2/..",
|
||||
"media/3/",
|
||||
"media/4/clip.png",
|
||||
"media/5/clip.png",
|
||||
] {
|
||||
entries.push((name.to_string(), b"data".to_vec()));
|
||||
}
|
||||
let path = dir.join("T.ectemplate");
|
||||
std::fs::write(&path, effectcraft_lottie::zip::store(&entries)).unwrap();
|
||||
s.execute("essential.importTemplate", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
|
||||
let media = dir.join("T Media");
|
||||
fn files(d: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for e in std::fs::read_dir(d).unwrap().map(|e| e.unwrap().path()) {
|
||||
if e.is_dir() { files(&e, out) } else { out.push(e) }
|
||||
}
|
||||
}
|
||||
let mut found = vec![];
|
||||
files(&root, &mut found);
|
||||
let in_item = |f: &std::path::Path| f.parent().and_then(std::path::Path::parent) == Some(media.as_path());
|
||||
for f in &found {
|
||||
assert!(f == &path || in_item(f), "written outside an item folder of the media folder: {f:?}");
|
||||
}
|
||||
let mut names: Vec<String> = found.iter().filter(|f| in_item(f)).map(|f| f.strip_prefix(&media).unwrap().to_string_lossy().replace('\\', "/")).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["4/clip.png", "5/clip.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_replacement_swaps_footage_per_instance() {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String, String> {
|
||||
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"));
|
||||
|
||||
@@ -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<String> = 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);
|
||||
}
|
||||
|
||||
@@ -70,8 +70,16 @@ pub fn store(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether an entry name is a relative path inside the archive: not absolute (`/…`, `\…`), no
|
||||
/// drive (`C:…`) and no `..` component.
|
||||
fn is_relative_name(name: &str) -> bool {
|
||||
let drive = name.as_bytes().get(1) == Some(&b':');
|
||||
!name.starts_with(['/', '\\']) && !drive && !name.split(['/', '\\']).any(|c| c == "..")
|
||||
}
|
||||
|
||||
/// Read the stored (uncompressed) entries of a ZIP archive written by [`store`] or any other
|
||||
/// writer that did not compress. Compressed entries are skipped.
|
||||
/// writer that did not compress. Compressed entries and entries whose names are not relative
|
||||
/// paths inside the archive are skipped.
|
||||
pub fn read_stored(data: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let mut out = vec![];
|
||||
let mut i = 0usize;
|
||||
@@ -82,8 +90,9 @@ pub fn read_stored(data: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let name_start = i + 30;
|
||||
let body = name_start + nlen + xlen;
|
||||
let (Some(name), Some(bytes)) = (data.get(name_start..name_start + nlen), data.get(body..body + size)) else { break };
|
||||
if method == 0 {
|
||||
out.push((String::from_utf8_lossy(name).to_string(), bytes.to_vec()));
|
||||
let name = String::from_utf8_lossy(name).to_string();
|
||||
if method == 0 && is_relative_name(&name) {
|
||||
out.push((name, bytes.to_vec()));
|
||||
}
|
||||
i = body + size;
|
||||
}
|
||||
@@ -101,4 +110,12 @@ mod tests {
|
||||
assert_eq!(back[1].0, "b/c.txt");
|
||||
assert_eq!(back[1].1, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_outside_the_archive_root_are_skipped() {
|
||||
let names = ["../up.txt", "a/../../up.txt", "/abs/x", "\\abs\\x", "C:\\x", "c:x", "a\\..\\x", "ok/x.txt", "ok/..x"];
|
||||
let z = super::store(&names.iter().map(|n| (n.to_string(), b"x".to_vec())).collect::<Vec<_>>());
|
||||
let back: Vec<String> = super::read_stored(&z).into_iter().map(|(n, _)| n).collect();
|
||||
assert_eq!(back, ["ok/x.txt", "ok/..x"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user