collective

Browse / github.com/storytold/deckcraft / patch

fix(morph): draw and upload only the changed region of each Morph frame (#1004)

fix(morph): draw and upload only the changed region of each Morph frame (#1004) Refs #1004 (Morph transition laggy). I could not reproduce the lag on my machine (see "What I could not verify"), so this removes the per-frame work that scales with the size of the slide, plus one real inefficiency found on the reporter's deck. It does not claim the issue is fully closed. ## What was slow 1. **Whole frame every frame.** Each Morph frame rasterised the whole slide, converted it to an egui image and uploaded it as a new texture, even when one rounded rectangle was the only thing moving. 2. **Identical groups were drawn twice.** In the attached `morph test.pptx` the `Ink` grou

Status
open
Score
0.00
Adopters
0 other operators
Author
SofiaAFigueredo (imported from pull request #1389, open)
Base
aaf6c1c38aa6c59beda8110bd014cc6c96c6ec74
Size
+612 −38 in 12 files
Review
pending
Shared
2026-10-28
Id
2ce6cb25bd4af194d1b77527

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/anim/src/lib.rs crates/anim/src/morph.rs crates/anim/src/morph_dirty.rs crates/anim/src/morph_dirty_tests.rs crates/anim/src/tests.rs crates/render/src/lib.rs crates/render/src/tests.rs crates/render/src/transition.rs crates/ui-egui/src/show.rs docs/animation-parity.md docs/gaps.md

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-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; 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-25 · **Change:** trivial (progress log: Morph draws only the changed region (#1004); previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; 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-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, 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-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, 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)@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-25 | Morph transitions (#1004): a group that is identical on both slides (ink drawings) moves instead of cross-fading two copies of itself, and each frame of a Morph draws and uploads to the GPU only the region where plain shapes moved, not the whole slide; text, effects and outline morphs still redraw everything. Live-show frame rate not yet measured | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | trivial | Progress log: Morph draws only the changed region (#1004). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs--- a/crates/anim/src/lib.rs+++ b/crates/anim/src/lib.rs@@ -18,6 +18,9 @@ mod effects; mod interact; mod mesh; mod morph;+mod morph_dirty;+#[cfg(test)]+mod morph_dirty_tests; mod path; mod show; mod timeline;@@ -39,6 +42,7 @@ pub use easing::ease; pub use interact::{Click, HitShape, Target}; pub use mesh::{MeshVertex, layer_mesh}; pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm};+pub use morph_dirty::Change as MorphChange; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs--- a/crates/anim/src/morph.rs+++ b/crates/anim/src/morph.rs@@ -1,5 +1,6 @@ //! Morph: matching shapes between two slides, interpolating their boxes and building the frames. +use deckcraft_geom::{KurboShape, Rect, preset}; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk}; @@ -113,6 +114,12 @@ pub struct MorphFrame {     /// Frame shapes whose outline turns from another geometry into their own (the renderer's     /// `PathMorph`).     pub paths: Vec<MorphPath>,+    /// Whether the two slides' backdrops (background, master and layout graphics) look the same, so+    /// the cross-fade of [`MorphFrame::mix`] changes nothing behind the shapes.+    pub same_backdrop: bool,+    /// For each of `shapes`, in slide points, the region it can draw in, or `None` when that is not+    /// known (text, effects, groups, custom geometry…). See [`MorphFrame::changed_since`].+    pub reach: Vec<Option<Rect>>, }  /// A frame shape whose outline morphs from `from` (the old shape's geometry) into its own.@@ -164,9 +171,21 @@ fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {     } } +/// `s` with the ids of the shapes inside it (a group's children) cleared: every shape of a+/// presentation has an id of its own, so the same group on two slides never has the same ones.+fn without_ids(s: &Shape) -> Shape {+    let mut c = Shape { id: ShapeId(0), ..s.clone() };+    if let ShapeKind::Group { children, .. } = &mut c.kind {+        for child in children.iter_mut() {+            *child = without_ids(child);+        }+    }+    c+}+ /// The content of a shape apart from what Morph interpolates (identity, box and fill). fn content(s: &Shape) -> Shape {-    Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }+    Shape { name: String::new(), xfrm: None, fill: None, ..without_ids(s) } }  /// Whether two shapes differ in their geometry only, and it is drawn as an outline (shapes,@@ -224,6 +243,64 @@ pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f6     if c.iter().all(|v| v.is_finite()) { c } else { cb } } +/// Whether slides `a` and `b` get the same backdrop: the same master and layout, background and+/// colour map, and no field (slide number, date) in the graphics they show, which differ per slide.+fn same_backdrop(pres: &Presentation, a: &Slide, b: &Slide) -> bool {+    let (Some((ma, la)), Some((mb, lb))) = (pres.master_for(a), pres.master_for(b)) else { return false };+    if !std::ptr::eq(ma, mb) || la.map(|l| l.id) != lb.map(|l| l.id) {+        return false;+    }+    if a.background != b.background || a.show_master_shapes != b.show_master_shapes || a.color_map != b.color_map {+        return false;+    }+    let (show_layout, show_master) = resolve::show_master_shapes(a, la);+    let mut field = false;+    let mut look = |shapes: &[Shape]| {+        walk(shapes, &mut |x, _| {+            let runs = x.text.iter().flat_map(|t| t.paragraphs.iter()).flat_map(|p| p.runs.iter());+            field |= x.ph.is_none() && runs.into_iter().any(|r| matches!(r.kind, deckcraft_model::text::RunKind::Field { .. }));+        })+    };+    if show_master {+        look(&ma.shapes);+    }+    if show_layout && let Some(l) = la {+        look(&l.shapes);+    }+    !field+}++/// The region of slide points frame shape `s` (of the slide `ctx` belongs to) draws in, when that+/// is known: a plain shape or an uncropped picture of preset geometry, without text or effects,+/// whose outline and line stay inside it. `None` for anything else.+fn reach_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Rect> {+    let ctx = ctx?;+    if s.text.as_ref().is_some_and(|t| !t.is_empty()) {+        return None;+    }+    match &s.kind {+        ShapeKind::Shape | ShapeKind::Connector { .. } => {}+        ShapeKind::Picture { fill } if fill.crop == [0.0; 4] && fill.artistic.is_none() => {}+        _ => return None,+    }+    if resolve::effects(ctx, s).0.is_some_and(|e| !e.is_empty() || e.raw3d.is_some()) {+        return None;+    }+    let Geom::Preset { name, adj } = &s.geom else { return None };+    let x = resolve::xfrm(ctx, s);+    if !(x.is_finite() && x.w >= 0.0 && x.h >= 0.0) {+        return None;+    }+    let geometry = preset::build(name, x.w, x.h, adj)?;+    let local = geometry.paths.iter().fold(Rect::new(0.0, 0.0, x.w, x.h), |r, p| r.union(p.path.bounding_box()));+    let line = resolve::line(ctx, s).0;+    // A mitred corner reaches up to four half-widths out; an arrowhead several widths.+    let width = line.width.unwrap_or(1.0).abs().min(1.0e4);+    let margin = if line.head.is_some() || line.tail.is_some() { 12.0 * width } else { 4.0 * width } + 2.0;+    let r = x.affine().transform_rect_bbox(local).inflate(margin, margin);+    (r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()).then_some(r)+}+ /// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes /// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the /// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only@@ -240,7 +317,7 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:     let mut next = 1u32;     walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));     walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));-    let mut f = MorphFrame { mix: s, ..Default::default() };+    let mut f = MorphFrame { mix: s, same_backdrop: same_backdrop(pres, a, b), ..Default::default() };     let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");     let (by_text, chars) = (by == "words" || by == "characters", by == "characters");     let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));@@ -253,6 +330,7 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:         if op < 1.0 {             f.opacity.push((id, op));         }+        f.reach.push(reach_of(if old { ca.as_ref() } else { cb.as_ref() }, &sh));         f.shapes.push((sh, old));         id     };@@ -313,6 +391,14 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:         }     }     f.text = text;+    // An outline that morphs draws beyond either shape's own.+    for p in &paths {+        if let Some(i) = f.shapes.iter().position(|(x, _)| x.id == p.id)+            && let Some(r) = f.reach.get_mut(i)+        {+            *r = None;+        }+    }     f.paths = paths;     f }diff --git a/crates/anim/src/morph_dirty.rs b/crates/anim/src/morph_dirty.rsnew file mode 100644--- /dev/null+++ b/crates/anim/src/morph_dirty.rs@@ -0,0 +1,47 @@+//! Which part of a Morph frame differs from the one before it, so the show draws and uploads only+//! that part: moving one object across a slide changes a few percent of the picture.++use deckcraft_geom::Rect;++use crate::morph::MorphFrame;++/// How a Morph frame differs from the previous one ([`MorphFrame::changed_since`]).+#[derive(Clone, Copy, Debug, PartialEq)]+pub enum Change {+    /// The same picture.+    Nothing,+    /// Only this region of the slide (points) differs; the rest is as before.+    Region(Rect),+    /// Anything may differ: draw the whole frame.+    Everything,+}++impl MorphFrame {+    /// How this frame differs from `prev`, the frame drawn just before it for the same pair of+    /// slides. Cautious: whenever what changed is not a shape with a known [`MorphFrame::reach`]+    /// (text, effects, groups, a different set of shapes, a backdrop that cross-fades…) the answer+    /// is [`Change::Everything`].+    pub fn changed_since(&self, prev: &MorphFrame) -> Change {+        let n = self.shapes.len();+        if prev.shapes.len() != n+            || self.reach.len() != n+            || prev.reach.len() != n+            || !self.text.is_empty()+            || !prev.text.is_empty()+            || (self.mix != prev.mix && !(self.same_backdrop && prev.same_backdrop))+        {+            return Change::Everything;+        }+        let mut region: Option<Rect> = None;+        for (i, ((s, old), (p, was_old))) in self.shapes.iter().zip(&prev.shapes).enumerate() {+            // An outline morphing between two geometries is drawn from the progress alone.+            let morphing = self.mix != prev.mix && self.paths.iter().any(|m| m.id == s.id);+            if old == was_old && s == p && self.opacity_of(s.id) == prev.opacity_of(p.id) && !morphing {+                continue;+            }+            let (Some(Some(now)), Some(Some(before))) = (self.reach.get(i), prev.reach.get(i)) else { return Change::Everything };+            region = Some(region.map_or(now.union(*before), |r| r.union(now.union(*before))));+        }+        region.map_or(Change::Nothing, Change::Region)+    }+}diff --git a/crates/anim/src/morph_dirty_tests.rs b/crates/anim/src/morph_dirty_tests.rsnew file mode 100644--- /dev/null+++ b/crates/anim/src/morph_dirty_tests.rs@@ -0,0 +1,143 @@+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]++use deckcraft_geom::Rect;+use deckcraft_model::{Background, ColorRef, Effects, Fill, Geom, Presentation, Rgba, Shape, ShapeId, Slide, TextBody, Xfrm};++use crate::{MorphChange, MorphFrame, morph_frame};++fn bx(id: u32, name: &str, x: f64) -> Shape {+    Shape { id: ShapeId(id), name: name.into(), xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..Default::default() }+}++fn frame(p: &Presentation, a: &Slide, b: &Slide, t: f64) -> MorphFrame {+    morph_frame(p, a, b, t, &|_| false)+}++fn slides(from: Vec<Shape>, to: Vec<Shape>) -> (Slide, Slide) {+    (Slide { shapes: from, ..Default::default() }, Slide { shapes: to, ..Default::default() })+}++fn region(c: MorphChange) -> Rect {+    match c {+        MorphChange::Region(r) => r,+        other => panic!("not a region: {other:?}"),+    }+}++#[test]+fn a_moving_box_changes_only_where_it_moves() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let (a, b) = slides(vec![bx(1, "Box", 0.0), bx(2, "Fixed", 700.0)], vec![bx(1, "Box", 400.0), bx(2, "Fixed", 700.0)]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    assert!(f1.same_backdrop && f1.reach.iter().all(Option::is_some));+    let r = region(f2.changed_since(&f1));+    let (x1, x2) = (f1.shapes[0].0.xfrm.unwrap(), f2.shapes[0].0.xfrm.unwrap());+    assert!(r.x0 <= x1.x && r.x1 >= x1.x + x1.w && r.x0 <= x2.x && r.x1 >= x2.x + x2.w, "{r:?}");+    // The box that stays put is outside the region, and so is most of the slide.+    assert!(r.x1 < 700.0 && r.width() < 960.0 / 2.0, "{r:?}");+    assert_eq!(f2.changed_since(&f2.clone()), MorphChange::Nothing);+}++#[test]+fn a_rotated_box_covers_its_corners() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let turned = |x| Shape { xfrm: Some(Xfrm { rot: 45.0, ..Xfrm::new(x, 100.0, 200.0, 100.0) }), ..bx(1, "Box", x) };+    let (a, b) = slides(vec![turned(0.0)], vec![turned(400.0)]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    let r = region(f2.changed_since(&f1));+    // A 200 x 100 box turned 45 degrees spans (200 + 100) / sqrt 2 = 212 each way.+    let at = f2.shapes[0].0.xfrm.unwrap();+    let c = at.center();+    assert!(r.x0 <= c.x - 106.0 && r.x1 >= c.x + 106.0 && r.y0 <= c.y - 106.0 && r.y1 >= c.y + 106.0, "{r:?}");+}++#[test]+fn a_speech_bubble_covers_its_tail() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let bubble = |x| Shape { geom: Geom::Preset { name: "wedgeRectCallout".into(), adj: vec![] }, ..bx(1, "Bubble", x) };+    let (a, b) = slides(vec![bubble(0.0)], vec![bubble(400.0)]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    let r = region(f2.changed_since(&f1));+    // The box ends at y = 200; the tail points 62.5% of its height below the centre, to y = 212.5.+    assert!(r.y1 >= 212.5, "{r:?}");+}++#[test]+fn a_line_widens_the_region() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let thick = |x| Shape { line: Some(deckcraft_model::Line::solid(ColorRef::rgb(Rgba::BLACK), 20.0)), ..bx(1, "Box", x) };+    let (a, b) = slides(vec![thick(0.0)], vec![thick(400.0)]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    let (plain_a, plain_b) = slides(vec![bx(1, "Box", 0.0)], vec![bx(1, "Box", 400.0)]);+    let plain = region(frame(&p, &plain_a, &plain_b, 0.4).changed_since(&frame(&p, &plain_a, &plain_b, 0.3)));+    let r = region(f2.changed_since(&f1));+    assert!(r.width() >= plain.width() + 40.0 && r.height() >= plain.height() + 40.0, "{r:?} vs {plain:?}");+}++#[test]+fn shapes_that_can_draw_beyond_their_box_redraw_everything() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let cases: Vec<(&str, Box<dyn Fn(Shape) -> Shape>)> = vec![+        ("text", Box::new(|s| Shape { text: Some(TextBody::from_text("Hi")), ..s })),+        ("effects", Box::new(|s| Shape { effects: Some(Effects { soft_edge: Some(4.0), ..Default::default() }), ..s })),+        ("3-D", Box::new(|s| Shape { effects: Some(Effects { raw3d: Some("<a:scene3d/>".into()), ..Default::default() }), ..s })),+        ("custom geometry", Box::new(|s| Shape { geom: Geom::Custom { paths: vec![deckcraft_model::CustomPath::default()] }, ..s })),+        ("group", Box::new(|s| Shape { kind: deckcraft_model::ShapeKind::Group { children: vec![], child: Xfrm::default() }, ..s })),+    ];+    for (what, make) in cases {+        let (a, b) = slides(vec![make(bx(1, "Box", 0.0))], vec![make(bx(1, "Box", 400.0))]);+        let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+        assert_eq!(f2.changed_since(&f1), MorphChange::Everything, "{what}");+    }+}++#[test]+fn a_still_text_box_next_to_a_moving_box_is_not_redrawn() {+    // Only changed shapes need a known reach: the text sits still while the box moves.+    let p = deckcraft_model::defaults::new_presentation(None);+    let title = Shape { text: Some(TextBody::from_text("Title")), ..bx(2, "Title", 700.0) };+    let (a, b) = slides(vec![bx(1, "Box", 0.0), title.clone()], vec![bx(1, "Box", 400.0), title]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    assert!(f1.text.is_empty());+    let r = region(f2.changed_since(&f1));+    assert!(r.x1 < 700.0, "{r:?}");+}++#[test]+fn a_backdrop_that_cross_fades_redraws_everything() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let (a, mut b) = slides(vec![bx(1, "Box", 0.0)], vec![bx(1, "Box", 400.0)]);+    assert!(frame(&p, &a, &b, 0.5).same_backdrop);+    b.background = Some(Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba { r: 200, g: 0, b: 0, a: 255 })) });+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    assert!(!f1.same_backdrop);+    assert_eq!(f2.changed_since(&f1), MorphChange::Everything);+}++#[test]+fn a_different_set_of_shapes_redraws_everything() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let (a, b) = slides(vec![bx(1, "Box", 0.0)], vec![bx(1, "Box", 400.0), bx(2, "Later", 0.0)]);+    let shown = frame(&p, &a, &b, 0.3);+    let hidden = morph_frame(&p, &a, &b, 0.4, &|id| id == ShapeId(2));+    assert_eq!(hidden.changed_since(&shown), MorphChange::Everything);+}++#[test]+fn a_shape_that_fades_in_changes_where_it_stands() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let (a, b) = slides(vec![bx(1, "Box", 0.0)], vec![bx(1, "Box", 0.0), bx(2, "New", 600.0)]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    let r = region(f2.changed_since(&f1));+    assert!(r.x0 >= 500.0 && r.x1 <= 900.0, "{r:?}");+}++#[test]+fn a_morphing_outline_changes_everywhere_it_may_reach() {+    let p = deckcraft_model::defaults::new_presentation(None);+    let star = |x| Shape { geom: Geom::Preset { name: "star5".into(), adj: vec![] }, ..bx(1, "Shape", x) };+    let (a, b) = slides(vec![bx(1, "Shape", 0.0)], vec![star(0.0)]);+    let (f1, f2) = (frame(&p, &a, &b, 0.3), frame(&p, &a, &b, 0.4));+    assert!(!f2.paths.is_empty());+    assert_eq!(f2.changed_since(&f1), MorphChange::Everything);+}diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs--- a/crates/anim/src/tests.rs+++ b/crates/anim/src/tests.rs@@ -667,6 +667,30 @@ fn morph_frame_moves_blends_and_fades() {     assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0); } +#[test]+fn morph_frame_moves_a_group_whose_children_have_other_ids() {+    // The same group on two slides: its children have ids of their own on each, yet it is one+    // group that moves, not two copies cross-fading.+    let p = deckcraft_model::defaults::new_presentation(None);+    let group = |id: u32, child_id: u32| Shape {+        kind: deckcraft_model::ShapeKind::Group { children: vec![shape(child_id, "Dot")], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) },+        ..shape(id, "Ink")+    };+    let mut a = Slide::default();+    let mut b = Slide::default();+    a.shapes = vec![Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 200.0, 100.0)), ..group(1, 10) }];+    b.shapes = vec![Shape { xfrm: Some(Xfrm::new(400.0, 100.0, 200.0, 100.0)), ..group(2, 20) }];+    let f = morph_frame(&p, &a, &b, 0.5, &|_| false);+    assert_eq!(f.shapes.len(), 1);+    assert!(f.opacity.is_empty());+    assert!((f.shapes[0].0.xfrm.unwrap().x - 200.0).abs() < 1e-9);+    // Children that differ in more than their ids do cross-fade.+    if let deckcraft_model::ShapeKind::Group { children, .. } = &mut b.shapes[0].kind {+        children[0].name = "Other".into();+    }+    assert_eq!(morph_frame(&p, &a, &b, 0.5, &|_| false).shapes.len(), 2);+}+ #[test] fn morph_frame_cross_fades_changed_content() {     let p = deckcraft_model::defaults::new_presentation(None);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@@ -58,7 +58,7 @@ pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts pub use table::cell_body as table_cell_body; pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights;-pub use transition::{SlideParts, composite_transition, render_transition};+pub use transition::{PixelRect, SlideParts, composite_transition, pixel_region, render_blend_part, render_transition};  /// Largest pixmap side we render (vello_cpu uses u16 sizes). pub const MAX_SIDE: u32 = 16_000;diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -1,7 +1,7 @@ use super::{     Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,-    crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,-    render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,+    crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, pixel_region,+    place_slide, render_blend, render_blend_part, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, }; use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef;@@ -626,6 +626,106 @@ fn blend_ends_match_the_slides_and_mixes_backdrops() {     let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o); } +#[test]+fn pixel_regions_cover_the_region_inside_the_frame() {+    let r = |x0, y0, x1, y1| Rect::new(x0, y0, x1, y1);+    // 10 x 20 points at 2 px per point, two pixels of margin each way.+    assert_eq!(pixel_region(r(10.0, 20.0, 20.0, 40.0), 2.0, (1920, 1080)), Some((18, 38, 24, 44)));+    // Clamped to the frame, and nothing for a region outside it or a bad scale.+    assert_eq!(pixel_region(r(-50.0, -50.0, 5000.0, 5000.0), 1.0, (100, 50)), Some((0, 0, 100, 50)));+    assert_eq!(pixel_region(r(2000.0, 0.0, 2100.0, 10.0), 1.0, (100, 50)), None);+    assert_eq!(pixel_region(r(0.0, 0.0, 10.0, 10.0), 0.0, (100, 50)), None);+    assert_eq!(pixel_region(r(f64::NAN, 0.0, 10.0, 10.0), 1.0, (100, 50)), None);+}++/// Morph frames at `t`: the whole frame, and the pixels `part` of it drawn on their own.+fn morph_frames(p: &Presentation, t: f64, o: &RenderOpts) -> (deckcraft_anim::MorphFrame, Image) {+    let (a, b) = (&p.slides[0], &p.slides[1]);+    let m = deckcraft_anim::morph_frame(p, a, b, t, &|_| false);+    let img = {+        let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };+        let o = RenderOpts { state: Some(&f), ..crate::transition::copy_opts(o) };+        let paths: Vec<PathMorph> = vec![];+        render_blend(p, 0, 1, m.mix, &m.shapes, &[], &paths, &o)+    };+    (m, img)+}++#[test]+fn drawing_only_the_changed_region_gives_the_same_frame() {+    // Two slides with a gradient box that moves and turns, a stroked ellipse that grows, a+    // static box in their way and one that fades in: every frame of the Morph, drawn as the+    // previous frame with the changed region replaced, equals the frame drawn whole.+    let stop = |pos, r, g, b| deckcraft_model::style::GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)) };+    let grad = Some(Fill::Gradient(deckcraft_model::style::Gradient {+        stops: vec![stop(0.0, 220, 40, 40), stop(1.0, 30, 60, 200)],+        shape: deckcraft_model::style::GradientShape::Linear { angle: 30.0, scaled: false },+        rotate_with_shape: true,+        tile: [0.0; 4],+    }));+    let mover = |id, x: f64, rot, w| Shape {+        id: ShapeId(id),+        name: "Mover".into(),+        xfrm: Some(Xfrm { rot, ..Xfrm::new(x, 80.0, w, 120.0) }),+        fill: grad.clone(),+        ..Default::default()+    };+    let ring = |id, w: f64| Shape {+        id: ShapeId(id),+        name: "Ring".into(),+        xfrm: Some(Xfrm::new(300.0, 300.0, w, 90.0)),+        geom: Geom::Preset { name: "ellipse".into(), adj: vec![] },+        line: Some(deckcraft_model::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 10, 10)), 9.0)),+        ..Default::default()+    };+    let still = Shape {+        id: ShapeId(30),+        name: "Still".into(),+        xfrm: Some(Xfrm::new(400.0, 120.0, 120.0, 60.0)),+        fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(40, 160, 70)))),+        ..Default::default()+    };+    let late = Shape { id: ShapeId(40), name: "Late".into(), xfrm: Some(Xfrm::new(700.0, 400.0, 100.0, 60.0)), ..Default::default() };+    let mut p = deck_with(vec![mover(10, 40.0, 0.0, 160.0), ring(11, 100.0), still.clone()]);+    let mut next = (*p.slides[0]).clone();+    next.shapes = vec![mover(10, 640.0, 40.0, 220.0), ring(11, 180.0), still, late];+    p.slides.push(std::sync::Arc::new(next));+    let o = RenderOpts { scale: 1.0, ..Default::default() };+    let steps: Vec<f64> = (0..=20).map(|i| i as f64 / 20.0).collect();+    let (mut prev_frame, mut shown) = morph_frames(&p, steps[0], &o);+    let mut partial = 0;+    for t in &steps[1..] {+        let (frame, whole) = morph_frames(&p, *t, &o);+        if let deckcraft_anim::MorphChange::Region(r) = frame.changed_since(&prev_frame) {+            let part = pixel_region(r, o.scale, (whole.width, whole.height)).expect("the region is on the slide");+            let f = |id: ShapeId| ShapeState { opacity: frame.opacity_of(id), ..Default::default() };+            let bits = render_blend_part(+                &p,+                (0, 1),+                frame.mix,+                &frame.shapes,+                &[],+                &[],+                part,+                &RenderOpts { state: Some(&f), ..crate::transition::copy_opts(&o) },+            );+            assert_eq!((bits.width, bits.height), (part.2, part.3));+            for y in 0..part.3 {+                let row = ((part.1 + y) as usize * shown.width as usize + part.0 as usize) * 4;+                let src = y as usize * bits.width as usize * 4;+                shown.pixels[row..row + part.2 as usize * 4].copy_from_slice(&bits.pixels[src..src + part.2 as usize * 4]);+            }+            partial += 1;+        } else {+            shown = whole.clone();+        }+        let worst = shown.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);+        assert!(worst <= 1, "t = {t}: a pixel differs by {worst}");+        prev_frame = frame;+    }+    assert!(partial >= 10, "only {partial} of the frames were drawn in part");+}+ fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {     let mut t = TextBody::from_text(text);     for p in &mut t.paragraphs {diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs--- a/crates/render/src/transition.rs+++ b/crates/render/src/transition.rs@@ -5,7 +5,7 @@ use deckcraft_anim::{Layer, Source, layer_mesh, transition_frame, uses_slide_parts}; use deckcraft_model::{Presentation, ShapeId}; -use crate::{Image, RenderOpts, ShapeState, SlidePart, render_blend, render_slide, render_slide_part};+use crate::{Image, PathMorph, Rect, RenderOpts, Shape, ShapeState, SlidePart, TextMorph, render_blend, render_slide, render_slide_part};  /// The two slides of a transition rendered as background and content, for the transitions that /// move contents over the backgrounds ([`deckcraft_anim::uses_slide_parts`]). `None` parts of the@@ -97,7 +97,42 @@ pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &Rende     composite_transition(old.as_ref(), &new, parts.as_ref(), &transition_frame(kind, option, t, aspect)) } -fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {+/// A block of pixels of a frame: left, top, width, height.+pub type PixelRect = (u32, u32, u32, u32);++/// The pixels of a frame of `size` pixels, drawn at `scale` pixels per point, that cover `region`+/// (slide points), with a margin for anti-aliasing. `None` when none of it lies in the frame.+pub fn pixel_region(region: Rect, scale: f64, size: (u32, u32)) -> Option<PixelRect> {+    const MARGIN: f64 = 2.0;+    if !(scale.is_finite() && scale > 0.0) {+        return None;+    }+    let edge = |v: f64, max: u32, up: bool| -> Option<u32> {+        let v = if up { (v * scale).ceil() + MARGIN } else { (v * scale).floor() - MARGIN };+        v.is_finite().then(|| v.clamp(0.0, max as f64) as u32)+    };+    let (x0, y0) = (edge(region.x0, size.0, false)?, edge(region.y0, size.1, false)?);+    let (x1, y1) = (edge(region.x1, size.0, true)?, edge(region.y1, size.1, true)?);+    (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))+}++/// [`render_blend`] drawing only the block `part` of the frame (see [`pixel_region`]): the picture+/// returned is `part`'s size and holds the pixels the whole frame has there.+pub fn render_blend_part(+    pres: &Presentation,+    (old, new): (usize, usize),+    mix: f64,+    shapes: &[(Shape, bool)],+    text: &[TextMorph],+    paths: &[PathMorph],+    part: PixelRect,+    opts: &RenderOpts,+) -> Image {+    let o = RenderOpts { offset: (opts.offset.0 - part.0 as f64, opts.offset.1 - part.1 as f64), size: Some((part.2, part.3)), ..copy_opts(opts) };+    render_blend(pres, old, new, mix, shapes, text, paths, &o)+}++pub(crate) fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {     RenderOpts {         scale: o.scale,         edit: o.edit,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@@ -262,11 +262,75 @@ fn start_prefetch(doc: &std::sync::Arc<Presentation>, next: usize, size: (u32, u     spawned.ok().map(|_| Prefetch { key, rx }) } +/// Draw Morph frame `m` (from slide `from` to slide `to`) into the transition's texture. A frame+/// that differs from the last one only where a few plain shapes moved is drawn and uploaded for+/// that region alone; anything else redraws the whole frame.+fn draw_morph_frame(+    ctx: &egui::Context,+    doc: &Presentation,+    (from, to): (usize, usize),+    m: deckcraft_anim::MorphFrame,+    size: (u32, u32),+    tr: &mut Transition,+) {+    let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };+    let opts = RenderOpts {+        scale: size.0 as f64 / doc.slide_size.width.max(1.0),+        size: Some(size),+        state: Some(&f),+        threads: render_threads(),+        ..Default::default()+    };+    let text: Vec<deckcraft_render::TextMorph> =+        m.text.iter().map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect();+    let paths: Vec<deckcraft_render::PathMorph> = m+        .paths+        .iter()+        .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })+        .collect();+    // A shadow, glow or soft edge reaches into a block from a shape that did not change, and a+    // block drawn alone has not got that shape's pixels to blur: such slides draw whole frames.+    let filtered =+        *tr.filtered.get_or_insert_with(|| [from, to].iter().any(|i| doc.slides.get(*i).is_some_and(|s| deckcraft_render::needs_filters(doc, s))));+    let change = match (&tr.morph, &tr.last_morph) {+        (Some(_), Some((last, last_size))) if *last_size == size && !filtered => m.changed_since(last),+        _ => deckcraft_anim::MorphChange::Everything,+    };+    // Some(None): nothing to draw; Some(Some(block)): draw that block; None: the whole frame.+    let part = match change {+        deckcraft_anim::MorphChange::Nothing => Some(None),+        deckcraft_anim::MorphChange::Region(r) => Some(deckcraft_render::pixel_region(r, opts.scale, size)),+        deckcraft_anim::MorphChange::Everything => None,+    };+    match (part, tr.morph.as_mut()) {+        (Some(None), Some(_)) => {}+        (Some(Some(block)), Some(tex)) => {+            let img = deckcraft_render::render_blend_part(doc, (from, to), m.mix, &m.shapes, &text, &paths, block, &opts);+            let ci = crate::textures::to_color_image(&img, false);+            tex.set_partial([block.0 as usize, block.1 as usize], ci, egui::TextureOptions::LINEAR);+        }+        _ => {+            let img = deckcraft_render::render_blend(doc, from, to, m.mix, &m.shapes, &text, &paths, &opts);+            let ci = crate::textures::to_color_image(&img, false);+            match tr.morph.as_mut() {+                Some(tex) => crate::textures::replace(ctx, tex, ci),+                None => tr.morph = Some(crate::textures::upload(ctx, "morph", ci)),+            }+        }+    }+    tr.last_morph = Some((m, size));+}+ struct Transition {     old: Option<TextureHandle>,     /// The slide left behind (Morph matches its shapes) and the Morph frame texture.     from: Option<usize>,     morph: Option<TextureHandle>,+    /// The Morph frame drawn into `morph` last, and its size in pixels: the next frame draws only+    /// what differs from it.+    last_morph: Option<(deckcraft_anim::MorphFrame, (u32, u32))>,+    /// Whether either slide of a Morph uses blur-based effects (decided on the first frame).+    filtered: Option<bool>,     /// Backgrounds and contents of the old and new slides, rendered once when the transition     /// moves contents over backgrounds (Pan, Ferris Wheel, Reveal…): old back, old content, new     /// back, new content.@@ -583,7 +647,7 @@ impl Show {                     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, from, morph: None, last_morph: None, filtered: None, parts: None, start: now, kind, option, dur })                 } else {                     None                 };@@ -1148,32 +1212,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {             _ => None,         };         if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) {-            let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };-            let threads =-                if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };-            let opts =-                RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() };-            let text: Vec<deckcraft_render::TextMorph> = m-                .text-                .iter()-                .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })-                .collect();-            let paths: Vec<deckcraft_render::PathMorph> = m-                .paths-                .iter()-                .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })-                .collect();-            let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts);-            let ci = crate::textures::to_color_image(&img, false);-            let frame = match tr.morph.take() {-                Some(mut h) => {-                    crate::textures::replace(&ctx, &mut h, ci);-                    h-                }-                None => crate::textures::upload(&ctx, "morph", ci),-            };-            painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);-            tr.morph = Some(frame);+            draw_morph_frame(&ctx, &doc, (from, idx), m, size, tr);+            if let Some(frame) = &tr.morph {+                painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);+            }             drew = true;             ctx.request_repaint();         } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) {@@ -2835,6 +2877,92 @@ mod tests {         assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one");     } +    /// The pieces of the textures uploaded since the last call: where each lands (`None`: the whole+    /// texture) and its size.+    fn uploads(ctx: &egui::Context) -> Vec<(Option<[usize; 2]>, [usize; 2])> {+        let mut delta = ctx.tex_manager().write().take_delta();+        // A texture is first allocated empty, then set.+        let sizes = delta.set.values().flatten().map(|d| (d.pos, d.image.size())).filter(|(_, size)| *size != [0, 0]).collect();+        delta.clear();+        sizes+    }++    /// Two slides with a box that moves across the slide, `still` shapes on both, and a Morph+    /// transition state at its start.+    fn morph_fixture(still: Vec<deckcraft_model::Shape>) -> (deckcraft_model::Presentation, Transition) {+        let mut doc = deckcraft_model::Presentation::default();+        let boxed = |x: f64| deckcraft_model::Shape {+            id: ShapeId(10),+            name: "Box".into(),+            xfrm: Some(deckcraft_model::Xfrm::new(x, 100.0, 120.0, 80.0)),+            ..Default::default()+        };+        std::sync::Arc::make_mut(&mut doc.slides[0]).shapes = [vec![boxed(40.0)], still.clone()].concat();+        let mut next = (*doc.slides[0]).clone();+        next.shapes = [vec![boxed(700.0)], still].concat();+        doc.slides.push(std::sync::Arc::new(next));+        let tr = Transition {+            old: None,+            from: Some(0),+            morph: None,+            last_morph: None,+            filtered: None,+            parts: None,+            start: 0.0,+            kind: "morph".into(),+            option: "objects".into(),+            dur: 1.0,+        };+        (doc, tr)+    }++    fn morph_frame_at(doc: &deckcraft_model::Presentation, t: f64) -> deckcraft_anim::MorphFrame {+        deckcraft_anim::morph_frame(doc, &doc.slides[0], &doc.slides[1], t, &|_| false)+    }++    #[test]+    fn a_morph_frame_draws_and_uploads_only_what_changed() {+        // A box moving across the slide is a small part of every frame (the show used to draw and+        // upload the whole picture for each).+        let (doc, mut tr) = morph_fixture(vec![]);+        let ctx = egui::Context::default();+        let full = 960 * 540;+        let area = |u: &[(Option<[usize; 2]>, [usize; 2])]| u.iter().map(|(_, [w, h])| w * h).sum::<usize>();+        // The first frame is drawn whole.+        draw_morph_frame(&ctx, &doc, (0, 1), morph_frame_at(&doc, 0.2), (960, 540), &mut tr);+        assert_eq!(uploads(&ctx), [(None, [960, 540])]);+        // The next one only where the box was and where it is.+        draw_morph_frame(&ctx, &doc, (0, 1), morph_frame_at(&doc, 0.3), (960, 540), &mut tr);+        let patch = uploads(&ctx);+        assert!(matches!(patch.as_slice(), [(Some(_), _)]), "{patch:?}");+        assert!(area(&patch) * 5 < full, "{patch:?}");+        // The same frame again changes nothing.+        draw_morph_frame(&ctx, &doc, (0, 1), morph_frame_at(&doc, 0.3), (960, 540), &mut tr);+        assert!(uploads(&ctx).is_empty());+        // A window that changed size starts over.+        draw_morph_frame(&ctx, &doc, (0, 1), morph_frame_at(&doc, 0.4), (480, 270), &mut tr);+        assert_eq!(uploads(&ctx), [(None, [480, 270])]);+    }++    #[test]+    fn a_morph_over_a_blurred_effect_draws_whole_frames() {+        // A soft-edged shape that does not move still blurs into the block the box moves through:+        // a block drawn alone cannot know its pixels, so every frame is drawn whole.+        let soft = deckcraft_model::Shape {+            id: ShapeId(20),+            name: "Soft".into(),+            xfrm: Some(deckcraft_model::Xfrm::new(300.0, 100.0, 120.0, 80.0)),+            effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }),+            ..Default::default()+        };+        let (doc, mut tr) = morph_fixture(vec![soft]);+        let ctx = egui::Context::default();+        for t in [0.2, 0.3, 0.4] {+            draw_morph_frame(&ctx, &doc, (0, 1), morph_frame_at(&doc, t), (960, 540), &mut tr);+            assert_eq!(uploads(&ctx), [(None, [960, 540])], "t = {t}");+        }+    }+     #[test]     fn media_in_the_click_sequence_plays_with_its_step() {         // #116: media set to play in the click sequence never started; only a click on it did.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:** trivial (Morph frames draw only what changed (#1004); 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)@@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | trivial | Morph plays smoother (#1004): a group that is the same on both slides moves instead of cross-fading two copies, and a frame draws and uploads only the region where plain shapes moved. No change to scores | | 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/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:** trivial (Morph frames draw only what changed (#1004); 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@@ -351,7 +351,11 @@ Detail in [localization-parity.md](localization-parity.md).   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.-- **Morph aligns outlines every frame** (#67, PR open). 1 h.+- **Morph still rasterises on the CPU every frame** (#1004). A frame that differs from the one+  before only where plain shapes (preset geometry, no text or effects) moved is now drawn and+  uploaded for that region alone; text, effects (and any slide with a shadow, glow or soft edge),+  groups that change, outline morphs and backdrops that differ still redraw the whole frame, and a composited (GPU) Morph like the one+  slides with animations got is not there yet. 4–8 h.  ## Ecosystem