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
2026-11-06 11:38:57 +01:00
committed by GitHub
parent ce9eaec650
commit 7fc1be0bf2
25 changed files with 1347 additions and 142 deletions
+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`]).