mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #793 from vangel76/fix-595-prefetch-order
#595: RAM preview prefetches nearest the playhead and keeps the frames just ahead
This commit is contained in:
commit
30523a81a5
3 files changed
+192
-15
No files matched your search
+183
-12
@@ -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();
|
||||
|
||||
@@ -1153,6 +1153,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
|
||||
{
|
||||
@@ -1193,7 +1196,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;
|
||||
}
|
||||
|
||||
@@ -2425,9 +2425,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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user