mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 18:31:30 -06: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:
@@ -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`]).
|
||||
|
||||
Reference in New Issue
Block a user