From 7fc1be0bf2d5701ea1fd5aeb9158a1d6c9f90d3f Mon Sep 17 00:00:00 2001 From: ash Date: Fri, 6 Nov 2026 16:08:57 +0530 Subject: [PATCH] Report offline footage and recover automatically when media returns (#3161) * 3118: report and recover offline footage consistently * 3118: preserve manual reload and refresh data watch paths * 3118: document availability and reload contracts --- crates/engine/src/cmd/frontend.rs | 2 +- crates/engine/src/cmd/layer_menu.rs | 4 + crates/engine/src/cmd/proxy.rs | 1 + crates/engine/src/cmd/query.rs | 6 +- crates/engine/src/footage.rs | 2 + crates/engine/src/footage/availability.rs | 504 ++++++++++++++++++ crates/engine/src/footage/doc_path.rs | 31 +- crates/engine/src/footage/doc_path/tests.rs | 43 ++ crates/engine/src/footage/footage_check.rs | 55 +- crates/engine/src/footage/footage_reload.rs | 430 +++++++++++++-- crates/engine/src/lib.rs | 3 +- crates/engine/src/session/edit.rs | 28 +- crates/host/tests/it/footage_reload.rs | 6 +- crates/project/src/item.rs | 36 ++ crates/render/src/disk_cache.rs | 5 + crates/render/src/footage.rs | 24 +- crates/render/src/lib.rs | 2 +- crates/ui-egui/src/app/frame.rs | 35 +- crates/ui-egui/src/i18n/ui.rs | 4 + crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus/frontend.rs | 8 + crates/ui-egui/src/panels/project.rs | 108 +++- crates/ui-egui/src/panels/timeline.rs | 20 +- crates/ui-egui/src/state.rs | 33 ++ .../ui-egui/tests/it/ui_unreadable_footage.rs | 97 ++++ 25 files changed, 1347 insertions(+), 142 deletions(-) create mode 100644 crates/engine/src/footage/availability.rs diff --git a/crates/engine/src/cmd/frontend.rs b/crates/engine/src/cmd/frontend.rs index 2dc894d5f..765476a90 100644 --- a/crates/engine/src/cmd/frontend.rs +++ b/crates/engine/src/cmd/frontend.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always), fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always), fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), - fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), + fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?: string, status?: all|available|missing}", always), fe!("effects.copyPresetsFolderPath", "Copy Presets Folder Path", [], None, "{}", always), // Preview. fe!( diff --git a/crates/engine/src/cmd/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs index e0c4efc72..894a808c2 100644 --- a/crates/engine/src/cmd/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -718,6 +718,9 @@ fn opens_as_bundle(dir: &std::path::Path) -> bool { /// plain folder after following links, is refused. pub(crate) fn reveal_url(s: &Session, stored: &str) -> std::result::Result { let path = s.stored_path(stored).map_err(|why| crate::doc_path::refusal(stored, why))?; + if !s.services.exists(&path.to_string_lossy()) { + return Err(format!("Missing footage: {stored}. Use File ▸ Dependencies ▸ Relink Missing Footage or Replace Footage.")); + } 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) { @@ -966,6 +969,7 @@ mod tests { 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(); + std::fs::write(d.join(f).join("a.mov"), b"fixture").unwrap(); } let s = crate::Session::default(); let reveal = |folder: &str| super::reveal_url(&s, &d.join(folder).join("a.mov").to_string_lossy()); diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index 58b147e89..42633b913 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -53,6 +53,7 @@ fn probe(s: &Session, path: &str, cmd: &str) -> Result { /// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step. pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> { + s.stored_paths.allow(&footage); s.edit("Set Proxy", None, |proj, _| { for i in items { if let Some(it) = proj.item_mut(*i) { diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index b80c9cffb..dd1581733 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -46,9 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage (or the proxy in use) that shows no picture although its file is there (a - // codec or profile the decoders lack): why, so an agent can tell the user. - if let Some(why) = s.footage.item_problem(i) { + // Include offline originals and unused proxies in the inventory reported to agents. + o["missing"] = json!(i.has_missing_footage()); + if let Some(why) = s.footage.item_problem(i).or_else(|| crate::render::missing_item_problem(i)) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/footage.rs b/crates/engine/src/footage.rs index 21e67d8be..b921dd128 100644 --- a/crates/engine/src/footage.rs +++ b/crates/engine/src/footage.rs @@ -4,3 +4,5 @@ pub mod footage_reload; pub mod media_browser; pub mod media_cache; pub mod sequence; + +pub(crate) mod availability; diff --git a/crates/engine/src/footage/availability.rs b/crates/engine/src/footage/availability.rs new file mode 100644 index 000000000..7ad2bc03f --- /dev/null +++ b/crates/engine/src/footage/availability.rs @@ -0,0 +1,504 @@ +//! Snapshot-based footage availability, shared by open-time checks and the disk watcher. +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use crate::{Services, Session}; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Project}; + +#[derive(Clone, PartialEq)] +pub(crate) struct Source { + pub id: ItemId, + pub proxy: bool, + pub footage: Footage, + paths: Vec, + folder: Option, +} + +impl Source { + /// Find the same source settings in a newer project, ignoring only the disk-derived + /// missing flag so an overlapping check cannot invalidate moved-source recovery. + pub fn current<'a>(&self, project: &'a mut Project) -> Option<&'a mut Footage> { + let item = project.item_mut(self.id)?; + let f = if self.proxy { + &mut item.proxy.as_mut()?.footage + } else { + let ItemKind::Footage(f) = &mut item.kind else { return None }; + f + }; + // Availability is an observation, not a source edit. Another disk check must not + // invalidate an open-time probe or recovery of a source moved with the project. + let missing = std::mem::replace(&mut f.missing, self.footage.missing); + let same = *f == self.footage; + f.missing = missing; + same.then_some(f) + } +} + +/// External originals and proxies that need availability checks. Embedded data and empty +/// paths remain usable without an external file and are excluded from this inventory. +fn entries(s: &Session) -> impl Iterator { + s.project.items.values().flat_map(|item| { + let original = match &item.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }; + original + .map(|f| (false, f)) + .into_iter() + .chain(item.proxy.as_ref().map(|p| (true, &p.footage))) + .filter(|(_, f)| f.kind != FootageKind::Data && !f.path.is_empty()) + .map(move |(proxy, f)| (item.id, proxy, f)) + }) +} + +/// Check whether a snapshot can be reused without allocating new sequence path strings. +pub(crate) fn sources_match(s: &Session, sources: &[Source]) -> bool { + let mut before = sources.iter(); + for (id, proxy, footage) in entries(s) { + let Some(old) = before.next() else { + return false; + }; + if old.id != id || old.proxy != proxy || old.footage != *footage { + return false; + } + } + before.next().is_none() +} + +/// Resolve accepted paths once for the worker. Refused document paths retain an empty probe +/// list, which reports missing without contacting the filesystem or a foreign share. +pub(crate) fn sources(s: &Session) -> Vec { + let mut out = Vec::new(); + for (id, proxy, f) in entries(s) { + let paths: Vec = if s.stored_paths.check(f).is_ok() { + std::iter::once(&f.path).chain(&f.sequence).filter_map(|p| s.stored_path(p).ok()?.to_str().map(str::to_string)).collect() + } else { + Vec::new() + }; + let folder = paths + .first() + .and_then(|p| std::path::Path::new(p).parent()) + .filter(|dir| !dir.as_os_str().is_empty() && paths.iter().all(|p| std::path::Path::new(p).parent() == Some(*dir))) + .and_then(|p| p.to_str()) + .map(str::to_string); + out.push(Source { id, proxy, footage: f.clone(), paths, folder }); + } + out +} + +/// Each unique file is checked at most once per pass. Sequences are available while any frame +/// exists; when their common directory is offline, avoid probing thousands of absent frames. +pub(crate) fn scan(sources: &[Source], services: &dyn Services) -> Vec { + let mut files = BTreeMap::new(); + let mut folders = BTreeMap::new(); + sources + .iter() + .map(|source| { + let mut paths = source.paths.iter(); + let Some(first) = paths.next() else { return true }; + let present = |p: &String, files: &mut BTreeMap| *files.entry(p.clone()).or_insert_with(|| services.exists(p)); + if present(first, &mut files) { + return false; + } + if let Some(dir) = &source.folder + && !*folders.entry(dir.clone()).or_insert_with(|| services.is_dir(dir)) + { + return true; + } + !paths.any(|p| present(p, &mut files)) + }) + .collect() +} + +/// Maximum availability probes per automatic polling pass. Explicit checks may finish a full +/// scan; automatic monitoring resumes partial sequence scans and keeps the last proven status. +pub(crate) const PROBE_BUDGET: usize = 256; + +struct Progress { + next: usize, + absent: usize, + missing: bool, +} + +#[derive(Default)] +pub(crate) struct Scanner { + progress: Vec, + source: usize, +} + +impl Scanner { + /// Discard partial evidence after watched sources change, retaining their last known flags. + pub fn reset(&mut self, sources: &[Source]) { + self.progress = sources.iter().map(|s| Progress { next: 0, absent: 0, missing: s.footage.missing }).collect(); + self.source = 0; + } + + /// Spend at most PROBE_BUDGET file/folder probes and return one status per source. + /// Partial sequence scans retain the last proven status and resume on a later poll. + pub fn poll(&mut self, sources: &[Source], services: &dyn Services) -> Vec { + if self.progress.len() != sources.len() { + self.reset(sources); + } + if sources.is_empty() { + return Vec::new(); + } + let mut remaining = PROBE_BUDGET; + let mut visited = 0usize; + let mut files = BTreeMap::new(); + let mut folders = BTreeMap::new(); + self.source %= sources.len(); + let mut continuation = false; + while remaining > 0 { + if visited >= sources.len() { + if !self.progress.iter().any(|p| p.absent > 0) { + break; + } + visited = 0; + continuation = true; + } + let Some(source) = sources.get(self.source) else { + break; + }; + let Some(progress) = self.progress.get_mut(self.source) else { + break; + }; + if continuation && progress.absent == 0 { + visited = visited.saturating_add(1); + self.source = self.source.saturating_add(1) % sources.len(); + continue; + } + let quota = (remaining / sources.len().saturating_sub(visited).max(1)).max(2).min(remaining); + let mut used = 0usize; + let mut checked_folder = false; + if source.paths.is_empty() { + progress.missing = true; + } + while used < quota && !source.paths.is_empty() { + let Some(path) = source.paths.get(progress.next) else { + progress.next = 0; + break; + }; + remaining = remaining.saturating_sub(1); + used = used.saturating_add(1); + if *files.entry(path.clone()).or_insert_with(|| services.exists(path)) { + progress.missing = false; + progress.absent = 0; + // Reuse this witness next time rather than scan absent earlier frames. + break; + } + progress.next = progress.next.saturating_add(1) % source.paths.len(); + progress.absent = progress.absent.saturating_add(1); + if progress.absent >= source.paths.len() { + progress.missing = true; + progress.absent = 0; + break; + } + if !checked_folder + && used < quota + && let Some(folder) = &source.folder + { + remaining = remaining.saturating_sub(1); + used = used.saturating_add(1); + checked_folder = true; + if !*folders.entry(folder.clone()).or_insert_with(|| services.is_dir(folder)) { + progress.missing = true; + progress.next = 0; + progress.absent = 0; + break; + } + } + } + visited = visited.saturating_add(1); + self.source = self.source.saturating_add(1) % sources.len(); + } + self.progress.iter().map(|p| p.missing).collect() + } +} + +/// A disk observation prepared entirely by the existing worker, including the potentially +/// expensive sequence stamps used for disk-cache keys. The UI only swaps a current snapshot. +pub(crate) struct Update { + before: Arc, + after: Arc, + paths: BTreeSet, + newly_missing: usize, + cache_enabled: bool, + salt: Option<(u64, u64)>, +} + +/// Prepare a replacement only when availability changed. Project cloning and optional disk +/// salt computation belong on the worker; apply later rejects an outdated project snapshot. +pub(crate) fn prepare(project: &Arc, sources: &[Source], missing: &[bool], cache_enabled: bool) -> Option { + let mut after = project.clone(); + let mut paths = BTreeSet::new(); + let mut newly_missing = 0; + for (source, missing) in sources.iter().zip(missing) { + if source.footage.missing == *missing { + continue; + } + let Some(current) = source.current(Arc::make_mut(&mut after)) else { continue }; + current.missing = *missing; + newly_missing += usize::from(*missing); + paths.insert(source.footage.path.clone()); + paths.extend(source.footage.sequence.iter().cloned()); + } + if paths.is_empty() { + return None; + } + let salt = cache_enabled.then(|| (Session::footage_fingerprint(&after), crate::render::disk_cache::footage_salt(&after))); + Some(Update { before: project.clone(), after, paths, newly_missing, cache_enabled, salt }) +} + +impl Update { + /// False means a newer project or cache configuration needs a fresh observation. + pub fn apply(self, s: &mut Session) -> bool { + if !Arc::ptr_eq(&s.project, &self.before) || s.disk_cache.is_some() != self.cache_enabled { + return false; + } + let dirty = s.is_dirty(); + s.project = self.after; + if let Some((fingerprint, salt)) = self.salt { + s.disk_salt = Some((fingerprint, salt)); + s.layer_cache.set_disk_salt(salt); + } + s.bump(); + if !dirty { + s.mark_saved(); + } + super::footage_reload::forget_media(s, &self.paths); + if self.newly_missing > 0 { + s.toast(format!( + "{} footage source(s) offline. Use File ▸ Dependencies ▸ Relink Missing Footage, or Set Proxy for a missing proxy.", + self.newly_missing + )); + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_project::Proxy; + use serde_json::json; + use std::sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, + }; + + #[derive(Default)] + struct Disk { + files: Mutex>, + looks: AtomicUsize, + existing_dir: bool, + } + impl Disk { + fn set(&self, paths: &[&str]) { + *self.files.lock().unwrap() = paths.iter().map(|p| p.to_string()).collect(); + } + } + impl Services for Disk { + fn read_file(&self, _: &str) -> std::io::Result> { + Err(std::io::ErrorKind::NotFound.into()) + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Ok(()) + } + fn exists(&self, path: &str) -> bool { + self.looks.fetch_add(1, Ordering::Relaxed); + self.files.lock().unwrap().contains(path) + } + fn is_dir(&self, path: &str) -> bool { + if self.existing_dir { + return true; + } + self.files.lock().unwrap().iter().any(|p| std::path::Path::new(p).parent() == Some(std::path::Path::new(path))) + } + } + fn session(disk: Arc) -> (Session, ItemId) { + let mut s = Session { services: disk, ..Default::default() }; + s.prefs.import.auto_reload_footage = "off".into(); + let f = Footage { path: "/media/original.png".into(), width: 64, height: 32, has_video: true, ..Default::default() }; + let id = Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(f.clone())); + Arc::make_mut(&mut s.project).item_mut(id).unwrap().proxy = + Some(Box::new(Proxy { footage: Footage { path: "/media/proxy.png".into(), ..f }, enabled: false })); + s.mark_saved(); + (s, id) + } + fn check(s: &mut Session) { + s.execute("footage.reloadChanged", json!({})).unwrap(); + } + + #[test] + fn disconnect_and_reconnect_update_original_and_unused_proxy_without_edits() { + let disk = Arc::new(Disk::default()); + disk.set(&["/media/original.png", "/media/proxy.png"]); + let (mut s, id) = session(disk.clone()); + check(&mut s); + assert!(!s.project.item(id).unwrap().has_missing_footage()); + disk.set(&["/media/proxy.png"]); + check(&mut s); + let sum = s.execute("project.summary", json!({})).unwrap(); + assert_eq!(sum["items"][0]["missing"], true); + assert!(sum["items"][0]["problem"].as_str().unwrap().contains("/media/original.png")); + disk.set(&["/media/original.png"]); + check(&mut s); + let item = s.project.item(id).unwrap(); + assert!(matches!(&item.kind, ItemKind::Footage(f) if !f.missing)); + assert!(item.proxy.as_ref().unwrap().footage.missing); + disk.set(&["/media/original.png", "/media/proxy.png"]); + check(&mut s); + assert!(!s.project.item(id).unwrap().has_missing_footage()); + assert!(!s.is_dirty()); + assert!(s.history.undo.is_empty()); + let revision = s.revision; + check(&mut s); + assert_eq!(s.revision, revision, "unchanged observations are idempotent"); + } + + #[test] + fn open_time_check_includes_proxies() { + let disk = Arc::new(Disk::default()); + disk.set(&["/media/original.png"]); + let (mut s, id) = session(disk); + let r = s.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r, json!({"checked": 2, "missing": 1, "probed": 0})); + assert!(s.project.item(id).unwrap().proxy.as_ref().unwrap().footage.missing); + assert!(!s.is_dirty()); + } + + #[test] + fn stale_observation_cannot_mark_a_replacement_missing() { + let (mut s, id) = session(Arc::new(Disk::default())); + let old = sources(&s); + let update = prepare(&s.project, &old, &[true, true], false).unwrap(); + if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind { + f.path = "/new.png".into(); + } + assert!(!update.apply(&mut s)); + assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if !f.missing)); + } + + #[test] + fn sequences_short_circuit_and_offline_folders_do_not_probe_every_frame() { + let disk = Arc::new(Disk::default()); + disk.set(&["/media/frame1.png"]); + let (mut s, id) = session(disk.clone()); + if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind { + f.kind = FootageKind::Sequence; + f.path = "/media/frame0.png".into(); + f.sequence = (0..10_000).map(|n| format!("/media/frame{n}.png")).collect(); + } + let source = sources(&s); + assert_eq!(scan(&source, disk.as_ref()), [false, true], "a later frame keeps the sequence available"); + assert!(disk.looks.load(Ordering::Relaxed) < 5); + disk.set(&[]); + disk.looks.store(0, Ordering::Relaxed); + assert_eq!(scan(&source, disk.as_ref()), [true, true]); + assert_eq!(disk.looks.load(Ordering::Relaxed), 2, "one file per source, no scan of an unmounted folder"); + } + + #[test] + fn applying_prepared_cache_salt_does_not_restat_files_on_the_ui_thread() { + let dir = std::env::temp_dir().join(format!("ec-availability-salt-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("plate.png"); + std::fs::write(&file, b"plate").unwrap(); + let mut s = Session::default(); + Arc::make_mut(&mut s.project).add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { path: file.to_string_lossy().into_owned(), has_video: true, missing: true, ..Default::default() }), + ); + let cache = crate::render::disk_cache::DiskCache::open(dir.join("cache"), 1 << 20).unwrap(); + s.disk_cache = Some(cache.clone()); + s.layer_cache.set_disk(Some((cache, 0))); + s.mark_saved(); + let update = prepare(&s.project, &sources(&s), &[false], true).unwrap(); + let expected = update.salt.unwrap().1; + // The filesystem can change again after the worker's observation. Applying it must + // retain that observation's salt, rather than scan the disk a second time on the UI. + std::fs::remove_file(file).unwrap(); + assert_ne!(crate::render::disk_cache::footage_salt(&update.after), expected); + assert!(update.apply(&mut s)); + assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(expected)); + assert!(!s.is_dirty()); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn automatic_sequence_checks_are_bounded_and_resume_without_starving_other_sources() { + let disk = Arc::new(Disk { existing_dir: true, ..Default::default() }); + let (mut s, id) = session(disk.clone()); + if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind { + f.kind = FootageKind::Sequence; + f.path = "/media/frame0.png".into(); + f.sequence = (0..10_000).map(|n| format!("/media/frame{n}.png")).collect(); + } + let sources = sources(&s); + let mut scanner = Scanner::default(); + let mut status = Vec::new(); + for _ in 0..100 { + disk.looks.store(0, Ordering::Relaxed); + status = scanner.poll(&sources, disk.as_ref()); + assert!(disk.looks.load(Ordering::Relaxed) <= PROBE_BUDGET); + assert!(status[1], "the single-file proxy is checked without waiting for the long sequence"); + if status[0] { + break; + } + } + assert_eq!(status, [true, true], "a complete absent cycle proves the sequence missing"); + disk.set(&["/media/frame9999.png"]); + for _ in 0..100 { + disk.looks.store(0, Ordering::Relaxed); + status = scanner.poll(&sources, disk.as_ref()); + assert!(disk.looks.load(Ordering::Relaxed) <= PROBE_BUDGET); + if !status[0] { + break; + } + } + assert_eq!(status, [false, true], "a late frame is eventually found"); + disk.looks.store(0, Ordering::Relaxed); + assert_eq!(scanner.poll(&sources, disk.as_ref()), [false, true]); + assert!(disk.looks.load(Ordering::Relaxed) <= 2, "keep the available late frame as a witness"); + } + + #[test] + fn save_disconnect_undo_redo_and_reconciliation_return_to_clean_saved_state() { + let disk = Arc::new(Disk::default()); + disk.set(&["/media/original.png", "/media/proxy.png"]); + let (mut s, id) = session(disk.clone()); + s.edit("Rename", None, |project, _| { + project.item_mut(id).unwrap().name = "Saved plate".into(); + Ok(()) + }) + .unwrap(); + s.mark_saved(); + let history = s.history.undo.clone(); + disk.set(&[]); + check(&mut s); + assert!(!s.is_dirty()); + assert!(s.undo()); + assert!(s.is_dirty()); + check(&mut s); + assert!(s.redo()); + check(&mut s); + assert!(!s.is_dirty(), "redo restores the saved observed snapshot retained by undo"); + assert_eq!(s.project.item(id).unwrap().name, "Saved plate"); + assert_eq!(s.history.undo.len(), history.len()); + assert_eq!(s.history.undo[0].0, history[0].0); + assert!(s.history.redo.is_empty()); + } + + #[test] + fn duplicate_sources_share_one_file_probe() { + let disk = Arc::new(Disk::default()); + disk.set(&["/media/original.png"]); + let (mut s, id) = session(disk.clone()); + Arc::make_mut(&mut s.project).item_mut(id).unwrap().proxy.as_mut().unwrap().footage.path = "/media/original.png".into(); + assert_eq!(scan(&sources(&s), disk.as_ref()), [false, false]); + assert_eq!(disk.looks.load(Ordering::Relaxed), 1); + } +} diff --git a/crates/engine/src/footage/doc_path.rs b/crates/engine/src/footage/doc_path.rs index e8d972413..25ef9c09a 100644 --- a/crates/engine/src/footage/doc_path.rs +++ b/crates/engine/src/footage/doc_path.rs @@ -82,7 +82,7 @@ fn paths_of(f: &Footage) -> impl Iterator { /// 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)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct StoredPaths(Arc>>); impl StoredPaths { @@ -97,7 +97,11 @@ impl StoredPaths { pub(crate) fn add_items(&mut self, p: &Project, pick: impl Fn(ItemId) -> bool, document: Option<&str>) { let map = Arc::make_mut(&mut self.0); for item in p.items.values().filter(|i| pick(i.id)) { - if let ItemKind::Footage(f) = &item.kind { + let original = match &item.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }; + for f in original.into_iter().chain(item.proxy.as_ref().map(|p| &p.footage)) { for q in paths_of(f) { map.entry(q.clone()).or_insert_with(|| document.map(str::to_string)); } @@ -147,15 +151,20 @@ pub(crate) fn resolve_footage(p: &mut Project, project: Option<&str>) -> Vec<(It pub(crate) fn resolve_footage_of(p: &mut Project, pick: impl Fn(ItemId) -> bool, project: Option<&str>) -> Vec<(ItemId, String, NotFollowed)> { let mut refused = vec![]; for item in p.items.values_mut().filter(|i| pick(i.id)) { - 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)); + let original = match &mut item.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }; + for f in original.into_iter().chain(item.proxy.as_mut().map(|p| &mut p.footage)) { + 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)); + } } } } diff --git a/crates/engine/src/footage/doc_path/tests.rs b/crates/engine/src/footage/doc_path/tests.rs index d9d971247..8ba2390c9 100644 --- a/crates/engine/src/footage/doc_path/tests.rs +++ b/crates/engine/src/footage/doc_path/tests.rs @@ -169,3 +169,46 @@ fn stored_paths_resolve_against_the_project() { #[cfg(windows)] assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok()); } + +#[test] +fn reveal_deleted_file_in_existing_folder_offers_relink_guidance() { + let dir = std::env::temp_dir().join(format!("ec-reveal-offline-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::default(); + let file = dir.join("deleted.png"); + let id = std::sync::Arc::make_mut(&mut s.project).add_item( + "Deleted", + Default::default(), + None, + ItemKind::Footage(Footage { path: file.to_string_lossy().into_owned(), ..Default::default() }), + ); + let err = s.execute("file.revealInFinder", json!({"item": id.0})).unwrap_err().to_string(); + assert!(err.contains("deleted.png") && err.contains("Relink Missing Footage"), "{err}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn relative_proxy_is_resolved_and_checked_next_to_the_project() { + let dir = std::env::temp_dir().join(format!("ec-proxy-path-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("proxy.png"), b"x").unwrap(); + let mut s = Session::default(); + let id = Arc::make_mut(&mut s.project).add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { path: dir.join("proxy.png").to_string_lossy().into_owned(), width: 64, height: 32, ..Default::default() }), + ); + Arc::make_mut(&mut s.project).item_mut(id).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { + footage: Footage { path: "proxy.png".into(), width: 32, height: 16, ..Default::default() }, + enabled: true, + })); + let project = dir.join("project.ecproj"); + s.execute("file.saveAs", json!({"path": project})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("file.open", json!({"path": project})).unwrap(); + let result = reopened.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(result, json!({"checked": 2, "missing": 0, "probed": 0})); + assert_eq!(std::path::Path::new(&reopened.project.item(id).unwrap().proxy.as_ref().unwrap().footage.path), dir.join("proxy.png")); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/engine/src/footage/footage_check.rs b/crates/engine/src/footage/footage_check.rs index 6bb803f0d..9e941a6ed 100644 --- a/crates/engine/src/footage/footage_check.rs +++ b/crates/engine/src/footage/footage_check.rs @@ -12,17 +12,13 @@ //! starts it after every open ([`Session::check_footage_on_open`]); agents and the CLI run it //! with `wait: true`. -use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind}; +#[cfg(test)] +use effectcraft_project::ItemKind; +use effectcraft_project::{Footage, FootageKind, ItemId}; use serde_json::{Value, json}; use crate::Session; -/// One item to check. -struct Job { - id: ItemId, - footage: Footage, -} - /// What the check found for one item. enum Found { Missing(bool), @@ -34,16 +30,7 @@ enum Found { /// Start the check (all footage, or `items`). `wait` runs it to completion first (always on /// wasm32); the result is then `{checked, missing, probed}`. pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate::Result { - let jobs: Vec = s - .project - .items - .values() - .filter(|i| items.as_ref().is_none_or(|v| v.contains(&i.id))) - .filter_map(|i| match &i.kind { - ItemKind::Footage(f) if f.kind != FootageKind::Data => Some(Job { id: i.id, footage: f.clone() }), - _ => None, - }) - .collect(); + let jobs: Vec<_> = super::availability::sources(s).into_iter().filter(|j| items.as_ref().is_none_or(|v| v.contains(&j.id))).collect(); if jobs.is_empty() { return Ok(json!({"checked": 0, "missing": 0, "probed": 0})); } @@ -54,7 +41,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: 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| { - let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len()); + let mut found: Vec<(super::availability::Source, Found)> = Vec::with_capacity(jobs.len()); let total = jobs.len() as u64; for (k, j) in jobs.into_iter().enumerate() { if !ctl.progress(k as u64, total) { @@ -74,10 +61,11 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: }; // Moved with the project: the same place under the project's folder. if !present + && !j.proxy && let Some(dir) = &project_dir && let Some(nf) = crate::commands::relink_moved::find_moved(services.as_ref(), dir, f) { - found.push((j.id, Found::Moved(Box::new(nf)))); + found.push((j, Found::Moved(Box::new(nf)))); continue; } // Saved without metadata (a hand-written or converted project): probe it now. @@ -102,9 +90,9 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: Some(Ok(mut nf)) => { nf.alpha = f.alpha; nf.loop_count = f.loop_count; - found.push((j.id, Found::Probed(Box::new(nf)))); + found.push((j, Found::Probed(Box::new(nf)))); } - _ => found.push((j.id, Found::Missing(!present))), + _ => found.push((j, Found::Missing(!present))), } } ctl.progress(total, total); @@ -115,12 +103,11 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: /// Write the findings into the project. Not an undo step: it records the state of the disk, /// not an edit (the same as After Effects marking footage missing at open). -fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { +fn apply(s: &mut Session, found: Vec<(super::availability::Source, Found)>) -> Value { let checked = found.len(); let (mut missing, mut probed, mut moved, mut changed) = (0, 0, 0, false); - let p = std::sync::Arc::make_mut(&mut s.project); - for (id, f) in found { - let Some(ItemKind::Footage(cur)) = p.item_mut(id).map(|i| &mut i.kind) else { continue }; + for (source, f) in found { + let Some(cur) = source.current(std::sync::Arc::make_mut(&mut s.project)) else { continue }; match f { Found::Missing(m) => { missing += usize::from(m); @@ -277,6 +264,24 @@ mod tests { let _ = std::fs::remove_dir_all(d); } + #[test] + fn moved_source_recovery_survives_a_watcher_status_update() { + let mut s = session_with(&["/old/project/plate.png"]); + s.mark_saved(); + let source = super::super::availability::sources(&s).pop().unwrap(); + let id = source.id; + let mut recovered = source.footage.clone(); + recovered.path = "/new/project/plate.png".into(); + let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true], false).unwrap(); + assert!(observed.apply(&mut s)); + assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing)); + let result = apply(&mut s, vec![(source, Found::Moved(Box::new(recovered)))]); + assert_eq!(result, json!({"checked": 1, "missing": 0, "probed": 0, "relinked": 1})); + assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if !f.missing && f.path == "/new/project/plate.png")); + assert!(!s.is_dirty()); + assert!(s.history.undo.is_empty()); + } + #[test] fn open_starts_the_check_when_asked() { let mut s = session_with(&["/nonexistent/ec/a.mov"]); diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 16d7c1d5d..7065620af 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -18,7 +18,9 @@ //! the state of the disk: it is not an undo step and doesn't modify the project. use std::collections::BTreeSet; -use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; + +use super::availability::{self, Source}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; @@ -37,7 +39,7 @@ pub type Stamps = Vec<(String, Option)>; /// 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. -pub fn watched(s: &Session) -> BTreeSet { +fn reload_paths(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { @@ -54,6 +56,119 @@ pub fn watched(s: &Session) -> BTreeSet { paths } +/// Automatic reload obeys Off; explicit Reload Changed still uses eligible non-sequence files. +fn automatic_paths(s: &Session) -> BTreeSet { + if s.prefs.import.auto_reload_footage == "off" { BTreeSet::new() } else { reload_paths(s) } +} + +/// Embedded data is excluded from availability warnings, but its external file can still be +/// reloaded. Compare these paths without cloning sequences during unrelated project edits. +fn data_paths(project: &effectcraft_project::Project) -> impl Iterator { + project.items.values().filter_map(|item| match &item.kind { + ItemKind::Footage(f) if f.kind == FootageKind::Data => Some((item.id, f.path.as_str(), f.sequence.as_slice())), + _ => None, + }) +} + +/// Files and source identities watched by the single background disk worker. +#[derive(Clone, Default)] +pub struct Watched { + reload: BTreeSet, + sources: Arc>, + project: Option>, + cache_enabled: bool, + reload_mode: String, + stored_paths: crate::doc_path::StoredPaths, +} + +impl PartialEq for Watched { + /// Compare watched inputs, excluding unrelated project edits whose latest Arc is refreshed + /// separately without waking the worker or restarting partial sequence scans. + fn eq(&self, other: &Self) -> bool { + self.cache_enabled == other.cache_enabled + && self.stored_paths == other.stored_paths + && self.reload_mode == other.reload_mode + && self.reload == other.reload + && (Arc::ptr_eq(&self.sources, &other.sources) || self.sources == other.sources) + } +} + +/// Capture the automatic reload policy and opted-in availability sources for a worker. +/// Watcher::update reuses this source inventory when only unrelated project state changed. +pub fn watched(s: &Session) -> Watched { + Watched { + reload: automatic_paths(s), + sources: Arc::new(if s.check_footage_on_open { availability::sources(s) } else { Vec::new() }), + project: Some(s.project.clone()), + cache_enabled: s.disk_cache.is_some(), + reload_mode: s.prefs.import.auto_reload_footage.clone(), + stored_paths: s.stored_paths.clone(), + } +} + +impl Watched { + /// A worker may sleep indefinitely only when neither content nor availability is watched. + fn is_empty(&self) -> bool { + self.reload.is_empty() && self.sources.is_empty() + } + /// Complete the synchronous pass used by explicit commands. Automatic availability uses + /// Scanner::poll instead, so long sequences cannot consume an unbounded polling pass. + fn scan(&self, services: &dyn Services) -> Observation { + Observation { stamps: self.reload.iter().map(|p| (p.clone(), services.stamp(p))).collect(), missing: availability::scan(&self.sources, services) } + } +} + +#[derive(Clone, PartialEq)] +struct Observation { + stamps: Stamps, + missing: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ReloadPolicy { + Automatic, + Manual, +} + +/// A complete disk observation paired with the sources that were actually checked. +pub struct Scan { + watched: Watched, + observation: Observation, + update: Option, + policy: ReloadPolicy, +} + +impl Scan { + /// Prepare availability and cache state before the session applies the observation. + /// Automatic callers construct this result on the worker; explicit commands run inline. + fn new(watched: Watched, observation: Observation, policy: ReloadPolicy) -> Self { + let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing, watched.cache_enabled)); + Self { watched, observation, update, policy } + } + + /// Apply a current observation, returning `stale: true` when its project needs rechecking. + /// Availability-only scans preserve manual reload stamps; eligible content changes still + /// follow the existing reload/probe path and return its contextual errors. + pub fn apply(self, s: &mut Session) -> crate::Result { + if let Some(update) = self.update + && !update.apply(s) + { + return Ok(json!({"files": 0, "reloaded": 0, "stale": true})); + } + // Availability-only scans must preserve the baseline used by the manual command. + // A scan captured before automatic reload was disabled must not reload stale paths. + if self.policy == ReloadPolicy::Automatic && (self.watched.reload_mode == "off" || s.prefs.import.auto_reload_footage == "off") { + return Ok(json!({"files": 0, "reloaded": 0})); + } + let current = match self.policy { + ReloadPolicy::Automatic => automatic_paths(s), + ReloadPolicy::Manual => reload_paths(s), + }; + let stamps = self.observation.stamps.into_iter().filter(|(p, _)| current.contains(p)).collect(); + apply_stamps(s, stamps) + } +} + /// Size and position in the stack of Photoshop layer `l` in the file at `path` now: the layer /// at its recorded index when the name still matches, else the first layer with its name. fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u32, u32)>)> { @@ -207,12 +322,17 @@ fn apply_reloads(s: &mut Session, updates: Vec<(ItemId, Footage)>, undoable: boo /// Make the media layer and the layer cache let go of `paths`, so renders read them again. fn forget(s: &mut Session, paths: &BTreeSet) { + forget_media(s, paths); + s.refresh_disk_salt(true); +} + +/// Invalidate decoded and rendered buffers without probing the disk for a new salt. +/// Availability updates already carry their worker-computed salt; content reload refreshes it. +pub(crate) fn forget_media(s: &mut Session, paths: &BTreeSet) { for p in paths { s.footage.forget(p); } s.layer_cache.clear(); - // Reloaded files may keep their footage settings but not their size and time. - s.refresh_disk_salt(true); s.events.push(crate::Event::PurgeCaches); } @@ -267,8 +387,16 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate /// `footage.reloadChanged`: stamp the watched files now and reload the ones that changed. pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { let services = s.services.clone(); - let now = watched(s).into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); - apply_stamps(s, now) + let watched = Watched { + reload: reload_paths(s), + sources: Arc::new(availability::sources(s)), + project: Some(s.project.clone()), + cache_enabled: s.disk_cache.is_some(), + reload_mode: s.prefs.import.auto_reload_footage.clone(), + stored_paths: s.stored_paths.clone(), + }; + let observation = watched.scan(services.as_ref()); + Scan::new(watched, observation, ReloadPolicy::Manual).apply(s) } /// How often the watched files are stamped while the window is in front. @@ -277,9 +405,11 @@ pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2); /// as soon as the window comes back, so this only covers a second screen. pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10); -enum Watch { - Paths(BTreeSet), - Focused(bool), +#[derive(Default)] +struct Watch { + paths: Watched, + focused: bool, + recheck: bool, } /// The desktop app's footage watcher: a worker thread that stamps the watched files and hands @@ -288,9 +418,10 @@ enum Watch { /// what to watch and whether the window is in front; nothing watched means the thread sleeps. /// Dropping the watcher ends the thread. pub struct Watcher { - tx: Sender, - result: Arc>>, - paths: BTreeSet, + tx: SyncSender<()>, + pending: Arc>, + result: Arc>>, + paths: Watched, focused: Option, } @@ -307,77 +438,114 @@ impl Watcher { if cfg!(target_arch = "wasm32") { return None; } - let (tx, rx) = std::sync::mpsc::channel(); - let result: Arc>> = Arc::default(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let pending = Arc::new(Mutex::new(Watch { focused: true, ..Default::default() })); + let input = pending.clone(); + let result: Arc>> = Arc::default(); let out = Arc::clone(&result); - std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?; - Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None }) + std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &input, &*services, &out, &wake, focused, unfocused)).ok()?; + Some(Watcher { tx, pending, result, paths: Watched::default(), focused: None }) + } + + /// Refresh the project snapshot after an edit without rebuilding sequence paths or waking + /// the worker when the sources, path provenance and reload policy are unchanged. + pub fn update(&mut self, s: &Session) { + let same_sources = availability::sources_match(s, &self.paths.sources); + let same_data = self.paths.project.as_ref().is_some_and(|before| data_paths(before).eq(data_paths(&s.project))); + if same_sources + && same_data + && self.paths.stored_paths == s.stored_paths + && self.paths.reload_mode == s.prefs.import.auto_reload_footage + && self.paths.cache_enabled == s.disk_cache.is_some() + { + let project = Some(s.project.clone()); + self.paths.project = project.clone(); + self.pending.lock().unwrap_or_else(PoisonError::into_inner).paths.project = project; + } else { + self.set_watched(watched(s)); + } } /// The files to watch ([`watched`]); a changed set is stamped at once. - pub fn set_watched(&mut self, paths: BTreeSet) { - if paths != self.paths { - self.paths = paths.clone(); - let _ = self.tx.send(Watch::Paths(paths)); + pub fn set_watched(&mut self, paths: Watched) { + let changed = paths != self.paths; + self.paths = paths.clone(); + self.pending.lock().unwrap_or_else(PoisonError::into_inner).paths = paths; + if changed { + let _ = self.tx.try_send(()); } } + /// An observation raced with an edit: refresh from the latest snapshot, through the same + /// bounded queue. Unrelated edits update the snapshot without waking or rescanning. + pub fn recheck(&self) { + self.pending.lock().unwrap_or_else(PoisonError::into_inner).recheck = true; + let _ = self.tx.try_send(()); + } + /// Whether the window is in front; coming back to the front stamps the files at once. pub fn set_focused(&mut self, focused: bool) { if self.focused != Some(focused) { self.focused = Some(focused); - let _ = self.tx.send(Watch::Focused(focused)); + self.pending.lock().unwrap_or_else(PoisonError::into_inner).focused = focused; + let _ = self.tx.try_send(()); } } /// Stamps the worker found changed (or the first ones for a new set of files), once; for /// [`apply_stamps`]. `None` while nothing changed. - pub fn take(&self) -> Option { - self.result.lock().unwrap_or_else(PoisonError::into_inner).take() + pub fn take(&self) -> Option { + self.result.lock().unwrap_or_else(PoisonError::into_inner).take().filter(|s| s.watched == self.paths) } } /// The watcher's worker: wait for a message or the interval, stamp, report only changes. fn watch( - rx: &Receiver, + rx: &Receiver<()>, + pending: &Mutex, services: &dyn Services, - out: &Mutex>, + out: &Mutex>, wake: &(dyn Fn() + Send + Sync), focused_every: Duration, unfocused_every: Duration, ) { - let mut paths = BTreeSet::new(); + let mut paths = Watched::default(); let mut focused = true; - let mut last: Option = None; + let mut last: Option = None; + let mut scanner = availability::Scanner::default(); loop { let msg = if paths.is_empty() { rx.recv().map_err(|_| RecvTimeoutError::Disconnected) } else { rx.recv_timeout(if focused { focused_every } else { unfocused_every }) }; - match msg { - Ok(Watch::Paths(p)) => { - paths = p; - // A new set is reported whole, so the session forgets files no longer watched. - last = None; - } - Ok(Watch::Focused(f)) => { - let regained = f && !focused; - focused = f; - if !regained { - continue; - } - } - Err(RecvTimeoutError::Timeout) => {} - Err(RecvTimeoutError::Disconnected) => return, + if matches!(msg, Err(RecvTimeoutError::Disconnected)) { + return; + } + let mut next = pending.lock().unwrap_or_else(PoisonError::into_inner); + let changed = next.paths != paths; + let regained = next.focused && !focused; + focused = next.focused; + let recheck = std::mem::take(&mut next.recheck); + paths = next.paths.clone(); + if changed { + scanner.reset(&paths.sources); + } + if changed || recheck { + last = None; + } + drop(next); + if msg.is_ok() && !changed && !regained && !recheck { + continue; } if paths.is_empty() { continue; } - let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect(); + let stamps = + Observation { stamps: paths.reload.iter().map(|p| (p.clone(), services.stamp(p))).collect(), missing: scanner.poll(&paths.sources, services) }; if last.as_ref() != Some(&stamps) { last = Some(stamps.clone()); - *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(Scan::new(paths.clone(), stamps, ReloadPolicy::Automatic)); wake(); } } @@ -408,6 +576,12 @@ mod watcher_tests { fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { Err(std::io::Error::other("read-only")) } + fn exists(&self, path: &str) -> bool { + self.stamp(path).is_some() + } + fn is_dir(&self, _: &str) -> bool { + true + } fn stamp(&self, path: &str) -> Option { self.looks.fetch_add(1, Ordering::SeqCst); self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied() @@ -423,7 +597,7 @@ mod watcher_tests { if wakes.load(Ordering::SeqCst) >= n && let Some(s) = w.take() { - return s; + return s.observation.stamps; } assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s"); std::thread::sleep(Duration::from_millis(5)); @@ -461,7 +635,7 @@ mod watcher_tests { disk.set("/a.psd", (10, 1)); let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); w.set_focused(true); - w.set_watched(["/a.psd".to_string()].into()); + w.set_watched(Watched { reload: ["/a.psd".to_string()].into(), ..Default::default() }); assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]); let looked = disk.looks.load(Ordering::SeqCst); wait_until("it keeps stamping while the window is in front", || disk.looks.load(Ordering::SeqCst) > looked + 3); @@ -472,6 +646,162 @@ mod watcher_tests { assert_eq!(wakes.load(Ordering::SeqCst), 2); } + #[test] + fn watcher_detects_disconnect_and_return_even_when_automatic_reload_is_off() { + let disk = Arc::new(Disk::default()); + disk.set("/live.png", (1, 1)); + let mut s = Session { services: disk.clone(), check_footage_on_open: true, ..Default::default() }; + s.prefs.import.auto_reload_footage = "off".into(); + let id = Arc::make_mut(&mut s.project).add_item( + "Live", + Default::default(), + None, + ItemKind::Footage(Footage { path: "/live.png".into(), width: 64, height: 32, ..Default::default() }), + ); + s.mark_saved(); + let (mut w, _) = start(&disk, TICK, TICK).unwrap(); + w.set_watched(watched(&s)); + disk.stamps.lock().unwrap().clear(); + wait_until("offline warning", || { + if let Some(scan) = w.take() { + scan.apply(&mut s).unwrap(); + } + s.project.item(id).unwrap().has_missing_footage() + }); + disk.set("/live.png", (1, 1)); + w.set_watched(watched(&s)); + wait_until("reconnected file", || { + if let Some(scan) = w.take() { + scan.apply(&mut s).unwrap(); + } + !s.project.item(id).unwrap().has_missing_footage() + }); + assert!(!s.is_dirty()); + assert!(s.history.undo.is_empty()); + } + + #[test] + fn unrelated_edits_reuse_source_snapshots_without_waking_the_worker() { + let disk = Arc::new(Disk::default()); + disk.set("/plate.png", (1, 1)); + let mut s = Session { services: disk.clone(), check_footage_on_open: true, ..Default::default() }; + s.prefs.import.auto_reload_footage = "off".into(); + let id = Arc::make_mut(&mut s.project).add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { path: "/plate.png".into(), ..Default::default() }), + ); + let (mut w, wakes) = start(&disk, LONG, LONG).unwrap(); + w.update(&s); + wait_for(&w, &wakes, 1); + let sources = w.paths.sources.clone(); + let looks = disk.looks.load(Ordering::SeqCst); + Arc::make_mut(&mut s.project).item_mut(id).unwrap().name = "Renamed".into(); + w.update(&s); + assert!(Arc::ptr_eq(&sources, &w.paths.sources), "sequence snapshots are reused"); + assert!(Arc::ptr_eq(w.pending.lock().unwrap().paths.project.as_ref().unwrap(), &s.project)); + std::thread::sleep(TICK * 3); + assert_eq!(disk.looks.load(Ordering::SeqCst), looks); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + } + + #[test] + fn stale_availability_update_is_rechecked_against_the_latest_project() { + let disk = Arc::new(Disk::default()); + let mut s = Session { services: disk.clone(), check_footage_on_open: true, ..Default::default() }; + s.prefs.import.auto_reload_footage = "off".into(); + let id = Arc::make_mut(&mut s.project).add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { path: "/gone.png".into(), ..Default::default() }), + ); + let (mut w, _) = start(&disk, LONG, LONG).unwrap(); + w.update(&s); + let mut scan = None; + wait_until("first observation", || { + scan = w.take(); + scan.is_some() + }); + Arc::make_mut(&mut s.project).item_mut(id).unwrap().name = "Keep this edit".into(); + assert_eq!(scan.unwrap().apply(&mut s).unwrap()["stale"], true); + w.update(&s); + w.recheck(); + wait_until("fresh availability", || { + if let Some(scan) = w.take() { + scan.apply(&mut s).unwrap(); + } + s.project.item(id).unwrap().has_missing_footage() + }); + assert_eq!(s.project.item(id).unwrap().name, "Keep this edit"); + } + + #[test] + fn manual_reload_with_automatic_reload_off_keeps_its_baseline_and_reloads_changed_data() { + struct DataProbe(Arc); + impl crate::Importer for DataProbe { + fn probe(&self, path: &str) -> std::result::Result { + let data = if self.0.stamp(path) == Some((2, 2)) { "updated" } else { "original" }; + Ok(Footage { path: path.into(), kind: FootageKind::Data, data: Some(data.into()), ..Default::default() }) + } + } + let disk = Arc::new(Disk::default()); + disk.set("/data.csv", (1, 1)); + let mut s = Session { services: disk.clone(), importer: Some(Arc::new(DataProbe(disk.clone()))), ..Default::default() }; + s.prefs.import.auto_reload_footage = "off".into(); + let id = Arc::make_mut(&mut s.project).add_item( + "Data", + Default::default(), + None, + ItemKind::Footage(Footage { path: "/data.csv".into(), kind: FootageKind::Data, data: Some("original".into()), ..Default::default() }), + ); + assert_eq!(s.execute("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + let baseline = s.footage_stamps.clone(); + assert_eq!(baseline.get("/data.csv"), Some(&(1, 1))); + let off = watched(&s); + Scan::new(off.clone(), off.scan(disk.as_ref()), ReloadPolicy::Automatic).apply(&mut s).unwrap(); + assert_eq!(s.footage_stamps, baseline, "availability-only scans retain the manual baseline"); + s.prefs.import.auto_reload_footage = "nonSequence".into(); + disk.set("/data.csv", (2, 2)); + let old = watched(&s); + let pending = Scan::new(old.clone(), old.scan(disk.as_ref()), ReloadPolicy::Automatic); + s.prefs.import.auto_reload_footage = "off".into(); + assert_eq!(pending.apply(&mut s).unwrap(), json!({"files": 0, "reloaded": 0})); + assert_eq!(s.footage_stamps, baseline, "a stale automatic result cannot reload or erase the baseline"); + assert_eq!(s.execute("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1})); + assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.data.as_deref() == Some("updated"))); + assert!(s.history.undo.is_empty()); + } + + #[test] + fn watcher_updates_external_data_paths_after_import_relink_and_removal() { + let disk = Arc::new(Disk::default()); + let mut s = Session { services: disk.clone(), check_footage_on_open: true, ..Default::default() }; + s.prefs.import.auto_reload_footage = "all".into(); + let (mut w, wakes) = start(&disk, LONG, LONG).unwrap(); + w.update(&s); + disk.set("/first.csv", (1, 1)); + let id = Arc::make_mut(&mut s.project).add_item( + "Data", + Default::default(), + None, + ItemKind::Footage(Footage { path: "/first.csv".into(), kind: FootageKind::Data, ..Default::default() }), + ); + w.update(&s); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/first.csv".into(), Some((1, 1)))]); + disk.set("/second.csv", (2, 2)); + if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind { + f.path = "/second.csv".into(); + } + w.update(&s); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/second.csv".into(), Some((2, 2)))]); + Arc::make_mut(&mut s.project).items.remove(&id); + w.update(&s); + assert!(w.paths.reload.is_empty()); + assert!(w.pending.lock().unwrap().paths.reload.is_empty()); + } + /// Behind another app the files are stamped only at the slow interval; the window coming /// back to the front stamps them at once. #[test] @@ -480,7 +810,7 @@ mod watcher_tests { disk.set("/b.psd", (1, 1)); let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); w.set_focused(false); - w.set_watched(["/b.psd".to_string()].into()); + w.set_watched(Watched { reload: ["/b.psd".to_string()].into(), ..Default::default() }); wait_for(&w, &wakes, 1); let looked = disk.looks.load(Ordering::SeqCst); disk.set("/b.psd", (2, 2)); @@ -502,11 +832,11 @@ mod watcher_tests { w.set_focused(true); std::thread::sleep(TICK * 10); assert_eq!(disk.looks.load(Ordering::SeqCst), 0); - w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into()); + w.set_watched(Watched { reload: ["/c.psd".to_string(), "/d.psd".to_string()].into(), ..Default::default() }); assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]); - w.set_watched(["/d.psd".to_string()].into()); + w.set_watched(Watched { reload: ["/d.psd".to_string()].into(), ..Default::default() }); assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd"); - w.set_watched(BTreeSet::new()); + w.set_watched(Watched::default()); // The worker may already be past its wait with the old set: that one round of stamps // (one file) can still come, and nothing after it. let looked = disk.looks.load(Ordering::SeqCst); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3e67873e6..b5250a3e0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -244,7 +244,8 @@ pub struct Session { pub next_task_id: u64, /// The running Learn tutorial (Home ▸ Learn), see [`learn`]. pub learn: Option, - /// Check footage in the background after File ▸ Open ([`footage_check`]): frontends with an + /// Track footage availability automatically, after File ▸ Open ([`footage_check`]) and in + /// the desktop disk watcher. Frontends with an /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when /// they want it. pub check_footage_on_open: bool, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index d63ec5ead..ec6a724ac 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -109,19 +109,11 @@ impl Session { /// attached), not at every edit: with a few large sequences that cost each keystroke in a /// text field over half a second (#1926). pub fn refresh_disk_salt(&mut self, force: bool) -> Option { - use std::hash::{Hash, Hasher}; if self.disk_cache.is_none() { self.disk_salt = None; return None; } - let mut h = std::collections::hash_map::DefaultHasher::new(); - for (id, it) in &self.project.items { - if let effectcraft_project::ItemKind::Footage(f) = &it.kind { - id.hash(&mut h); - format!("{f:?}").hash(&mut h); - } - } - let footage = h.finish(); + let footage = Self::footage_fingerprint(&self.project); if let Some((seen, salt)) = self.disk_salt && seen == footage && !force @@ -134,6 +126,24 @@ impl Session { Some(salt) } + /// In-memory identity of the footage settings represented by a disk salt. Workers use the + /// same fingerprint when preparing availability changes without disk access on the UI. + pub(crate) fn footage_fingerprint(project: &effectcraft_project::Project) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for (id, item) in &project.items { + if let effectcraft_project::ItemKind::Footage(f) = &item.kind { + id.hash(&mut h); + format!("{f:?}").hash(&mut h); + } + if let Some(proxy) = &item.proxy { + id.hash(&mut h); + format!("{proxy:?}").hash(&mut h); + } + } + h.finish() + } + /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay /// valid ([`effectcraft_project::Comp::same_pixels`]). diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index 876c543ec..61dc9dbdc 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -147,7 +147,7 @@ fn a_file_still_being_written_is_tried_again() { } /// The desktop app's watcher ([`footage_reload::Watcher`]) notices a footage file another app -/// saved, hands its stamps over once, and reloads it through [`footage_reload::apply_stamps`]; +/// saved, hands its stamps over once, and reloads it through [`footage_reload::Scan::apply`]; /// while nothing changes it hands over nothing (so the interface is never woken for it). #[test] fn the_watcher_notices_a_footage_file_changed_on_disk() { @@ -186,14 +186,14 @@ fn the_watcher_notices_a_footage_file_changed_on_disk() { } }; // First look: remembered, nothing to reload. - assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 1)).unwrap(), json!({"files": 0, "reloaded": 0})); + assert_eq!(next(&w, 1).apply(&mut s).unwrap(), json!({"files": 0, "reloaded": 0})); assert_eq!(wakes.load(Ordering::SeqCst), 1); std::thread::sleep(fast * 15); assert!(w.take().is_none(), "an unchanged file is never reported"); assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface"); // Saved in another app: reported once, reloaded, shown. save(&path, &flat(GREEN, true)); - assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 2)).unwrap(), json!({"files": 1, "reloaded": 1})); + assert_eq!(next(&w, 2).apply(&mut s).unwrap(), json!({"files": 1, "reloaded": 1})); assert!(near(pixel(&s, cid, 32, 24), GREEN)); assert_eq!(wakes.load(Ordering::SeqCst), 2); std::thread::sleep(fast * 10); diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 32a92d8d2..4fcc06ab9 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -55,6 +55,13 @@ pub struct Item { } impl Item { + /// Whether an external original or proxy is known to be offline, even if the other can + /// still provide a preview. Generated items and embedded data have no external dependency. + pub fn has_missing_footage(&self) -> bool { + let missing = |f: &crate::Footage| f.missing && !f.path.is_empty() && f.kind != FootageKind::Data; + matches!(&self.kind, ItemKind::Footage(f) if missing(f)) || self.proxy.as_ref().is_some_and(|p| missing(&p.footage)) + } + pub fn type_name(&self) -> &'static str { match &self.kind { ItemKind::Folder => "Folder", @@ -440,3 +447,32 @@ impl Heard { self } } + +#[cfg(test)] +mod availability_tests { + use super::*; + + #[test] + fn inventory_status_tracks_original_and_proxy_independently() { + let footage = crate::Footage { path: "/original.png".into(), ..Default::default() }; + let mut item = Item { + id: ItemId(1), + name: "Plate".into(), + label: Label::None, + comment: String::new(), + parent: None, + kind: ItemKind::Footage(footage.clone()), + proxy: None, + }; + assert!(!item.has_missing_footage()); + item.proxy = Some(Box::new(Proxy { footage: crate::Footage { path: "/proxy.png".into(), missing: true, ..footage.clone() }, enabled: false })); + assert!(item.has_missing_footage(), "unused missing proxies still need repair"); + item.proxy.as_mut().unwrap().footage.missing = false; + assert!(!item.has_missing_footage()); + if let ItemKind::Footage(f) = &mut item.kind { + f.missing = true; + } + item.proxy.as_mut().unwrap().enabled = true; + assert!(item.has_missing_footage(), "a working proxy does not make the original available"); + } +} diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index e05ff739f..ef41fdf66 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -1004,6 +1004,11 @@ pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Opt hash_debug(&mut h, f); footage_stamp(&mut h, f, virtual_id); } + if let Some(proxy) = it.proxy.as_ref().filter(|p| p.enabled) { + h.write_u64(id.0); + hash_debug(&mut h, proxy); + footage_stamp(&mut h, &proxy.footage, virtual_id); + } } let v = h.finish(); (v as u64) ^ ((v >> 64) as u64) diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 8db3530d3..4c90967d3 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -99,20 +99,38 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } - /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// Missing files and [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy /// instead (the reason then says it is the proxy's), else the footage item's own. A /// composition has one only through its proxy. fn item_problem(&self, item: &effectcraft_project::Item) -> Option { match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { - Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + Some(px) => { + missing_problem(&px.footage, "Use Set Proxy to locate the proxy file.").or_else(|| self.problem(&px.footage)).map(|why| format!("Proxy: {why}")) + } None => match &item.kind { - ItemKind::Footage(f) => self.problem(f), + ItemKind::Footage(f) => missing_problem(f, "Use File ▸ Dependencies ▸ Relink Missing Footage or Replace Footage.").or_else(|| self.problem(f)), _ => None, }, } } } +/// A known offline source, without filesystem access while painting or querying. +fn missing_problem(f: &Footage, remedy: &str) -> Option { + (f.missing && !f.path.is_empty() && f.kind != effectcraft_project::FootageKind::Data).then(|| format!("Missing footage: {}. {remedy}", f.path)) +} + +/// Inventory warning, including an unused proxy or an original currently replaced by a proxy. +/// Viewer warnings instead use `item_problem`, which follows the active render source. +pub fn missing_item_problem(item: &effectcraft_project::Item) -> Option { + if let ItemKind::Footage(f) = &item.kind + && let Some(why) = missing_problem(f, "Use File ▸ Dependencies ▸ Relink Missing Footage or Replace Footage.") + { + return Some(why); + } + item.proxy.as_ref().and_then(|p| missing_problem(&p.footage, "Use Set Proxy to locate the proxy file.")).map(|why| format!("Proxy: {why}")) +} + /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` /// (a viewer frame nobody waits for any more, #329). Every read goes through /// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dd5335623..dbd948320 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -46,7 +46,7 @@ mod opts; mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; -pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage}; +pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, roi_inside, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index ec7b14cdf..8ebea08fc 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -568,24 +568,11 @@ impl EffectcraftApp { self.tick_footage_reload(ctx); } - /// Settings ▸ Import ▸ Reload Footage Changed on Disk: a worker thread stamps the footage - /// files (every two seconds while the window is in front, and as soon as it comes back to - /// the front after saving in another app) and wakes this frame only when a stamp changed; - /// the changed files are reloaded here. Nothing here asks for a repaint: an idle app with - /// unchanged footage stays asleep. + /// One background worker tracks source availability and optional content reloads. It wakes + /// the UI only for changes; availability remains enabled when automatic reload is off. fn tick_footage_reload(&mut self, ctx: &egui::Context) { use effectcraft_engine::footage_reload; - if let Some(stamps) = self.footage_watcher.as_ref().and_then(footage_reload::Watcher::take) - && let Err(e) = footage_reload::apply_stamps(&mut self.session, stamps) - { - self.ui.status = e.to_string(); - } - let off = self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty(); - if off { - if let Some(w) = &mut self.footage_watcher { - w.set_watched(Default::default()); - } - self.footage_watch_key = None; + if self.session.project.items.is_empty() && self.footage_watcher.is_none() { return; } if self.footage_watcher.is_none() { @@ -595,10 +582,22 @@ impl EffectcraftApp { let Some(watcher) = &mut self.footage_watcher else { return }; watcher.set_focused(self.focused); // Which files are watched follows the project and the preference, not the frame. - let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone()); + let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone(), self.session.disk_cache.is_some()); if self.footage_watch_key.as_ref() != Some(&key) { self.footage_watch_key = Some(key); - watcher.set_watched(footage_reload::watched(&self.session)); + watcher.update(&self.session); + } + let before = self.session.revision; + if let Some(scan) = watcher.take() { + match scan.apply(&mut self.session) { + Ok(result) if result.get("stale").and_then(serde_json::Value::as_bool) == Some(true) => watcher.recheck(), + Err(e) => self.ui.status = e.to_string(), + _ => {} + } + } + if self.session.revision != before { + self.footage_watch_key = Some((self.session.revision, self.session.prefs.import.auto_reload_footage.clone(), self.session.disk_cache.is_some())); + watcher.update(&self.session); } } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 2f7da020c..09c8b3af0 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1308,6 +1308,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Render", "レンダリング"), ("Save Current as Template…", "テンプレートとして保存…"), ("New Composition From Footage", "フッテージから新規コンポジション"), + ("All", "すべて"), + ("Available", "利用可能"), + ("Missing", "見つかりません"), + ("Filter by footage status", "フッテージの状態で絞り込む"), ("Search project", "プロジェクトを検索"), ("Search", "検索"), ("(no composition)", "(コンポジションなし)"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0884e0d4f..063d1201f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -337,7 +337,7 @@ pub struct EffectcraftApp { /// files, started with the first project that has items ([`Self::tick_footage_reload`]). footage_watcher: Option, /// The project revision and preference the watched set was last computed for. - footage_watch_key: Option<(u64, String)>, + footage_watch_key: Option<(u64, String, bool)>, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 0da47cb4f..ddd08c2a9 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -188,6 +188,14 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "app.find" => { + if let Some(status) = p.get("status").and_then(Value::as_str) { + app.ui.project_status = match status { + "all" => crate::state::ProjectStatus::All, + "available" => crate::state::ProjectStatus::Available, + "missing" => crate::state::ProjectStatus::Missing, + _ => return Err("status must be all, available or missing".into()), + }; + } if let Some(q) = p.get("query").and_then(Value::as_str) { app.ui.project_search = q.to_string(); } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 435103fb5..720aad956 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -335,22 +335,43 @@ fn keyboard_nav(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option moved } +/// Match names and dependency status, retain folder ancestors, and expand them while filtering. +/// Iterative traversal bounds cyclic document trees and builds each folder's child list once. pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { - fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { - let mut kids = app.session.project.children(folder); - sort_items(&mut kids, &app.ui.project_sort, app.ui.project_sort_desc); - for it in kids { - if !q.is_empty() && !it.is_folder() && !it.name.to_lowercase().contains(q) { - continue; - } - out.push((it.id, depth)); - if it.is_folder() && (app.ui.project_open_folders.contains(&it.id.0) || !q.is_empty()) { - walk(app, Some(it.id), depth + 1, q, out); + let project = &app.session.project; + let q = app.ui.project_search.to_lowercase(); + let filtered = !q.is_empty() || app.ui.project_status != crate::state::ProjectStatus::All; + let mut included = std::collections::BTreeSet::new(); + if filtered { + for item in project.items.values().filter(|i| !i.is_folder() && app.ui.project_status.matches(i) && i.name.to_lowercase().contains(&q)) { + let mut id = Some(item.id); + while let Some(current) = id { + if !included.insert(current) { + break; + } + id = project.item(current).and_then(|i| i.parent); } } } - let mut rows = vec![]; - walk(app, None, 0, &app.ui.project_search.to_lowercase(), &mut rows); + let mut children: std::collections::BTreeMap, Vec<&Item>> = std::collections::BTreeMap::new(); + for item in project.items.values() { + children.entry(item.parent).or_default().push(item); + } + for kids in children.values_mut() { + sort_items(kids, &app.ui.project_sort, app.ui.project_sort_desc); + } + let mut pending: Vec<_> = children.get(&None).into_iter().flatten().rev().map(|i| (*i, 0usize)).collect(); + let mut seen = std::collections::BTreeSet::new(); + let mut rows = Vec::new(); + while let Some((item, depth)) = pending.pop() { + if !seen.insert(item.id) || (filtered && !included.contains(&item.id)) { + continue; + } + rows.push((item.id, depth)); + if item.is_folder() && (filtered || app.ui.project_open_folders.contains(&item.id.0)) { + pending.extend(children.get(&Some(item.id)).into_iter().flatten().rev().map(|i| (*i, depth.saturating_add(1)))); + } + } rows } @@ -375,6 +396,7 @@ pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[Ite } } app.ui.project_search.clear(); + app.ui.project_status = crate::state::ProjectStatus::All; app.session.state.project_selection = items; // Force the panel's selection scroll on its next draw, even when the item was already selected. ctx.data_mut(|d| d.remove_temp::>(selection_seen_id())); @@ -1153,6 +1175,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { details_header(app, ui, &p, &ctx, head); // Search. let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0)); + let filter_w = 88.0_f32.min(sr.width() * 0.45); + let filter = Rect::from_min_max(pos2(sr.max.x - filter_w, sr.min.y), sr.max); + let sr = Rect::from_min_max(sr.min, pos2((filter.min.x - 4.0).max(sr.min.x), sr.max.y)); + let id = egui::Id::unique("project-status"); + let pop = id.with("popup"); + let status = app.ui.project_status; + let response = widgets::dropdown_tip(ui, filter, crate::i18n::tr(status.label()), Some(crate::i18n::tr("Filter by footage status")), &t, id); + app.auto.add("project.status", filter, status.label()); + if response.clicked() { + widgets::open_popup(ui, pop); + } + if widgets::popup_is_open(ui, pop) { + let options: Vec<_> = crate::state::ProjectStatus::ALL.iter().map(|s| crate::i18n::tr(s.label()).to_string()).collect(); + if let Some(index) = widgets::popup_menu(ui, pop, filter.left_bottom(), &options, crate::state::ProjectStatus::ALL.iter().position(|s| *s == status)) + && let Some(chosen) = crate::state::ProjectStatus::ALL.get(index) + { + app.ui.project_status = *chosen; + } + } let mut q = app.ui.project_search.clone(); widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t); app.ui.project_search = q; @@ -1226,7 +1267,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("project.item.{}", id.0), r, &it.name); // For screen readers: a tree item named by the item and its type, with its selection, // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply. - let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty()); + let open = it.is_folder().then(|| { + app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty() || app.ui.project_status != crate::state::ProjectStatus::All + }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name())))); ctx.accesskit_node_builder(resp.id, |n| { n.set_selected(selected); @@ -1251,7 +1294,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { proxy_indicator(app, ui, &g, it, &mut actions); // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / // profile): a warning marker at the end of the name cell, the reason on hover. - let problem = app.session.footage.item_problem(it); + let problem = app.session.footage.item_problem(it).or_else(|| effectcraft_engine::render::missing_item_problem(it)); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { @@ -1550,3 +1593,40 @@ mod tests { assert_eq!(cols, ["type", "duration", "fps", "path", "comment"]); } } + +#[cfg(test)] +mod availability_filter_tests { + use super::*; + use crate::state::ProjectStatus; + use effectcraft_engine::project::{Footage, Proxy}; + + #[test] + fn status_and_search_keep_only_matching_items_and_their_folder_ancestors() { + let mut s = effectcraft_engine::Session::default(); + let p = Arc::make_mut(&mut s.project); + let folder = p.add_item("Shots", Label::None, None, ItemKind::Folder); + let missing = + p.add_item("Lost plate", Label::None, Some(folder), ItemKind::Footage(Footage { path: "/gone.png".into(), missing: true, ..Default::default() })); + let present = p.add_item("Other plate", Label::None, None, ItemKind::Footage(Footage { path: "/here.png".into(), ..Default::default() })); + let mut app = EffectcraftApp::new(s); + app.ui.project_status = ProjectStatus::Missing; + app.ui.project_search = "plate".into(); + assert_eq!(visible_rows(&app), vec![(folder, 0), (missing, 1)]); + app.ui.project_status = ProjectStatus::Available; + assert_eq!(visible_rows(&app), vec![(present, 0)]); + app.ui.project_search = "absent name".into(); + assert!(visible_rows(&app).is_empty()); + } + + #[test] + fn unused_missing_proxy_is_in_the_missing_filter() { + let mut s = effectcraft_engine::Session::default(); + let p = Arc::make_mut(&mut s.project); + let id = p.add_item("Plate", Label::None, None, ItemKind::Footage(Footage { path: "/original.png".into(), ..Default::default() })); + p.item_mut(id).unwrap().proxy = + Some(Box::new(Proxy { footage: Footage { path: "/proxy.png".into(), missing: true, ..Default::default() }, enabled: false })); + let mut app = EffectcraftApp::new(s); + app.ui.project_status = ProjectStatus::Missing; + assert_eq!(visible_rows(&app), vec![(id, 0)]); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7454daaa0..bb3adfbfd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2935,6 +2935,8 @@ fn layer_twirl_and_icon(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, } /// The layer name: the rename field or the (selected) name cell. +/// Reserve space for source warnings so selection backgrounds and inline renaming cannot +/// obscure the marker or its recovery tooltip. fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, name_x: f32) { let t = g.tl.t; let ctx = g.tl.ctx.clone(); @@ -2945,7 +2947,21 @@ fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowC let r = rc.r; let cy = rc.cy; let is_sel = rc.is_sel; - let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); + let problem = match &layer.source { + LayerSource::Footage { item } | LayerSource::Comp { item } => { + app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i).or_else(|| effectcraft_engine::render::missing_item_problem(i))) + } + _ => None, + }; + let end = cw.name_end - 4.0; + if let Some(why) = &problem { + let marker = Rect::from_center_size(pos2(end - 7.0, cy), vec2(13.0, 12.0)).intersect(lp.clip_rect()); + lp.add(egui::Shape::convex_polygon(vec![marker.center_top(), marker.right_bottom(), marker.left_bottom()], t.warning, Stroke::NONE)); + lp.text(marker.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK); + ui.interact(marker, egui::Id::unique(("tl-problem", layer.id.0)), Sense::hover()).on_hover_text(why); + app.auto.add(&format!("timeline.layer.{}.problem", layer.id.0), marker, why); + } + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2((end - if problem.is_some() { 17.0 } else { 0.0 }).max(name_x), r.max.y)); let rename_id = egui::Id::unique("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -2972,7 +2988,7 @@ fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowC let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col); let cell = Rect::from_min_max( pos2(name_rect.min.x - 3.0, r.min.y + 1.0), - pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0), + pos2((name_rect.min.x + g.size().x + 4.0).max(name_rect.max.x).min(name_rect.max.x + 2.0), r.max.y - 1.0), ); lp.rect_filled(cell, 0.0, SELECTED_NAME_BG); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6abe76b2f..cab59fd5f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -594,6 +594,36 @@ impl Default for AnimToolsState { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProjectStatus { + #[default] + All, + Available, + Missing, +} + +impl ProjectStatus { + pub const ALL: [Self; 3] = [Self::All, Self::Available, Self::Missing]; + /// English catalog key; localization belongs at the widget rendering boundary. + pub fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Available => "Available", + Self::Missing => "Missing", + } + } + /// Filter the dependency inventory, including unused missing proxies. Generated items + /// count as available even though they have no external source file. + pub fn matches(self, item: &effectcraft_engine::project::Item) -> bool { + match self { + Self::All => true, + Self::Available => !item.has_missing_footage(), + Self::Missing => item.has_missing_footage(), + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, @@ -638,6 +668,8 @@ pub struct UiState { pub effects_search: String, pub effects_open: BTreeSet, pub project_search: String, + #[serde(default)] + pub project_status: ProjectStatus, pub project_open_folders: BTreeSet, /// Project panel sort column (`name`, `type`, `size`, `fps`) and direction, as in AE's /// clickable column headers. Folders sort with everything else. @@ -748,6 +780,7 @@ impl Default for UiState { effects_search: String::new(), effects_open: BTreeSet::new(), project_search: String::new(), + project_status: ProjectStatus::All, project_open_folders: BTreeSet::new(), project_sort: default_project_sort(), project_sort_desc: false, diff --git a/crates/ui-egui/tests/it/ui_unreadable_footage.rs b/crates/ui-egui/tests/it/ui_unreadable_footage.rs index a62884f43..f1c9707ba 100644 --- a/crates/ui-egui/tests/it/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/it/ui_unreadable_footage.rs @@ -102,3 +102,100 @@ fn a_precomp_with_an_undecodable_proxy_has_a_notice_collapsed_or_not() { assert!(!h.state().auto.query("viewer.footageProblem.").is_empty(), "collapse {collapse}"); } } + +#[test] +fn missing_footage_marks_project_timeline_and_viewer_and_clears_after_recovery() { + let (mut h, clip) = harness(|_| false, None); + let set_missing = |h: &mut Harness<'static, EffectcraftApp>, missing| { + let item = Arc::make_mut(&mut h.state_mut().session.project).item_mut(clip).unwrap(); + let ItemKind::Footage(f) = &mut item.kind else { panic!("footage fixture") }; + f.missing = missing; + }; + set_missing(&mut h, true); + h.run_steps(2); + assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).unwrap().label.contains("Missing footage")); + assert_eq!(h.state().auto.query("timeline.layer.").iter().filter(|e| e.id.ends_with(".problem")).count(), 1); + assert!(!h.state().auto.query("viewer.footageProblem.").is_empty()); + set_missing(&mut h, false); + h.run_steps(2); + assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".problem"))); + assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); +} + +#[test] +fn missing_original_with_working_proxy_warns_inventory_without_a_viewer_failure() { + let proxy = Footage { path: "proxy.png".into(), has_video: true, width: 32, height: 18, ..Default::default() }; + let (mut h, clip) = harness(|_| false, Some(proxy)); + if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut h.state_mut().session.project).item_mut(clip).unwrap().kind { + f.missing = true; + } + h.run_steps(2); + assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).unwrap().label.contains("clip.mp4")); + assert!(h.state().auto.query("viewer.footageProblem.").is_empty(), "the active proxy still supplies the preview"); +} + +#[test] +fn missing_active_proxy_explains_the_proxy_repair_action() { + let proxy = Footage { path: "lost_proxy.png".into(), missing: true, has_video: true, width: 32, height: 18, ..Default::default() }; + let (h, clip) = harness(|_| false, Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).unwrap(); + assert!(marker.label.starts_with("Proxy:") && marker.label.contains("Set Proxy")); + assert!(h.state().auto.query("viewer.footageProblem.").iter().any(|e| e.label.contains("lost_proxy.png"))); +} + +#[test] +fn restored_original_and_active_proxy_render_new_pixels_through_media_and_disk_caches() { + use effectcraft_engine::render::RenderOpts; + for proxy in [false, true] { + let dir = std::env::temp_dir().join(format!("ec-offline-pixels-{}-{proxy}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.png"); + image::RgbaImage::from_pixel(16, 16, image::Rgba([255, 0, 0, 255])).save(&original).unwrap(); + let mut s = effectcraft_host::session(); + s.prefs.import.auto_reload_footage = "off".into(); + let item = s.execute("file.import", json!({"paths": [original], "sequence": false})).unwrap()["items"][0].as_u64().unwrap(); + let active = if proxy { + let path = dir.join("proxy.png"); + image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 255, 0, 255])).save(&path).unwrap(); + s.execute("file.setProxy", json!({"item": item, "path": path})).unwrap(); + path + } else { + original + }; + let inner = s.execute("comp.new", json!({"name": "Media", "width": 16, "height": 16, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + // A zero-echo identity effect reads the real layer-input cache, including for proxies. + s.execute("effect.apply", json!({"layer": "#1", "effect": "Echo"})).unwrap(); + s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/numberOfEchoes", "value": 0})).unwrap(); + s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/echoOperator", "value": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Output", "width": 16, "height": 16, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let cache = effectcraft_render::disk_cache::DiskCache::open(dir.join("cache"), 1 << 20).unwrap(); + cache.set_min_layer_ms(0); + s.disk_cache = Some(cache.clone()); + let salt = s.refresh_disk_salt(true).unwrap(); + s.layer_cache.set_disk(Some((cache.clone(), salt))); + let before = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(before.data[8 * 16 + 8], if proxy { [0.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }); + cache.flush(); + assert!(cache.stats().entries > 0, "the first render populated the disk cache"); + s.layer_cache.clear(); + let cached = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(cached.data, before.data); + assert!(cache.stats().hits > 0, "the same media was read back through the disk cache"); + std::fs::remove_file(&active).unwrap(); + assert_eq!(s.execute("footage.reloadChanged", json!({})).unwrap()["reloaded"], 0, "availability alone drives recovery in this fixture"); + let offline = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(offline.data[8 * 16 + 8], [0.0, 0.0, 0.0, 0.0]); + image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 0, 255, 255])).save(&active).unwrap(); + assert_eq!(s.execute("footage.reloadChanged", json!({})).unwrap()["reloaded"], 0, "availability alone drives recovery in this fixture"); + let restored = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(restored.data[8 * 16 + 8], [0.0, 0.0, 1.0, 1.0], "restored pixels replace decoded and composited cached frames"); + cache.flush(); + drop(cache); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); + } +}