From f565f7b38d3987ea71fd1ecac6ba3d6f8c5f88ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 17:27:39 +0000 Subject: [PATCH] fix(engine): follow stored media paths only on this computer or the project's share Media and proxy paths read from a project file, a project template or an interchange file are opened only when they name a local file or a file on the network share the document itself is on (craft-docpath); a relative path is taken from the document's folder. Other paths count as missing: the item renders the offline slate with the reason, the open says how many were not opened, and Link Media lists them. This covers the open-time scan, rendering and proxies, the growing-files look and interchange import. Files imported or linked in this session are opened as given. The Scratch Disks folders use the same check, and Captured Audio and Video (voice-over takes, pasted images, the default proxy folder) and Audio Previews follow the rule Auto Save and Video Previews already follow. --- Cargo.lock | 5 + Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/autosave.rs | 4 +- crates/engine/src/autosave_tests.rs | 39 +++++ crates/engine/src/commands.rs | 2 + crates/engine/src/doc_path.rs | 64 ++++++++ crates/engine/src/interchange.rs | 14 +- crates/engine/src/lib.rs | 3 + crates/engine/src/media_pool.rs | 71 ++++++++- crates/engine/src/media_watch.rs | 22 +-- crates/engine/src/project_tools.rs | 48 +++--- crates/engine/src/proxies.rs | 14 +- crates/engine/src/relink.rs | 29 ++-- crates/engine/src/stored_paths_tests.rs | 195 ++++++++++++++++++++++++ crates/engine/src/voiceover.rs | 6 +- crates/ui-egui/src/lib.rs | 2 +- docs/project-files.md | 15 +- 18 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 crates/engine/src/doc_path.rs create mode 100644 crates/engine/src/stored_paths_tests.rs diff --git a/Cargo.lock b/Cargo.lock index e50797eab..bd24b5204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,10 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-ui" version = "0.1.0" @@ -1902,6 +1906,7 @@ dependencies = [ name = "filmcraft-engine" version = "0.8.0" dependencies = [ + "craft-docpath", "filmcraft-audio-dsp", "filmcraft-captions", "filmcraft-codecs", diff --git a/Cargo.toml b/Cargo.toml index 2530bb351..3531e5608 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ log = "0.4" rayon = "1" proptest = "1" roxmltree = "0.21" +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } # ui # Shared frontend components; keep this dependency above the engine boundary. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 16283a375..088163f10 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -42,6 +42,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +craft-docpath = { workspace = true } web-time = "1" [dev-dependencies] diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 7d76704ab..362c2af92 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -50,8 +50,8 @@ pub struct AutoSavePrefs { pub recovery_journal: bool, /// Longest time (s) unsaved changes may stay out of the journal while edits keep coming. pub recovery_interval_seconds: u32, - /// Folders chosen in Project Settings ▸ Scratch Disks on this computer (Project Auto Save, - /// Video Previews). A project file may name these, or folders inside its own folder; see + /// Folders chosen in Project Settings ▸ Scratch Disks on this computer. A project file may + /// name these, or folders inside its own folder; see /// [`crate::project_tools::trusted_scratch`]. pub scratch_folders: Vec, } diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index 6c204e3ac..4ac6346a4 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -286,3 +286,42 @@ fn project_scratch_folders_outside_the_project_are_not_used() { s.shutdown(); let _ = std::fs::remove_dir_all(&d); } + +/// The Captured Audio and Video and Audio Previews folders a project names follow the same rule: +/// voice-over takes, pasted images and proxies go next to the project unless the folder is +/// inside the project's folder; folders on another computer are never used. +#[test] +fn project_captured_folder_outside_the_project_is_not_used() { + let d = temp_dir("scratch-captured"); + let show = d.join("show"); + std::fs::create_dir_all(&show).unwrap(); + let proj = show.join("cut.fcproj").to_string_lossy().to_string(); + let mut s = demo(); + s.execute("file.saveAs", json!({"path": proj})).unwrap(); + let mut named = vec![d.join("elsewhere").to_string_lossy().to_string(), r"\\192.0.2.1\share\cap".to_string(), "file://192.0.2.1/s/cap".to_string()]; + if cfg!(unix) { + named.push(format!("/{}", show.join("Captured").display())); + } + let beside = show.to_string_lossy().to_string(); + for dir in &named { + s.edit("scratch", |p, _| { + p.settings.scratch.captured = Some(dir.clone()); + p.settings.scratch.audio_previews = Some(dir.clone()); + Ok(()) + }) + .unwrap(); + assert_eq!(crate::voiceover::media_dir(&s, &serde_json::Value::Null, "Captured"), beside, "{dir}"); + let paths = crate::project_tools::scratch_paths(&s); + assert_eq!(paths["captured"]["path"], json!(beside), "{dir}"); + assert_eq!(paths["audioPreviews"]["path"], json!(beside), "{dir}"); + } + let inner = show.join("Captured").to_string_lossy().to_string(); + s.edit("scratch", |p, _| { + p.settings.scratch.captured = Some(inner.clone()); + Ok(()) + }) + .unwrap(); + assert_eq!(crate::voiceover::media_dir(&s, &serde_json::Value::Null, "Captured"), inner); + assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": "file://192.0.2.1/s/cap"})).is_err()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 99ef31534..f7737f571 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -700,6 +700,7 @@ fn import_source( v.frame_rate = crate::settings::timebase_rate(&s.prefs.media.indeterminate_timebase); } let path_s = path.to_string(); + s.media.allow(path); let id = s.edit(&format!("Import {name}"), |p, _| { Ok(p.add_item( &name, @@ -4356,6 +4357,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti s.media.set_use_proxies(proxies); s.offline = Default::default(); let first = proj.sequences().next().map(|i| i.id); + s.media.add_stored(&proj, path.as_deref()); s.project = std::sync::Arc::new(proj); s.history = Default::default(); s.history.limit = 200; diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 000000000..7168b181a --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,64 @@ +//! Media paths stored in a document (a project, a project template, an interchange file) 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 opened (see +//! `craft_docpath`). A refused path is handled like a missing file: the item renders the offline +//! slate and File ▸ Link Media… can reconnect it. +//! +//! Paths the user picks in this session (import, Link Media, file dialogs, the control channel) +//! are not stored-document paths and are followed as given. The media pool tells them apart +//! ([`crate::MediaPool::follow`]): it records the paths of each document it loads, with that +//! document, and forgets a path once the user picks it again. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// 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 document's own share. + Refused(Refused), + /// The path is relative and the document has no file to take it from. + NoDocumentFolder, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NoDocumentFolder => 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 path or `file:` URL stored in the document at `document` +/// (None: a document without a file). A relative `document` is taken from the working directory, +/// where it was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result { + let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); + let path: PathBuf = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // document does not choose. + if path.is_relative() { + return Err(NotFollowed::NoDocumentFolder); + } + path.into_os_string().into_string().map_err(|_| NotFollowed::Refused(Refused::Invalid)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder() { + let dir = std::env::temp_dir().join("films"); + let doc = dir.join("cut.fcproj"); + assert_eq!(resolve("clip.mp4", Some(&doc)), Ok(dir.join("clip.mp4").to_string_lossy().into_owned())); + assert_eq!(resolve("clip.mp4", None), Err(NotFollowed::NoDocumentFolder)); + assert_eq!(resolve(r"\\192.0.2.1\share\x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("file://192.0.2.1/s/x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + } +} diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index bd37f6bb0..5102308c8 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -74,13 +74,23 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu _ => None, }) .collect(); + // The document's media paths are opened as stored paths of the document. + s.media.add_stored_paths(new_media.iter().map(|(_, p)| p.clone()), Some(path)); let mut linked = 0; let mut offline = Vec::new(); for (id, mpath) in new_media { - match s.media.open_file(&mpath, &*s.services).ok() { + let file = match s.media.follow(&mpath) { + Ok(file) => file, + Err(why) => { + log::warn!("{mpath} is not opened: {why}"); + offline.push(mpath); + continue; + } + }; + match s.media.open_file(&file, &*s.services).ok() { Some(src) => { let info = src.info().clone(); - let identity = crate::relink::identity_of(&*s.services, &mpath).ok(); + let identity = crate::relink::identity_of(&*s.services, &file).ok(); let rebase = (format == Format::Edl).then_some(info.start_timecode).flatten(); s.edit("Link Media", |proj, _| { if let Some(item) = proj.items.get_mut(&id) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1e444ab27..5d0a044d0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod clip_ops; pub mod color; pub mod commands; pub mod demo; +pub mod doc_path; pub mod essential_sound; pub mod export_tools; pub mod graphic_templates; @@ -1308,6 +1309,8 @@ mod shortcuts_tests; #[cfg(test)] mod split_edit_ripple_trim_tests; #[cfg(test)] +mod stored_paths_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod timeline_move_tests; diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index 8b4c4d95c..23278317f 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -11,8 +11,11 @@ //! from it. Proxy frames are smaller; the source still reports the full-resolution size, and the //! compositor derives its pixel scale from the frame it gets, so effects and Motion render the //! same picture at lower resolution. Export asks for [`MediaPool::full_res_provider`]. +//! - Paths that came from a document (the project file, a template, an interchange file) are +//! opened through [`crate::doc_path`] ([`MediaPool::follow`]); a refused one renders as missing. use std::collections::{BTreeSet, HashMap}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -53,6 +56,9 @@ pub struct MediaPool { failures: Arc>>, /// Openers tried before the built-in ones (MP4/MOV + codecs register here). pub openers: RwLock>, + /// Media and proxy paths that came from a document, with that document's path (see + /// [`Self::follow`]). + stored: RwLock>>, } impl Default for MediaPool { @@ -65,6 +71,7 @@ impl Default for MediaPool { use_proxies: AtomicBool::new(false), failures: Arc::default(), openers: RwLock::new(filmcraft_codecs::openers()), + stored: RwLock::default(), } } } @@ -173,6 +180,54 @@ impl MediaPool { self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); } + /// Record the media and proxy paths of `p`, loaded from the document at `document` (None: one + /// without a file), as stored paths: they are opened only as [`crate::doc_path::resolve`] + /// allows. A path already recorded keeps the document it came from first. + pub fn add_stored(&self, p: &Project, document: Option<&str>) { + let paths = p.items.values().filter_map(|i| i.as_media()).flat_map(|m| [Some(&m.media), m.proxy.as_ref()]).filter_map(|r| match r { + Some(MediaRef::File { path }) => Some(path.clone()), + _ => None, + }); + self.add_stored_paths(paths, document); + } + + /// [`Self::add_stored`] for single paths. + pub fn add_stored_paths(&self, paths: impl IntoIterator, document: Option<&str>) { + let document = document.filter(|d| !d.is_empty()).map(PathBuf::from); + let mut stored = self.stored.write().unwrap_or_else(|e| e.into_inner()); + for p in paths { + stored.entry(p).or_insert_with(|| document.clone()); + } + } + + /// Forget the stored paths (a new project). + pub fn clear_stored(&self) { + self.stored.write().unwrap_or_else(|e| e.into_inner()).clear(); + } + + /// The user picked `path` in this session (import, Link Media): open it as given. + pub fn allow(&self, path: &str) { + self.stored.write().unwrap_or_else(|e| e.into_inner()).remove(path); + } + + /// The file to open for `path`, a media or proxy path of the project: a stored path as + /// [`crate::doc_path::resolve`] resolves it, any other path as given. + pub fn follow(&self, path: &str) -> Result { + // The web host reads only the files the user gave it, under its own virtual paths. + if cfg!(target_arch = "wasm32") { + return Ok(path.to_string()); + } + match self.stored.read().unwrap_or_else(|e| e.into_inner()).get(path) { + Some(document) => crate::doc_path::resolve(path, document.as_deref()), + None => Ok(path.to_string()), + } + } + + /// [`Self::follow`] as a media error (a refused path is a missing file). + fn follow_media(&self, path: &str) -> Result { + self.follow(path).map_err(|why| MediaError::Offline(format!("{path} is not opened: {why}"))) + } + /// Forget every opened source (files are re-read on next use). pub fn clear(&self) { self.sources.write().unwrap_or_else(|e| e.into_inner()).clear(); @@ -279,11 +334,13 @@ impl MediaPool { self.set_offline(item, OfflineReason::MadeOffline, path, "made offline"); Arc::new(SlateSource::new(m.info.clone(), &file_name(path), OfflineReason::MadeOffline)) } - MediaRef::File { path } => match if m.info.kind == filmcraft_media::MediaKind::ImageSequence { - open_image_sequence(path, services, m.info.frame_rate(), &it.name) - } else { - self.open_file(path, services) - } { + MediaRef::File { path } => match self.follow_media(path).and_then(|file| { + if m.info.kind == filmcraft_media::MediaKind::ImageSequence { + open_image_sequence(&file, services, m.info.frame_rate(), &it.name) + } else { + self.open_file(&file, services) + } + }) { Ok(s) => { self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); self.unsupported_as_slate(item, path, s) @@ -315,7 +372,7 @@ impl MediaPool { { return Some(s.clone()); } - match self.open_file(path, services) { + match self.follow_media(path).and_then(|file| self.open_file(&file, services)) { Ok(s) => { // a proxy with fewer audio streams than the original (say one track of an OBS // recording's seven) still stands in for the picture; the audio comes from the @@ -323,7 +380,7 @@ impl MediaPool { let audio_from = if s.info().audio_streams.len() < m.info.audio_streams.len() { log::warn!("proxy has fewer audio streams than the original: {path}; audio from full-resolution media"); match &m.media { - MediaRef::File { path: original } if !m.offline => self.open_file(original, services).ok(), + MediaRef::File { path: original } if !m.offline => self.follow_media(original).and_then(|file| self.open_file(&file, services)).ok(), _ => None, } } else { diff --git a/crates/engine/src/media_watch.rs b/crates/engine/src/media_watch.rs index ddef6f118..99070c049 100644 --- a/crates/engine/src/media_watch.rs +++ b/crates/engine/src/media_watch.rs @@ -18,13 +18,13 @@ //! //! [`MediaInfo`]: filmcraft_media::MediaInfo -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Arc, Mutex, PoisonError}; use filmcraft_project::{ItemId, ItemKind, MediaRef, Project}; use serde_json::{Value, json}; -use crate::{Result, Session}; +use crate::{MediaPool, Result, Session}; /// A file's size and modification time (nanoseconds since 1970). pub type Stamp = (u64, u128); @@ -44,13 +44,14 @@ fn stamp(_: &str) -> Option { None } -/// The files of `p`'s file media items (made-offline ones aside). -pub fn media_files(p: &Project) -> BTreeSet { +/// The files of `p`'s file media items (made-offline ones aside): each item's path → the file the +/// media pool opens for it. Paths the pool does not follow ([`MediaPool::follow`]) are left out. +pub fn media_files(p: &Project, pool: &MediaPool) -> BTreeMap { p.items .values() .filter_map(|i| match &i.kind { ItemKind::Media(m) if !m.offline => match &m.media { - MediaRef::File { path } => Some(path.clone()), + MediaRef::File { path } => Some((path.clone(), pool.follow(path).ok()?)), _ => None, }, _ => None, @@ -60,7 +61,7 @@ pub fn media_files(p: &Project) -> BTreeSet { /// `media.refreshChanged`: stamp the project's media files now and read the changed ones again. pub(crate) fn refresh_changed(s: &mut Session, _: &Value) -> Result { - let now = media_files(&s.project).into_iter().map(|p| (p.clone(), stamp(&p))).collect(); + let now = media_files(&s.project, &s.media).into_iter().map(|(p, file)| (p, stamp(&file))).collect(); s.apply_media_stamps(now) } @@ -71,14 +72,14 @@ impl Session { if self.media_scan.is_some() || cfg!(target_arch = "wasm32") { return; } - let files = media_files(&self.project); + let files = media_files(&self.project, &self.media); if files.is_empty() { return; } let slot: MediaScan = Arc::default(); let out = slot.clone(); let spawned = std::thread::Builder::new().name("media-watch".into()).spawn(move || { - let stamps = files.into_iter().map(|p| (p.clone(), stamp(&p))).collect(); + let stamps = files.into_iter().map(|(p, file)| (p, stamp(&file))).collect(); *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); }); if spawned.is_ok() { @@ -125,8 +126,9 @@ impl Session { if !changed.contains(path) || failed.contains(path) { continue; } + let Ok(file) = self.media.follow(path) else { continue }; // Opened the way import opens it, so the info is what a new import would get. - match self.media.open_file(path, &*self.services) { + match self.media.open_file(&file, &*self.services) { Ok(src) => { let mut info = src.info().clone(); if info.kind == filmcraft_media::MediaKind::Still @@ -134,7 +136,7 @@ impl Session { { v.frame_rate = crate::settings::timebase_rate(&self.prefs.media.indeterminate_timebase); } - let identity = crate::relink::identity_of(&*self.services, path).ok(); + let identity = crate::relink::identity_of(&*self.services, &file).ok(); fresh.push((*id, path.clone(), info, identity, src)); } Err(_) => { diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 8255e1a0a..c5f967a75 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -687,6 +687,8 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { s.previews.reset_temp(); } s.media.clear(); + s.media.clear_stored(); + s.media.add_stored(&s.project, Some(path.as_str())); s.revision += 1; s.saved_revision = s.revision; s.events.push(crate::Event::ProjectChanged { revision: s.revision }); @@ -960,10 +962,15 @@ pub fn scratch_paths(s: &Session) -> Value { .to_string(), None => same(&None), }; + let scratch = |o: &Option| { + trusted_scratch(o.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders) + .map(|d| d.to_string_lossy().to_string()) + .unwrap_or_else(|| same(&None)) + }; json!({ - "captured": {"setting": sc.captured, "path": same(&sc.captured)}, + "captured": {"setting": sc.captured, "path": scratch(&sc.captured)}, "videoPreviews": {"setting": sc.video_previews, "path": previews_dir(s).map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| same(&sc.video_previews))}, - "audioPreviews": {"setting": sc.audio_previews, "path": same(&sc.audio_previews)}, + "audioPreviews": {"setting": sc.audio_previews, "path": scratch(&sc.audio_previews)}, "autoSave": {"setting": sc.auto_save, "path": auto_save}, }) } @@ -988,14 +995,17 @@ pub fn previews_dir_for(project: &Project, path: &str, chosen: &[String]) -> std /// The Scratch Disks folder `dir` from the settings of the project saved at `project_path`, if /// FilmCraft may write there: a folder chosen in Project Settings ▸ Scratch Disks on this computer /// (`chosen`, kept in the preferences), or a folder inside the project's own folder. Other folders -/// named in a project file, network shares and device paths give None: the default location. +/// named in a project file, and paths [`crate::doc_path::resolve`] refuses (other computers, +/// devices), give None: the default location. pub fn trusted_scratch(dir: Option<&str>, project_path: Option<&str>, chosen: &[String]) -> Option { - let dir = dir.filter(|d| !d.is_empty() && !network_or_device(d))?; + let dir = dir.filter(|d| !d.is_empty())?; + let resolved = std::path::PathBuf::from(crate::doc_path::resolve(dir, project_path.map(std::path::Path::new)).ok()?); if chosen.iter().any(|c| c == dir) { - return Some(dir.into()); + return Some(resolved); } - let root = std::path::Path::new(project_path?).parent().filter(|r| !r.as_os_str().is_empty())?; - inside(std::path::Path::new(dir), root).then(|| dir.into()) + let project = std::path::absolute(project_path?).ok()?; + let root = project.parent().filter(|r| !r.as_os_str().is_empty())?; + inside(&resolved, root).then_some(resolved) } /// Whether `dir` lies inside `root`, also after following the links in the part that exists. @@ -1015,18 +1025,6 @@ fn inside(dir: &std::path::Path, root: &std::path::Path) -> bool { } } -/// Network-share (`\\server\share`, `//server/share`) and device (`\\?\`, `\\.\`, `NUL`, -/// `COM1`…) paths, on every platform. -pub(crate) fn network_or_device(dir: &str) -> bool { - let d = dir.replace('\\', "/"); - d.starts_with("//") - || d.split('/').any(|c| { - let stem = c.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase(); - matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") - || stem.strip_prefix("COM").or_else(|| stem.strip_prefix("LPT")).is_some_and(|n| n.len() == 1 && n.bytes().all(|b| b.is_ascii_digit())) - }) -} - /// Remember the Scratch Disks folders chosen in this session, so projects naming them may use /// them later (see [`trusted_scratch`]). fn remember_scratch_folders(s: &mut Session, dirs: &[String]) { @@ -1050,7 +1048,7 @@ fn remember_scratch_folders(s: &mut Session, dirs: &[String]) { /// FilmCraft will not write to (see [`trusted_scratch`]). pub(crate) fn warn_untrusted_scratch(s: &mut Session) { let sc = &s.project.settings.scratch; - let ignored: Vec = [&sc.auto_save, &sc.video_previews] + let ignored: Vec = [&sc.auto_save, &sc.video_previews, &sc.captured, &sc.audio_previews] .into_iter() .filter_map(|d| d.as_deref().filter(|d| !d.is_empty())) .filter(|d| trusted_scratch(Some(d), s.path.as_deref(), &s.prefs.auto_save.scratch_folders).is_none()) @@ -1064,7 +1062,7 @@ pub(crate) fn warn_untrusted_scratch(s: &mut Session) { let list = ignored.join(", "); log::warn!("project scratch disks outside the project folder not used: {list}"); s.toast(format!( - "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves and previews go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it." + "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it." )); } @@ -1078,13 +1076,13 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result { None => {} Some(Value::Null) => *slot = None, Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None, - Some(Value::String(x)) if matches!(k, "videoPreviews" | "autoSave") && network_or_device(x) => { + Some(Value::String(x)) + if matches!(crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)), Err(crate::doc_path::NotFollowed::Refused(_))) => + { return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer, not a network share or device"))); } Some(Value::String(x)) => { - if matches!(k, "videoPreviews" | "autoSave") { - chosen.push(x.clone()); - } + chosen.push(x.clone()); *slot = Some(x.clone()); } Some(_) => return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` is a folder path or null"))), diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index f7ca3cfa2..d6c720fa6 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -347,9 +347,10 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let pr = preset(str_p(p, "preset").unwrap_or(DEFAULT_PROXY_PRESET)).ok_or_else(|| bad("media.createProxies", "unknown preset (see media.proxyPresets)"))?; let items = items_param(s, p); // default: Project Settings ▸ Scratch Disks ▸ Captured and Generated, else next to the media - let dest = str_p(p, "destination") - .map(str::to_string) - .or_else(|| s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()).map(|d| format!("{d}/Proxies"))); + let dest = str_p(p, "destination").map(str::to_string).or_else(|| { + crate::project_tools::trusted_scratch(s.project.settings.scratch.captured.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders) + .map(|d| d.join("Proxies").to_string_lossy().into_owned()) + }); let mut work = Vec::new(); let mut taken = Vec::new(); let mut skipped = Vec::new(); @@ -362,6 +363,13 @@ pub fn create(s: &mut Session, p: &Value) -> Result { skipped.push(json!({"item": i.0, "reason": format!("{name} has no video to proxy")})); continue; } + let path = match s.media.follow(&path) { + Ok(file) => file, + Err(why) => { + skipped.push(json!({"item": i.0, "reason": format!("{path} is not opened: {why}")})); + continue; + } + }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); let out = unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken).to_string_lossy().into_owned(); taken.push(out.clone()); diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 95c8a7efa..c3b6b3879 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, always, bad, bool_p, str_p, u64_p}; -use crate::{EngineError, Result, Services, Session}; +use crate::{EngineError, MediaPool, Result, Services, Session}; /// Bytes hashed at each end of the file. pub const FINGERPRINT_SPAN: usize = 1 << 20; @@ -100,18 +100,20 @@ fn split_ext(name: &str) -> (&str, &str) { } } -/// Every media item whose file is missing (or that was made offline), in project order. -pub fn scan(p: &Project, services: &dyn Services) -> Vec { +/// Every media item whose file is missing (or that was made offline), in project order. A +/// stored path the media pool does not follow ([`MediaPool::follow`]) counts as missing. +pub fn scan(p: &Project, pool: &MediaPool, services: &dyn Services) -> Vec { + let exists = |path: &str| pool.follow(path).is_ok_and(|file| services.file_size(&file).is_ok()); let mut out = Vec::new(); for it in p.items.values() { let ItemKind::Media(m) = &it.kind else { continue }; let MediaRef::File { path } = &m.media else { continue }; let status = if m.offline { "offline" - } else if services.file_size(path).is_err() { + } else if !exists(path) { "missing" } else if let Some(MediaRef::File { path: pp }) = &m.proxy - && services.file_size(pp).is_err() + && !exists(pp) { "proxyMissing" } else { @@ -132,7 +134,7 @@ pub fn scan(p: &Project, services: &dyn Services) -> Vec { /// Rescan and remember the result. Returns the list. pub fn refresh(s: &mut Session) -> Vec { - let list = scan(&s.project, &*s.services); + let list = scan(&s.project, &s.media, &*s.services); s.offline.missing = list.iter().filter(|m| m.status != "proxyMissing").map(|m| ItemId(m.item)).collect(); list } @@ -352,6 +354,9 @@ fn apply(s: &mut Session, plans: Vec, align_timecode: bool, label: &str) - } Ok(()) })?; + for p in &plans { + s.media.allow(&p.cand.path); + } for (item, path, src) in sources { s.media.insert_file(item, &path, src); } @@ -368,7 +373,7 @@ fn others(s: &Session, skip: ItemId, old: &str, new: &str, o: &MatchOptions) -> let remap = derive_remap(old, new); let new_dir = Path::new(new).parent().map(Path::to_path_buf); let mut out = Vec::new(); - for m in scan(&s.project, &*s.services) { + for m in scan(&s.project, &s.media, &*s.services) { let id = ItemId(m.item); if id == skip || m.status != "missing" { continue; @@ -431,7 +436,7 @@ pub fn relink(s: &mut Session, p: &Value) -> Result { /// `media.autoRelink`: `{from, to}` remaps a folder prefix; `{folder}` searches a folder tree. pub fn auto_relink(s: &mut Session, p: &Value) -> Result { let o = MatchOptions::from_params(p); - let missing: Vec = scan(&s.project, &*s.services).into_iter().filter(|m| m.status == "missing").collect(); + let missing: Vec = scan(&s.project, &s.media, &*s.services).into_iter().filter(|m| m.status == "missing").collect(); let mut plans = Vec::new(); let mut failed = Vec::new(); if let (Some(from), Some(to)) = (str_p(p, "from"), str_p(p, "to")) { @@ -505,7 +510,7 @@ pub fn search(s: &Session, p: &Value) -> Result { let o = MatchOptions::from_params(p); let items: Vec = match u64_p(p, "item") { Some(i) => vec![ItemId(i)], - None => scan(&s.project, &*s.services).into_iter().filter(|m| m.status == "missing").map(|m| ItemId(m.item)).collect(), + None => scan(&s.project, &s.media, &*s.services).into_iter().filter(|m| m.status == "missing").map(|m| ItemId(m.item)).collect(), }; let index = index_folder(Path::new(folder), 20_000); let mut out = Vec::new(); @@ -734,6 +739,12 @@ pub fn commands() -> Vec { /// After a project opens: find missing files and ask for them. pub fn on_open(s: &mut Session) -> usize { let list = refresh(s); + let refused: Vec = + list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect(); + if !refused.is_empty() { + log::warn!("media paths stored in the project not opened: {}", refused.join(", ")); + s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len())); + } s.offline.prompt = !s.offline.missing.is_empty(); if !s.offline.missing.is_empty() { s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len())); diff --git a/crates/engine/src/stored_paths_tests.rs b/crates/engine/src/stored_paths_tests.rs new file mode 100644 index 000000000..ad5718c28 --- /dev/null +++ b/crates/engine/src/stored_paths_tests.rs @@ -0,0 +1,195 @@ +//! Media paths stored in a project or an interchange file are opened only when they name a file +//! on this computer or on the document's own share (`doc_path`): the open-time scan, rendering +//! and the growing-files look never reach another computer, while a path beside the document and +//! a file the user links in this session still open. + +use std::path::Path; +use std::sync::{Arc, Mutex, PoisonError}; + +use filmcraft_media::DemoScene; +use filmcraft_project::{ItemId, MediaRef}; +use filmcraft_render::offline::OfflineReason; +use serde_json::json; + +use crate::media_test_util::{make_movie, session_with, tmp_dir}; +use crate::{FsServices, Services, Session}; + +const W: u32 = 160; +const H: u32 = 90; + +/// The file system, remembering every path it is asked about. +#[derive(Default)] +struct Recording(Arc>>); + +impl Recording { + fn note(&self, path: &str) { + self.0.lock().unwrap_or_else(PoisonError::into_inner).push(path.to_string()); + } +} + +impl Services for Recording { + fn read_file(&self, path: &str) -> std::io::Result> { + self.note(path); + FsServices.read_file(path) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + self.note(path); + FsServices.write_file(path, data) + } + fn file_size(&self, path: &str) -> std::io::Result { + self.note(path); + FsServices.file_size(path) + } + fn read_range(&self, path: &str, offset: u64, len: usize) -> std::io::Result> { + self.note(path); + FsServices.read_range(path, offset, len) + } + fn reader(&self, path: &str) -> Option> { + self.note(path); + FsServices.reader(path) + } + fn list_dir(&self, dir: &str) -> Option>> { + self.note(dir); + FsServices.list_dir(dir) + } +} + +/// A session reading files through [`Recording`], and the paths it was asked about. +fn recording_session() -> (Session, Arc>>) { + let seen = Arc::>>::default(); + let s = Session { services: Arc::new(Recording(seen.clone())), ..Session::default() }; + (s, seen) +} + +/// Paths that name another computer, or `//` (on Unix the system reads it as the +/// local file; the resolver reads it as a share and refuses it). +fn foreign(paths: &[String]) -> Vec<&String> { + paths.iter().filter(|p| p.contains("192.0.2.1") || p.starts_with("//") || p.starts_with(r"\\") || p.starts_with("file:")).collect() +} + +/// `//` for an absolute Unix path (None elsewhere). +fn slashed(path: &Path) -> Option { + cfg!(unix).then(|| format!("/{}", path.display())) +} + +#[test] +fn stored_media_paths_open_only_on_this_computer_or_beside_the_project() { + let root = tmp_dir("stored-paths"); + let media = root.join("Media"); + let project = root.join("Project"); + std::fs::create_dir_all(&media).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + let a = media.join("a.mov"); + let beside = project.join("b.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + make_movie(&beside, DemoScene::CityNight, W, H, 12); + + let mut stored = vec![r"\\192.0.2.1\share\a.mov".to_string(), "file://192.0.2.1/s/a.mov".to_string()]; + stored.extend(slashed(&a)); + let refused = stored.len(); + stored.push("b.mov".to_string()); + let files: Vec<&Path> = (0..refused).map(|_| a.as_path()).chain([beside.as_path()]).collect(); + let (mut s, items, _) = session_with(&files); + let p = Arc::make_mut(&mut s.project); + for (id, path) in items.iter().zip(&stored) { + p.item_mut(*id).unwrap().as_media_mut().unwrap().media = MediaRef::File { path: path.clone() }; + } + let doc = project.join("p.fcproj").to_string_lossy().into_owned(); + s.execute("file.save", json!({"path": doc})).unwrap(); + + let (mut t, seen) = recording_session(); + let r = t.execute("file.open", json!({"path": doc})).unwrap(); + assert_eq!(r["missingMedia"], json!(refused), "{r}"); + for &id in &items { + assert!(t.media.full_res_source(&t.project, id, &*t.services).is_some()); + } + t.execute("media.refreshChanged", json!({})).unwrap(); + + let asked = seen.lock().unwrap().clone(); + assert!(foreign(&asked).is_empty(), "stored paths on other computers were opened: {:?}", foreign(&asked)); + for (id, path) in items.iter().zip(&stored).take(refused) { + let st = t.media.offline_status(*id).unwrap_or_else(|| panic!("{path} must render offline")); + assert_eq!(st.reason, OfflineReason::Missing); + assert!(st.error.contains("is not opened"), "{st:?}"); + assert!(!t.media_stamps.contains_key(path), "{path} was stamped"); + } + let rel = items[refused]; + assert_eq!(t.media.offline_status(rel), None, "a path beside the project opens"); + assert!(t.media_stamps.contains_key("b.mov")); + + // linking a file in this session opens it, even at the path the project stored + if let Some(path) = slashed(&a) { + let id = items[refused - 1]; + t.execute("media.relink", json!({"item": id.0, "path": path, "force": true})).unwrap(); + t.media.remove(id); + assert!(t.media.full_res_source(&t.project, id, &*t.services).is_some()); + assert_eq!(t.media.offline_status(id), None, "a file linked in this session opens"); + } +} + +/// A one-clip FCP7 XML sequence using the file at `pathurl`. +fn xml(pathurl: &str) -> String { + let rate = "24FALSE"; + format!( + r#" + + + + Cut + 12 + {rate} + + + + + +"# + ) +} + +#[test] +fn interchange_media_paths_open_only_on_this_computer_or_beside_the_document() { + let root = tmp_dir("stored-paths-xml"); + std::fs::create_dir_all(root.join("media")).unwrap(); + let shot = root.join("media").join("shot.mov"); + make_movie(&shot, DemoScene::Aurora, W, H, 12); + + let mut urls = vec!["file://192.0.2.1/s/shot.mov".to_string()]; + urls.extend(slashed(&shot).map(|p| format!("file://{p}"))); + for (k, url) in urls.iter().enumerate() { + let doc = root.join(format!("refused-{k}.xml")); + std::fs::write(&doc, xml(url)).unwrap(); + let (mut s, seen) = recording_session(); + let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap(); + assert_eq!(r["documents"][0]["linkedMedia"], json!(0), "{r}"); + let asked = seen.lock().unwrap().clone(); + assert!(foreign(&asked).is_empty(), "{url} was opened: {:?}", foreign(&asked)); + let ids: Vec = s.project.items.keys().copied().collect(); + for id in ids { + s.media.full_res_source(&s.project, id, &*s.services); + } + let asked = seen.lock().unwrap().clone(); + assert!(foreign(&asked).is_empty(), "{url} was rendered: {:?}", foreign(&asked)); + } + + let doc = root.join("beside.xml"); + std::fs::write(&doc, xml("media/shot.mov")).unwrap(); + let (mut s, _) = recording_session(); + let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap(); + assert_eq!(r["documents"][0]["linkedMedia"], json!(1), "a path beside the document links: {r}"); +} diff --git a/crates/engine/src/voiceover.rs b/crates/engine/src/voiceover.rs index fa3dd42a7..c1633ec06 100644 --- a/crates/engine/src/voiceover.rs +++ b/crates/engine/src/voiceover.rs @@ -396,8 +396,10 @@ pub(crate) fn media_dir(s: &Session, p: &Value, fallback: &str) -> String { if let Some(d) = str_p(p, "dir") { return d.to_string(); } - if let Some(d) = s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()) { - return d; + if let Some(d) = + crate::project_tools::trusted_scratch(s.project.settings.scratch.captured.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders) + { + return d.to_string_lossy().into_owned(); } if let Some(d) = s.path.as_deref().and_then(|x| std::path::Path::new(x).parent()).filter(|d| !d.as_os_str().is_empty()) { return d.to_string_lossy().into_owned(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb4a2344a..e3a644ff8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1701,7 +1701,7 @@ impl FilmcraftApp { let focused = ctx.input(|i| i.focused); let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); ctx.data_mut(|d| d.insert_temp(focus_key, focused)); - if !on || filmcraft_engine::media_watch::media_files(&self.session.project).is_empty() { + if !on || filmcraft_engine::media_watch::media_files(&self.session.project, &self.session.media).is_empty() { return; } let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); diff --git a/docs/project-files.md b/docs/project-files.md index fafb4f685..3ca0bc0be 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -131,11 +131,12 @@ deleted. Only files named exactly `-.fcproj` are ever deleted. ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest first. -Project Settings ▸ Scratch Disks can send auto-saves (Project Auto Save) and render previews (Video -Previews) to another folder. A folder named in a project file is used only when it is inside the -project's own folder or was chosen in Scratch Disks on this computer (remembered in +Project Settings ▸ Scratch Disks can send auto-saves (Project Auto Save), render previews (Video +Previews), captured and generated media (Captured Audio and Video, also the default for proxies) +and audio previews to another folder. A folder named in a project file is used only when it is +inside the project's own folder or was chosen in Scratch Disks on this computer (remembered in `autoSave.scratchFolders`). Otherwise FilmCraft uses the default location and says so when the -project opens. Network shares and device paths are never used for these two. +project opens. Folders on another computer or a device are never used. Preferences are stored in `/preferences.json`. The `prefs.*` commands read and change them. @@ -268,6 +269,12 @@ have no identity, so relinking them skips the fingerprint check. deleted only with `deleteFiles: true`. - The media pool caches sources per item **and** per reference (path, offline flag). So relinking, Make Offline and undoing either take effect on the next frame. +- **Stored paths.** Media and proxy paths read from a document (the project file, a project + template, an interchange file) are opened only when they name a file on this computer or on + the network share the document itself is on (`crates/engine/src/doc_path.rs`, built on + `craft-docpath`); a relative path is taken from the document's folder. Other paths (another + computer, a device) count as missing: the item shows the offline slate, the open says why, and + the Link Media dialog lists it. A file imported or linked in this session is opened as given. ### Link Media