diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 267985e37..66a2dcf82 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -9,31 +9,35 @@ //! render reads the new pixels. //! //! The automatic part compares each footage file's size and modification time with what they -//! were when the file was last seen. The desktop app takes those stamps every two seconds (and -//! when its window comes back to the front) on a worker thread, so a file on a slow network share -//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them -//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload -//! records the state of the disk: it is not an undo step and doesn't modify the project. +//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that +//! stamps the files every two seconds while the window is in front (every ten seconds while it +//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp +//! changed, so a file on a slow network share never holds up the interface and an idle app never +//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot +//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records +//! the state of the disk: it is not an undo step and doesn't modify the project. use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer}; use serde_json::{Value, json}; -use crate::Session; +use crate::{Services, Session}; /// A file's size and modification time (see [`crate::Services::stamp`]). pub type Stamp = (u64, u128); -/// Stamps taken on a worker thread, waiting for [`poll_scan`]. -pub type Scan = Arc)>>>>; +/// The stamps of a set of files, in path order. +pub type Stamps = Vec<(String, Option)>; /// The files footage items read: each item's file and every frame of an image sequence. Image /// 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. -fn watched(s: &Session) -> BTreeSet { +pub fn watched(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { @@ -267,31 +271,244 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { apply_stamps(s, now) } -/// Start stamping the watched files on a worker thread, unless a scan is still running (or the -/// build has no threads: the web app, whose footage lives in browser storage). -pub fn start_scan(s: &mut Session) { - if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") { - return; +/// How often the watched files are stamped while the window is in front. +pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2); +/// How often they are stamped while another app has the front: a file saved there is noticed +/// as soon as the window comes back, so this only covers a second screen. +pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10); + +enum Watch { + Paths(BTreeSet), + Focused(bool), +} + +/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands +/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked, +/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it +/// what to watch and whether the window is in front; nothing watched means the thread sleeps. +/// Dropping the watcher ends the thread. +pub struct Watcher { + tx: Sender, + result: Arc>>, + paths: BTreeSet, + focused: Option, +} + +impl Watcher { + /// Start the worker, stamping through `services`; `wake` runs on the worker whenever new + /// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app, + /// whose footage lives in browser storage) or one couldn't be started. + pub fn start(services: Arc, wake: impl Fn() + Send + Sync + 'static) -> Option { + Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL) } - let paths = watched(s); - if paths.is_empty() { - return; + + /// [`Watcher::start`] with its own stamping intervals (tests). + pub fn start_with(services: Arc, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::channel(); + let result: Arc>> = Arc::default(); + let out = Arc::clone(&result); + std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?; + Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None }) } - let services = s.services.clone(); - let slot: Scan = Arc::default(); - let out = slot.clone(); - let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || { - let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); - *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps); - }); - if spawned.is_ok() { - s.footage_scan = Some(slot); + + /// The files to watch ([`watched`]); a changed set is stamped at once. + pub fn set_watched(&mut self, paths: BTreeSet) { + if paths != self.paths { + self.paths = paths.clone(); + let _ = self.tx.send(Watch::Paths(paths)); + } + } + + /// 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)); + } + } + + /// Stamps the worker found changed (or the first ones for a new set of files), once; for + /// [`apply_stamps`]. `None` while nothing changed. + pub fn take(&self) -> Option { + self.result.lock().unwrap_or_else(PoisonError::into_inner).take() } } -/// Apply a finished scan (`None` while there is none, or it is still running). -pub fn poll_scan(s: &mut Session) -> Option> { - let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; - s.footage_scan = None; - Some(apply_stamps(s, stamps)) +/// The watcher's worker: wait for a message or the interval, stamp, report only changes. +fn watch( + rx: &Receiver, + services: &dyn Services, + out: &Mutex>, + wake: &(dyn Fn() + Send + Sync), + focused_every: Duration, + unfocused_every: Duration, +) { + let mut paths = BTreeSet::new(); + let mut focused = true; + let mut last: Option = None; + 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 paths.is_empty() { + continue; + } + let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect(); + if last.as_ref() != Some(&stamps) { + last = Some(stamps.clone()); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + wake(); + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod watcher_tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + /// Files whose stamps the test sets; counts how often they were stamped. + #[derive(Default)] + struct Disk { + stamps: Mutex>, + looks: AtomicUsize, + } + impl Disk { + fn set(&self, path: &str, stamp: Stamp) { + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp); + } + } + impl Services for Disk { + fn read_file(&self, path: &str) -> std::io::Result> { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other("read-only")) + } + fn stamp(&self, path: &str) -> Option { + self.looks.fetch_add(1, Ordering::SeqCst); + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied() + } + } + + const TICK: Duration = Duration::from_millis(20); + const LONG: Duration = Duration::from_secs(60); + + fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps { + let t0 = std::time::Instant::now(); + loop { + if wakes.load(Ordering::SeqCst) >= n + && let Some(s) = w.take() + { + return s; + } + assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn start(disk: &Arc, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc)> { + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let w = Watcher::start_with( + Arc::clone(disk) as Arc, + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + focused, + unfocused, + )?; + Some((w, wakes)) + } + + /// The interface is woken for the first stamps of a set and for every change, and for + /// nothing else: an unchanged file polled many times is no wakeup at all. + #[test] + fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() { + let disk = Arc::new(Disk::default()); + 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()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]); + let looked = disk.looks.load(Ordering::SeqCst); + std::thread::sleep(TICK * 15); + assert!(disk.looks.load(Ordering::SeqCst) > looked + 3, "it keeps stamping while the window is in front"); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup"); + assert!(w.take().is_none()); + disk.set("/a.psd", (12, 2)); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + } + + /// Behind another app the files are stamped only at the slow interval; the window coming + /// back to the front stamps them at once. + #[test] + fn behind_another_app_the_watcher_rests_until_the_window_is_back() { + let disk = Arc::new(Disk::default()); + 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()); + wait_for(&w, &wakes, 1); + let looked = disk.looks.load(Ordering::SeqCst); + disk.set("/b.psd", (2, 2)); + std::thread::sleep(TICK * 15); + assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind"); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + w.set_focused(true); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]); + } + + /// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and + /// reported whole, and dropping the watcher ends the worker. + #[test] + fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() { + let disk = Arc::new(Disk::default()); + disk.set("/c.psd", (1, 1)); + disk.set("/d.psd", (2, 2)); + let (mut w, wakes) = start(&disk, TICK, TICK).unwrap(); + 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()); + 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()); + 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()); + std::thread::sleep(TICK * 3); + let looked = disk.looks.load(Ordering::SeqCst); + std::thread::sleep(TICK * 10); + assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "nothing watched, nothing stamped"); + let result = Arc::downgrade(&w.result); + drop(w); + let t0 = std::time::Instant::now(); + while result.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running"); + std::thread::sleep(Duration::from_millis(5)); + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index db9e02cda..a7484a3f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -241,8 +241,6 @@ pub struct Session { /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, - /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). - pub footage_scan: Option, /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one @@ -351,7 +349,6 @@ impl Default for Session { stored_paths: Default::default(), footage_stamps: Default::default(), disk_salt: None, - footage_scan: None, script_calls: ScriptCalls::default(), } } diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index aa683e2b8..4cba41851 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -153,7 +153,8 @@ page!(Import { sequence_fps: f64 = 30.0, report_missing_frames: bool = true, /// Automatically Reload Footage whose file another app changed (size or modification time), - /// checked every two seconds and when the window comes back to the front: `off`, + /// checked every two seconds while the window is in front, every ten seconds while it is + /// not, and when it comes back to the front: `off`, /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. auto_reload_footage: String = "nonSequence".into(), /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index faf2b09e3..323087906 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -146,27 +146,54 @@ fn a_file_still_being_written_is_tried_again() { assert!(near(pixel(&s, cid, 32, 24), BLUE)); } +/// 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`]; +/// while nothing changes it hands over nothing (so the interface is never woken for it). #[test] -fn background_scan_reloads_changed_footage() { - let path = tmp("scan.psd"); +fn the_watcher_notices_a_footage_file_changed_on_disk() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + let path = tmp("watched.psd"); std::fs::write(&path, flat(RED, false)).unwrap(); let (mut s, _, cid) = flat_in_comp(&path); - let scan = |s: &mut Session| { - footage_reload::start_scan(s); - let t0 = std::time::Instant::now(); + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let fast = Duration::from_millis(20); + let mut w = footage_reload::Watcher::start_with( + s.services.clone(), + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + fast, + Duration::from_secs(60), + ) + .unwrap(); + w.set_focused(true); + w.set_watched(footage_reload::watched(&s)); + let next = |w: &footage_reload::Watcher| { + let t0 = Instant::now(); loop { - if let Some(r) = footage_reload::poll_scan(s) { - return r.unwrap(); + if let Some(stamps) = w.take() { + return stamps; } - assert!(t0.elapsed().as_secs() < 10, "scan never finished"); - std::thread::yield_now(); + assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported"); + std::thread::sleep(Duration::from_millis(5)); } }; - assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0})); + // First look: remembered, nothing to reload. + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).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!(scan(&mut s), json!({"files": 1, "reloaded": 1})); + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w)).unwrap(), json!({"files": 1, "reloaded": 1})); assert!(near(pixel(&s, cid, 32, 24), GREEN)); - assert!(s.footage_scan.is_none()); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + std::thread::sleep(fast * 10); + assert!(w.take().is_none()); } #[test] diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 115c5f33a..61eef75a9 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -76,6 +76,8 @@ impl EffectcraftApp { gpu_checked: false, gpu_failures: None, gpu_failure_applied: None, + footage_watcher: None, + footage_watch_key: None, device_lost_close: None, dialog_answers: std::sync::mpsc::channel(), file_dialogs: FileDialogsUp::default(), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..abc4b8a62 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -524,33 +524,40 @@ impl EffectcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(10)); } - self.tick_footage_reload(ctx, now); + self.tick_footage_reload(ctx); } - /// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the - /// window comes back to the front (after saving in another app), stamp the footage files on a - /// worker thread and reload the ones that changed. - fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) { + /// 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. + fn tick_footage_reload(&mut self, ctx: &egui::Context) { use effectcraft_engine::footage_reload; - if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) { + 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 (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused")); - let focused = self.focused; - let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); - ctx.data_mut(|d| d.insert_temp(focus_key, focused)); - if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() { + 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; return; } - let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); - if now - last >= 2.0 || (focused && !was_focused) { - ctx.data_mut(|d| d.insert_temp(last_key, now)); - footage_reload::start_scan(&mut self.session); + if self.footage_watcher.is_none() { + let wake = ctx.clone(); + self.footage_watcher = footage_reload::Watcher::start(self.session.services.clone(), move || wake.request_repaint()); } - if self.session.footage_scan.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } else { - ctx.request_repaint_after(std::time::Duration::from_secs(2)); + 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()); + if self.footage_watch_key.as_ref() != Some(&key) { + self.footage_watch_key = Some(key); + watcher.set_watched(footage_reload::watched(&self.session)); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b259bfde..65c68ac52 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -312,6 +312,11 @@ pub struct EffectcraftApp { gpu_checked: bool, gpu_failures: Option, gpu_failure_applied: Option, + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: the worker that stamps the footage + /// files, started with the first project that has items ([`Self::tick_footage_reload`]). + footage_watcher: Option, + /// The project revision and preference the watched set was last computed for. + footage_watch_key: Option<(u64, String)>, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, diff --git a/docs/preferences.md b/docs/preferences.md index 2402b9a32..c8ce09b4d 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -120,9 +120,11 @@ Commands (CLI, MCP, control channel): - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import (they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md)) - `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as - in After Effects) or `all` (image sequences too). Every two seconds, - and as soon as the window comes back to the front, the desktop app compares each footage file's - size and modification time with what they were, and reloads what another app saved: a + in After Effects) or `all` (image sequences too). Every two seconds while its window is in + front (every ten seconds while another app has the front), and as soon as the window comes back + to the front, the desktop app compares each footage file's size and modification time with what + they were, on a worker thread that only wakes the interface when something changed, and reloads + what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again.