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
This commit is contained in:
ash authored and GitHub committed 2026-11-06 11:38:57 +01:00
1 parent ce9eaec650
commit 7fc1be0bf2
25 files changed
+1347 -142

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
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!(
+4
View File
@@ -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<String, String> {
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());
+1
View File
@@ -53,6 +53,7 @@ fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
/// 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) {
+3 -3
View File
@@ -46,9 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
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
+2
View File
@@ -4,3 +4,5 @@ pub mod footage_reload;
pub mod media_browser;
pub mod media_cache;
pub mod sequence;
pub(crate) mod availability;
+504
View File
@@ -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<String>,
folder: Option<String>,
}
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<Item = (ItemId, bool, &Footage)> {
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<Source> {
let mut out = Vec::new();
for (id, proxy, f) in entries(s) {
let paths: Vec<String> = 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<bool> {
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<String, bool>| *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<Progress>,
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<bool> {
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<Project>,
after: Arc<Project>,
paths: BTreeSet<String>,
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<Project>, sources: &[Source], missing: &[bool], cache_enabled: bool) -> Option<Update> {
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<BTreeSet<String>>,
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<Vec<u8>> {
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<Disk>) -> (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);
}
}
+20 -11
View File
@@ -82,7 +82,7 @@ fn paths_of(f: &Footage) -> impl Iterator<Item = &String> {
/// 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<HashMap<String, Option<String>>>);
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));
}
}
}
}
@@ -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();
}
+30 -25
View File
@@ -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<Vec<ItemId>>, wait: bool) -> crate::Result<Value> {
let jobs: Vec<Job> = 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<Vec<ItemId>>, 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<Vec<ItemId>>, 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<Vec<ItemId>>, 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<Vec<ItemId>>, 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"]);
+380 -50
View File
@@ -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<Stamp>)>;
/// 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<String> {
fn reload_paths(s: &Session) -> BTreeSet<String> {
let sequences = s.prefs.import.auto_reload_footage == "all";
let mut paths = BTreeSet::new();
for it in s.project.items.values() {
@@ -54,6 +56,119 @@ pub fn watched(s: &Session) -> BTreeSet<String> {
paths
}
/// Automatic reload obeys Off; explicit Reload Changed still uses eligible non-sequence files.
fn automatic_paths(s: &Session) -> BTreeSet<String> {
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<Item = (ItemId, &str, &[String])> {
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<String>,
sources: Arc<Vec<Source>>,
project: Option<Arc<effectcraft_project::Project>>,
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<bool>,
}
#[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<availability::Update>,
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<Value> {
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<String>) {
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<String>) {
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<Stamp>)>) -> crate
/// `footage.reloadChanged`: stamp the watched files now and reload the ones that changed.
pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result<Value> {
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<String>),
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<Watch>,
result: Arc<Mutex<Option<Stamps>>>,
paths: BTreeSet<String>,
tx: SyncSender<()>,
pending: Arc<Mutex<Watch>>,
result: Arc<Mutex<Option<Scan>>>,
paths: Watched,
focused: Option<bool>,
}
@@ -307,77 +438,114 @@ impl Watcher {
if cfg!(target_arch = "wasm32") {
return None;
}
let (tx, rx) = std::sync::mpsc::channel();
let result: Arc<Mutex<Option<Stamps>>> = 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<Mutex<Option<Scan>>> = 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<String>) {
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<Stamps> {
self.result.lock().unwrap_or_else(PoisonError::into_inner).take()
pub fn take(&self) -> Option<Scan> {
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<Watch>,
rx: &Receiver<()>,
pending: &Mutex<Watch>,
services: &dyn Services,
out: &Mutex<Option<Stamps>>,
out: &Mutex<Option<Scan>>,
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<Stamps> = None;
let mut last: Option<Observation> = 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<Stamp> {
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<Disk>);
impl crate::Importer for DataProbe {
fn probe(&self, path: &str) -> std::result::Result<Footage, String> {
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);
+2 -1
View File
@@ -244,7 +244,8 @@ pub struct Session {
pub next_task_id: u64,
/// The running Learn tutorial (Home ▸ Learn), see [`learn`].
pub learn: Option<learn::Progress>,
/// 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,
+19 -9
View File
@@ -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<u64> {
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`]).
+3 -3
View File
@@ -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);
+36
View File
@@ -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");
}
}
+5
View File
@@ -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)
+21 -3
View File
@@ -99,20 +99,38 @@ pub trait FootageSource: Send + Sync {
fn problem(&self, _footage: &Footage) -> Option<String> {
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<String> {
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<String> {
(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<String> {
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
+1 -1
View File
@@ -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,
+17 -18
View File
@@ -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);
}
}
}
+4
View File
@@ -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)", "(コンポジションなし)"),
+1 -1
View File
@@ -337,7 +337,7 @@ pub struct EffectcraftApp {
/// files, started with the first project that has items ([`Self::tick_footage_reload`]).
footage_watcher: Option<effectcraft_engine::footage_reload::Watcher>,
/// 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<bool>,
+8
View File
@@ -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();
}
+94 -14
View File
@@ -335,22 +335,43 @@ fn keyboard_nav(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option<ItemId>
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<ItemId>, 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<Option<ItemId>, 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::<Option<u64>>(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)]);
}
}
+18 -2
View File
@@ -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);
}
+33
View File
@@ -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<String>,
pub project_search: String,
#[serde(default)]
pub project_status: ProjectStatus,
pub project_open_folders: BTreeSet<u64>,
/// 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,
@@ -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();
}
}