diff --git a/crates/engine/src/footage.rs b/crates/engine/src/footage.rs index b921dd128..d64afcaf7 100644 --- a/crates/engine/src/footage.rs +++ b/crates/engine/src/footage.rs @@ -1,6 +1,7 @@ pub mod doc_path; pub mod footage_check; pub mod footage_reload; +pub mod lut_reload; pub mod media_browser; pub mod media_cache; pub mod sequence; diff --git a/crates/engine/src/footage/lut_reload.rs b/crates/engine/src/footage/lut_reload.rs new file mode 100644 index 000000000..96cb14c4a --- /dev/null +++ b/crates/engine/src/footage/lut_reload.rs @@ -0,0 +1,84 @@ +//! LUT files that change on disk (#3493). +//! +//! A render follows a LUT file by itself: [`effectcraft_effects::load_lut`] stamps the file and +//! reads it again once it changed, and a layer's cache key holds the stamp. What the desktop app +//! keeps of earlier renders, the frames of its viewer and RAM preview, is dropped when +//! [`effectcraft_effects::lut_generation`] moves. The [`Watcher`] looks at the LUT files in use +//! every two seconds, so an edit shows without anything rendering first, and wakes the interface +//! only when one changed. + +use std::sync::mpsc::{RecvTimeoutError, SyncSender}; +use std::time::Duration; + +/// Moves each time a LUT file in use was found changed on disk: frames rendered before may show +/// the old LUT ([`effectcraft_effects::lut_generation`]). +pub fn generation() -> u64 { + effectcraft_effects::lut_generation() +} + +/// How often the LUT files in use are stamped. +pub const INTERVAL: Duration = Duration::from_secs(2); + +/// A worker thread that stamps the LUT files in use ([`effectcraft_effects::check_lut_files`]) +/// and runs `wake` when one changed. Dropping the watcher ends the thread. +pub struct Watcher { + _stop: SyncSender<()>, +} + +impl Watcher { + /// Start the worker. `None` where there are no threads (the web app, whose LUTs don't come + /// from files) or one couldn't be started. + pub fn start(wake: impl Fn() + Send + 'static) -> Option { + Self::start_with(wake, INTERVAL) + } + + /// [`Watcher::start`] stamping every `every` (tests). + pub fn start_with(wake: impl Fn() + Send + 'static, every: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1); + std::thread::Builder::new() + .name("lut-watch".into()) + .spawn(move || { + let mut seen = effectcraft_effects::lut_generation(); + // (ends once the watcher, the only sender, is dropped) + while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(every) { + let now = effectcraft_effects::check_lut_files(); + if now != seen { + seen = now; + wake(); + } + } + }) + .ok()?; + Some(Watcher { _stop: tx }) + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + /// A LUT file edited on disk wakes the interface without a render loading it. + #[test] + fn an_edited_lut_file_wakes_the_interface() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("watched.cube"); + std::fs::write(&path, "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n").unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(effectcraft_effects::load_lut(&src).is_some()); + let before = effectcraft_effects::lut_generation(); + let (tx, rx) = std::sync::mpsc::channel(); + let _watcher = Watcher::start_with( + move || { + let _ = tx.send(()); + }, + Duration::from_millis(10), + ) + .unwrap(); + std::fs::write(&path, "LUT_1D_SIZE 3\n0 0 0\n0.5 0.5 0.5\n1 1 1\n").unwrap(); + rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(effectcraft_effects::lut_generation() > before); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f8edfbe08..0b7608756 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -61,6 +61,7 @@ pub use effectcraft_track as track; pub use footage::doc_path; pub use footage::footage_check; pub use footage::footage_reload; +pub use footage::lut_reload; pub use footage::media_browser; pub use footage::media_cache; pub use footage::sequence; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 66387bc31..bf5964ba6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -97,6 +97,8 @@ impl EffectcraftApp { gpu_failure_applied: None, footage_watcher: None, footage_watch_key: None, + lut_watcher: None, + lut_generation: effectcraft_engine::lut_reload::generation(), 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 c3a5bfbf6..c76e3f410 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -654,6 +654,21 @@ impl EffectcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs(10)); } self.tick_footage_reload(ctx); + self.tick_lut_files(ctx); + } + + /// A LUT file in use changed on disk (#3493): the frames kept were rendered with the old one. + /// Renders read it again by themselves; the watcher wakes the interface when no render did. + fn tick_lut_files(&mut self, ctx: &egui::Context) { + if self.lut_watcher.is_none() { + let wake = ctx.clone(); + self.lut_watcher = effectcraft_engine::lut_reload::Watcher::start(move || wake.request_repaint()); + } + let generation = effectcraft_engine::lut_reload::generation(); + if generation != self.lut_generation { + self.lut_generation = generation; + self.frames.clear_stale(); + } } /// One background worker tracks source availability and optional content reloads. It wakes diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a0c2f94b5..ccc4123d2 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -298,6 +298,9 @@ struct Cache { playhead: Option, /// Scale of the frame on screen (the newest urgent request): frames of other scales go first. scale: Option, + /// Frames whose render started before this are not kept: a source file they read changed + /// since ([`Frames::clear_stale`]). + stale_before: Option, } impl Cache { @@ -768,7 +771,7 @@ impl Frames { #[cfg(target_arch = "wasm32")] let queue = Queue::default(); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, gpu_pressure: None, gpu_adjusted: None, playhead: None, scale: None })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, gpu_pressure: None, gpu_adjusted: None, playhead: None, scale: None, stale_before: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(queue)), #[cfg(not(target_arch = "wasm32"))] @@ -1403,6 +1406,15 @@ impl Frames { } } + /// Drop every frame, and the ones still rendering when they finish: a source file changed on + /// disk (a LUT file, #3493), and a render that started before may have read the old one. + pub fn clear_stale(&self) { + self.clear(); + if let Ok(mut c) = self.cache.lock() { + c.stale_before = Some(web_time::Instant::now()); + } + } + /// The playing preview's position (`None` when no preview plays): prefetch renders the /// frames it reaches first before the others, evictions spare the frames it shows next, and /// queued prefetch frames of its series it no longer reaches are dropped (#595). @@ -1899,6 +1911,7 @@ impl Worker { } if let Ok(mut c) = self.cache.lock() && (!img.is_gpu() || !self.gpu_retired.load(Ordering::Acquire)) + && c.stale_before.is_none_or(|at| t0 >= at) { c.insert_with(key, img, files, since); } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 45398de21..d6bf56a20 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -218,6 +218,29 @@ fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live( assert!(frames.queue.lock().unwrap().running.is_empty()); } +/// #3493: a LUT file changed on disk drops the frames kept, and a frame whose render started +/// before is not kept when it finishes (it may have read the old file); one started after is. +#[test] +fn clear_stale_drops_kept_frames_and_ones_rendering() { + let frames = Frames::default(); + let worker = frames.worker(); + let kept = key(1, 0, 0); + insert(&frames, kept); + let (late, fresh) = (key(1, 1, 0), key(1, 2, 0)); + let started = web_time::Instant::now(); + frames.clear_stale(); + assert!(!frames.is_cached(&kept)); + for k in [late, fresh] { + frames.inflight.lock().unwrap().insert(k); + frames.queue.lock().unwrap().running.insert(k); + } + worker.finish_key(late, false, img(), None, None, started); + assert!(!frames.is_cached(&late), "a render started before the change"); + worker.finish_key(fresh, false, img(), None, None, web_time::Instant::now()); + assert!(frames.is_cached(&fresh), "a render started after it"); + assert_eq!(frames.inflight(), 0, "both jobs are released"); +} + fn key(content: u64, frame: i64, opts: u64) -> FrameKey { FrameKey { revision: content, content, comp: 1, frame, scale: 1000, view: 0, opts } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2e911ea54..699f68e0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -373,6 +373,10 @@ pub struct EffectcraftApp { footage_watcher: Option, /// The project revision and preference the watched set was last computed for. footage_watch_key: Option<(u64, String, bool)>, + /// The worker that stamps the LUT files in use ([`Self::tick_lut_files`], #3493). + lut_watcher: Option, + /// The LUT generation the frames kept were rendered at ([`effectcraft_engine::lut_reload::generation`]). + lut_generation: u64, /// 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,