From fa9a94a1c02626bfd458d57b0c18d510b1522e7c Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:32:41 +0200 Subject: [PATCH] Morph: shapes move, resize and blend; Words and Characters options morph text (#5) * Morph: shapes move, resize and blend in the slide show Morph used to fall back to a plain crossfade in the show: morph_pairs and morph_xfrm existed, but nothing rendered frames with them. - anim: morph_frame builds a frame from two slides. Paired shapes move along morph_xfrm (eased); a pure fill colour change blends, other content changes cross-fade on the same moving box. Unpaired shapes fade out/in, shapes with entrance animations or hidden media stay out of the frame. - render: render_blend draws such a frame in one pass: both backdrops (background, master and layout graphics) cross-fade, every shape resolves against the slide it comes from, so placeholders keep their own layout. Renderer::slide is split into canvas/fields/backdrop to share the code. - ui-egui: the show renders morph frames with these, crossfade otherwise. generated with Claude Code * Contributors: add my own entry (alexanderalber) generated with Claude Code * Morph: Words and Characters options morph text token by token The transition option (objects/words/characters) was stored and round-tripped but ignored by the show. With Words or Characters, paired shapes whose text differs now morph their text instead of cross-fading it: - text: GlyphRun records per glyph the paragraph character it draws, so glyphs can be grouped into words or characters. - anim: morph_frame reads the option and lists such pairs in MorphFrame::text. - render: morph_text draws them. Shared words (or characters) travel from their place in the old text to their place in the new one and grow or shrink to the new size; a different look cross-fades on the way. The remaining tokens fade while following a scale and shift fitted to the shared ones, so a line moves and rearranges as one. Rotated text falls back to a cross-fade. draw_layout_with's glyph drawing moved into draw_glyph for reuse. - ui-egui: the show passes the pairs to render_blend. generated with Claude Code --------- Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/anim/src/lib.rs | 5 +- crates/anim/src/morph.rs | 135 ++++++++++++++++++- crates/anim/src/tests.rs | 73 ++++++++++ crates/anim/src/transition.rs | 2 +- crates/render/src/lib.rs | 190 +++++++++++++++++++------- crates/render/src/morph_text.rs | 227 ++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 83 ++++++++++++ crates/text/src/lib.rs | 5 + crates/ui-egui/src/show.rs | 48 ++++++- 10 files changed, 715 insertions(+), 56 deletions(-) create mode 100644 crates/render/src/morph_text.rs diff --git a/contributors/people.toml b/contributors/people.toml index fd3d0317..a8084abe 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -22,6 +22,9 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.alexanderalber] +real_name = "Alexander Alber" + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index 5452ff15..cf5627b7 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -3,7 +3,8 @@ //! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and //! per-paragraph) [`AnimState`] at any point of the show. //! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images. -//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation. +//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by +//! [`morph_xfrm`]). //! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings). //! //! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or @@ -26,7 +27,7 @@ pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; -pub use morph::{morph_pairs, morph_xfrm}; +pub use morph::{MorphFrame, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{ShowAction, ShowState, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index ddbde9c5..7fd1e054 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -1,8 +1,10 @@ -//! Morph: matching shapes between two slides and interpolating their boxes. +//! Morph: matching shapes between two slides, interpolating their boxes and building the frames. -use deckcraft_model::{Shape, ShapeId, Slide, Xfrm}; +use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::{ColorRef, Fill, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk}; use crate::clampf; +use crate::easing::smooth; fn text_of(s: &Shape) -> String { s.text.as_ref().map(|t| t.text()).unwrap_or_default() @@ -91,3 +93,132 @@ pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm { flip_v: if late { b.flip_v } else { a.flip_v }, } } + +/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the +/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top. +#[derive(Clone, Debug, Default)] +pub struct MorphFrame { + /// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves + /// its placeholder, theme and style against that slide). + pub shapes: Vec<(Shape, bool)>, + /// Opacity of the frame's shapes by id; shapes not listed are opaque. + pub opacity: Vec<(ShapeId, f64)>, + pub mix: f64, + /// With the Words or Characters option: pairs of frame shapes whose texts morph by words or + /// characters instead of cross-fading (the renderer's `TextMorph`). + pub text: Vec, +} + +/// Two frame shapes whose texts morph by words or characters. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MorphText { + /// Frame shape ids: from the old slide, from the new slide. + pub old: ShapeId, + pub new: ShapeId, + /// The boxes of the two shapes on their own slides. + pub from: Xfrm, + pub to: Xfrm, + pub chars: bool, +} + +impl MorphFrame { + /// Opacity of shape `id` of the frame. + pub fn opacity_of(&self, id: ShapeId) -> f64 { + self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0) + } +} + +fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba { + let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8; + Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) } +} + +fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option { + let ctx = ctx?; + match resolve::fill(ctx, s) { + (Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)), + _ => None, + } +} + +fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option { + match ctx { + Some(c) => Some(resolve::xfrm(c, s)), + None => s.xfrm, + } +} + +/// 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() } +} + +/// 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, otherwise the new shape fades in under the fading old one on the same moving +/// box; with the transition option `words` or `characters` their texts are listed in +/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for +/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame. +pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame { + let s = smooth(clampf(t, 0.0, 1.0, 0.0)); + let ca = Ctx::for_slide(pres, a); + let cb = Ctx::for_slide(pres, b); + let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect(); + 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 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(_)); + let mut text = Vec::new(); + let mut push = |mut sh: Shape, old: bool, op: f64| { + let id = ShapeId(next); + sh.id = id; + next = next.saturating_add(1); + if op < 1.0 { + f.opacity.push((id, op)); + } + f.shapes.push((sh, old)); + id + }; + for sa in &a.shapes { + if !pairs.iter().any(|(ia, _)| *ia == sa.id) { + push(sa.clone(), true, 1.0 - s); + } + } + for sb in &b.shapes { + let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia)); + let Some(sa) = pair else { + if !hidden_b(sb.id) { + push(sb.clone(), false, s); + } + continue; + }; + let x = match (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) { + (Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)), + (_, xb) => xb, + }; + if content(sa) == content(sb) { + let mut m = Shape { xfrm: x, ..sb.clone() }; + if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb)) + && fa != fb + { + m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s)))); + } + push(m, false, 1.0); + } else { + let new = push(Shape { xfrm: x, ..sb.clone() }, false, s); + let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s); + if by_text + && has_text(sa) + && has_text(sb) + && let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) + { + text.push(MorphText { old, new, from, to, chars }); + } + } + } + f.text = text; + f +} diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 4a7b3693..005c69ff 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -543,6 +543,79 @@ fn morph_xfrm_interpolates() { assert!(m.rot.is_finite()); } +fn solid(s: Shape, r: u8) -> Shape { + Shape { fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r, g: 0, b: 0, a: 255 }))), ..s } +} + +#[test] +fn morph_frame_moves_blends_and_fades() { + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + let moved = |id, x| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..shape(id, "Box") }; + a.shapes = vec![solid(moved(1, 0.0), 0), shape(2, "Gone")]; + let new = Shape { text: Some(TextBody::from_text("Hi")), ..shape(3, "New") }; + b.shapes = vec![solid(moved(1, 400.0), 200), new, shape(4, "Later")]; + let f = morph_frame(&p, &a, &b, 0.5, &|id| id == ShapeId(4)); + assert!((f.mix - 0.5).abs() < 1e-9); + // Gone fades out, Box moves halfway and blends red, New fades in, Later stays out. + let names: Vec<(&str, bool)> = f.shapes.iter().map(|(s, old)| (s.name.as_str(), *old)).collect(); + assert_eq!(names, [("Gone", true), ("Box", false), ("New", false)]); + let m = &f.shapes[1].0; + assert!((m.xfrm.unwrap().x - 200.0).abs() < 1e-9); + assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 })))); + assert_eq!(f.opacity_of(m.id), 1.0); + assert!((f.opacity_of(f.shapes[0].0.id) - 0.5).abs() < 1e-9); + assert!((f.opacity_of(f.shapes[2].0.id) - 0.5).abs() < 1e-9); + // Ids are fresh and unique. + let mut ids: Vec<_> = f.shapes.iter().map(|(s, _)| s.id).collect(); + ids.dedup(); + assert_eq!(ids.len(), 3); + assert!(ids.iter().all(|i| i.0 > 4)); + // At the start only the old slide shows. + let f0 = morph_frame(&p, &a, &b, 0.0, &|id| id == ShapeId(4)); + assert!(f0.shapes.iter().filter(|(s, _)| f0.opacity_of(s.id) > 0.0).all(|(s, _)| s.name != "New")); + assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0); +} + +#[test] +fn morph_frame_cross_fades_changed_content() { + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![Shape { text: Some(TextBody::from_text("Before")), ..shape(1, "T") }]; + b.shapes = vec![Shape { text: Some(TextBody::from_text("After")), ..shape(1, "T") }]; + let f = morph_frame(&p, &a, &b, 0.25, &|_| false); + // The new text under the fading old one, both on the moving box. + assert_eq!(f.shapes.len(), 2); + assert_eq!((&f.shapes[0].0.text, f.shapes[0].1), (&b.shapes[0].text, false)); + assert_eq!((&f.shapes[1].0.text, f.shapes[1].1), (&a.shapes[0].text, true)); + let s = f.mix; + assert!((f.opacity_of(f.shapes[0].0.id) - s).abs() < 1e-9); + assert!((f.opacity_of(f.shapes[1].0.id) - (1.0 - s)).abs() < 1e-9); +} + +#[test] +fn morph_frame_lists_texts_for_words_and_characters() { + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![Shape { text: Some(TextBody::from_text("The Science")), ..shape(1, "T") }]; + b.shapes = vec![Shape { text: Some(TextBody::from_text("Science rocks")), ..shape(1, "T") }]; + for (option, want) in [("objects", None), ("words", Some(false)), ("characters", Some(true))] { + b.transition = Some(Transition { kind: "morph".into(), option: option.into(), ..Default::default() }); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + assert_eq!(f.text.first().map(|m| m.chars), want, "{option}"); + if let Some(m) = f.text.first() { + assert_eq!((m.new, m.old), (f.shapes[0].0.id, f.shapes[1].0.id)); + assert_eq!(m.from, a.shapes[0].xfrm.unwrap()); + } + } + // Same text: one shape moves, nothing to morph. + b.shapes[0].text = a.shapes[0].text.clone(); + assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty()); +} + fn pres(n: usize) -> Presentation { Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() } } diff --git a/crates/anim/src/transition.rs b/crates/anim/src/transition.rs index 876c88fe..02892462 100644 --- a/crates/anim/src/transition.rs +++ b/crates/anim/src/transition.rs @@ -199,7 +199,7 @@ fn scale_rect(r: R, s: f64) -> R { /// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at /// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone; /// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph -/// frames with [`crate::morph_pairs`]). +/// frames with [`crate::morph_frame`]). pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec { let t = clampf(t, 0.0, 1.0, 0.0); let v = if t >= 1.0 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1d0edb09..3f3ed9de 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ mod chart; mod images; +mod morph_text; mod paint; mod placed; mod table; @@ -29,6 +30,7 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; pub use images::decode as decode_image; +pub use morph_text::TextMorph; pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). @@ -241,6 +243,26 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima } } +/// 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`: +/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or +/// characters ([`TextMorph`]), on top of the later shape of the pair. `opts.state` applies to +/// `shapes` only. +pub fn render_blend(pres: &Presentation, old: usize, new: usize, mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], opts: &RenderOpts) -> Image { + let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, opts)) + })) { + Ok(img) => img, + Err(_) => { + log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + render_slide(pres, new, opts) + } + } +} + /// Render a layout (Slide Master view thumbnail and canvas). pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) { @@ -371,46 +393,56 @@ struct Frame<'a> { } impl Renderer { - fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { + fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { let (w, h) = output_size(pres, opts); let mut ctx = RenderContext::new_with( w, h, - vello_cpu::RenderSettings { - num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads }, - ..Default::default() - }, + vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() }, ); if let Some(c) = opts.clear { ctx.set_paint(color(c, 1.0)); ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); } let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); - let fields = SlideFields { + (ctx, w, h, view) + } + + fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields { + SlideFields { num: pres.slide_number(index), date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()), footer: pres.header_footer.footer_text.clone(), - }; - let f = Frame { pres, opts, fields }; + } + } + + /// Background plus the master and layout graphics the slide shows. + fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { + self.background(ctx, f, rctx, Some(slide), view); + let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); + if show_master { + let mctx = Ctx::for_master(f.pres, rctx.master); + for s in &rctx.master.shapes { + if s.ph.is_none() { + self.shape(ctx, f, &mctx, s, view, 0); + } + } + } + if show_layout && let Some(l) = rctx.layout { + let lctx = Ctx::for_layout(f.pres, rctx.master, l); + for s in &l.shapes { + if s.ph.is_none() { + self.shape(ctx, f, &lctx, s, view, 0); + } + } + } + } + + fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, 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) { - self.background(&mut ctx, &f, &rctx, Some(slide), view); - let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); - if show_master { - let mctx = Ctx::for_master(pres, rctx.master); - for s in &rctx.master.shapes { - if s.ph.is_none() { - self.shape(&mut ctx, &f, &mctx, s, view, 0); - } - } - } - if show_layout && let Some(l) = rctx.layout { - let lctx = Ctx::for_layout(pres, rctx.master, l); - for s in &l.shapes { - if s.ph.is_none() { - self.shape(&mut ctx, &f, &lctx, s, view, 0); - } - } - } + self.backdrop(&mut ctx, &f, &rctx, slide, view); for s in &slide.shapes { self.shape(&mut ctx, &f, &rctx, s, view, 0); } @@ -418,6 +450,60 @@ impl Renderer { self.finish(ctx, w, h) } + #[allow(clippy::too_many_arguments)] + fn blend( + &mut self, + pres: &Presentation, + old: (&Slide, usize), + new: (&Slide, usize), + mix: f64, + shapes: &[(Shape, bool)], + text: &[TextMorph], + opts: &RenderOpts, + ) -> Image { + let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); + let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; + // Backdrop graphics never take shape states: their ids may clash with the frame's shapes. + let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts }; + if mix < 1.0 { + let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) }; + self.backdrop(&mut ctx, &f, &co, old.0, view); + } + if mix > 0.0 { + let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) }; + let layered = mix < 1.0; + if layered { + ctx.push_layer(None, None, Some(mix as f32), None, None); + } + self.backdrop(&mut ctx, &f, &cn, new.0, view); + if layered { + ctx.pop_layer(); + } + } + let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) }; + let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) }; + let mut drawn: Vec = Vec::new(); + for (s, from_old) in shapes { + let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) }; + let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else { + self.shape(&mut ctx, f, c, s, view, 0); + continue; + }; + self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0); + drawn.push(s.id); + if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) { + continue; + } + let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id); + if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) { + let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b)); + morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view); + } + } + self.finish(ctx, w, h) + } + fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { let (w, h) = output_size(pres, opts); let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); @@ -971,30 +1057,8 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, continue; } let m = m * Affine::translate(ps.offset); - let alpha = run.alpha * ps.opacity; - let k = run.size / run.face.upem.max(1.0); - ctx.set_paint(color(run.color, alpha)); for (gid, x, y) in &run.glyphs { - let path = glyph(&run.face, *gid); - if path.elements().is_empty() { - continue; - } - let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k); - ctx.set_transform(gm); - if alpha > 0.0 && run.color.a > 0 { - ctx.fill_path(&path); - if run.fake_bold { - ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - } - } - if let Some((oc, ow)) = run.outline { - ctx.set_paint(color(oc, ps.opacity)); - ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - ctx.set_paint(color(run.color, alpha)); - } + draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity); } } for d in l.decos.iter().filter(|d| !d.behind) { @@ -1002,6 +1066,34 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, } } +/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. +pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { + let alpha = run.alpha * opacity; + if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + return; + } + let path = glyph(&run.face, gid); + if path.elements().is_empty() { + return; + } + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + ctx.set_transform(at * skew * Affine::scale(k)); + if alpha > 0.0 && run.color.a > 0 { + ctx.set_paint(color(run.color, alpha)); + ctx.fill_path(&path); + if run.fake_bold { + ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + if let Some((oc, ow)) = run.outline { + ctx.set_paint(color(oc, opacity)); + ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } +} + /// Render one shape alone onto a transparent canvas (drag previews, copy as picture). pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { let rctx = Ctx::for_slide(pres, slide)?; diff --git a/crates/render/src/morph_text.rs b/crates/render/src/morph_text.rs new file mode 100644 index 00000000..995435f7 --- /dev/null +++ b/crates/render/src/morph_text.rs @@ -0,0 +1,227 @@ +//! Morph by words or characters: the words (or characters) two texts share travel from their place +//! in the old text to their place in the new one, growing or shrinking to the new size; the rest +//! of the old text fades out and the rest of the new one fades in, both riding the moving box. + +use deckcraft_geom::Xfrm; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::{Shape, ShapeId}; +use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout}; +use kurbo::{Affine, Point, Vec2}; +use vello_cpu::RenderContext; + +use crate::{color, draw_glyph, draw_layout, shape_geometry}; + +/// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextMorph { + /// The frame shape from the old slide and the one from the new slide. + pub old: ShapeId, + pub new: ShapeId, + /// Their boxes on their own slides: where the shared words start and end. + pub from: Xfrm, + pub to: Xfrm, + /// Characters instead of words. + pub chars: bool, + /// Progress, 0..1 (already eased). + pub t: f64, +} + +/// A word or character: its text, its glyphs (run, glyph) and the origin of its first glyph. +struct Token { + key: String, + glyphs: Vec<(usize, usize)>, + origin: Point, + run: usize, +} + +/// Words (or characters) of a layout in reading order, plus glyphs that belong to none (bullets). +fn tokens(l: &TextLayout, chars: bool) -> (Vec, Vec<(usize, usize)>) { + let mut cells: Vec<(usize, usize, char, usize, usize)> = Vec::new(); + let mut loose = Vec::new(); + for (ri, run) in l.runs.iter().enumerate() { + for gi in 0..run.glyphs.len() { + match run.cells.get(gi) { + Some((ci, ch)) => cells.push((run.para, *ci, *ch, ri, gi)), + None => loose.push((ri, gi)), + } + } + } + cells.sort_by_key(|c| (c.0, c.1, c.3, c.4)); + let mut out: Vec = Vec::new(); + let mut last: Option<(usize, usize)> = None; + for (para, ci, ch, ri, gi) in cells { + if ch.is_whitespace() { + last = None; + continue; + } + let same_char = last == Some((para, ci)); + let next_char = last.is_some_and(|(p, c)| p == para && c.checked_add(1) == Some(ci)); + let extend = same_char || (!chars && next_char); + match out.last_mut() { + Some(tok) if extend => { + if !same_char { + tok.key.push(ch); + } + tok.glyphs.push((ri, gi)); + } + _ => { + let origin = l.runs.get(ri).and_then(|r| r.glyphs.get(gi)).map(|g| Point::new(g.1, g.2)).unwrap_or(Point::ZERO); + out.push(Token { key: ch.to_string(), glyphs: vec![(ri, gi)], origin, run: ri }); + } + } + last = Some((para, ci)); + } + (out, loose) +} + +/// For each token of `b`, the token of `a` with the same text, in reading order, each used once. +fn matches(a: &[Token], b: &[Token]) -> Vec> { + let mut used = vec![false; a.len()]; + b.iter() + .map(|tb| { + let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?; + if let Some(u) = used.get_mut(i) { + *u = true; + } + Some(i) + }) + .collect() +} + +fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option { + let body = s.text.as_ref().filter(|b| !b.is_empty())?; + let rect = shape_geometry(s, x.w, x.h).text_rect; + Some(deckcraft_text::layout(ctx, s, body, &Opts { rect, fields, prompt_color: None, no_shrink: false })) +} + +fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, Point)> { + let run = l.runs.get(ri)?; + let g = run.glyphs.get(gi)?; + Some((run, g.0, Point::new(g.1, g.2))) +} + +fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool { + a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +/// Draw the morphing text of `a` (old slide, `ca`) and `b` (new slide, `cb`); `now` is the box both +/// frame shapes share at this moment, `view` the slide-to-pixels transform. +pub(crate) fn draw( + ctx: &mut RenderContext, + (ca, a, fa): (&Ctx, &Shape, &dyn Fields), + (cb, b, fb): (&Ctx, &Shape, &dyn Fields), + m: &TextMorph, + now: Xfrm, + view: Affine, +) { + let t = if m.t.is_finite() { m.t.clamp(0.0, 1.0) } else { 1.0 }; + let la = layout(ca, a, m.from, fa); + let lb = layout(cb, b, m.to, fb); + let mt = view * now.affine(); + // Rotated (vertical) text: no per-word travel, the two texts cross-fade on the moving box. + let rotated = la.as_ref().is_some_and(|l| l.rotation != 0.0) || lb.as_ref().is_some_and(|l| l.rotation != 0.0); + if rotated || la.is_none() || lb.is_none() { + for (l, s, alpha, x) in [(la, a, 1.0 - t, m.from), (lb, b, t, m.to)] { + if let Some(l) = l + && alpha > 0.001 + { + ctx.push_layer(None, None, Some(alpha as f32), None, None); + draw_layout(ctx, &l, shape_geometry(s, x.w, x.h).text_rect, mt); + ctx.pop_layer(); + } + } + return; + } + let (Some(la), Some(lb)) = (la, lb) else { return }; + let (ta, loose_a) = tokens(&la, m.chars); + let (tb, loose_b) = tokens(&lb, m.chars); + let pairs = matches(&ta, &tb); + let (ma, mb) = (view * m.from.affine(), view * m.to.affine()); + let c = mt.as_coeffs(); + let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]); + let mut moved_a = vec![false; ta.len()]; + // Shared tokens travel. + for (j, i) in pairs.iter().enumerate() { + let (Some(i), Some(tok_b)) = (*i, tb.get(j)) else { continue }; + let Some(tok_a) = ta.get(i) else { continue }; + if let Some(f) = moved_a.get_mut(i) { + *f = true; + } + let (Some(ra), Some(rb)) = (la.runs.get(tok_a.run), lb.runs.get(tok_b.run)) else { continue }; + let p = (ma * tok_a.origin).lerp(mb * tok_b.origin, t); + let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 }; + let same = same_look(ra, rb); + let alpha_a = if same { 1.0 } else { 1.0 - t }; + let sa = lerp(1.0, ratio, t); + for &g in &tok_a.glyphs { + if let Some((run, gid, pos)) = glyph_at(&la, g) { + let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sa) * Affine::translate(pos - tok_a.origin); + draw_glyph(ctx, run, gid, at, alpha_a); + } + } + if !same { + let sb = lerp(1.0 / ratio, 1.0, t); + for &g in &tok_b.glyphs { + if let Some((run, gid, pos)) = glyph_at(&lb, g) { + let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sb) * Affine::translate(pos - tok_b.origin); + draw_glyph(ctx, run, gid, at, t); + } + } + } + } + // The rest fades while following the shared tokens: they define a scale and shift from the old + // text to the new one (F(p) = s·p + c, in pixels), so a line grows, shrinks and rearranges as + // one. With nothing shared it rides the moving box. + let shared: Vec<(Point, Point, f64)> = pairs + .iter() + .enumerate() + .filter_map(|(j, i)| { + let (tok_a, tok_b) = (ta.get((*i)?)?, tb.get(j)?); + let (ra, rb) = (la.runs.get(tok_a.run)?, lb.runs.get(tok_b.run)?); + let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 }; + Some((ma * tok_a.origin, mb * tok_b.origin, ratio)) + }) + .collect(); + let n = shared.len() as f64; + let s = shared.iter().map(|x| x.2).sum::() / n; + let c = shared.iter().fold(Vec2::ZERO, |acc, (pa, pb, _)| acc + (pb.to_vec2() - pa.to_vec2() * s)) / n; + let (place_a, place_b) = if !shared.is_empty() && s.is_finite() && s > 0.0 && c.is_finite() { + // Old text: p ↦ lerp(p, F(p), t). New text: q ↦ lerp(F⁻¹(q), q, t). + let a = Affine::translate(c * t) * Affine::scale(lerp(1.0, s, t)); + let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t); + (a * ma, b * mb) + } else { + (mt, mt) + }; + let unmatched_a = + ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied()); + for g in unmatched_a.chain(loose_a) { + if let Some((run, gid, pos)) = glyph_at(&la, g) { + draw_glyph(ctx, run, gid, place_a * Affine::translate(pos.to_vec2()), 1.0 - t); + } + } + let unmatched_b = + tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied()); + for g in unmatched_b.chain(loose_b) { + if let Some((run, gid, pos)) = glyph_at(&lb, g) { + draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t); + } + } + // Underlines, strikethroughs and highlights fade with their text. + for (l, alpha, place) in [(&la, 1.0 - t, place_a), (&lb, t, place_b)] { + for d in &l.decos { + ctx.set_transform(place); + ctx.set_paint(color(d.color, alpha)); + ctx.fill_rect(&d.rect); + } + } +} + +#[cfg(test)] +pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec { + tokens(l, chars).0.into_iter().map(|t| t.key).collect() +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 2a2074db..bdabbd59 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -157,3 +157,86 @@ fn custom_path_parse() { assert!(p.elements().len() >= 5); assert!(parse_path("").elements().is_empty()); } + +#[test] +fn blend_ends_match_the_slides_and_mixes_backdrops() { + let sh = Shape { + id: ShapeId(500), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let mut p = deck_with(vec![sh.clone()]); + let mut dark = (*p.slides[0]).clone(); + dark.background = + Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) }); + p.slides.push(std::sync::Arc::new(dark)); + let o = RenderOpts { scale: 0.5, ..Default::default() }; + let shapes = vec![(sh, false)]; + // At the ends the frame is the slide itself; the shape stays opaque throughout. + assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &o).pixels, render_slide(&p, 1, &o).pixels); + let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &o); + let px = mid.pixel(5, 5); + assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}"); + assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75)); + // Hostile input: bad indices and NaN never panic. + assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &o).width, 0); + let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &o); +} + +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 { + for r in &mut p.runs { + r.props.size = Some(40.0); + } + } + Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() } +} + +#[test] +fn morph_text_tokens_are_words_or_characters() { + let p = Presentation::default(); + let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap(); + let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0); + let l = deckcraft_text::layout( + &rctx, + &s, + s.text.as_ref().unwrap(), + &Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]); + assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello"); + assert_eq!(morph_text::token_keys(&l, true).len(), 18); +} + +#[test] +fn morph_text_ends_match_the_slides() { + let from = Xfrm::new(100.0, 60.0, 600.0, 120.0); + let to = Xfrm::new(200.0, 300.0, 600.0, 120.0); + let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]); + let mut b = (*p.slides[0]).clone(); + b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)]; + p.slides.push(std::sync::Arc::new(b)); + let o = RenderOpts { scale: 0.5, ..Default::default() }; + let frame = |t: f64| { + let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0); + let mut sb = text_shape(10, "world says Hello", 0.0, 0.0); + let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0); + sb.xfrm = Some(x); + sa.xfrm = Some(x); + let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t }; + render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &o) + }; + // Warm up: faces load lazily (a background scan runs), so the first render may still fall back. + let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5)); + let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count(); + let d0 = close(&frame(0.0), &render_slide(&p, 0, &o)); + assert!(d0 < 40, "start differs {d0}"); + let d1 = close(&frame(1.0), &render_slide(&p, 1, &o)); + assert!(d1 < 40, "end differs {d1}"); + // Halfway the shared words are on their way: ink between the two texts' rows. + let mid = frame(0.5); + let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count(); + assert!(ink > 50, "ink {ink}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 5f49f2fc..8c5ed32d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -26,6 +26,8 @@ pub struct GlyphRun { pub color: Rgba, /// Glyph id and pen position of its origin (baseline), in shape-local points. pub glyphs: Vec<(u32, f64, f64)>, + /// Per glyph: the paragraph character it draws (index, char); empty for bullets. + pub cells: Vec<(usize, char)>, /// Draw a heavier stroke because the family has no bold face. pub fake_bold: bool, /// Slant because the family has no italic face. @@ -849,6 +851,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts .into_iter() .map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy)) .collect(), + cells: vec![], fake_bold: false, fake_italic: false, outline: None, @@ -881,6 +884,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts size, color: st.color, glyphs: vec![], + cells: vec![], fake_bold: st.fake_bold, fake_italic: st.fake_italic, outline: st.outline, @@ -897,6 +901,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts for (gid, gx, gy) in &cell.glyphs { let k = if st.baseline != 0.0 { 0.66 } else { 1.0 }; r.glyphs.push((*gid, x + gx * k, base_y + gy)); + r.cells.push((*ci, cell.ch)); } } let adv = line.caret_x.get(ci - line.start + 1).copied().unwrap_or(x) - line.caret_x.get(ci - line.start).copied().unwrap_or(x); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 7d69dfbf..1335f03f 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -51,6 +51,9 @@ struct Key { struct Transition { old: Option, + /// The slide left behind (Morph matches its shapes) and the Morph frame texture. + from: Option, + morph: Option, start: f64, kind: String, option: String, @@ -119,8 +122,9 @@ impl Show { .map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0)) .unwrap_or(("none".into(), String::new(), 0.0)); let old = self.tex.as_ref().map(|(_, t)| t.clone()); + let from = self.tex.as_ref().map(|(k, _)| k.slide); self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { - Some(Transition { old, start: now, kind, option, dur }) + Some(Transition { old, from, morph: None, start: now, kind, option, dur }) } else { None }; @@ -389,7 +393,47 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Some(tex) }; let mut drew = false; - if let (Some(tr), Some(new)) = (&show.trans, &tex) { + 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 }; + let morph = match (&show.trans, tl) { + (Some(tr), Some(tl)) => { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + let hidden_b = |id: ShapeId| { + let st = state_for(&tl, id, 0, 0.0); + !st.visible || st.opacity <= 0.0 || hidden.contains(&id) + }; + tr.from + .and_then(|i| doc.slides.get(i).map(|a| (i, a))) + .zip(doc.slides.get(idx)) + .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b))) + } + _ => 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 = 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 img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &opts); + let ci = crate::textures::to_color_image(&img, false); + let frame = match tr.morph.take() { + Some(mut h) => { + h.set(ci, egui::TextureOptions::LINEAR); + h + } + None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR), + }; + 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); + drew = true; + ctx.request_repaint(); + } else if let (Some(tr), Some(new)) = (&show.trans, &tex) { let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); if p < 1.0 { for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {