diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 164b242a..e079f5f5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -109,6 +109,38 @@ impl std::hash::Hash for FrameKey { } } +/// Where a playing preview is and what it plays next (see [`Frames::set_playhead`]): the RAM +/// preview renders the frames it reaches first before later ones, and keeps them over frames it +/// reaches later or not at all (#595). +#[derive(Clone, Copy, Debug)] +pub struct Playhead { + /// The frame series that plays ([`FrameKey::same_series`]; its `frame` is ignored). + pub series: FrameKey, + /// The frame on screen. + pub cur: i64, + /// The preview's range, skip step and looping (as in its `PreviewPlan`). + pub start: i64, + pub end: i64, + pub step: i64, + pub looping: bool, +} + +impl Playhead { + /// How many frames the preview shows before it reaches `k` (0: on screen); `None` for + /// frames it doesn't reach (other series, outside the range, behind it without looping). + pub fn ahead(&self, k: &FrameKey) -> Option { + if !k.same_series(&self.series) || k.frame < self.start || k.frame > self.end { + return None; + } + let step = self.step.max(1); + if k.frame >= self.cur { + return Some((k.frame - self.cur) / step); + } + // Looping: through the end of the range and around from its start. + self.looping.then(|| (self.end.saturating_sub(self.cur) / step).saturating_add(1).saturating_add((k.frame - self.start) / step)) + } +} + struct Cache { map: HashMap, order: VecDeque, @@ -118,6 +150,8 @@ struct Cache { budget: usize, /// Bytes of GPU frames to keep at most ([`GPU_BUDGET`]). gpu_budget: usize, + /// The playing preview, if any: evictions spare the frames it shows next. + playhead: Option, } impl Cache { @@ -132,12 +166,26 @@ impl Cache { if gpu { self.gpu_bytes += sz; } - while self.bytes > self.budget { - let Some(old) = self.order.pop_front() else { break }; - self.remove(&old); - } + self.evict_to_budget(); self.evict_gpu(); } + /// The frame to evict next: the least recently shown, but while a preview plays, the least + /// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU + /// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet) + /// to keep the ones just shown, so a preview longer than the cache played into a gap that + /// prefetch kept refilling while it evicted the next frames (#595). + fn victim(&self) -> Option { + let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) }; + let mut farthest: Option<(i64, usize)> = None; + for (i, k) in self.order.iter().enumerate() { + match p.ahead(k) { + None => return Some(i), + Some(d) if farthest.is_none_or(|(f, _)| d > f) => farthest = Some((d, i)), + Some(_) => {} + } + } + farthest.map(|(_, i)| i) + } /// Video memory: drop the oldest GPU frames past their own budget. fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { @@ -181,7 +229,7 @@ impl Cache { } fn evict_to_budget(&mut self) { while self.bytes > self.budget { - let Some(old) = self.order.pop_front() else { break }; + let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break }; self.remove(&old); } } @@ -388,7 +436,7 @@ impl Default for Frames { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); 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 })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -460,11 +508,27 @@ pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { /// [`fit_texture`] averaging down by at least `min_k` (see [`fit_factor`]). pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> egui::ColorImage { - let [w, h] = img.size; let k = fit_factor(img.size, max_side, min_k); if k == 1 { return img; } + average_down(&img, k) +} + +/// A shared frame as texture data (see [`fit_texture_by`]): the frame itself when it needs no +/// averaging, without copying its pixels. The viewer puts every frame a preview shows on screen; +/// copying a 4K frame first held the UI thread for about a millisecond each time (#595). +pub fn fit_shared_texture_by(img: &Arc, max_side: usize, min_k: usize) -> egui::ImageData { + let k = fit_factor(img.size, max_side, min_k); + if k == 1 { + return egui::ImageData::Color(img.clone()); + } + average_down(img, k).into() +} + +/// `img` averaged down by `k` (rounded up at the edges). +fn average_down(img: &egui::ColorImage, k: usize) -> egui::ColorImage { + let [w, h] = img.size; let (fw, fh) = (w.div_ceil(k), h.div_ceil(k)); let px: Vec = (0..fw * fh) .into_par_iter() @@ -712,6 +776,26 @@ impl Frames { } } + /// 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). + pub fn set_playhead(&self, playhead: Option) { + if let Ok(mut c) = self.cache.lock() { + c.playhead = playhead; + } + let Ok(mut q) = self.queue.lock() else { return }; + q.playhead = playhead; + let Some(p) = playhead else { return }; + let Ok(mut inf) = self.inflight.lock() else { return }; + q.jobs.retain(|j| { + let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); + if gone { + inf.remove(&j.key); + } + !gone + }); + } + /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { self.request_with(src, key, comp, t, opts, false); @@ -844,7 +928,7 @@ impl Frames { } let job = { let Ok(mut q) = self.queue.lock() else { break }; - let Some(i) = Queue::best(&q.jobs) else { break }; + let Some(i) = q.best() else { break }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -972,7 +1056,7 @@ impl Worker { let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = Queue::best(&q.jobs) else { return false }; + let Some(i) = q.best() else { return false }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -1184,12 +1268,25 @@ struct Queue { /// The running viewer (urgent) frames and how to call them off. urgent_cancels: Vec<(FrameKey, Arc)>, seq: u64, + /// The playing preview, if any: its prefetch renders nearest first. + playhead: Option, } impl Queue { - /// The job that matters most: urgent first (newest), then prefetch in request order. - fn best(jobs: &[Job]) -> Option { - jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i) + /// The job that matters most: urgent first (newest), then, while a preview plays, the + /// prefetch frame it reaches first, then prefetch in request order. In request order alone, + /// frames asked for again after an eviction (the ones right ahead of the playhead) waited + /// behind every frame further ahead (#595). + fn best(&self) -> Option { + let p = self.playhead.as_ref(); + self.jobs + .iter() + .enumerate() + .max_by_key(|(_, j)| { + let near = if j.urgent { None } else { p.and_then(|p| p.ahead(&j.key)) }; + (j.urgent, near.is_some(), near.map_or(0, |d| -d), if j.urgent { j.seq as i64 } else { -(j.seq as i64) }) + }) + .map(|(i, _)| i) } } @@ -1521,6 +1618,80 @@ mod tests { assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0))); } + fn playhead(cur: i64, looping: bool) -> Playhead { + Playhead { series: key(1, 0, 0), cur, start: 0, end: 99, step: 1, looping } + } + + /// #595: a playing preview's cache is full. The frames just ahead of the playhead were + /// rendered before the ones just shown, so plain LRU evicted them first and the preview + /// played into a gap. Frames the preview no longer reaches go first, then the ones it + /// reaches last. + #[test] + fn a_full_cache_keeps_the_frames_just_ahead_of_the_playhead() { + let f = Frames::default(); + f.set_budget(4 * 16); + // Prefetch rendered 11..=13, then 10 was shown (the most recently used). + for i in [11, 12, 13, 10] { + insert(&f, key(1, i, 0)); + } + let cached = |f: &Frames| f.cached_frames(&key(1, 0, 0)); + f.set_playhead(Some(playhead(10, false))); + // The next frame rendered is the one the preview reaches last: the frames just ahead stay. + insert(&f, key(1, 14, 0)); + assert_eq!(cached(&f), [10, 11, 12, 13]); + // Without looping, a frame behind the playhead goes before any frame ahead of it. + f.set_playhead(Some(playhead(12, false))); + insert(&f, key(1, 14, 0)); + assert_eq!(cached(&f), [10, 12, 13, 14]); + // Looping, the frame just behind is the one reached last. + f.set_playhead(Some(playhead(13, true))); + insert(&f, key(1, 0, 0)); + assert_eq!(cached(&f), [0, 10, 13, 14]); + // Stopped: least recently shown first again. + f.set_playhead(None); + insert(&f, key(1, 50, 0)); + assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0))); + } + + /// #595: prefetch renders the frames the preview reaches first, whatever order they were + /// asked for in (a frame evicted and asked for again waited behind every frame further + /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. + #[test] + fn prefetch_renders_the_frames_the_playhead_reaches_first() { + struct Idle; + impl RemoteFrames for Idle { + fn slots(&self) -> usize { + 0 + } + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + let src = RenderSource { + project: Arc::default(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + for frame in [40, 30, 20, 5, 12, 11] { + f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default()); + } + f.request(&src, FrameKey { comp: 2, ..key(1, 1, 0) }, ItemId(2), Tick::ZERO, RenderOpts::default()); + f.set_playhead(Some(playhead(10, false))); + let mut order = vec![]; + loop { + let mut q = f.queue.lock().unwrap(); + let Some(i) = q.best() else { break }; + let j = q.jobs.swap_remove(i); + order.push((j.key.comp, j.key.frame)); + } + assert_eq!(order, [(1, 11), (1, 12), (1, 20), (1, 30), (1, 40), (2, 1)]); + assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); + } + #[test] fn frames_are_keyed_by_content_and_series_are_kept_apart() { let f = Frames::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e00..7b177bdd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1140,6 +1140,9 @@ impl EffectcraftApp { }; let mut queued = self.frames.inflight(); let series = self.frame_series(cid, scale); + // The preview's position orders prefetch and eviction (#595). + let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); + self.frames.set_playhead(playhead); if self.playback.caching && let Some(p) = plan { @@ -1180,7 +1183,10 @@ impl EffectcraftApp { return; } let step = plan.map_or(1, |p| p.step); - for k in 0..ahead * 3 { + // Never further ahead than the RAM preview holds: those frames would evict the ones the + // preview shows first, or be evicted before it gets there (#595). + let window = (ahead * 3).min(self.frames_that_fit(&c, scale) - 1); + for k in 0..window { if queued >= ahead as usize || !video { break; } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c..32eba1e7 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2389,9 +2389,9 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: FrameImage::Cpu(img) => { // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_texture_by((*img).clone(), max, minify); + let fitted = crate::frames::fit_shared_texture_by(&img, max, minify); match &mut app.viewer_tex { - Some((tex, k, m)) if tex.size() == fitted.size => { + Some((tex, k, m)) if tex.size() == fitted.size() => { tex.set(fitted, opts); (*k, *m) = (key, minify); }