mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
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:
25 files changed
+1347
-142
No files matched your search
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,5 @@ pub mod footage_reload;
|
||||
pub mod media_browser;
|
||||
pub mod media_cache;
|
||||
pub mod sequence;
|
||||
|
||||
pub(crate) mod availability;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)", "(コンポジションなし)"),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user