#595: RAM preview prefetches nearest the playhead and keeps the frames just ahead

Problem: while a preview played, the RAM preview cache evicted the least
recently used frame. The frames just ahead of the playhead had been rendered
(prefetched) before the frames just shown, so once the cache was full they
were evicted first; prefetch asked for them again, but its queue ran in
request order, so they waited behind every frame further ahead. Playback
ran into a gap right ahead of the playhead while frames far ahead stayed
green, re-rendered the same frames over and over (about four renders per
frame shown when the preview was longer than the cache) and stalled for
seconds. The prefetch window could also reach further ahead than the cache
holds.

Fix: the playing preview tells Frames where it is (Frames::set_playhead).
Evictions then take frames the preview does not reach first (other series,
behind it without looping), least recently used first, then the frame it
reaches last; prefetch renders the frame it reaches first; queued prefetch
frames it no longer reaches are dropped (and leave the in-flight set). The
prefetch window stops at what the cache holds. The viewer puts a CPU frame
that needs no averaging on screen without copying its pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-11 13:35:14 +02:00
1 parent a0decc80dd
commit 271000b294
3 files changed
+192 -15

No files matched your search

+183 -12
View File
@@ -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<i64> {
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<FrameKey, FrameImage>,
order: VecDeque<FrameKey>,
@@ -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<Playhead>,
}
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<usize> {
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<egui::ColorImage>, 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<egui::Color32> = (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<Playhead>) {
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<AtomicBool>)>,
seq: u64,
/// The playing preview, if any: its prefetch renders nearest first.
playhead: Option<Playhead>,
}
impl Queue {
/// The job that matters most: urgent first (newest), then prefetch in request order.
fn best(jobs: &[Job]) -> Option<usize> {
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<usize> {
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();
+7 -1
View File
@@ -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;
}
+2 -2
View File
@@ -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);
}