#3493: drop the viewer's frames once a LUT file in use changes

Renders now follow a LUT file, but the viewer and RAM preview kept the
frames already rendered with the old one. A small worker stamps the LUT
files in use every two seconds and wakes the interface when one changed;
the interface then drops its frames, and a frame whose render started
before the change is not kept when it finishes.
This commit is contained in:
arnav-hiwarkar committed 2026-11-12 11:01:32 +05:30
1 parent 6a78d94a5b
commit 2166596625
8 files changed
+144 -1

No files matched your search

+1
View File
@@ -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;
+84
View File
@@ -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<Watcher> {
Self::start_with(wake, INTERVAL)
}
/// [`Watcher::start`] stamping every `every` (tests).
pub fn start_with(wake: impl Fn() + Send + 'static, every: Duration) -> Option<Watcher> {
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);
}
}
+1
View File
@@ -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;
+2
View File
@@ -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(),
+15
View File
@@ -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
+14 -1
View File
@@ -298,6 +298,9 @@ struct Cache {
playhead: Option<Playhead>,
/// Scale of the frame on screen (the newest urgent request): frames of other scales go first.
scale: Option<u32>,
/// Frames whose render started before this are not kept: a source file they read changed
/// since ([`Frames::clear_stale`]).
stale_before: Option<web_time::Instant>,
}
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);
}
+23
View File
@@ -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 }
}
+4
View File
@@ -373,6 +373,10 @@ pub struct EffectcraftApp {
footage_watcher: Option<effectcraft_engine::footage_reload::Watcher>,
/// The project revision and preference the watched set was last computed for.
footage_watch_key: Option<(u64, String, bool)>,
/// The worker that stamps the LUT files in use ([`Self::tick_lut_files`], #3493).
lut_watcher: Option<effectcraft_engine::lut_reload::Watcher>,
/// 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<bool>,