From 31a828b02703943fad7859e7953ab5d883a5c584 Mon Sep 17 00:00:00 2001 From: Keresztes Barna Date: Thu, 22 Oct 2026 12:11:09 +0200 Subject: [PATCH 1/2] show: keep the slide backdrop while animations play Each animation frame re-rendered the whole slide, backdrop included. A full-slide layout picture with an 18 pt glow cost ~345 ms of the ~390 ms frame (2-3 fps at 1080p). Frames now draw only the slide's shapes over the backdrop, which is rendered once, ahead of time while the slide rests: ~28 ms a frame. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/show.rs | 163 +++++++++++++++++++++++++++++++++---- 2 files changed, 147 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..380cd57b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 51f859af..24def25a 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -31,6 +31,8 @@ pub struct Show { /// The next slide's first frame, rendered in the background while this one is shown so /// advancing only uploads it (#240). prefetch: Option, + /// The current slide's backdrop, under its animation frames. + backdrop: Option, blank: Option, /// See All Slides covers the show until a slide is chosen. pub grid: bool, @@ -93,8 +95,52 @@ 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) } } +/// What a slide's backdrop was rendered for. +#[derive(Clone, Copy, Debug, PartialEq)] +struct BackKey { + slide: usize, + size: (u32, u32), + doc: usize, +} + +/// A slide's backdrop (background, master and layout graphics), kept while the slide is shown so +/// animation frames render only the slide's own shapes over it. The backdrop can cost far more +/// than the shapes: a full-slide picture with a glow in the layout made animations crawl at 2–3 +/// frames a second. `rx` brings it from another thread when it is rendered ahead of time. +struct Backdrop { + key: BackKey, + img: Option>, + rx: Option>, +} + +fn render_backdrop(doc: &Presentation, key: BackKey, threads: u16) -> deckcraft_render::Image { + let opts = RenderOpts { scale: key.size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(key.size), threads, ..Default::default() }; + deckcraft_render::render_slide_part(doc, key.slide, deckcraft_render::SlidePart::Backdrop, &opts) +} + +/// `top` laid over `back` (both premultiplied RGBA of the same size; `top` alone otherwise). +fn over(back: &deckcraft_render::Image, top: &deckcraft_render::Image) -> egui::ColorImage { + if back.width != top.width || back.height != top.height || back.pixels.len() != top.pixels.len() { + return crate::textures::to_color_image(top, false); + } + let px = back + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(top.pixels.as_chunks::<4>().0) + .map(|(b, t)| { + let k = 255 - t[3] as u32; + let mix = |i: usize| (t[i] as u32 + (b[i] as u32 * k + 127) / 255).min(255) as u8; + Color32::from_rgba_premultiplied(mix(0), mix(1), mix(2), mix(3)) + }) + .collect(); + egui::ColorImage::new([top.width as usize, top.height as usize], px) +} + #[allow(clippy::too_many_arguments)] -/// Slide `idx` at `step`, `t` seconds into it, `size` pixels, with `hidden` media hidden. +/// Slide `idx` at `step`, `t` seconds into it, `size` pixels, with `hidden` media hidden. With a +/// `backdrop` (of this slide, at this size), only the slide's shapes are rendered, over it. fn render_frame( doc: &Presentation, idx: usize, @@ -107,6 +153,7 @@ fn render_frame( threads: u16, plays: &[TriggerPlay], stamps: &[StepStamp], + backdrop: Option<&deckcraft_render::Image>, ) -> egui::ColorImage { let f = move |id: ShapeId| { let mut st = state_for(&tl, id, step, t, plays, stamps); @@ -115,19 +162,18 @@ fn render_frame( } 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) + let opts = 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() + }; + match backdrop { + Some(back) => over(back, &deckcraft_render::render_slide_part(doc, idx, deckcraft_render::SlidePart::Content, &opts)), + None => crate::textures::to_color_image(&deckcraft_render::render_slide(doc, idx, &opts), false), + } } /// Media on slide `idx` hidden until it plays, as it is when the slide is entered. @@ -148,7 +194,7 @@ fn start_prefetch(doc: &std::sync::Arc, next: usize, size: (u32, u 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, &[], &[])); + let _ = tx.send(render_frame(&doc, next, tl, 0, 0.0, size, hidden, &[], threads, &[], &[], None)); }); spawned.ok().map(|_| Prefetch { key, rx }) } @@ -185,6 +231,7 @@ impl Show { tex: None, next_tex: None, prefetch: None, + backdrop: None, blank: None, grid: false, zoom_arm: false, @@ -232,6 +279,37 @@ impl Show { self.laser = false; } + /// The backdrop for `key`: kept from earlier, waited for if it is being rendered ahead of + /// time, or rendered now. + fn backdrop(&mut self, doc: &Presentation, key: BackKey) -> std::sync::Arc { + if let Some(b) = self.backdrop.as_mut().filter(|b| b.key == key) { + if b.img.is_none() { + b.img = b.rx.take().and_then(|rx| rx.recv().ok()).map(std::sync::Arc::new); + } + if let Some(img) = &b.img { + return img.clone(); + } + } + let img = std::sync::Arc::new(render_backdrop(doc, key, render_threads())); + self.backdrop = Some(Backdrop { key, img: Some(img.clone()), rx: None }); + img + } + + /// Start rendering the backdrop for `key` on another thread, unless it is already there or + /// on its way. Nothing on the web (no threads): the first animation frame renders it. + fn prefetch_backdrop(&mut self, doc: &std::sync::Arc, key: BackKey) { + if cfg!(target_arch = "wasm32") || self.backdrop.as_ref().is_some_and(|b| b.key == key) { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let doc = doc.clone(); + let threads = render_threads().saturating_sub(1).max(1); + let spawned = std::thread::Builder::new().name("show-backdrop".into()).spawn(move || { + let _ = tx.send(render_backdrop(&doc, key, threads)); + }); + self.backdrop = spawned.ok().map(|_| Backdrop { key, img: None, rx: Some(rx) }); + } + fn timeline(&mut self, doc: &Presentation) -> &Timeline { let i = self.state.slide; if self.timeline.as_ref().is_none_or(|(k, _)| *k != i) { @@ -714,6 +792,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let gif = crate::media::gif_key(&gifs); let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; + let back_key = BackKey { slide: idx, size, doc: key.doc }; let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { show.tex.as_ref().map(|(_, t)| t.clone()) } else { @@ -726,7 +805,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Some(ci) => ci, None => { let tl = show.timeline(&doc).clone(); - render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps) + // Animating: the backdrop doesn't change, only the shapes over it are drawn. + let back = animating.then(|| show.backdrop(&doc, back_key)); + render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps, back.as_deref()) } }; let tex = match show.tex.take() { @@ -741,6 +822,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { }; // While this slide rests, render the next one's first frame (not one with GIFs, whose // frame depends on when it is entered). + // A slide with animations gets its backdrop ready while it rests, so they start smoothly. + if !animating && !show.timeline(&doc).is_empty() { + show.prefetch_backdrop(&doc, back_key); + } if !animating && show.trans.is_none() && !show.preview_only { let next = show.state.peek_next(&doc); let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); @@ -2009,11 +2094,55 @@ mod tests { 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, plays: vec![] }); 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, &[], &[]); + let want = render_frame(&doc, 1, ShowState::timeline(&doc, 1), 0, 0.0, size, vec![], &[], 0, &[], &[], None); assert_eq!(got.size, want.size); assert_eq!(got.pixels, want.pixels); } + /// A glowing full-slide picture in a layout made every animation frame re-render (and re-blur) + /// the backdrop: 2–3 frames a second. Frames now draw the slide's shapes over the kept + /// backdrop, and must look the same as a whole-slide render. + #[test] + fn an_animation_frame_over_the_kept_backdrop_matches_a_full_render() { + let mut s = deckcraft_engine::Session::with_new(); + let r = s.execute("shape.insert", &json!({"preset": "star5", "rect": [100.0, 100.0, 300.0, 200.0]})).unwrap(); + s.execute("animation.set", &json!({"effect": "fade", "ids": [r["id"]]})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + // A glowing master graphic under the animated star. + let mut deco = doc.slides[0].shapes.last().unwrap().clone(); + deco.id = ShapeId(9000); + deco.xfrm = deco.xfrm.map(|x| deckcraft_geom::Xfrm { x: x.x - 60.0, y: x.y + 40.0, ..x }); + deco.effects = Some(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { + color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 120, 255)), + radius: 12.0, + }), + ..Default::default() + }); + std::sync::Arc::make_mut(&mut doc.masters[0]).shapes.push(deco); + let doc = std::sync::Arc::new(doc); + let size = (480, 270); + let key = BackKey { slide: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize }; + let mut show = Show::new(&doc, 0, false, false); + show.prefetch_backdrop(&doc, key); + let back = show.backdrop(&doc, key); + let tl = ShowState::timeline(&doc, 0); + assert!(!tl.is_empty()); + // Half-way through the fade, the star half see-through over the glow. + let fast = render_frame(&doc, 0, tl.clone(), 0, 0.25, size, vec![], &[], 0, &[], &[], Some(&back)); + let full = render_frame(&doc, 0, tl, 0, 0.25, size, vec![], &[], 0, &[], &[], None); + assert_eq!(fast.size, full.size); + let worst = fast + .pixels + .iter() + .zip(&full.pixels) + .map(|(a, b)| a.to_array().iter().zip(b.to_array()).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) + .max(); + assert!(worst.is_some_and(|d| d <= 2), "frames differ by up to {worst:?}"); + // The backdrop is kept, not rendered again. + assert!(std::sync::Arc::ptr_eq(&back, &show.backdrop(&doc, key))); + } + #[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 From df0fab857d48fd1c850f06224cd9b382cc85ec82 Mon Sep 17 00:00:00 2001 From: Keresztes Barna Date: Thu, 22 Oct 2026 17:59:53 +0200 Subject: [PATCH 2/2] show: keep the document a slide backdrop was rendered from The backdrop cache knew its document by address only: after an edit during the show, a new copy could reuse the freed address and get the old backdrop. The cache now holds that document and matches on it. (CodeRabbit review on #1161.) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 24def25a..8b015337 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -108,6 +108,9 @@ struct BackKey { /// than the shapes: a full-slide picture with a glow in the layout made animations crawl at 2–3 /// frames a second. `rx` brings it from another thread when it is rendered ahead of time. struct Backdrop { + /// The presentation it was rendered from, held so its address (in `key`) can't be reused by + /// a later edit while the backdrop is kept. + doc: std::sync::Arc, key: BackKey, img: Option>, rx: Option>, @@ -281,8 +284,8 @@ impl Show { /// The backdrop for `key`: kept from earlier, waited for if it is being rendered ahead of /// time, or rendered now. - fn backdrop(&mut self, doc: &Presentation, key: BackKey) -> std::sync::Arc { - if let Some(b) = self.backdrop.as_mut().filter(|b| b.key == key) { + fn backdrop(&mut self, doc: &std::sync::Arc, key: BackKey) -> std::sync::Arc { + if let Some(b) = self.backdrop.as_mut().filter(|b| b.key == key && std::sync::Arc::ptr_eq(&b.doc, doc)) { if b.img.is_none() { b.img = b.rx.take().and_then(|rx| rx.recv().ok()).map(std::sync::Arc::new); } @@ -291,23 +294,23 @@ impl Show { } } let img = std::sync::Arc::new(render_backdrop(doc, key, render_threads())); - self.backdrop = Some(Backdrop { key, img: Some(img.clone()), rx: None }); + self.backdrop = Some(Backdrop { doc: doc.clone(), key, img: Some(img.clone()), rx: None }); img } /// Start rendering the backdrop for `key` on another thread, unless it is already there or /// on its way. Nothing on the web (no threads): the first animation frame renders it. fn prefetch_backdrop(&mut self, doc: &std::sync::Arc, key: BackKey) { - if cfg!(target_arch = "wasm32") || self.backdrop.as_ref().is_some_and(|b| b.key == key) { + if cfg!(target_arch = "wasm32") || self.backdrop.as_ref().is_some_and(|b| b.key == key && std::sync::Arc::ptr_eq(&b.doc, doc)) { return; } let (tx, rx) = std::sync::mpsc::channel(); - let doc = doc.clone(); + let worker_doc = doc.clone(); let threads = render_threads().saturating_sub(1).max(1); let spawned = std::thread::Builder::new().name("show-backdrop".into()).spawn(move || { - let _ = tx.send(render_backdrop(&doc, key, threads)); + let _ = tx.send(render_backdrop(&worker_doc, key, threads)); }); - self.backdrop = spawned.ok().map(|_| Backdrop { key, img: None, rx: Some(rx) }); + self.backdrop = spawned.ok().map(|_| Backdrop { doc: doc.clone(), key, img: None, rx: Some(rx) }); } fn timeline(&mut self, doc: &Presentation) -> &Timeline { @@ -2141,6 +2144,10 @@ mod tests { assert!(worst.is_some_and(|d| d <= 2), "frames differ by up to {worst:?}"); // The backdrop is kept, not rendered again. assert!(std::sync::Arc::ptr_eq(&back, &show.backdrop(&doc, key))); + // Another document under the same key (an edit whose copy reused the old address) gets + // its own backdrop. + let edited = std::sync::Arc::new((*doc).clone()); + assert!(!std::sync::Arc::ptr_eq(&back, &show.backdrop(&edited, key))); } #[test]