collective

Browse / github.com/storytold/deckcraft / patch

Slide shows play animations and GIFs smoothly, even in the browser

Slide shows play animations and GIFs smoothly, even in the browser ## What this fixes Animated slides and animated GIFs stuttered badly in slide shows. In the browser build they were close to unusable: a photo slide with a few animated shapes played at **3.5 frames per second**, and a GIF slide froze for about a third of a second every time the GIF changed frame. With this PR, animations and GIFs play at the display's frame rate on desktop and in the browser. ## Why it was slow 1. **The whole slide was redrawn for every animation frame.** DeckCraft draws slides on the CPU. During an animation, each frame re-rasterized every shape on the slide at full scree

Status
open
Score
0.00
Adopters
0 other operators
Author
nagasakimark (imported from pull request #1333, open)
Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8
Size
+1839 −88 in 21 files
Review
pending
Shared
2026-10-28
Id
9268d8f18d2a9fafc54f570b

How it went

No other operator has reported using it yet.

Diff

raw · .cargo/config.toml Cargo.lock README.md ROADMAP.md apps/deckcraft-web/index.html contributors/people.toml crates/render/src/lib.rs crates/render/src/tests_layers.rs crates/ui-egui/Cargo.toml crates/ui-egui/src/lib.rs crates/ui-egui/src/show.rs crates/ui-egui/src/show_bench_tests.rs crates/ui-egui/src/show_layers.rs crates/ui-egui/src/show_layers_tests.rs docs/animation-parity.md docs/architecture.md docs/benchmarks/slideshow.md docs/gaps.md docs/hardware-parity.md docs/slideshow-parity.md packaging/web/README.md

diff --git a/.cargo/config.toml b/.cargo/config.toml--- a/.cargo/config.toml+++ b/.cargo/config.toml@@ -5,3 +5,8 @@ CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }  [alias] xtask = "run --package xtask --"++# The web build uses WebAssembly SIMD (in every current browser: Chrome/Edge 91, Firefox 89,+# Safari 16.4). The CPU renderer (vello_cpu) only vectorises with it: about 2.4x faster slides.+[target.wasm32-unknown-unknown]+rustflags = ["-C", "target-feature=+simd128"]diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1174,6 +1174,7 @@ dependencies = [  "deckcraft-text",  "egui",  "egui_kittest",+ "image",  "js-sys",  "log",  "serde",diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -269,7 +269,7 @@ ALSA headers).  | Build | File | Notes | |---|---|---|-| Static site | `deckcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |+| Static site | `deckcraft-web-<ver>.zip` | Runs in a modern browser with WebAssembly SIMD (Chrome and Edge 91+, Firefox 89+, Safari 16.4+); host it on any static server |  ## The Crafting Apps diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: slide-show layers composited on the GPU, wasm SIMD; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-25 | Slide show plays animations and animated GIFs the way PowerPoint does: a slide's layers are rasterized once and the GPU moves, scales, turns, fades and clips them, so an animation frame costs about 0.03 ms of CPU and uploads no texture (it was a whole-slide render and an 8 MB upload at 1080p; a photo slide with five animated shapes went from 3.5 to display rate in the browser). By-paragraph builds get a sprite per paragraph, GIF frames are kept as textures, the presenter's current slide shows the same frames, and animated groups now move their contents. The web build uses WebAssembly SIMD (1.2–3× faster CPU rendering there). Numbers: [docs/benchmarks/slideshow.md](docs/benchmarks/slideshow.md) | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |@@ -278,6 +279,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | trivial | Progress log: slide-show layers composited on the GPU and WebAssembly SIMD for the web build. Scores unchanged (Performance stays estimated until a PowerPoint comparison) | | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |diff --git a/apps/deckcraft-web/index.html b/apps/deckcraft-web/index.html--- a/apps/deckcraft-web/index.html+++ b/apps/deckcraft-web/index.html@@ -16,5 +16,18 @@ <body>   <canvas id="deckcraft_canvas"></canvas>   <div id="deckcraft_loading">Loading DeckCraft&hellip;</div>+  <script>+    // DeckCraft is built with WebAssembly SIMD (Chrome and Edge 91+, Firefox 89+, Safari 16.4+).+    // Say so instead of failing silently in a browser without it. The bytes are a module with+    // one function returning `i8x16.splat(0)`.+    (function () {+      var simd = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 8, 1, 6, 0, 65, 0, 253, 15, 11]);+      var ok = typeof WebAssembly === "object" && WebAssembly.validate(simd);+      if (!ok) {+        document.getElementById("deckcraft_loading").textContent =+          "DeckCraft needs a browser with WebAssembly SIMD: Chrome or Edge 91, Firefox 89, Safari 16.4 or later.";+      }+    })();+  </script> </body> </html>diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -62,3 +62,6 @@ sync_github_name = false  [people.GhostMitra] sync_github_name = true++[people.nagasakimark]+display_name = "nagasakimark"diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -37,6 +37,7 @@ use std::sync::Arc;  use deckcraft_color::Rgba; use deckcraft_fonts::FontDb;+use deckcraft_geom::Xfrm; use deckcraft_geom::preset::{self, FillMode, Geometry, SubPath}; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::style::{Effects, Fill, Line};@@ -320,6 +321,8 @@ pub struct Renderer {     group_fill: Option<(Fill, Affine, Rect)>,     /// Their aligned outlines, kept from frame to frame.     morphs: morph_path::Cache,+    /// Draw only this paragraph of each shape's text ([`ShapePart::Paragraph`]).+    text_only: Option<usize>, }  impl Default for Renderer {@@ -332,6 +335,7 @@ impl Default for Renderer {             view: Affine::IDENTITY,             group_fill: None,             morphs: morph_path::Cache::default(),+            text_only: None,         }     } }@@ -453,6 +457,49 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt     } } +/// What [`render_slide_shapes`] draws of each shape.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+pub enum ShapePart {+    /// The whole shape.+    #[default]+    Whole,+    /// Only paragraph `n` of its text: not its fill, outline, effects or other paragraphs.+    Paragraph(usize),+}++/// Render the top-level shapes of slide `index` listed in `ids` (in slide order, as `part`) over+/// transparency, or over the slide's backdrop with `backdrop`, and say how far their drawing+/// reaches, in output pixels (`None`: nothing drawn). A slide show draws its animated shapes this+/// way, each on a picture of its own that the GPU moves; a 1×1 `opts.size` only measures. A panic+/// inside the renderer becomes a transparent image (logged), never a crash.+pub fn render_slide_shapes(+    pres: &Presentation,+    index: usize,+    ids: &[ShapeId],+    part: ShapePart,+    backdrop: bool,+    opts: &RenderOpts,+) -> (Image, Option<Rect>) {+    let Some(slide) = pres.slides.get(index) else { return (Image::default(), None) };+    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {+        RENDERER.with(|r| {+            let mut r = r.borrow_mut();+            r.drawn = None;+            let img = r.shapes(pres, slide, index, ids, part, backdrop, opts);+            (img, r.drawn.take())+        })+    })) {+        Ok(out) => out,+        Err(_) => {+            log::error!("rendering shapes of slide {index} panicked; showing nothing");+            note_panic();+            RENDERER.with(|r| *r.borrow_mut() = Renderer::default());+            let (w, h) = output_size(pres, opts);+            (Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] }, None)+        }+    }+}+ /// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the /// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by /// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:@@ -620,6 +667,45 @@ pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect {     Rect::new(bounds.x0 - l * fw, bounds.y0 - t * fh, bounds.x0 - l * fw + fw, bounds.y0 - t * fh + fh) } +/// The transform from a shape's own space (box `x`) to its parent's, as animation state `st`+/// moves it: translated by `st.offset` in the parent, scaled and turned around its centre.+fn moved(x: &Xfrm, st: &ShapeState) -> Affine {+    if st.offset == Vec2::ZERO && st.scale == (1.0, 1.0) && st.rotate == 0.0 {+        return x.affine();+    }+    let c = Point::new(x.w / 2.0, x.h / 2.0);+    let around = Affine::translate(c.to_vec2())+        * Affine::rotate(st.rotate.to_radians())+        * Affine::scale_non_uniform(st.scale.0, st.scale.1)+        * Affine::translate(-c.to_vec2());+    Affine::translate(st.offset) * x.affine() * around+}++/// How animation moves a top-level shape: its box size and the transforms (slide points) from+/// its own space to the slide, as drawn still and as a [`ShapeState`] moves it ([`shape_motion`]).+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct Motion {+    pub w: f64,+    pub h: f64,+    pub still: Affine,+    pub moved: Affine,+}++/// How state `st` moves top-level shape `id` of slide `index` (a 3-D camera's turn included, as+/// drawn). `None` for a shape that isn't there or has no finite box. A slide show uses it to move+/// a picture of the still shape instead of drawing the shape again.+pub fn shape_motion(pres: &Presentation, index: usize, id: ShapeId, st: &ShapeState) -> Option<Motion> {+    let slide = pres.slides.get(index)?;+    let s = slide.shapes.iter().find(|s| s.id == id)?;+    let rctx = Ctx::for_slide(pres, slide)?;+    let x = resolve::xfrm(&rctx, s);+    if !x.is_finite() {+        return None;+    }+    let turn = camera_turn(&rctx, s, x.w, x.h).unwrap_or(Affine::IDENTITY);+    Some(Motion { w: x.w, h: x.h, still: x.affine() * turn, moved: moved(&x, st) * turn })+}+ struct Frame<'a> {     pres: &'a Presentation,     opts: &'a RenderOpts<'a>,@@ -707,6 +793,41 @@ impl Renderer {         self.finish(ctx, w, h)     } +    #[allow(clippy::too_many_arguments)]+    fn shapes(+        &mut self,+        pres: &Presentation,+        slide: &Slide,+        index: usize,+        ids: &[ShapeId],+        part: ShapePart,+        backdrop: bool,+        opts: &RenderOpts,+    ) -> Image {+        let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));+        let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };+        if let Some(rctx) = Ctx::for_slide(pres, slide) {+            if backdrop {+                self.backdrop(&mut ctx, &f, &rctx, slide, view);+                // The backdrop's own drawing is not what the chosen shapes reach.+                self.drawn = None;+            } else {+                // Shapes filled with the background (`useBgFill`) still show it.+                let (fill, ph) = resolve::background(&rctx, Some(slide));+                self.bg = Some((fill, ph, view));+            }+            self.text_only = match part {+                ShapePart::Whole => None,+                ShapePart::Paragraph(n) => Some(n),+            };+            for s in slide.shapes.iter().filter(|s| ids.contains(&s.id)) {+                self.shape(&mut ctx, &f, &rctx, s, view, 0);+            }+            self.text_only = None;+        }+        self.finish(ctx, w, h)+    }+     #[allow(clippy::too_many_arguments)]     fn blend(         &mut self,@@ -904,18 +1025,14 @@ impl Renderer {         if !x.is_finite() {             return;         }-        let mut m = parent * x.affine();-        if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 {-            let c = Point::new(x.w / 2.0, x.h / 2.0);-            let around = Affine::translate(c.to_vec2())-                * Affine::rotate(st.rotate.to_radians())-                * Affine::scale_non_uniform(st.scale.0, st.scale.1)-                * Affine::translate(-c.to_vec2());-            m = parent * Affine::translate(st.offset) * x.affine() * around;-        }+        let mut m = parent * moved(&x, &st);         if let Some(a) = camera_turn(rctx, s, x.w, x.h) {             m *= a;         }+        // Only a paragraph's text: nothing else draws text of its own.+        if self.text_only.is_some() && matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Ink { .. }) {+            return;+        }         let layered = st.opacity < 0.999 || st.clip.is_some();         if layered {             ctx.set_transform(m);@@ -925,7 +1042,13 @@ impl Renderer {         match &s.kind {             ShapeKind::Group { children, child } => {                 let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);-                let gm = parent * deckcraft_geom::group_child_affine(&x, ch);+                // The children move with the group's animation (fly in, spin, grow, motion paths).+                let motion = if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 {+                    moved(&x, &st) * x.affine().inverse()+                } else {+                    Affine::IDENTITY+                };+                let gm = parent * motion * deckcraft_geom::group_child_affine(&x, ch);                 // A group's own fill (a group filled with its parent's passes that on).                 let own = match &s.fill {                     Some(Fill::Group) | None => self.group_fill.clone(),@@ -1024,6 +1147,78 @@ impl Renderer {         }     } +    /// A shape's text (or, in the editor, its placeholder prompt), set in its box under `m`.+    #[allow(clippy::too_many_arguments)]+    fn shape_text(+        &mut self,+        ctx: &mut RenderContext,+        f: &Frame,+        rctx: &Ctx,+        s: &Shape,+        m: Affine,+        depth: usize,+        w: f64,+        h: f64,+        geo: &Geometry,+        paras: &[(usize, ParaState)],+    ) {+        // Text (or the placeholder prompt in the editor).+        let skip_text = f.opts.skip_text == Some(s.id);+        if let Some(body) = &s.text+            && !skip_text+        {+            // Text in a group is set at the shape's size on the slide, in points: a group's+            // child-coordinate scale sizes and places the box, never the type (#233's twin).+            let (tw, th, m) = self.text_space(m, depth, w, h);+            let text_geo;+            let geo = if (tw, th) == (w, h) {+                geo+            } else {+                text_geo = shape_geometry(s, tw, th);+                &text_geo+            };+            let tr = geo.text_rect;+            let editing = f.opts.edit && f.opts.editing_text == Some(s.id);+            if !body.is_empty() || editing {+                // Imported placeholders can have no paragraph until the first keystroke.+                let mut empty;+                let body = if editing && body.paragraphs.is_empty() {+                    empty = body.clone();+                    empty.paragraphs.push(Default::default());+                    &empty+                } else {+                    body+                };+                let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });+                if editing {+                    preview_empty_bullets(&mut l);+                }+                let tm = text_transform(m, tr, l.rotation);+                // Only the paragraphs drawn, where their animation moves them.+                let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();+                let shift = |o: Vec2| if o == Vec2::ZERO || m.determinant().abs() < 1e-12 { tm } else { m * Affine::translate(o) * m.inverse() * tm };+                for li in &l.lines {+                    let ps = para(li.para);+                    let Some(b) = line_bounds(li).filter(|_| ps.visible && ps.opacity > 0.001) else { continue };+                    // Glyphs reach a little past their carets and line box (italics, swashes).+                    let pad = b.height() * 0.3;+                    self.track(shift(ps.offset), b.inflate(pad, pad));+                }+                match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {+                    Some(w) => warp::draw(ctx, &l, tr, m, paras, &w),+                    None => draw_layout_with(ctx, &l, tr, m, paras),+                }+            } else if f.opts.edit && s.ph.is_some() {+                let kind = s.ph_type().unwrap_or(PhType::Body);+                let prompt_body = prompt_body(rctx, s, kind);+                if !prompt_body.is_empty() {+                    let grey = Rgba::rgb(0x59, 0x59, 0x59);+                    draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey));+                }+            }+        }+    }+     #[allow(clippy::too_many_arguments)]     fn geometry_shape(         &mut self,@@ -1042,6 +1237,13 @@ impl Renderer {             None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()),         };         let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });+        if let Some(n) = self.text_only {+            // One paragraph alone: every other one hidden, the box and its effects left out.+            let count = s.text.as_ref().map_or(0, |t| t.paragraphs.len());+            let only: Vec<(usize, ParaState)> = (0..count).map(|i| (i, ParaState { visible: i == n, ..Default::default() })).collect();+            self.shape_text(ctx, f, rctx, s, m, depth, w, h, &geo, &only);+            return;+        }         let (fill, fill_ph) = resolve::fill(rctx, s);         let (line, line_ph) = resolve::line(rctx, s);         let (effects, fx_ph) = resolve::effects(rctx, s);@@ -1344,56 +1546,7 @@ impl Renderer {             ctx.pop_layer();             ctx.pop_layer();         }-        // Text (or the placeholder prompt in the editor).-        let skip_text = f.opts.skip_text == Some(s.id);-        if let Some(body) = &s.text-            && !skip_text-        {-            // Text in a group is set at the shape's size on the slide, in points: a group's-            // child-coordinate scale sizes and places the box, never the type (#233's twin).-            let (tw, th, m) = self.text_space(m, depth, w, h);-            let text_geo;-            let geo = if (tw, th) == (w, h) {-                &geo-            } else {-                text_geo = shape_geometry(s, tw, th);-                &text_geo-            };-            let tr = geo.text_rect;-            let editing = f.opts.edit && f.opts.editing_text == Some(s.id);-            if !body.is_empty() || editing {-                // Imported placeholders can have no paragraph until the first keystroke.-                let mut empty;-                let body = if editing && body.paragraphs.is_empty() {-                    empty = body.clone();-                    empty.paragraphs.push(Default::default());-                    &empty-                } else {-                    body-                };-                let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });-                if editing {-                    preview_empty_bullets(&mut l);-                }-                let tm = text_transform(m, tr, l.rotation);-                for b in l.lines.iter().filter_map(line_bounds) {-                    // Glyphs reach a little past their carets and line box (italics, swashes).-                    let pad = b.height() * 0.3;-                    self.track(tm, b.inflate(pad, pad));-                }-                match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {-                    Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),-                    None => draw_layout_with(ctx, &l, tr, m, &st.paras),-                }-            } else if f.opts.edit && s.ph.is_some() {-                let kind = s.ph_type().unwrap_or(PhType::Body);-                let prompt_body = prompt_body(rctx, s, kind);-                if !prompt_body.is_empty() {-                    let grey = Rgba::rgb(0x59, 0x59, 0x59);-                    draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey));-                }-            }-        }+        self.shape_text(ctx, f, rctx, s, m, depth, w, h, &geo, &st.paras);         if empty_ph && f.opts.edit {             // Dashed placeholder outline.             ctx.set_transform(m);@@ -2419,6 +2572,8 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) #[cfg(test)] mod tests; +#[cfg(test)]+mod tests_layers; #[cfg(test)] mod tests_wild; diff --git a/crates/render/src/tests_layers.rs b/crates/render/src/tests_layers.rsnew file mode 100644--- /dev/null+++ b/crates/render/src/tests_layers.rs@@ -0,0 +1,97 @@+//! Drawing single shapes, single paragraphs and moving groups, for the slide show's layers.++use super::*;+use deckcraft_model::style::ColorRef;+use deckcraft_model::text::{Paragraph, Run, TextBody};+use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Xfrm};++fn deck_with(shapes: Vec<Shape>) -> Presentation {+    let mut p = Presentation::default();+    std::sync::Arc::make_mut(&mut p.slides[0]).shapes = shapes;+    p+}++fn red_box(id: u32, x: f64, y: f64, w: f64, h: f64) -> Shape {+    Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, w, h)), fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(220, 20, 20)))), ..Default::default() }+}++fn ink(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize {+    (y0..y1.min(img.height)).flat_map(|y| (x0..x1.min(img.width)).map(move |x| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[3] > 0).count()+}++#[test]+fn one_shape_alone_measures_and_draws_in_its_own_region() {+    let p = deck_with(vec![red_box(1, 100.0, 50.0, 200.0, 100.0), red_box(2, 500.0, 300.0, 100.0, 100.0)]);+    let probe = RenderOpts { scale: 0.5, size: Some((1, 1)), ..Default::default() };+    let (_, drawn) = render_slide_shapes(&p, 0, &[ShapeId(1)], ShapePart::Whole, false, &probe);+    let d = drawn.expect("the shape draws");+    // 0.5 px/pt: the box is 50,25 – 150,75 (its outline reaches a little further).+    assert!(d.x0 <= 50.0 && d.x0 > 45.0 && d.y0 <= 25.0 && d.y0 > 20.0 && d.x1 >= 150.0 && d.x1 < 155.0, "{d:?}");+    // Drawn into just that region, it matches the same pixels of all the slide's shapes.+    let region = RenderOpts { scale: 0.5, size: Some((120, 70)), offset: (-40.0, -15.0), ..Default::default() };+    let (part, _) = render_slide_shapes(&p, 0, &[ShapeId(1)], ShapePart::Whole, false, &region);+    let whole = render_slide_part(&p, 0, SlidePart::Content, &RenderOpts { scale: 0.5, ..Default::default() });+    for (x, y) in [(20u32, 20u32), (60, 35), (100, 55), (5, 5)] {+        assert_eq!(part.pixel(x, y), whole.pixel(x + 40, y + 15), "at {x},{y}");+    }+    assert_eq!(ink(&part, 0, 120, 0, 70), ink(&whole, 40, 160, 15, 85));+    // The other shape is left out; with the backdrop the slide's white is under it.+    assert_eq!(+        ink(+            &render_slide_shapes(&p, 0, &[ShapeId(1)], ShapePart::Whole, false, &RenderOpts { scale: 0.5, ..Default::default() }).0,+            240,+            310,+            140,+            210+        ),+        0+    );+    let (back, _) = render_slide_shapes(&p, 0, &[], ShapePart::Whole, true, &RenderOpts { scale: 0.5, ..Default::default() });+    assert_eq!(back.pixel(300, 200), [255, 255, 255, 255]);+}++#[test]+fn a_paragraph_alone_draws_only_its_line() {+    let para = |t: &str| Paragraph { runs: vec![Run::new(t)], ..Default::default() };+    let s = Shape {+        id: ShapeId(7),+        xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),+        fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 200, 240)))),+        text: Some(TextBody { paragraphs: vec![para("First line"), para("Second line"), para("Third line")], ..Default::default() }),+        ..Default::default()+    };+    let p = deck_with(vec![s]);+    let o = RenderOpts { scale: 1.0, ..Default::default() };+    let (whole, _) = render_slide_shapes(&p, 0, &[ShapeId(7)], ShapePart::Whole, false, &o);+    let (second, d) = render_slide_shapes(&p, 0, &[ShapeId(7)], ShapePart::Paragraph(1), false, &o);+    let d = d.expect("the paragraph draws");+    // No box fill: only glyphs, on one line, below the first and above the third.+    assert_eq!(second.pixel(110, 110)[3], 0, "the fill is left out");+    assert!(ink(&second, 100, 500, 100, 300) > 50, "the paragraph's glyphs are drawn");+    let (first, d1) = render_slide_shapes(&p, 0, &[ShapeId(7)], ShapePart::Paragraph(0), false, &o);+    let d1 = d1.expect("first paragraph");+    assert!(d1.y1 <= d.y1 && d1.y0 < d.y0, "the first paragraph sits above the second: {d1:?} {d:?}");+    assert!(ink(&first, 100, 500, 100, 300) > 50);+    assert!(ink(&whole, 100, 500, 100, 300) > ink(&second, 100, 500, 100, 300));+}++#[test]+fn an_animated_group_moves_its_children() {+    let child = red_box(11, 0.0, 0.0, 100.0, 100.0);+    let g = Shape {+        id: ShapeId(10),+        xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)),+        kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 100.0, 100.0) },+        ..Default::default()+    };+    let p = deck_with(vec![g]);+    let moved =+        |id: ShapeId| if id == ShapeId(10) { ShapeState { offset: Vec2::new(300.0, 0.0), ..Default::default() } } else { ShapeState::default() };+    let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&moved), ..Default::default() });+    assert_eq!(img.pixel(150, 150), [255, 255, 255, 255], "the group left its place");+    let px = img.pixel(450, 150);+    assert!(px[0] > 200 && px[1] < 60, "the child is drawn where the group moved: {px:?}");+    let m = shape_motion(&p, 0, ShapeId(10), &moved(ShapeId(10))).expect("motion");+    assert_eq!(m.moved * Point::new(0.0, 0.0), Point::new(400.0, 100.0));+    assert_eq!(m.still * Point::new(0.0, 0.0), Point::new(100.0, 100.0));+}diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml--- a/crates/ui-egui/Cargo.toml+++ b/crates/ui-egui/Cargo.toml@@ -28,6 +28,8 @@ js-sys = "0.3"  [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }+# Pictures and GIFs for the slide-show benchmark (`show_bench_tests.rs`).+image = { workspace = true }  [build-dependencies] serde_json = { workspace = true }diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -27,6 +27,7 @@ pub mod ribbon; pub mod selpane; pub mod settings; pub mod show;+mod show_layers; pub mod sorter; pub mod squiggles; pub mod status;diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs--- a/crates/ui-egui/src/show.rs+++ b/crates/ui-egui/src/show.rs@@ -35,6 +35,14 @@ pub struct Show {     prefetch: Option<Prefetch>,     /// The current slide's backdrop, under its animation frames.     backdrop: Option<Backdrop>,+    /// The current slide's layers when it has animations or animated GIFs: frames are composited+    /// from pictures rendered once (`show_layers`), with the presentation they were made from.+    layers: Option<(std::sync::Arc<Presentation>, crate::show_layers::Layers)>,+    /// The last composited frame, for the presenter's view of the current slide.+    quads: Vec<crate::show_layers::Quad>,+    /// What the last composited frame showed, so a transition away from the slide can start+    /// from it when the whole-slide picture is older.+    shown: Option<Shown>,     blank: Option<Color32>,     /// See All Slides covers the show until a slide is chosen.     pub grid: bool,@@ -255,8 +263,54 @@ fn start_prefetch(doc: &std::sync::Arc<Presentation>, next: usize, size: (u32, u     spawned.ok().map(|_| Prefetch { key, rx }) } +/// A composited frame's moment: enough to render the whole slide as it showed.+#[derive(Clone)]+struct Shown {+    key: Key,+    t: f64,+    plays: Vec<TriggerPlay>,+    stamps: Vec<StepStamp>,+    times: Vec<(ShapeId, f64)>,+}++/// Render the whole slide as the show shows it now into `show.tex`, unless it already is that+/// (GIF frames aside). A slide composited from layers keeps it for the transition to the next one.+#[allow(clippy::too_many_arguments)]+fn bring_whole_up_to_date(+    ctx: &egui::Context,+    show: &mut Show,+    doc: &std::sync::Arc<Presentation>,+    size: (u32, u32),+    hidden: &[ShapeId],+    times: &[(ShapeId, f64)],+    gif: u64,+    now: f64,+) {+    let (idx, step) = (show.state.slide, show.state.step);+    let t = if show.state.playing { now - show.step_start } else { 0.0 };+    let (plays, stamps) = (show.state.trigger_plays(), show.state.trigger_stamps());+    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.to_vec(), plays: done, gif };+    if show.tex.as_ref().is_some_and(|(k, _)| Key { gif: 0, ..k.clone() } == Key { gif: 0, ..key.clone() }) {+        return;+    }+    let tl = show.timeline(doc).clone();+    let ci = render_frame(doc, idx, tl, step, t, size, hidden.to_vec(), times, render_threads(), &plays, &stamps, None);+    let tex = match show.tex.take() {+        Some((_, mut h)) => {+            crate::textures::replace(ctx, &mut h, ci);+            h+        }+        None => crate::textures::upload(ctx, "show", ci),+    };+    show.tex = Some((key, tex));+}+ struct Transition {     old: Option<TextureHandle>,+    /// The old slide was composited and `old` is an older picture of it: render it as it last+    /// showed before the first frame of the transition.+    refresh: Option<Shown>,     /// The slide left behind (Morph matches its shapes) and the Morph frame texture.     from: Option<usize>,     morph: Option<TextureHandle>,@@ -289,6 +343,9 @@ impl Show {             next_tex: None,             prefetch: None,             backdrop: None,+            layers: None,+            quads: vec![],+            shown: None,             blank: None,             grid: false,             presenter_grid: false,@@ -418,12 +475,14 @@ impl Show {                 // Take the old slide's texture rather than cloning its handle: the new slide is                 // otherwise rendered into the same texture, and the transition blends the new                 // slide with itself (#33).+                let tex_key = self.tex.as_ref().map(|(k, _)| Key { gif: 0, ..k.clone() });+                let refresh = self.shown.take().filter(|_| !self.quads.is_empty()).filter(|sh| tex_key != Some(Key { gif: 0, ..sh.key.clone() }));                 let (from, old) = self.tex.take().map(|(k, t)| (k.slide, t)).unzip();                 if from != Some(i) {                     self.last_viewed = from;                 }                 self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {-                    Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur })+                    Some(Transition { old, refresh, from, morph: None, parts: None, start: now, kind, option, dur })                 } else {                     None                 };@@ -792,6 +851,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {             actions.push(show.state.prev(&doc));         }     }+    let ppp = ctx.pixels_per_point();+    // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit+    // (drawn stretched to the screen).+    let size = crate::textures::fit_size(+        ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32),+        crate::textures::max_side(&ctx),+    );     // Apply input first: a step started by this click must restart the clock before it is read.     for a in actions.drain(..) {         if !show.apply(a, &doc, now) {@@ -835,13 +901,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {     // Draw.     let painter = ui.painter_at(full);     painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK);-    let ppp = ctx.pixels_per_point();-    // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit-    // (drawn stretched to the screen).-    let size = crate::textures::fit_size(-        ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32),-        crate::textures::max_side(&ctx),-    );     if show.ended {         painter.text(             pos2(full.center().x, full.min.y + 40.0),@@ -873,7 +932,55 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {         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) {+        // A slide with animations or animated GIFs is composited from layers rendered once+        // (PowerPoint-style), except while a transition plays or a video is on it.+        let gif_ids: Vec<ShapeId> = gifs.iter().map(|g| g.id).collect();+        let animated = show.timeline(&doc).animated_shapes();+        let layered = show.trans.is_none() && !media.iter().any(|(_, _, c)| c.video) && (!animated.is_empty() || !gif_ids.is_empty());+        let layers_key = crate::show_layers::LayersKey { slide: idx, size, doc: key.doc, hidden: hidden.clone() };+        if !layered {+            if show.trans.is_none() {+                show.layers = None;+            }+        } else if show.layers.as_ref().is_none_or(|(_, l)| l.key != layers_key) {+            let tl = show.timeline(&doc).clone();+            show.layers =+                crate::show_layers::Layers::plan(&doc, layers_key, &animated, &gif_ids, &|id| tl.paragraph_targets(id)).map(|l| (doc.clone(), l));+        }+        let view = crate::show_layers::View {+            size,+            rect: srect,+            uv: show.zoom_uv(),+            max_side: crate::textures::max_side(&ctx),+            scale: size.0 as f64 / doc.slide_size.width.max(1.0),+        };+        let frame_tl = show.timeline(&doc).clone();+        let frame_state = |id: ShapeId| {+            let mut st = state_for(&frame_tl, id, step, t, &plays, &stamps);+            if hidden.contains(&id) {+                st.visible = false;+            }+            st+        };+        let gif_frames: Vec<(ShapeId, usize)> = gifs.iter().map(|g| (g.id, g.frame)).collect();+        let lframe = crate::show_layers::Frame { state: &frame_state, gif_times: &times, gif_frames: &gif_frames, threads: render_threads() };+        let use_layers = layered && show.layers.as_ref().is_some_and(|(_, l)| l.ready());+        if !use_layers {+            show.quads.clear();+        }+        // With layers, the whole-slide picture is only what a transition to the next slide+        // starts from: brought up to date once the slide has rested a moment (and, at the+        // latest, as the slide changes).+        let rested = !animating && now - show.step_end.max(show.slide_start) > 0.4;+        let same_but_gif = show.tex.as_ref().is_some_and(|(k, _)| Key { gif: 0, ..k.clone() } == Key { gif: 0, ..key.clone() });+        let mut rendered = false;+        let tex = if use_layers {+            if rested && !same_but_gif {+                bring_whole_up_to_date(&ctx, &mut show, &doc, size, &hidden, &times, gif, now);+                rendered = true;+            }+            show.tex.as_ref().map(|(_, t)| t.clone())+        } else if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {             show.tex.as_ref().map(|(_, t)| t.clone())         } else {             // The frame rendered ahead of time, when it is this one (waiting for it beats starting over).@@ -898,12 +1005,27 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {                 None => crate::textures::upload(&ctx, "show", ci),             };             show.tex = Some((key.clone(), tex.clone()));+            rendered = true;             Some(tex)         };+        if layered+            && !rendered+            && let Some((_, l)) = show.layers.as_mut()+            && !l.ready()+        {+            // Build them while the slide rests, one a frame (never in a frame that already drew+            // the whole slide); all at once when something moves.+            let all = animating || !gif_ids.is_empty();+            if !l.build(&ctx, &doc, &view, &lframe, all) {+                ctx.request_repaint();+            }+        } else if layered && rendered && show.layers.as_ref().is_some_and(|(_, l)| !l.ready()) {+            ctx.request_repaint();+        }         // 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() {+        if !animating && !layered && !show.timeline(&doc).is_empty() {             show.prefetch_backdrop(&doc, back_key);         }         if !animating && show.trans.is_none() && !show.preview_only {@@ -915,6 +1037,30 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {                 show.prefetch = start_prefetch(&doc, n, size);             }         }+        // A transition away from a composited slide starts from the slide as it last showed.+        if let Some(tr) = show.trans.as_mut()+            && let Some(sh) = tr.refresh.take()+        {+            let tl = ShowState::timeline(&doc, sh.key.slide);+            let ci = render_frame(+                &doc,+                sh.key.slide,+                tl,+                sh.key.step,+                sh.t,+                size,+                sh.key.hidden.clone(),+                &sh.times,+                render_threads(),+                &sh.plays,+                &sh.stamps,+                None,+            );+            match tr.old.as_mut() {+                Some(h) => crate::textures::replace(&ctx, h, ci),+                None => tr.old = Some(crate::textures::upload(&ctx, "show-old", ci)),+            }+        }         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 };@@ -1001,7 +1147,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {         }         if !drew {             show.trans = None;-            if let Some(tex) = &tex {+            if use_layers && let Some((ldoc, l)) = show.layers.as_mut() {+                show.quads = l.frame(&ctx, ldoc, &view, &lframe);+                crate::show_layers::draw(&painter, &view, &show.quads);+                show.shown = Some(Shown { key: key.clone(), t, plays: plays.clone(), stamps: stamps.clone(), times: times.clone() });+            } else if let Some(tex) = &tex {                 painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE);             }             // Playing video: the current frame in its box, or full screen.@@ -2037,7 +2187,13 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no                 let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max);                 let cr = fit(Rect::from_min_max(left.min, pos2(left.max.x, left.min.y + left.height() * 0.8)), doc);                 let cr = Rect::from_min_size(left.min, cr.size());-                if let Some(t) = &cur_tex {+                if !show.quads.is_empty() {+                    // The slide composited as on the audience screen.+                    let size = (cr.width().max(1.0) as u32, cr.height().max(1.0) as u32);+                    let full = show.layers.as_ref().map_or(size, |(_, l)| l.key.size);+                    let view = crate::show_layers::View { size: full, rect: cr, uv: unit_uv(), max_side: 1, scale: 1.0 };+                    crate::show_layers::draw(ui.painter(), &view, &show.quads);+                } else if let Some(t) = &cur_tex {                     ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);                 }                 if let Some(z) = show.zoom {@@ -2918,3 +3074,11 @@ mod tests {         assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing);     } }++#[cfg(test)]+#[path = "show_layers_tests.rs"]+mod layers_tests;++#[cfg(test)]+#[path = "show_bench_tests.rs"]+mod bench_tests;diff --git a/crates/ui-egui/src/show_bench_tests.rs b/crates/ui-egui/src/show_bench_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/show_bench_tests.rs@@ -0,0 +1,278 @@+//! Slide-show frame benchmark (ignored by default): drives the real show headlessly at+//! 1920×1080 and reports, per frame while an animation, transition or GIF plays, the CPU time of+//! the app's frame (UI, slide rendering, tessellation) and the texture bytes sent to the GPU.+//!+//! `cargo test --release -p deckcraft-ui-egui --lib show_frame_bench -- --ignored --nocapture`+//!+//! The same scenarios, built for wasm32 and run in V8, give the browser numbers in+//! `docs/benchmarks/slideshow.md`.++use std::time::Instant;++use deckcraft_geom::Xfrm;+use deckcraft_model::{Shape, ShapeId, ShapeKind, style::PictureFill};+use serde_json::json;++use super::*;++const W: f32 = 1920.0;+const H: f32 = 1080.0;++struct Bench {+    ctx: egui::Context,+    app: SlideApp,+    t: f64,+    events: Vec<egui::Event>,+}++fn last_id(v: &serde_json::Value) -> u64 {+    v.get("id").and_then(serde_json::Value::as_u64).unwrap_or(0)+}++/// A smooth, photo-like picture (our own procedural content).+fn photo(w: u32, h: u32, seed: u32) -> Vec<u8> {+    let img = image::RgbImage::from_fn(w, h, |px, py| {+        let (fx, fy, s) = (px as f32 / w as f32, py as f32 / h as f32, seed as f32);+        let a = (fx * 7.0 + s).sin() * (fy * 5.0 - s).cos();+        let b = ((fx + fy) * 11.0 + s * 0.7).sin();+        let n = ((px.wrapping_mul(73856093) ^ py.wrapping_mul(19349663) ^ seed) % 23) as f32 / 23.0;+        let c = |v: f32| v.clamp(0.0, 255.0) as u8;+        image::Rgb([+            c(40.0 + 90.0 * fy + 60.0 * a + 12.0 * n),+            c(80.0 + 120.0 * (1.0 - fy) * (0.5 + 0.5 * b) + 12.0 * n),+            c(120.0 + 100.0 * fx + 40.0 * a + 12.0 * n),+        ])+    });+    let mut out = std::io::Cursor::new(Vec::new());+    img.write_to(&mut out, image::ImageFormat::Png).expect("png");+    out.into_inner()+}++/// An animated GIF: a ball crossing a gradient, `n` frames of 50 ms.+fn gif(w: u32, h: u32, n: u32) -> Vec<u8> {+    let mut out = Vec::new();+    {+        let mut enc = image::codecs::gif::GifEncoder::new(&mut out);+        enc.set_repeat(image::codecs::gif::Repeat::Infinite).expect("repeat");+        let frames = (0..n).map(|i| {+            let p = i as f32 / n as f32;+            let (cx, cy) = (w as f32 * (0.2 + 0.6 * p), h as f32 * (0.5 - 0.3 * (p * std::f32::consts::TAU).sin()));+            let buf = image::RgbaImage::from_fn(w, h, |px, py| {+                let d = ((px as f32 - cx).powi(2) + (py as f32 - cy).powi(2)).sqrt();+                if d < h as f32 * 0.15 {+                    image::Rgba([250, 200, 40, 255])+                } else {+                    image::Rgba([(px * 255 / w) as u8, 60, (py * 255 / h) as u8, 255])+                }+            });+            image::Frame::from_parts(buf, 0, 0, image::Delay::from_numer_denom_ms(50, 1))+        });+        enc.encode_frames(frames).expect("gif");+    }+    out+}++fn picture(s: &mut deckcraft_engine::Session, name: &str, mime: &str, data: Vec<u8>, xf: Xfrm, id: u32, bottom: bool) {+    s.edit(|doc, _| {+        let media = doc.add_media(name, mime, data).expect("media");+        let sl = std::sync::Arc::make_mut(&mut doc.slides[0]);+        let sh = Shape {+            id: ShapeId(id),+            xfrm: Some(xf),+            kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },+            ..Default::default()+        };+        if bottom {+            sl.shapes.insert(0, sh);+        } else {+            sl.shapes.push(sh);+        }+        Ok(())+    })+    .expect("edit");+}++/// Scenario `name`: the session and the slide the show starts on.+fn scenario(name: &str) -> (deckcraft_engine::Session, usize) {+    let mut s = deckcraft_engine::Session::with_new();+    let sample = |s: &mut deckcraft_engine::Session| deckcraft_engine::sample::open_sample(s).expect("sample");+    match name {+        // Sample deck, slide 2: bullets fading in by paragraph.+        "agenda" => {+            sample(&mut s);+            (s, 1)+        }+        // Sample deck, slide 4: four shadowed shapes zooming in one after another.+        "process" => {+            sample(&mut s);+            (s, 3)+        }+        // Title slide to the agenda: a Push transition.+        "push" => {+            sample(&mut s);+            (s, 0)+        }+        // Section header to the process slide: a Morph transition.+        "morph" => {+            sample(&mut s);+            (s, 2)+        }+        // A full-slide photo, a title and five shapes flying, spinning, wiping, growing and+        // following a path at once, with shadows and a glow.+        "photo" => {+            s.execute("slide.layout", &json!({"layout": "titleOnly"})).expect("layout");+            picture(&mut s, "photo.png", "image/png", photo(1920, 1080, 3), Xfrm::new(0.0, 0.0, 960.0, 540.0), 9000, true);+            let title = s.doc().expect("doc").current_slide().and_then(|sl| sl.shapes.iter().find(|x| x.ph.is_some())).map_or(0, |x| x.id.0);+            s.execute("text.set", &json!({"id": title, "text": "Aurora over the fjord"})).expect("title");+            let specs = [+                ("roundRect", [60.0, 160.0, 220.0, 130.0], "fly", "entrance", "b"),+                ("star5", [330.0, 150.0, 160.0, 160.0], "spin", "emphasis", ""),+                ("rect", [540.0, 160.0, 360.0, 140.0], "wipe", "entrance", "l"),+                ("ellipse", [120.0, 340.0, 200.0, 150.0], "growShrink", "emphasis", ""),+                ("hexagon", [600.0, 340.0, 180.0, 150.0], "lines", "path", ""),+            ];+            for (i, (preset, r, effect, class, opt)) in specs.iter().enumerate() {+                let v = s.execute("shape.insert", &json!({"preset": preset, "rect": r, "text": format!("Point {}", i + 1)})).expect("shape");+                let id = last_id(&v);+                let color = ["accent1", "accent2", "accent4", "accent5", "accent6"].get(i).copied().unwrap_or("accent1");+                s.execute("shape.fill", &json!({"id": id, "color": color})).expect("fill");+                s.execute("shape.effects", &json!({"id": id, "shadow": "bottom"})).expect("shadow");+                if i == 1 {+                    s.execute("shape.effects", &json!({"id": id, "glow": {"color": "accent2", "radius": 8}})).expect("glow");+                }+                s.execute("edit.select", &json!({"ids": [id]})).expect("select");+                let mut p = json!({"effect": effect, "class": class, "start": if i == 0 { "onClick" } else { "withPrevious" }, "duration": 2000});+                if !opt.is_empty() {+                    p["option"] = json!(opt);+                }+                s.execute("animation.add", &p).expect("animation");+            }+            s.execute("edit.deselect", &json!({})).expect("deselect");+            (s, 0)+        }+        // Two animated GIFs over a photo with a title.+        "gif" => {+            s.execute("slide.layout", &json!({"layout": "titleOnly"})).expect("layout");+            picture(&mut s, "photo.png", "image/png", photo(1920, 1080, 5), Xfrm::new(0.0, 0.0, 960.0, 540.0), 9000, true);+            let title = s.doc().expect("doc").current_slide().and_then(|sl| sl.shapes.iter().find(|x| x.ph.is_some())).map_or(0, |x| x.id.0);+            s.execute("text.set", &json!({"id": title, "text": "Watch the ball"})).expect("title");+            picture(&mut s, "a.gif", "image/gif", gif(480, 360, 24), Xfrm::new(60.0, 150.0, 400.0, 300.0), 9001, false);+            picture(&mut s, "b.gif", "image/gif", gif(480, 360, 16), Xfrm::new(500.0, 150.0, 400.0, 300.0), 9002, false);+            (s, 0)+        }+        _ => panic!("unknown scenario {name}"),+    }+}++impl Bench {+    fn new(name: &str) -> Bench {+        let (session, slide) = scenario(name);+        let mut app = SlideApp::new(session, crate::Services::default());+        app.ui.presenter_view = false;+        let mut b = Bench { ctx: egui::Context::default(), app, t: 0.0, events: vec![] };+        for _ in 0..3 {+            b.frame();+        }+        b.app.start_show(slide, false);+        for _ in 0..30 {+            b.frame();+        }+        b+    }++    fn show(&self) -> Option<&Show> {+        self.app.show.as_ref()+    }++    fn click(&mut self) {+        for pressed in [true, false] {+            self.events.push(egui::Event::Key {+                key: egui::Key::Space,+                physical_key: None,+                pressed,+                repeat: false,+                modifiers: egui::Modifiers::NONE,+            });+        }+    }++    /// One 60 Hz frame: its CPU time (ms) and the texture bytes it uploads.+    fn frame(&mut self) -> (f64, usize) {+        let mut raw = egui::RawInput {+            screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))),+            time: Some(self.t),+            predicted_dt: 1.0 / 60.0,+            max_texture_side: Some(8192),+            events: std::mem::take(&mut self.events),+            ..Default::default()+        };+        raw.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(1.0);+        let ctx = self.ctx.clone();+        let app = &mut self.app;+        let start = Instant::now();+        let out = self.ctx.run_ui(raw, |ui| {+            app.logic(&ctx);+            app.ui(ui);+        });+        let _ = self.ctx.tessellate(out.shapes, out.pixels_per_point);+        let ms = start.elapsed().as_secs_f64() * 1000.0;+        self.t += 1.0 / 60.0;+        let bytes = out.textures_delta.set.values().flat_map(|ds| ds.iter()).map(|d| d.image.width() * d.image.height() * 4).sum();+        (ms, bytes)+    }+}++/// Frames of scenario `name` while something plays: animation steps on the starting slide (one+/// click after another), the transition after one click, or the GIFs.+fn measure(name: &str) -> (Vec<f64>, Vec<usize>) {+    let mut b = Bench::new(name);+    let (mut ms, mut up) = (vec![], vec![]);+    if name == "push" || name == "morph" {+        let next = b.show().map_or(0, |s| s.state.slide) + 1;+        let dur =+            b.app.session.active().and_then(|d| d.doc.slides.get(next).and_then(|s| s.transition.as_ref()).map(|t| t.duration_ms)).unwrap_or(1000);+        b.click();+        for _ in 0..(dur as usize * 60).div_ceil(1000).max(1) {+            let (m, u) = b.frame();+            ms.push(m);+            up.push(u);+        }+        return (ms, up);+    }+    let gifs = name == "gif";+    let start = b.show().map_or(0, |s| s.state.slide);+    let mut idle = 0;+    for _ in 0..2000 {+        if b.show().is_none_or(|s| s.state.slide != start) || ms.len() >= 180 {+            break;+        }+        if !gifs && idle > 10 {+            b.click();+            idle = 0;+        }+        let (m, u) = b.frame();+        if gifs || b.show().is_some_and(|s| s.state.playing || s.state.trigger_playing()) {+            ms.push(m);+            up.push(u);+        } else {+            idle += 1;+        }+    }+    (ms, up)+}++#[test]+#[ignore = "benchmark: run with --release --ignored --nocapture"]+fn show_frame_bench() {+    println!("scenario  frames  >16.7ms  mean ms  median   p95 ms   max ms  upload MB/frame");+    for name in ["agenda", "process", "photo", "gif", "push", "morph"] {+        let (mut ms, up) = measure(name);+        ms.sort_by(f64::total_cmp);+        let n = ms.len().max(1);+        let mean = ms.iter().sum::<f64>() / n as f64;+        let at = |q: f64| ms.get(((ms.len().saturating_sub(1)) as f64 * q).round() as usize).copied().unwrap_or(0.0);+        let over = ms.iter().filter(|m| **m > 1000.0 / 60.0).count() * 100 / n;+        let mb = up.iter().sum::<usize>() as f64 / n as f64 / 1e6;+        println!("{name:<9} {:>6} {over:>7}% {mean:>8.2} {:>7.2} {:>8.2} {:>8.2} {mb:>8.2}", ms.len(), at(0.5), at(0.95), at(1.0));+    }+}diff --git a/crates/ui-egui/src/show_layers.rs b/crates/ui-egui/src/show_layers.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/show_layers.rs@@ -0,0 +1,495 @@+//! Slide-show compositing, the way PowerPoint plays animations: a slide with animations or+//! animated GIFs is split once into pictures that are rasterized once, and every frame after that+//! only moves, scales, turns, fades and clips those pictures on the GPU.+//!+//! The layers, bottom to top: the backdrop with the shapes under the first animated one (one+//! slide-sized picture), then every top-level shape from the first animated one to the last on a+//! picture of its own (a sprite, as big as what it draws), then the shapes over the last animated+//! one (one slide-sized picture). A shape whose text builds by paragraph keeps each animated+//! paragraph on its own sprite. An animated GIF keeps one sprite per frame it has shown.+//!+//! What a sprite can't show by moving (a colour change, a shape animated inside a group) is drawn+//! again with that state, only that shape. A sprite that comes to rest moved, turned or scaled is+//! drawn again, once, in that pose, so a still slide is as sharp as a fully rendered one.++use std::collections::HashMap;+use std::hash::{Hash, Hasher};++use deckcraft_geom::{Affine, Point};+use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};+use deckcraft_render::{ParaState, RenderOpts, ShapePart, ShapeState};+use egui::{Color32, Mesh, Pos2, Rect, TextureHandle, pos2};++/// The bytes of GIF frame sprites one slide keeps before the oldest frames are dropped.+const GIF_BUDGET: usize = if cfg!(target_arch = "wasm32") { 96 << 20 } else { 256 << 20 };++/// What the layers were built for: a change rebuilds them.+#[derive(Clone, Debug, PartialEq)]+pub(crate) struct LayersKey {+    pub slide: usize,+    pub size: (u32, u32),+    /// The presentation's address (held alive by the show while the layers are kept).+    pub doc: usize,+    /// Media hidden while not playing.+    pub hidden: Vec<ShapeId>,+}++/// A picture of part of the slide and where it sits, in output pixels `[x0, y0, x1, y1]`.+struct Tex {+    tex: TextureHandle,+    px: [f64; 4],+}++impl Tex {+    fn bytes(&self) -> usize {+        let [x0, y0, x1, y1] = self.px;+        ((x1 - x0).max(0.0) * (y1 - y0).max(0.0) * 4.0) as usize+    }+}++enum Item {+    /// Shapes that never move, with the backdrop under them for the bottom layer. `None` until+    /// built, `Some(None)` when they draw nothing.+    Full {+        ids: Vec<ShapeId>,+        backdrop: bool,+        tex: Option<Option<TextureHandle>>,+    },+    Sprite(Box<Sprite>),+}++/// One top-level shape on its own picture.+struct Sprite {+    id: ShapeId,+    /// Shapes animated inside it (a group's children).+    inner: Vec<ShapeId>,+    /// Animated GIFs at or inside it.+    gifs: Vec<ShapeId>,+    /// Paragraphs of its text animated on their own.+    paras: Vec<usize>,+    built: bool,+    /// The still shape (its separately animated paragraphs left out), by the GIF frames it shows.+    base: HashMap<Vec<usize>, Option<Tex>>,+    /// The order the GIF frame sprites were made in, oldest first.+    base_order: Vec<Vec<usize>>,+    para_tex: Vec<(usize, Option<Tex>)>,+    /// The shape drawn in one state (a pose it rests in, or a state moving can't show).+    baked: Option<(u64, Option<Tex>)>,+    /// What it showed last frame: a sprite showing the same thing twice is at rest.+    last: Option<u64>,+    /// The GIF frames of the still picture shown this frame.+    shown: Option<Vec<usize>>,+}++/// Where the output pixels go on screen: `rect` shows the part `uv` (0–1) of a `size` picture.+#[derive(Clone, Copy, Debug)]+pub(crate) struct View {+    pub size: (u32, u32),+    pub rect: Rect,+    pub uv: Rect,+    /// The largest texture side the GPU takes.+    pub max_side: u32,+    /// Pixels per slide point.+    pub scale: f64,+}++impl View {+    fn screen(self, p: Point) -> Pos2 {+        let (w, h) = (self.size.0.max(1) as f64, self.size.1.max(1) as f64);+        let (uw, uh) = ((self.uv.width() as f64).max(1e-6), (self.uv.height() as f64).max(1e-6));+        let nx = (p.x / w - self.uv.min.x as f64) / uw;+        let ny = (p.y / h - self.uv.min.y as f64) / uh;+        pos2(self.rect.min.x + (nx * self.rect.width() as f64) as f32, self.rect.min.y + (ny * self.rect.height() as f64) as f32)+    }+    /// Slide points to output pixels.+    fn px(&self) -> Affine {+        Affine::scale(self.scale)+    }+}++/// What a frame shows: each shape's animation state and the GIFs' clocks and frames.+pub(crate) struct Frame<'a> {+    pub state: &'a dyn Fn(ShapeId) -> ShapeState,+    pub gif_times: &'a [(ShapeId, f64)],+    pub gif_frames: &'a [(ShapeId, usize)],+    pub threads: u16,+}++/// One textured quadrilateral of a composited frame: corners in output pixels, where they come+/// from in the texture (0–1), and how opaque it is drawn.+#[derive(Clone, Debug)]+pub(crate) struct Quad {+    tex: egui::TextureId,+    at: [Point; 4],+    uv: [Pos2; 4],+    alpha: u8,+}++/// Draw a composited frame's quads into `view.rect` (showing the part `view.uv` of the slide).+pub(crate) fn draw(painter: &egui::Painter, view: &View, quads: &[Quad]) {+    let painter = painter.with_clip_rect(view.rect);+    for q in quads {+        if q.at.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {+            continue;+        }+        let color = Color32::from_white_alpha(q.alpha);+        let mut mesh = Mesh::with_texture(q.tex);+        for (p, uv) in q.at.iter().zip(q.uv.iter()) {+            mesh.vertices.push(egui::epaint::Vertex { pos: view.screen(*p), uv: *uv, color });+        }+        mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);+        painter.add(egui::Shape::mesh(mesh));+    }+}++pub(crate) struct Layers {+    pub key: LayersKey,+    items: Vec<Item>,+    /// Pictures made for this slide (names only; the textures go with their handles).+    made: usize,+}++fn walk<'a>(s: &'a Shape, depth: usize, out: &mut Vec<&'a Shape>) {+    if depth > 32 {+        return;+    }+    out.push(s);+    if let ShapeKind::Group { children, .. } = &s.kind {+        for c in children {+            walk(c, depth + 1, out);+        }+    }+}++fn hash_state(st: &ShapeState, h: &mut impl Hasher) {+    st.visible.hash(h);+    for v in [st.opacity, st.offset.x, st.offset.y, st.scale.0, st.scale.1, st.rotate] {+        v.to_bits().hash(h);+    }+    st.clip.map(|c| c.map(f64::to_bits)).hash(h);+    st.tint.map(|t| (t.r, t.g, t.b, t.a)).hash(h);+    for (i, p) in &st.paras {+        (i, p.visible, p.opacity.to_bits(), p.offset.x.to_bits(), p.offset.y.to_bits()).hash(h);+    }+}++/// Nothing moved, turned, scaled or clipped (a sprite drawn as is shows it exactly).+fn still_pose(st: &ShapeState) -> bool {+    let turned = st.rotate.rem_euclid(360.0);+    st.offset.x == 0.0+        && st.offset.y == 0.0+        && st.scale == (1.0, 1.0)+        && (turned == 0.0 || turned.is_nan())+        && st.clip.is_none_or(|[l, t, r, b]| l <= 0.0 && t <= 0.0 && r >= 1.0 && b >= 1.0)+        && st.paras.iter().all(|(_, p)| p.offset.x == 0.0 && p.offset.y == 0.0)+}++fn untouched(st: &ShapeState) -> bool {+    st.visible && st.opacity >= 1.0 && still_pose(st) && st.tint.is_none() && st.paras.iter().all(|(_, p)| p.visible && p.opacity >= 1.0)+}++impl Layers {+    /// Plan the layers of slide `key.slide`: `animated` are the shapes with effects, `gifs` the+    /// animated GIF pictures. `None` when nothing on the slide moves.+    pub fn plan(doc: &Presentation, key: LayersKey, animated: &[ShapeId], gifs: &[ShapeId], paras: &dyn Fn(ShapeId) -> Vec<usize>) -> Option<Layers> {+        let slide = doc.slides.get(key.slide)?;+        let moving = |s: &Shape| {+            let mut all = vec![];+            walk(s, 0, &mut all);+            all.iter().any(|x| animated.contains(&x.id) || gifs.contains(&x.id))+        };+        let first = slide.shapes.iter().position(moving)?;+        let last = slide.shapes.iter().rposition(moving)?;+        let mut items = vec![Item::Full { ids: slide.shapes.iter().take(first).map(|s| s.id).collect(), backdrop: true, tex: None }];+        for s in slide.shapes.iter().take(last + 1).skip(first) {+            let mut all = vec![];+            walk(s, 0, &mut all);+            let inner: Vec<ShapeId> = all.iter().skip(1).map(|x| x.id).filter(|id| animated.contains(id)).collect();+            let gifs: Vec<ShapeId> = all.iter().map(|x| x.id).filter(|id| gifs.contains(id)).collect();+            let paras = if animated.contains(&s.id) { paras(s.id) } else { vec![] };+            items.push(Item::Sprite(Box::new(Sprite {+                id: s.id,+                inner,+                gifs,+                paras,+                built: false,+                base: HashMap::new(),+                base_order: vec![],+                para_tex: vec![],+                baked: None,+                last: None,+                shown: None,+            })));+        }+        let top: Vec<ShapeId> = slide.shapes.iter().skip(last + 1).map(|s| s.id).collect();+        items.push(Item::Full { ids: top, backdrop: false, tex: None });+        Some(Layers { key, items, made: 0 })+    }++    pub fn ready(&self) -> bool {+        self.items.iter().all(|i| match i {+            Item::Full { tex, .. } => tex.is_some(),+            Item::Sprite(s) => s.built,+        })+    }++    /// Build the next layer that isn't built yet (all of them with `all`). True once every layer+    /// is ready.+    pub fn build(&mut self, ctx: &egui::Context, doc: &Presentation, view: &View, f: &Frame, all: bool) -> bool {+        let idx = self.key.slide;+        for n in 0..self.items.len() {+            let name = format!("show-layer-{}", self.made);+            let Some(item) = self.items.get_mut(n) else { break };+            let did = match item {+                Item::Full { ids, backdrop, tex } if tex.is_none() => {+                    let img = if ids.is_empty() && !*backdrop {+                        None+                    } else {+                        let opts = opts(view, view.size, (0.0, 0.0), Some(f.state), f.gif_times, f.threads);+                        let (img, _) = deckcraft_render::render_slide_shapes(doc, idx, ids, ShapePart::Whole, *backdrop, &opts);+                        Some(crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)))+                    };+                    *tex = Some(img);+                    true+                }+                Item::Sprite(s) if !s.built => {+                    s.build(ctx, doc, idx, view, f, &name);+                    true+                }+                _ => false,+            };+            if did {+                self.made += 1;+                if !all {+                    return self.ready();+                }+            }+        }+        self.ready()+    }++    /// The quads of frame `f`, bottom to top, drawing any picture it needs that isn't there yet.+    /// The layers must be [`ready`](Self::ready).+    pub fn frame(&mut self, ctx: &egui::Context, doc: &Presentation, view: &View, f: &Frame) -> Vec<Quad> {+        let idx = self.key.slide;+        let (w, h) = (view.size.0 as f64, view.size.1 as f64);+        let mut out = vec![];+        for n in 0..self.items.len() {+            let name = format!("show-layer-{}", self.made);+            let Some(item) = self.items.get_mut(n) else { break };+            match item {+                Item::Full { tex: Some(Some(t)), .. } => out.push(Quad {+                    tex: t.id(),+                    at: [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)],+                    uv: [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)],+                    alpha: 255,+                }),+                Item::Full { .. } => {}+                Item::Sprite(s) => {+                    if s.frame(ctx, doc, idx, view, f, &name, &mut out) {+                        self.made += 1;+                    }+                }+            }+        }+        out+    }+}++fn opts<'a>(+    view: &View,+    size: (u32, u32),+    offset: (f64, f64),+    state: Option<&'a dyn Fn(ShapeId) -> ShapeState>,+    gif_times: &'a [(ShapeId, f64)],+    threads: u16,+) -> RenderOpts<'a> {+    RenderOpts { scale: view.scale, size: Some(size), offset, state, gif_times, threads, ..Default::default() }+}++/// Render top-level shape `id` (as `part`) into a picture just big enough for what it draws.+/// `None` when it draws nothing on or near the slide.+#[allow(clippy::too_many_arguments)]+fn sprite(+    ctx: &egui::Context,+    doc: &Presentation,+    idx: usize,+    id: ShapeId,+    part: ShapePart,+    view: &View,+    state: Option<&dyn Fn(ShapeId) -> ShapeState>,+    f: &Frame,+    name: &str,+) -> Option<Tex> {+    let ids = [id];+    // Measure: a 1×1 output costs next to nothing and still reports the whole drawing.+    let probe = opts(view, (1, 1), (0.0, 0.0), state, f.gif_times, 0);+    let (_, drawn) = deckcraft_render::render_slide_shapes(doc, idx, &ids, part, false, &probe);+    let d = drawn?;+    let (w, h) = (view.size.0 as f64, view.size.1 as f64);+    // A sprite may reach off the slide (a shape that flies in from there), within reason.+    let x0 = (d.x0.floor() - 2.0).max(-w);+    let y0 = (d.y0.floor() - 2.0).max(-h);+    let x1 = (d.x1.ceil() + 2.0).min(2.0 * w);+    let y1 = (d.y1.ceil() + 2.0).min(2.0 * h);+    let max = view.max_side.max(1) as f64;+    let (sw, sh) = ((x1 - x0).min(max), (y1 - y0).min(max));+    if !(sw >= 1.0 && sh >= 1.0) || !x0.is_finite() || !y0.is_finite() {+        return None;+    }+    let o = opts(view, (sw as u32, sh as u32), (-x0, -y0), state, f.gif_times, f.threads);+    let (img, _) = deckcraft_render::render_slide_shapes(doc, idx, &ids, part, false, &o);+    let tex = crate::textures::upload(ctx, name.to_string(), crate::textures::to_color_image(&img, false));+    Some(Tex { tex, px: [x0, y0, x0 + sw, y0 + sh] })+}++/// `t` drawn with its pixel `p` at `to(p)` (output pixels), faded by `alpha`. With `clip`, only+/// the quadrilateral `clip.0` (output pixels where it is drawn), taking its pixels from `clip.1`+/// (where they are in `t`'s still picture), is drawn.+fn quad(out: &mut Vec<Quad>, t: &Tex, to: Affine, alpha: f64, clip: Option<([Point; 4], [Point; 4])>) {+    let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;+    if a == 0 {+        return;+    }+    let [x0, y0, x1, y1] = t.px;+    let (tw, th) = ((x1 - x0).max(1e-6), (y1 - y0).max(1e-6));+    let uv = |p: Point| pos2(((p.x - x0) / tw) as f32, ((p.y - y0) / th) as f32);+    let corners = [Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)];+    let (at, from): ([Point; 4], [Point; 4]) = match clip {+        Some((at, from)) => (at, from),+        None => (corners.map(|p| to * p), corners),+    };+    out.push(Quad { tex: t.tex.id(), at, uv: from.map(uv), alpha: a });+}++impl Sprite {+    fn gif_key(&self, f: &Frame) -> Vec<usize> {+        self.gifs.iter().map(|g| f.gif_frames.iter().find(|x| x.0 == *g).map_or(0, |x| x.1)).collect()+    }++    /// The state the still sprite is drawn in: every separately animated paragraph left out.+    fn body_state(&self) -> impl Fn(ShapeId) -> ShapeState + use<> {+        let (id, paras) = (self.id, self.paras.clone());+        move |sid| {+            if sid == id {+                ShapeState { paras: paras.iter().map(|p| (*p, ParaState { visible: false, ..Default::default() })).collect(), ..Default::default() }+            } else {+                ShapeState::default()+            }+        }+    }++    fn ensure_base(&mut self, ctx: &egui::Context, doc: &Presentation, idx: usize, view: &View, f: &Frame, name: &str) -> bool {+        let key = self.gif_key(f);+        if self.base.contains_key(&key) {+            return false;+        }+        let body = self.body_state();+        let t = sprite(ctx, doc, idx, self.id, ShapePart::Whole, view, Some(&body), f, name);+        let held: usize = self.base.values().flatten().map(Tex::bytes).sum::<usize>() + t.as_ref().map_or(0, Tex::bytes);+        if held > GIF_BUDGET {+            // Drop the oldest frames until the new one fits.+            let mut held = held;+            while held > GIF_BUDGET && !self.base_order.is_empty() {+                let old = self.base_order.remove(0);+                if let Some(Some(o)) = self.base.remove(&old) {+                    held = held.saturating_sub(o.bytes());+                }+            }+        }+        self.base.insert(key.clone(), t);+        self.base_order.push(key);+        true+    }++    fn build(&mut self, ctx: &egui::Context, doc: &Presentation, idx: usize, view: &View, f: &Frame, name: &str) {+        self.ensure_base(ctx, doc, idx, view, f, name);+        self.para_tex = self+            .paras+            .iter()+            .enumerate()+            .map(|(k, p)| (*p, sprite(ctx, doc, idx, self.id, ShapePart::Paragraph(*p), view, None, f, &format!("{name}-p{k}"))))+            .collect();+        self.built = true;+    }++    /// This frame's quads of the shape. True when a new texture was made.+    #[allow(clippy::too_many_arguments)]+    fn frame(&mut self, ctx: &egui::Context, doc: &Presentation, idx: usize, view: &View, f: &Frame, name: &str, out: &mut Vec<Quad>) -> bool {+        let st = (f.state)(self.id);+        if !st.visible || st.opacity <= 0.001 {+            self.last = None;+            return false;+        }+        let gif = self.gif_key(f);+        let inner: Vec<ShapeState> = self.inner.iter().map(|i| (f.state)(*i)).collect();+        let mut h = std::collections::hash_map::DefaultHasher::new();+        hash_state(&st, &mut h);+        for s in &inner {+            hash_state(s, &mut h);+        }+        gif.hash(&mut h);+        let key = h.finish();+        let resting = self.last == Some(key);+        self.last = Some(key);+        // Moving the still picture shows the frame unless something inside the shape changed+        // (a colour, a shape in a group) or it rests in a pose resampling would soften.+        let movable = st.tint.is_none() && inner.iter().all(untouched);+        let motion = deckcraft_render::shape_motion(doc, idx, self.id, &st);+        if movable+            && (!resting || still_pose(&st))+            && let Some(m) = motion+        {+            let made = self.ensure_base(ctx, doc, idx, view, f, name);+            self.shown = Some(gif);+            self.composite(out, view, &st, &m);+            return made;+        }+        let made = match &self.baked {+            Some((k, _)) if *k == key => false,+            _ => {+                let state = f.state;+                self.baked = Some((key, sprite(ctx, doc, idx, self.id, ShapePart::Whole, view, Some(state), f, &format!("{name}-baked"))));+                true+            }+        };+        if let Some((_, Some(t))) = &self.baked {+            quad(out, t, Affine::IDENTITY, 1.0, None);+        }+        made+    }++    /// The still pictures moved as `st` moves the shape (`m`: how, in slide points).+    fn composite(&self, out: &mut Vec<Quad>, view: &View, st: &ShapeState, m: &deckcraft_render::Motion) {+        let px = view.px();+        let (still, moved) = (px * m.still, px * m.moved);+        let det = still.determinant();+        if !det.is_finite() || det.abs() <= 1e-12 {+            return;+        }+        let back = still.inverse();+        // The reveal clip, in the shape's own space (points from its box's top-left corner).+        let clip = st.clip.filter(|[l, t, r, b]| !(*l <= 0.0 && *t <= 0.0 && *r >= 1.0 && *b >= 1.0)).map(|[l, t, r, b]| {+            [Point::new(l * m.w, t * m.h), Point::new(r * m.w, t * m.h), Point::new(r * m.w, b * m.h), Point::new(l * m.w, b * m.h)]+        });+        if let Some(Some(t)) = self.current_base() {+            let c = clip.map(|q| (q.map(|p| moved * p), q.map(|p| still * p)));+            quad(out, t, moved * back, st.opacity, c);+        }+        for (p, tex) in &self.para_tex {+            let Some(t) = tex else { continue };+            let ps = st.paras.iter().find(|(i, _)| i == p).map(|(_, s)| *s).unwrap_or_default();+            if !ps.visible || ps.opacity <= 0.001 {+                continue;+            }+            let shift = Affine::translate(ps.offset);+            // The clip stays with the shape; the paragraph moves under it.+            let c = clip.map(|q| (q.map(|p| moved * p), q.map(|p| still * (shift.inverse() * p))));+            quad(out, t, moved * shift * back, st.opacity * ps.opacity, c);+        }+    }++    fn current_base(&self) -> Option<&Option<Tex>> {+        self.shown.as_ref().and_then(|k| self.base.get(k))+    }+}diff --git a/crates/ui-egui/src/show_layers_tests.rs b/crates/ui-egui/src/show_layers_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/show_layers_tests.rs@@ -0,0 +1,410 @@+//! Composited slide-show frames (`show_layers`) look like whole-slide renders of the same moment.+//!+//! egui's output is drawn here by a small software rasterizer (bilinear texture sampling,+//! premultiplied source-over, as the GPU does), and compared with `render_frame`.++use std::collections::HashMap;++use deckcraft_model::ShapeId;+use egui::{Color32, ColorImage, TextureId, epaint::Primitive};+use serde_json::json;++use super::*;++const W: f32 = 960.0;+const H: f32 = 540.0;++struct Rig {+    ctx: egui::Context,+    app: SlideApp,+    t: f64,+    textures: HashMap<TextureId, ColorImage>,+    events: Vec<egui::Event>,+    picture: Vec<[f32; 4]>,+}++impl Rig {+    fn new(session: deckcraft_engine::Session, slide: usize) -> Rig {+        let mut app = SlideApp::new(session, crate::Services::default());+        app.ui.presenter_view = false;+        let mut r = Rig { ctx: egui::Context::default(), app, t: 0.0, textures: HashMap::new(), events: vec![], picture: vec![] };+        r.frames(3);+        r.app.start_show(slide, false);+        // Past the time the show's toolbar shows after the pointer last moved.+        r.frames(240);+        r+    }++    fn frames(&mut self, n: usize) {+        for _ in 0..n {+            self.frame();+        }+    }++    fn click(&mut self) {+        for pressed in [true, false] {+            self.events.push(egui::Event::Key {+                key: egui::Key::Space,+                physical_key: None,+                pressed,+                repeat: false,+                modifiers: egui::Modifiers::NONE,+            });+        }+    }++    fn frame(&mut self) {+        let mut raw = egui::RawInput {+            screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))),+            time: Some(self.t),+            predicted_dt: 1.0 / 60.0,+            max_texture_side: Some(4096),+            events: std::mem::take(&mut self.events),+            ..Default::default()+        };+        raw.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(1.0);+        let ctx = self.ctx.clone();+        let app = &mut self.app;+        let out = self.ctx.run_ui(raw, |ui| {+            app.logic(&ctx);+            app.ui(ui);+        });+        for (id, delta) in out.textures_delta.set.iter().flat_map(|(id, ds)| ds.iter().map(move |d| (id, d))) {+            let egui::ImageData::Color(img) = &delta.image;+            match delta.pos {+                None => {+                    self.textures.insert(*id, ColorImage::clone(img));+                }+                Some([x, y]) => {+                    if let Some(t) = self.textures.get_mut(id) {+                        for row in 0..img.size[1] {+                            for col in 0..img.size[0] {+                                if let (Some(dst), Some(src)) =+                                    (t.pixels.get_mut((y + row) * t.size[0] + x + col), img.pixels.get(row * img.size[0] + col))+                                {+                                    *dst = *src;+                                }+                            }+                        }+                    }+                }+            }+        }+        let prims = self.ctx.tessellate(out.shapes, out.pixels_per_point);+        self.picture = self.raster(&prims);+        for id in &out.textures_delta.free {+            self.textures.remove(id);+        }+        self.t += 1.0 / 60.0;+    }++    /// Bilinear sample of texture `t` at `uv` (premultiplied, 0–255).+    fn sample(t: &ColorImage, u: f32, v: f32) -> [f32; 4] {+        let (w, h) = (t.size[0] as i64, t.size[1] as i64);+        let (x, y) = (u * w as f32 - 0.5, v * h as f32 - 0.5);+        let (x0, y0) = (x.floor(), y.floor());+        let (fx, fy) = (x - x0, y - y0);+        let px = |xi: i64, yi: i64| {+            let c = t.pixels.get((yi.clamp(0, h - 1) * w + xi.clamp(0, w - 1)) as usize).copied().unwrap_or(Color32::TRANSPARENT);+            [c.r() as f32, c.g() as f32, c.b() as f32, c.a() as f32]+        };+        let (a, b, c, d) = (px(x0 as i64, y0 as i64), px(x0 as i64 + 1, y0 as i64), px(x0 as i64, y0 as i64 + 1), px(x0 as i64 + 1, y0 as i64 + 1));+        std::array::from_fn(|i| (a[i] * (1.0 - fx) + b[i] * fx) * (1.0 - fy) + (c[i] * (1.0 - fx) + d[i] * fx) * fy)+    }++    fn raster(&self, prims: &[egui::ClippedPrimitive]) -> Vec<[f32; 4]> {+        let (w, h) = (W as usize, H as usize);+        let mut px = vec![[0.0f32; 4]; w * h];+        for p in prims {+            let Primitive::Mesh(m) = &p.primitive else { continue };+            let Some(tex) = self.textures.get(&m.texture_id) else { continue };+            for tri in m.indices.chunks_exact(3) {+                let v = [m.vertices[tri[0] as usize], m.vertices[tri[1] as usize], m.vertices[tri[2] as usize]];+                let area = (v[1].pos.x - v[0].pos.x) * (v[2].pos.y - v[0].pos.y) - (v[2].pos.x - v[0].pos.x) * (v[1].pos.y - v[0].pos.y);+                if area.abs() < 1e-9 {+                    continue;+                }+                let minx = v.iter().map(|q| q.pos.x).fold(f32::MAX, f32::min).max(p.clip_rect.min.x).max(0.0).floor() as usize;+                let maxx = v.iter().map(|q| q.pos.x).fold(f32::MIN, f32::max).min(p.clip_rect.max.x).min(W).ceil() as usize;+                let miny = v.iter().map(|q| q.pos.y).fold(f32::MAX, f32::min).max(p.clip_rect.min.y).max(0.0).floor() as usize;+                let maxy = v.iter().map(|q| q.pos.y).fold(f32::MIN, f32::max).min(p.clip_rect.max.y).min(H).ceil() as usize;+                for y in miny..maxy {+                    for x in minx..maxx {+                        let (cx, cy) = (x as f32 + 0.5, y as f32 + 0.5);+                        if !p.clip_rect.contains(egui::pos2(cx, cy)) {+                            continue;+                        }+                        let e = |a: usize, b: usize| (v[b].pos.x - v[a].pos.x) * (cy - v[a].pos.y) - (v[b].pos.y - v[a].pos.y) * (cx - v[a].pos.x);+                        let (w0, w1, w2) = (e(1, 2) / area, e(2, 0) / area, e(0, 1) / area);+                        if w0 < -1e-4 || w1 < -1e-4 || w2 < -1e-4 {+                            continue;+                        }+                        let u = w0 * v[0].uv.x + w1 * v[1].uv.x + w2 * v[2].uv.x;+                        let vv = w0 * v[0].uv.y + w1 * v[1].uv.y + w2 * v[2].uv.y;+                        let col: [f32; 4] = std::array::from_fn(|i| {+                            let c = |q: &egui::epaint::Vertex| q.color.to_array()[i] as f32;+                            w0 * c(&v[0]) + w1 * c(&v[1]) + w2 * c(&v[2])+                        });+                        let s = Self::sample(tex, u, vv);+                        let src: [f32; 4] = std::array::from_fn(|i| s[i] * col[i] / 255.0);+                        let d = &mut px[y * w + x];+                        let k = 1.0 - src[3] / 255.0;+                        for i in 0..4 {+                            d[i] = src[i] + d[i] * k;+                        }+                    }+                }+            }+        }+        px+    }++    /// The whole-slide render of what the show shows now, as the show drew it before layers.+    fn expected(&mut self) -> ColorImage {+        let (uid, doc) = self.app.session.active().map(|d| (d.uid, d.doc.clone())).expect("doc");+        let now = self.t - 1.0 / 60.0;+        let slide = self.app.show.as_ref().map_or(0, |s| s.state.slide);+        let gifs = self.app.media.gifs(uid, &doc, slide, now, &self.app.session.gif_paused);+        let times: Vec<(ShapeId, f64)> = gifs.iter().map(|g| (g.id, g.t)).collect();+        let show = self.app.show.as_mut().expect("show");+        let t = if show.state.playing { now - show.step_start } else { 0.0 };+        let (idx, step) = (show.state.slide, show.state.step);+        let tl = show.timeline(&doc).clone();+        let (plays, stamps) = (show.state.trigger_plays(), show.state.trigger_stamps());+        render_frame(&doc, idx, tl, step, t, (W as u32, H as u32), vec![], &times, 0, &plays, &stamps, None)+    }++    fn layered(&self) -> bool {+        self.app.show.as_ref().is_some_and(|s| s.layers.as_ref().is_some_and(|(_, l)| l.ready()))+    }++    /// Mean and 99th-percentile channel difference between the composited frame and `want`, and+    /// the share of pixels differing by more than 48 in some channel.+    /// Write the composited frame and `want` as PPM files (DECKCRAFT_DUMP=dir), to look at.+    fn dump(&self, want: &ColorImage, name: &str) {+        let Ok(dir) = std::env::var("DECKCRAFT_DUMP") else { return };+        let ppm = |px: Vec<[u8; 3]>| {+            let mut b = format!("P6 {} {} 255\n", W as usize, H as usize).into_bytes();+            for p in px {+                b.extend_from_slice(&p);+            }+            b+        };+        let got = ppm(self.picture.iter().map(|p| [p[0] as u8, p[1] as u8, p[2] as u8]).collect());+        let wanted = ppm(want.pixels.iter().map(|c| [c.r(), c.g(), c.b()]).collect());+        let _ = std::fs::write(format!("{dir}/{name}-got.ppm"), got);+        let _ = std::fs::write(format!("{dir}/{name}-want.ppm"), wanted);+    }++    fn diff(&self, want: &ColorImage) -> (f32, f32, f32) {+        self.dump(want, &format!("t{:.3}", self.t));+        let mut d: Vec<f32> = vec![];+        let mut far = 0usize;+        for (got, w) in self.picture.iter().zip(&want.pixels) {+            let m = (0..4).map(|i| (got[i] - w.to_array()[i] as f32).abs()).fold(0.0, f32::max);+            if m > 48.0 {+                far += 1;+            }+            d.push(m);+        }+        let mean = d.iter().sum::<f32>() / d.len().max(1) as f32;+        d.sort_by(f32::total_cmp);+        let p99 = d.get(d.len() * 99 / 100).copied().unwrap_or(0.0);+        (mean, p99, far as f32 / d.len().max(1) as f32)+    }+}++fn last_id(v: &serde_json::Value) -> u64 {+    v.get("id").and_then(serde_json::Value::as_u64).unwrap_or(0)+}++/// A slide with a picture-like gradient background shape, a title, and shapes that fly in, spin,+/// wipe, grow and follow a path at once, one with a shadow and one with a glow; then bullets+/// that fade in by paragraph on the next click.+fn moving_deck() -> deckcraft_engine::Session {+    let mut s = deckcraft_engine::Session::with_new();+    let mut x = |id: &str, p: serde_json::Value| s.execute(id, &p).expect(id);+    x("slide.layout", json!({"layout": "titleOnly"}));+    let bg = x("shape.insert", json!({"preset": "rect", "rect": [0, 0, 960, 540]}));+    x("shape.fill", json!({"id": last_id(&bg), "gradient": {"stops": [[0, "accent1"], [1, "accent2"]], "angle": 30}}));+    x("arrange.sendToBack", json!({"ids": [last_id(&bg)]}));+    let specs = [+        ("roundRect", [60.0, 160.0, 220.0, 130.0], "fly", "entrance", "b"),+        ("star5", [330.0, 150.0, 160.0, 160.0], "spin", "emphasis", ""),+        ("rect", [540.0, 160.0, 360.0, 140.0], "wipe", "entrance", "l"),+        ("ellipse", [120.0, 340.0, 200.0, 150.0], "growShrink", "emphasis", ""),+        ("hexagon", [600.0, 340.0, 180.0, 150.0], "lines", "path", ""),+    ];+    for (i, (preset, r, effect, class, opt)) in specs.iter().enumerate() {+        let v = x("shape.insert", json!({"preset": preset, "rect": r, "text": format!("Point {}", i + 1)}));+        let id = last_id(&v);+        if i == 0 {+            x("shape.effects", json!({"id": id, "shadow": "bottom"}));+        }+        if i == 1 {+            x("shape.effects", json!({"id": id, "glow": {"color": "accent2", "radius": 8}}));+        }+        x("edit.select", json!({"ids": [id]}));+        let mut p = json!({"effect": effect, "class": class, "start": if i == 0 { "onClick" } else { "withPrevious" }, "duration": 1000});+        if !opt.is_empty() {+            p["option"] = json!(opt);+        }+        x("animation.add", p);+    }+    let tb = x("insert.textBox", json!({"rect": [380, 440, 500, 90], "text": "First point\nSecond point\nThird point"}));+    x("edit.select", json!({"ids": [last_id(&tb)]}));+    x("animation.add", json!({"effect": "fly", "class": "entrance", "option": "b", "start": "onClick", "duration": 1000}));+    x("animation.options", json!({"textBuild": "byParagraph"}));+    x("edit.deselect", json!({}));+    x("slide.new", json!({"layout": "blank"}));+    x("transition.set", json!({"kind": "fade"}));+    s+}++#[test]+fn composited_frames_match_whole_slide_renders() {+    let mut r = Rig::new(moving_deck(), 0);+    assert!(r.layered(), "a slide with animations is composited from layers");+    // At rest before the first click: pictures drawn as they are, so the frame matches exactly.+    let (mean, p99, far) = {+        let want = r.expected();+        r.diff(&want)+    };+    assert!(mean < 0.5 && p99 < 3.0 && far < 0.0005, "resting frame: mean {mean}, p99 {p99}, far {far}");+    r.click();+    for k in 1..=7 {+        r.frames(8);+        let want = r.expected();+        let (mean, p99, far) = r.diff(&want);+        // Moving pictures are resampled: edges and text soften a little, nothing more.+        assert!(mean < 2.5 && p99 < 40.0 && far < 0.01, "frame {k} of the step: mean {mean}, p99 {p99}, far {far}");+    }+    // At rest after the step: poses are drawn again exactly (the grown ellipse, the moved hexagon).+    r.frames(30);+    let (mean, p99, far) = {+        let want = r.expected();+        r.diff(&want)+    };+    assert!(mean < 0.5 && p99 < 3.0 && far < 0.0005, "resting after the step: mean {mean}, p99 {p99}, far {far}");+    // Paragraphs flying in one by one.+    for _ in 0..3 {+        r.click();+        for k in 1..=3 {+            r.frames(12);+            let (mean, p99, far) = {+                let want = r.expected();+                r.diff(&want)+            };+            assert!(mean < 2.5 && p99 < 40.0 && far < 0.01, "paragraph frame {k}: mean {mean}, p99 {p99}, far {far}");+        }+        r.frames(30);+    }+    let (mean, p99, far) = {+        let want = r.expected();+        r.diff(&want)+    };+    assert!(mean < 0.5 && p99 < 3.0 && far < 0.0005, "all paragraphs in: mean {mean}, p99 {p99}, far {far}");+    let _ = ShapeId(0);+}++/// Leaving a composited slide right after its last animation: the transition starts from the+/// slide as it ended, not from an older whole-slide picture.+#[test]+fn a_transition_starts_from_the_composited_slide_as_it_ended() {+    let mut r = Rig::new(moving_deck(), 0);+    for _ in 0..4 {+        r.click();+        r.frames(80);+    }+    r.frames(3);+    let want = r.expected();+    r.click();+    r.frame();+    let show = r.app.show.as_ref().expect("show");+    assert_eq!(show.state.slide, 1, "the click moved on");+    let old = show.trans.as_ref().and_then(|t| t.old.as_ref()).expect("the fade starts from the old slide");+    let img = r.textures.get(&old.id()).expect("its picture was uploaded");+    assert_eq!(img.size, want.size);+    if let Ok(dir) = std::env::var("DECKCRAFT_DUMP") {+        let ppm = |px: &[Color32]| {+            let mut b = format!("P6 {} {} 255\n", W as usize, H as usize).into_bytes();+            for c in px {+                b.extend_from_slice(&[c.r(), c.g(), c.b()]);+            }+            b+        };+        let _ = std::fs::write(format!("{dir}/old-got.ppm"), ppm(&img.pixels));+        let _ = std::fs::write(format!("{dir}/old-want.ppm"), ppm(&want.pixels));+    }+    let diff = img+        .pixels+        .iter()+        .zip(&want.pixels)+        .map(|(a, b)| (0..4).map(|i| (a.to_array()[i] as i32 - b.to_array()[i] as i32).abs()).max().unwrap_or(0))+        .filter(|d| *d > 8)+        .count();+    assert!(diff * 1000 < want.pixels.len(), "{diff} pixels differ from the slide as it ended");+}++/// Three 2×2 frames (red, green, blue), 100 ms each.+fn three_frame_gif() -> Vec<u8> {+    let mut b = b"GIF89a\x02\x00\x02\x00\x81\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00".to_vec();+    b.extend_from_slice(b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00");+    for index in [0u8, 1, 2] {+        b.extend_from_slice(b"\x21\xf9\x04\x00\x0a\x00\x00\x00");+        b.extend_from_slice(b"\x2c\x00\x00\x00\x00\x02\x00\x02\x00\x00");+        // LZW (min code size 2): clear, four pixels of `index`, end.+        let codes = [4u16, index as u16, index as u16, index as u16, index as u16, 5];+        let (mut acc, mut bits, mut out) = (0u32, 0u32, vec![]);+        for (k, c) in codes.iter().enumerate() {+            let width = if k >= 4 { 4 } else { 3 };+            acc |= (*c as u32) << bits;+            bits += width;+            while bits >= 8 {+                out.push((acc & 0xff) as u8);+                acc >>= 8;+                bits -= 8;+            }+        }+        if bits > 0 {+            out.push(acc as u8);+        }+        b.push(2);+        b.push(out.len() as u8);+        b.extend_from_slice(&out);+        b.push(0);+    }+    b.push(0x3b);+    b+}++#[test]+fn composited_gif_frames_match_whole_slide_renders() {+    let mut s = deckcraft_engine::Session::with_new();+    s.execute("slide.layout", &json!({"layout": "titleOnly"})).expect("layout");+    let id = ShapeId(4200);+    s.edit(|doc, _| {+        let media = doc.add_media("rgb.gif", "image/gif", three_frame_gif()).expect("gif");+        let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);+        slide.shapes.push(deckcraft_model::Shape {+            id,+            xfrm: Some(deckcraft_geom::Xfrm::new(200.0, 150.0, 400.0, 300.0)),+            kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },+            ..Default::default()+        });+        Ok(())+    })+    .expect("edit");+    let mut r = Rig::new(s, 0);+    assert!(r.layered(), "a slide with an animated GIF is composited");+    let mut seen = std::collections::HashSet::new();+    for _ in 0..12 {+        r.frames(2);+        let want = r.expected();+        let (mean, p99, far) = r.diff(&want);+        assert!(mean < 0.5 && p99 < 3.0 && far < 0.0005, "GIF frame: mean {mean}, p99 {p99}, far {far}");+        let mid = r.picture[(300 * W as usize) + 400];+        seen.insert((mid[0] as u8 / 128, mid[1] as u8 / 128, mid[2] as u8 / 128));+    }+    assert!(seen.len() >= 3, "the GIF played through its frames: {seen:?}");+}diff --git a/docs/animation-parity.md b/docs/animation-parity.md--- a/docs/animation-parity.md+++ b/docs/animation-parity.md@@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (show playback composites animations on the GPU from layers rendered once; animated groups move their contents; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)@@ -76,6 +76,8 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Preview from the selected effect | yes | yes: with an effect selected the pane's button reads Play From and starts at that effect, earlier effects already played (`animpane_tests`) | | Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) | | Multiple animations on one object | yes | yes |+| Playback at display rate: shapes drawn once, then moved, scaled, turned, faded and clipped by the GPU | yes | yes: a slide with animations is split into layers rendered once (`show_layers`); frames only composite textures (0.03 ms of CPU and no texture upload per frame, was 8–60 ms and an 8 MB upload at 1080p; [benchmarks](benchmarks/slideshow.md)). Colour changes and shapes animated inside a group are drawn again for that shape alone |+| Animated groups (fly, spin, grow, motion paths on a group) | yes | yes: the group's contents move with it (they stayed in place before) | | Round trip of the timing tree | yes | flattened to the pane list on read, rebuilt on write (`every_animation_effect_survives`) |  ## Estimates@@ -94,6 +96,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Show playback composites animations from layers rendered once (GPU moves, fades and clips them); animated groups move their contents. Feature scores unchanged; playback is now measured (`show_frame_bench`, [benchmarks/slideshow.md](benchmarks/slideshow.md)) | | 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h | | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | | 2026-10-16 | trivial | PPTX paragraph builds edited in PowerPoint keep their clicks; sub-points save With Previous of their parent (#844) |diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: minor, community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (slide-show layers: animations and GIFs composited on the GPU; previous: minor, native chart payloads stored in bounded ZIP members; previous: minor, community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what@@ -55,7 +55,14 @@ effects project cards through a perspective camera and `anim::layer_mesh` subdiv textures stay straight; soft edges are per-corner opacities. The show renders the slides once per transition and only composites cached textures each frame; `render::composite_transition` is the CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape-comes from `anim::timeline` each frame. Animations ▸ Preview plays that same state on the editor+comes from `anim::timeline` each frame. A slide with animations or animated GIFs is composited+the way PowerPoint plays it (`ui-egui/src/show_layers.rs`): its layers are rasterized once (the+backdrop with the shapes under the first animated one, each shape from the first animated one to+the last on a sprite of its own, by-paragraph text builds one sprite per paragraph, the shapes over+the last one) with `render::render_slide_shapes`, and each frame only moves, scales, turns, fades+and clips those textures (`render::shape_motion` gives the same transform the CPU renderer+applies). What moving can't show (a colour change, a shape animated inside a group) and a pose a+shape comes to rest in are drawn again for that shape alone; GIF frames are kept as sprites. Animations ▸ Preview plays that same state on the editor canvas for the current slide, then draws the slide at rest. PDF export renders each slide at print resolution and overlays an invisible real-text layer with embedded font subsets. @@ -81,6 +88,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts.  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Slide show composites slides with animations or animated GIFs from layers rasterized once (`show_layers`, `render::render_slide_shapes`, `render::shape_motion`); the web build uses WebAssembly SIMD | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-14 | minor | Community PR wave folded: one L2/L5/L6 row each; command registry 320 (`cmd!` registrations in `cmd/*`), 41 dialogs (titled in `dialogs.rs`), 202k lines, 24 crates (`metafile` added at L0), 1,641 tests. The per-PR rows below give the counts each PR saw on its own branch | | 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs |diff --git a/docs/benchmarks/slideshow.md b/docs/benchmarks/slideshow.mdnew file mode 100644--- /dev/null+++ b/docs/benchmarks/slideshow.md@@ -0,0 +1,94 @@+# Slide show frame performance++> **Last updated:** 2026-10-25 · **Change:** created (before/after the slide-show layers and+> WebAssembly SIMD)++What one slide-show frame costs while something plays, before and after the show started+compositing animated slides from layers rendered once (`crates/ui-egui/src/show_layers.rs`) and the+web build started using WebAssembly SIMD.++## How it was measured++- **What is timed:** the app's whole frame (`SlideApp::logic` and `ui`, slide rendering, egui+  tessellation), driven headlessly at 60 Hz in a 1920×1080 window (the slide drawn at 2 px per+  point). The GPU upload itself isn't timed; the texture bytes each frame sends are reported.+- **Which frames:** only frames where something plays: an animation step on the scenario's slide+  (one click after the other, to the last step), the 1 s or 2 s of a transition, or 3 s of GIFs.+- **Native:** `cargo test --release -p deckcraft-ui-egui --lib show_frame_bench -- --ignored --nocapture`,+  release build, two render threads.+- **Browser:** the same scenarios and driver built for `wasm32-unknown-unknown` (release, with and+  without `+simd128`), bound with wasm-bindgen and run in Node 22, whose V8 is Chrome's WebAssembly+  engine; single-threaded, as in the browser.+- **Machine:** a 2-vCPU x86_64 cloud container (7 GB). A typical laptop is faster; compare the+  columns with each other, not with another machine.+- **Before:** upstream `main` at 38d2ade. **After:** this change.++| Scenario | What plays |+|---|---|+| agenda | Sample deck, slide 2: six bullets fading in by paragraph (four clicks) |+| process | Sample deck, slide 4: four shadowed shapes zooming in one after another |+| photo | A full-slide photo, a title and five shapes flying, spinning, wiping, growing and following a path at once, with shadows and a glow |+| gif | A full-slide photo, a title and two animated GIFs (480×360, 24 and 16 frames of 50 ms) |+| push | Sample deck: title slide to the agenda, a Push transition (already composited before) |+| morph | Sample deck: section header to the process slide, a Morph transition (still rendered every frame) |++## Browser (WebAssembly), as shipped before → after++CPU per frame in ms: mean, and 95th percentile in brackets. "Late" is the share of frames over+16.7 ms (a 60 Hz frame).++| Scenario | Before (no SIMD) | Before + SIMD only | After (layers + SIMD) | Late before → after | Upload per frame before → after |+|---|---|---|---|---|---|+| agenda | 15.15 (16.68) | 12.89 (14.04) | **0.08** (0.09) | 4% → 0% | 8.29 MB → 0 |+| process | 27.60 (38.18) | 18.94 (25.24) | **0.07** (0.08) | 90% → 0% | 8.29 MB → 0 |+| photo | 282.41 (295.42) | 118.18 (129.23) | **0.06** (0.11) | 100% → 0% | 8.29 MB → 0 |+| gif | 127.24 (376.72) | 46.54 (140.06) | **2.41** (21.37) | 34% → 7% | 2.86 MB → 0.22 MB |+| push | 0.16 (0.07) | 0.12 (0.05) | 0.13 (0.05) | 0% → 0% | 0.14 MB → 0.14 MB |+| morph | 21.93 (22.72) | 7.42 (7.73) | **7.53** (8.47) | 98% → 0% | 8.36 MB → 8.36 MB |++- The photo slide played at **3.5 frames a second** in the browser; its animation frames now cost+  0.06 ms of CPU and send nothing to the GPU, so it plays at the display's rate.+- An animated GIF used to cost a whole-slide render each time it changed frame (377 ms at the+  95th percentile: about 2.7 GIF frames a second); now each GIF frame is drawn once, the first time+  it shows (the remaining late frames, about 21 ms, are those first draws), and kept.+- Morph is faster only through SIMD (about 3×): it still renders every frame (see+  [gaps.md](../gaps.md#performance)).+- The after column with the release pipeline's `wasm-opt -Oz` pass: photo 0.04 ms, gif 2.38 ms,+  morph 7.19 ms; the size optimisation costs nothing measurable here.++The compositing alone, without SIMD: agenda 0.17, process 0.08, photo 0.05, gif 6.61 (p95 59.58)+ms per frame. SIMD alone makes the CPU renderer 1.2–3× faster (2.4× on the photo slide).++## Native (desktop), before → after++| Scenario | Before | After | Late before → after | Upload per frame before → after |+|---|---|---|---|---|+| agenda | 7.54 (9.72) | **0.03** (0.04) | 0% → 0% | 8.29 MB → 0 |+| process | 11.92 (17.31) | **0.03** (0.04) | 10% → 0% | 8.29 MB → 0 |+| photo | 59.92 (63.27) | **0.03** (0.03) | 100% → 0% | 8.29 MB → 0 |+| gif | 10.71 (32.09) | **0.82** (7.80) | 34% → 0% | 2.86 MB → 0.22 MB |+| push | 0.10 (0.02) | 0.17 (0.05) | 0% → 0% | 0.14 MB → 0.14 MB |+| morph | 6.31 (6.38) | 6.59 (7.15) | 0% → 0% | 8.36 MB → 8.36 MB |++## What it costs: entering a slide++A composited slide's layers are rendered once, while the slide rests after it appears (one layer a+frame, never in the frame that draws the whole slide; all at once if an animation starts first).+CPU over the first 30 frames of the slide, total and worst single frame, in ms:++| Scenario | Browser before (no SIMD) | Browser before + SIMD | Browser after | Native before | Native after |+|---|---|---|---|---|---|+| agenda | 40 / 38 | 29 / 28 | 98 / 42 | 11 / 11 | 36 / 14 |+| process | 9 / 8 | 7 / 6 | 41 / 6 | 4 / 4 | 23 / 4 |+| photo | 263 / 262 | 119 / 118 | 221 / 103 | 54 / 53 | 114 / 52 |+| gif | 4215 / 395 | 1505 / 139 | 807 / 139 | 357 / 35 | 236 / 42 |++The slide's first picture costs what it did; building the layers adds about one more whole-slide+render, spread over the next few frames while nothing moves. Leaving a composited slide right after+an animation ends renders it once more for the transition to start from.++## Revision history++| Date | Change | Summary |+|---|---|---|+| 2026-10-25 | major | Created: frame cost before and after the slide-show layers and WebAssembly SIMD, native and in V8 |diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (show animations and GIFs composited on the GPU; show frame cost measured; Morph and video frames remain; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  Every known shortfall, one entry each, ranked by how much it stops someone who presents with@@ -32,7 +32,7 @@ revision history. | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | | 14 | PDF export rasterizes slide artwork | File format | B | 5–8 | [file-format-parity.md](file-format-parity.md) |-| 15 | Slides rasterize on the CPU; no performance baseline | Performance / hardware | | 15–30 | [hardware-parity.md](hardware-parity.md) |+| 15 | Slides rasterize on the CPU (animations and GIFs are composited on the GPU); no PowerPoint comparison | Performance / hardware | | 15–30 | [hardware-parity.md](hardware-parity.md) |  ## Stability @@ -336,8 +336,16 @@ Detail in [localization-parity.md](localization-parity.md).  - **Baseline measured, not compared.** `cargo xtask bench` (and `deckcraft-cli bench FILE`) times   open, per-slide render, save, PPTX round trip and PDF export, and peak memory, against a saved-  baseline; show frame rate isn't measured and there are no PowerPoint numbers to set beside it.-  2–4 h.+  baseline; the show's frame cost is measured by `show_frame_bench` ([benchmarks/slideshow.md](benchmarks/slideshow.md)).+  There are no PowerPoint numbers to set beside them. 2–4 h.+- **Morph transitions and slides with a playing video render every frame on the CPU.** Animated+  slides and GIFs are composited from layers rendered once; Morph (`render_blend`) and the video+  overlay still draw the whole slide each frame and upload it (8 MB a frame at 1080p). Moving+  matched shapes as sprites when only their position and size change: 4–8 h.+- **A slide with animations costs one extra render when it is entered.** Its layers are built while+  it rests, one per frame (all at once if an animation starts first): 20–120 ms of CPU at 1080p+  spread over a few frames, about twice that in the browser. 1–3 h to build them on a worker+  thread on desktop. - **Morph aligns outlines every frame** (#67, PR open). 1 h.  ## Ecosystem@@ -359,6 +367,7 @@ Detail in [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Performance: the show composites animated slides and GIFs from layers rendered once and its frame cost is measured (`show_frame_bench`); new entries for Morph and video frames still rendered on the CPU and for the one-off layer build. Hours unchanged | | 2026-10-22 | minor | Animation Pane ▸ Play From starts the preview at the selected effect; gap 10 keeps the effect gallery (7–13 h) | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (show frames measured; animations and GIFs composited on the GPU; wasm SIMD; previous: minor, community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted  What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too@@ -53,9 +53,16 @@ because it mostly follows from hardware use.  ## Performance -Nothing is measured against PowerPoint yet: no benchmark of open time, render time per slide,-show frame rate or memory exists in `xtask`. Known: slides rasterize on the CPU, Morph aligns-outlines every frame (#67, fix open), and the decoders run in software.+Nothing is measured against PowerPoint yet. `cargo xtask bench` times open, render, save and+export; `show_frame_bench` (in `crates/ui-egui`) times slide-show frames, natively and, built for+wasm, in V8 ([benchmarks/slideshow.md](benchmarks/slideshow.md)). Slides rasterize on the CPU, but+the show plays animations and animated GIFs the way PowerPoint does: each slide's layers are+rasterized once and the GPU moves, scales, turns, fades and clips them, so an animation frame+costs about 0.03 ms of CPU and uploads nothing (it was a whole-slide render and an 8 MB upload at+1080p: 3.5 fps for a photo slide in the browser). The browser build uses WebAssembly SIMD, which+makes the CPU renderer 1.2–3× faster there (2.4× on a photo slide). Morph transitions and slides with a playing video+still render every frame; Morph aligns outlines every frame (#67, fix open); the decoders run in+software.  ## Estimates @@ -73,6 +80,7 @@ outlines every frame (#67, fix open), and the decoders run in software.  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Performance: slide-show frames measured (`show_frame_bench`); animations and GIFs composited from layers rendered once; the web build uses WebAssembly SIMD. Scores and hours unchanged pending a PowerPoint comparison | | 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size | | 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-14 | trivial | Community PR wave folded: one row each for pinch/two-finger gestures and pen pressure |diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md--- a/docs/slideshow-parity.md+++ b/docs/slideshow-parity.md@@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (animated slides and GIFs composited on the GPU; presenter view shows the animating slide; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in@@ -27,7 +27,8 @@ ready** (estimated), **10–16 h** to parity. | "Advance on click" off: a click never leaves the slide (animation steps still play, keys still move on) | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed, Custom Show) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`). A Custom Show action plays that show | | Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable. Click sequence since #116. Slide Show ▸ Show Media Controls (on by default) hides the playback bar. Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media` |-| Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls |+| Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls. Each GIF frame is drawn once and kept as a texture: playing one costs no slide render (was a whole-slide render per GIF frame: 32 ms native and 377 ms in the browser at 1080p, 95th percentile) |+| Smooth playback of animated slides, also in the browser | yes | yes | Animated slides are composited from layers rendered once ([benchmarks](benchmarks/slideshow.md)); the presenter's current slide shows the same frames. Slides with a video and Morph transitions still render each frame on the CPU | | See All Slides grid during the show | yes | yes: G, or All on the show bar; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown | | Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it | The presenter keeps the whole slide and draws a frame on the zoomed part | | Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt |@@ -79,6 +80,7 @@ Reading View in a window with navigation: present (checklist row done).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Animated slides and GIFs are composited from layers rendered once, so they play at display rate in the desktop and browser builds; the presenter's current slide composites the same frames. Breadth unchanged (new row is a quality row) | | 2026-10-22 | minor | See All Slides from presenter view opens in presenter view, not on the audience screen (#1130) | | 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% | | 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click |diff --git a/packaging/web/README.md b/packaging/web/README.md--- a/packaging/web/README.md+++ b/packaging/web/README.md@@ -85,4 +85,5 @@ DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall  For example: `<iframe src="https://example.com/deckcraft/?webgl" ...>`. -A browser with neither WebGPU nor WebGL2 gets a message in place of the app.+A browser with neither WebGPU nor WebGL2 gets a message in place of the app, and so does one+without WebAssembly SIMD (the build uses it; Chrome and Edge 91+, Firefox 89+, Safari 16.4+).