From 07dd513c544ed0e40f96fd0c4ac76f2a0a48f703 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:11:38 +0300 Subject: [PATCH] Show: render the next slide in the background while the current one rests (#240) Advancing rendered the new slide on the UI thread at full screen size before anything changed on screen. While a slide rests (no animation or transition playing) the show now renders the next slide's first frame on a background thread; entering that slide uploads the ready image instead of rendering, waiting for the render if it is still running. Slides with animated GIFs and the web build keep rendering on entry. Co-authored-by: Cursor --- crates/ui-egui/src/show.rs | 131 ++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..8e8e910f 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -27,6 +27,9 @@ pub struct Show { trans: Option, tex: Option<(Key, TextureHandle)>, next_tex: Option<(usize, TextureHandle)>, + /// The next slide's first frame, rendered in the background while this one is shown so + /// advancing only uploads it (#240). + prefetch: Option, blank: Option, pen: bool, strokes: Vec>, @@ -44,7 +47,7 @@ pub struct Show { links: Option<(usize, Vec)>, } -#[derive(Clone, PartialEq)] +#[derive(Clone, Debug, PartialEq)] struct Key { slide: usize, step: usize, @@ -56,6 +59,74 @@ struct Key { gif: u64, } +/// A slide frame being rendered on another thread: what it will be (`key`) and where it arrives. +struct Prefetch { + key: Key, + rx: std::sync::mpsc::Receiver, +} + +fn render_threads() -> u16 { + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } +} + +#[allow(clippy::too_many_arguments)] +/// Slide `idx` at `step`, `t` seconds into it, `size` pixels, with `hidden` media hidden. +fn render_frame( + doc: &Presentation, + idx: usize, + tl: Timeline, + step: usize, + t: f64, + size: (u32, u32), + hidden: Vec, + times: &[(ShapeId, f64)], + threads: u16, +) -> egui::ColorImage { + let f = move |id: ShapeId| { + let mut st = state_for(&tl, id, step, t); + if hidden.contains(&id) { + st.visible = false; + } + st + }; + let img = deckcraft_render::render_slide( + doc, + idx, + &RenderOpts { + scale: size.0 as f64 / doc.slide_size.width.max(1.0), + size: Some(size), + state: Some(&f), + gif_times: times, + threads, + ..Default::default() + }, + ); + crate::textures::to_color_image(&img, false) +} + +/// Media on slide `idx` hidden until it plays, as it is when the slide is entered. +fn hidden_on_entry(doc: &Presentation, idx: usize) -> Vec { + crate::media::slide_media(doc, idx).into_iter().filter(|(_, _, clip)| clip.hide_while_not_playing).map(|m| m.0).collect() +} + +/// Start rendering slide `next`'s first frame on another thread. `None` on the web (no threads). +fn start_prefetch(doc: &std::sync::Arc, next: usize, size: (u32, u32)) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let hidden = hidden_on_entry(doc, next); + let key = Key { slide: next, step: 0, size, doc: std::sync::Arc::as_ptr(doc) as usize, hidden: hidden.clone(), gif: 0 }; + let (tx, rx) = std::sync::mpsc::channel(); + let doc = doc.clone(); + // Leave a core for the UI thread. + let threads = render_threads().saturating_sub(1).max(1); + let spawned = std::thread::Builder::new().name("show-prefetch".into()).spawn(move || { + let tl = ShowState::timeline(&doc, next); + let _ = tx.send(render_frame(&doc, next, tl, 0, 0.0, size, hidden, &[], threads)); + }); + spawned.ok().map(|_| Prefetch { key, rx }) +} + struct Transition { old: Option, /// The slide left behind (Morph matches its shapes) and the Morph frame texture. @@ -87,6 +158,7 @@ impl Show { trans: None, tex: None, next_tex: None, + prefetch: None, blank: None, pen: false, strokes: vec![], @@ -413,30 +485,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { show.tex.as_ref().map(|(_, t)| t.clone()) } else { - let tl = show.timeline(&doc).clone(); - let hide = hidden.clone(); - let f = move |id: ShapeId| { - let mut st = state_for(&tl, id, step, t); - if hide.contains(&id) { - st.visible = false; + // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). + let ready = match show.prefetch.take() { + Some(p) if p.key == key && !animating => p.rx.recv().ok(), + _ => None, + }; + let ci = match ready { + Some(ci) => ci, + None => { + let tl = show.timeline(&doc).clone(); + render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads()) } - st }; - let threads = - if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let img = deckcraft_render::render_slide( - &doc, - idx, - &RenderOpts { - scale: size.0 as f64 / doc.slide_size.width.max(1.0), - size: Some(size), - state: Some(&f), - gif_times: ×, - threads, - ..Default::default() - }, - ); - let ci = crate::textures::to_color_image(&img, false); let tex = match show.tex.take() { Some((_, mut h)) => { h.set(ci, egui::TextureOptions::LINEAR); @@ -447,6 +507,17 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.tex = Some((key, tex.clone())); Some(tex) }; + // While this slide rests, render the next one's first frame (not one with GIFs, whose + // frame depends on when it is entered). + if !animating && show.trans.is_none() && !show.preview_only { + let next = deckcraft_anim::show_order(&doc).into_iter().find(|i| *i > idx); + let wanted = next.filter(|n| app.media.gifs(&doc, *n, now, &app.session.gif_paused).is_empty()); + if let Some(n) = wanted + && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) + { + show.prefetch = start_prefetch(&doc, n, size); + } + } let mut drew = false; let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None }; @@ -867,6 +938,20 @@ mod tests { use super::*; + #[test] + fn the_prefetched_frame_is_the_frame_shown_on_entry() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({"title": "Second"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let size = (96, 54); + let p = start_prefetch(&doc, 1, size).expect("prefetch starts"); + assert_eq!(p.key, Key { slide: 1, step: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: vec![], gif: 0 }); + let got = p.rx.recv().expect("the frame arrives"); + let want = render_frame(&doc, 1, ShowState::timeline(&doc, 1), 0, 0.0, size, vec![], &[], 0); + assert_eq!(got.size, want.size); + assert_eq!(got.pixels, want.pixels); + } + #[test] fn a_transition_keeps_the_old_slide_in_its_own_texture() { // #33: the old slide's handle was cloned, so the new slide was rendered into the same