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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 21:11:38 +03:00
co-authored by Cursor
parent 457406a7a5
commit 07dd513c54
+108 -23
View File
@@ -27,6 +27,9 @@ pub struct Show {
trans: Option<Transition>,
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<Prefetch>,
blank: Option<Color32>,
pen: bool,
strokes: Vec<Vec<Pos2>>,
@@ -44,7 +47,7 @@ pub struct Show {
links: Option<(usize, Vec<deckcraft_render::PlacedLink>)>,
}
#[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<egui::ColorImage>,
}
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<ShapeId>,
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<ShapeId> {
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<Presentation>, next: usize, size: (u32, u32)) -> Option<Prefetch> {
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<TextureHandle>,
/// 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(), &times, 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: &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