//! Slide transition frames without a GPU: the layers of [`deckcraft_anim::transition_layers`] //! composited on the CPU from two rendered slide images. The slide show draws the same layers with //! the UI toolkit's meshes; this is the headless twin used by the CLI, comparisons and tests. 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}; /// 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 /// old slide (the show's first slide) are black and nothing. #[derive(Clone, Debug, Default)] pub struct SlideParts { pub old_back: Option, pub old_content: Option, pub new_back: Image, pub new_content: Image, } impl SlideParts { /// Render the parts of slides `old` and `new`. pub fn render(pres: &Presentation, old: Option, new: usize, opts: &RenderOpts) -> SlideParts { SlideParts { old_back: old.map(|i| render_slide_part(pres, i, SlidePart::Backdrop, opts)), old_content: old.map(|i| render_slide_part(pres, i, SlidePart::Content, opts)), new_back: render_slide_part(pres, new, SlidePart::Backdrop, opts), new_content: render_slide_part(pres, new, SlidePart::Content, opts), } } } /// Composite transition `layers` over black into an image the size of `new`. `old` is the outgoing /// slide (`None`: black, as when the show starts); `parts` the slides' backgrounds and contents /// (without them, a background draws its whole slide and content draws nothing). pub fn composite_transition(old: Option<&Image>, new: &Image, parts: Option<&SlideParts>, layers: &[Layer]) -> Image { let (w, h) = (new.width, new.height); let n = (w as usize).saturating_mul(h as usize); let mut out = Image { width: w, height: h, pixels: [0u8, 0, 0, 255].repeat(n) }; for l in layers { let src = match (l.source.is_part(), parts) { (true, None) => match l.source.whole() { Some(s) => s, None => continue, }, _ => l.source, }; let tex = match (src, parts) { (Source::Old, _) => old.map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])), (Source::New, _) => Tex::Image(new), (Source::Black, _) => Tex::Solid([0.0, 0.0, 0.0]), (Source::White, _) => Tex::Solid([255.0, 255.0, 255.0]), (Source::OldBack, Some(p)) => p.old_back.as_ref().map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])), (Source::NewBack, Some(p)) => Tex::Image(&p.new_back), (Source::OldContent, Some(p)) => match &p.old_content { Some(i) => Tex::Image(i), None => continue, }, (Source::NewContent, Some(p)) => Tex::Image(&p.new_content), (_, None) => continue, }; draw_layer(&mut out, l, &tex); } out } /// The frame of slide `index`'s transition at progress `t` (0..1): the previous slide (black for /// the first) going out, slide `index` coming in, at `opts`' scale and size. Morph frames are /// rendered shape by shape like the show does. pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &RenderOpts) -> Image { let Some(slide) = pres.slides.get(index) else { return Image::default() }; let (kind, option) = slide.transition.as_ref().map(|tr| (tr.kind.as_str(), tr.option.as_str())).unwrap_or(("none", "")); let prev = index.checked_sub(1).filter(|i| pres.slides.get(*i).is_some()); if kind == "morph" && let Some(from) = prev && let Some(a) = pres.slides.get(from) { let tt = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; if tt < 1.0 { let m = deckcraft_anim::morph_frame(pres, a, slide, tt, &|_| false); let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; let o = RenderOpts { state: Some(&f), ..copy_opts(opts) }; let text: Vec = m.text.iter().map(|x| crate::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect(); let paths: Vec = m .paths .iter() .map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) .collect(); return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o); } } let new = render_slide(pres, index, opts); let old = prev.map(|i| render_slide(pres, i, opts)); let parts = uses_slide_parts(kind, option).then(|| SlideParts::render(pres, prev, index, opts)); let aspect = pres.slide_size.width / pres.slide_size.height.max(1.0); composite_transition(old.as_ref(), &new, parts.as_ref(), &transition_frame(kind, option, t, aspect)) } fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> { RenderOpts { scale: o.scale, edit: o.edit, show_hidden: o.show_hidden, state: o.state, skip: o.skip, skip_text: o.skip_text, editing_text: o.editing_text, gif_times: o.gif_times, threads: o.threads, offset: o.offset, size: o.size, clear: o.clear, date_text: o.date_text.clone(), now: o.now, transparent_background: o.transparent_background, } } enum Tex<'a> { Image(&'a Image), /// Straight RGB, opaque. Solid([f32; 3]), } impl Tex<'_> { /// Premultiplied RGBA (0..255) at texture coordinate (u, v), bilinear, clamped to the edge. fn sample(&self, u: f64, v: f64) -> [f32; 4] { match self { Tex::Solid(c) => [c[0], c[1], c[2], 255.0], Tex::Image(img) => { let (w, h) = (img.width as usize, img.height as usize); if w == 0 || h == 0 { return [0.0; 4]; } let x = (u * w as f64 - 0.5).clamp(0.0, (w - 1) as f64); let y = (v * h as f64 - 0.5).clamp(0.0, (h - 1) as f64); let (x0, y0) = (x.floor() as usize, y.floor() as usize); let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); let (fx, fy) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); let px = |xx: usize, yy: usize| -> [f32; 4] { let i = (yy * w + xx) * 4; match img.pixels.get(i..i + 4) { Some(p) => [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32], None => [0.0; 4], } }; let (a, b, c, d) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1)); let mut o = [0.0f32; 4]; for k in 0..4 { let top = a[k] + (b[k] - a[k]) * fx; let bot = c[k] + (d[k] - c[k]) * fx; o[k] = top + (bot - top) * fy; } o } } } } fn draw_layer(out: &mut Image, l: &Layer, tex: &Tex) { if !(l.alpha.is_finite() && l.alpha > 0.0) { return; } let (verts, tris) = layer_mesh(l); let (w, h) = (out.width as f64, out.height as f64); for t in tris { let (Some(a), Some(b), Some(c)) = (verts.get(t[0] as usize), verts.get(t[1] as usize), verts.get(t[2] as usize)) else { continue }; let p = [[a.pos[0] * w, a.pos[1] * h], [b.pos[0] * w, b.pos[1] * h], [c.pos[0] * w, c.pos[1] * h]]; let uv = [a.uv, b.uv, c.uv]; let al = [a.a, b.a, c.a].map(|x| if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }); if al.iter().all(|x| *x <= 0.0) { continue; } fill_triangle(out, p, uv, al, tex); } } /// Rasterise one textured triangle: pixel centres inside it take the texture's colour at the /// barycentric texture coordinate, at the interpolated opacity, blended source-over. fn fill_triangle(out: &mut Image, p: [[f64; 2]; 3], uv: [[f64; 2]; 3], al: [f64; 3], tex: &Tex) { let area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]); if !area.is_finite() || area.abs() < 1e-12 { return; } let (w, h) = (out.width as i64, out.height as i64); let minx = p.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64; let maxx = (p.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(w); let miny = p.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64; let maxy = (p.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(h); if minx >= maxx || miny >= maxy { return; } let edge = |a: [f64; 2], b: [f64; 2], x: f64, y: f64| (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); let width = out.width as usize; for y in miny..maxy { // Sample a hair off the pixel centre so a point on an edge shared by two triangles falls // in exactly one of them (no double-blended seams). let fy = y as f64 + 0.5 + 2.31e-6; for x in minx..maxx { let fx = x as f64 + 0.5 + 1.17e-6; let w0 = edge(p[1], p[2], fx, fy) / area; let w1 = edge(p[2], p[0], fx, fy) / area; let w2 = edge(p[0], p[1], fx, fy) / area; if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 { continue; } let u = w0 * uv[0][0] + w1 * uv[1][0] + w2 * uv[2][0]; let v = w0 * uv[0][1] + w1 * uv[1][1] + w2 * uv[2][1]; let alpha = (w0 * al[0] + w1 * al[1] + w2 * al[2]) as f32; if alpha <= 0.0 { continue; } let s = tex.sample(u, v); let i = (y as usize * width + x as usize) * 4; let Some(d) = out.pixels.get_mut(i..i + 4) else { continue }; let sa = s[3] * alpha / 255.0; for k in 0..4 { let src = s[k] * alpha; let v = src + d[k] as f32 * (1.0 - sa); d[k] = v.round().clamp(0.0, 255.0) as u8; } } } } #[cfg(test)] mod tests { use super::*; fn solid(w: u32, h: u32, c: [u8; 4]) -> Image { Image { width: w, height: h, pixels: c.repeat((w * h) as usize) } } #[test] fn layers_composite_in_order() { let old = solid(40, 20, [255, 0, 0, 255]); let new = solid(40, 20, [0, 0, 255, 255]); let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 0.0)); assert_eq!(img.pixel(20, 10), [255, 0, 0, 255]); let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 1.0)); assert_eq!(img.pixel(20, 10), [0, 0, 255, 255]); } #[test] fn missing_old_slide_is_black() { let new = solid(10, 10, [0, 255, 0, 255]); let img = composite_transition(None, &new, None, &[Layer::full(Source::Old, 1.0)]); assert_eq!(img.pixel(5, 5), [0, 0, 0, 255]); } #[test] fn half_alpha_blends() { let old = solid(10, 10, [200, 0, 0, 255]); let new = solid(10, 10, [0, 0, 200, 255]); let l = [Layer::full(Source::Old, 1.0), Layer::full(Source::New, 0.5)]; let p = composite_transition(Some(&old), &new, None, &l).pixel(5, 5); assert!((p[0] as i32 - 100).abs() <= 1 && (p[2] as i32 - 100).abs() <= 1, "{p:?}"); } // ---------- golden-ish frames on synthetic slides ---------- const W: u32 = 160; const H: u32 = 90; const OLD: [u8; 4] = [220, 40, 40, 255]; const NEW: [u8; 4] = [30, 60, 200, 255]; #[derive(Clone, Copy, Debug, PartialEq)] enum Is { Old, New, Black, White, /// Something between (a blend). Mix, } fn classify(p: [u8; 4]) -> Is { let d = |c: [u8; 4]| (p[0] as i32 - c[0] as i32).abs() + (p[1] as i32 - c[1] as i32).abs() + (p[2] as i32 - c[2] as i32).abs(); let near = 40; if d(OLD) < near { Is::Old } else if d(NEW) < near { Is::New } else if d([0, 0, 0, 255]) < near { Is::Black } else if d([255, 255, 255, 255]) < near { Is::White } else { // A face shaded in 3-D: a darker copy of one of the slides. let shaded = |c: [u8; 4]| { let dot = |a: [u8; 4], b: [u8; 4]| (0..3).map(|i| a[i] as f64 * b[i] as f64).sum::(); let k = dot(p, c) / dot(c, c); (0.3..=1.05).contains(&k) && (0..3).all(|i| (p[i] as f64 - k * c[i] as f64).abs() < 14.0) }; if shaded(OLD) { Is::Old } else if shaded(NEW) { Is::New } else { Is::Mix } } } fn frame(kind: &str, option: &str, t: f64) -> Image { let old = solid(W, H, OLD); let new = solid(W, H, NEW); composite_transition(Some(&old), &new, None, &transition_frame(kind, option, t, 16.0 / 9.0)) } fn at(img: &Image, x: f64, y: f64) -> Is { let px = ((x * W as f64) as u32).min(W - 1); let py = ((y * H as f64) as u32).min(H - 1); classify(img.pixel(px, py)) } /// (kind, option, t, x, y, what PowerPoint shows there), read off PowerPoint's own frames. const PROBES: &[(&str, &str, f64, f64, f64, Is)] = &[ ("fade", "smoothly", 0.1, 0.5, 0.5, Is::Old), ("fade", "smoothly", 0.5, 0.5, 0.5, Is::Mix), ("fade", "smoothly", 0.9, 0.5, 0.5, Is::New), ("fade", "throughBlack", 0.5, 0.5, 0.5, Is::Black), ("cut", "throughBlack", 0.3, 0.5, 0.5, Is::Black), ("cut", "", 0.3, 0.5, 0.5, Is::New), // Push / Cover / Uncover: "l" moves things toward the left; the new slide comes from the right. ("push", "l", 0.25, 0.97, 0.5, Is::New), ("push", "l", 0.25, 0.1, 0.5, Is::Old), ("push", "r", 0.25, 0.03, 0.5, Is::New), ("push", "u", 0.25, 0.5, 0.97, Is::New), ("push", "d", 0.25, 0.5, 0.03, Is::New), ("cover", "l", 0.5, 0.75, 0.5, Is::New), ("cover", "l", 0.5, 0.25, 0.5, Is::Old), ("cover", "rd", 0.5, 0.05, 0.05, Is::New), ("cover", "rd", 0.2, 0.97, 0.97, Is::Old), ("uncover", "l", 0.5, 0.25, 0.5, Is::Old), ("uncover", "l", 0.5, 0.75, 0.5, Is::New), ("uncover", "lu", 0.5, 0.5, 0.5, Is::New), // Wipe "r" sweeps rightward with a soft edge; "u" upward; diagonals from a corner. ("wipe", "r", 0.5, 0.05, 0.5, Is::New), ("wipe", "r", 0.5, 0.97, 0.5, Is::Old), ("wipe", "l", 0.5, 0.97, 0.5, Is::New), ("wipe", "u", 0.5, 0.5, 0.97, Is::New), ("wipe", "u", 0.5, 0.5, 0.03, Is::Old), ("wipe", "d", 0.5, 0.5, 0.03, Is::New), ("wipe", "ru", 0.5, 0.03, 0.97, Is::New), ("wipe", "ru", 0.5, 0.97, 0.03, Is::Old), ("wipe", "r", 0.5, 0.55, 0.5, Is::Mix), // Split: Out opens from the middle, In closes from the edges. ("split", "horzOut", 0.5, 0.5, 0.5, Is::New), ("split", "horzOut", 0.5, 0.5, 0.02, Is::Old), ("split", "horzIn", 0.5, 0.5, 0.02, Is::New), ("split", "horzIn", 0.5, 0.5, 0.5, Is::Old), ("split", "vertOut", 0.5, 0.5, 0.5, Is::New), ("split", "vertOut", 0.5, 0.02, 0.5, Is::Old), ("split", "vertIn", 0.5, 0.02, 0.5, Is::New), // Shape: grows from the centre; In shrinks the old slide over the first half, Out grows // the new one over the second. ("shape", "circle", 0.5, 0.5, 0.5, Is::New), ("shape", "circle", 0.5, 0.02, 0.03, Is::Old), ("shape", "diamond", 0.5, 0.5, 0.5, Is::New), ("shape", "diamond", 0.5, 0.02, 0.03, Is::Old), ("shape", "plus", 0.5, 0.5, 0.05, Is::New), ("shape", "plus", 0.5, 0.05, 0.05, Is::Old), ("shape", "in", 0.25, 0.5, 0.5, Is::Old), ("shape", "in", 0.25, 0.05, 0.05, Is::New), ("shape", "in", 0.75, 0.5, 0.5, Is::New), ("shape", "out", 0.25, 0.5, 0.5, Is::Old), ("shape", "out", 0.75, 0.5, 0.5, Is::New), ("shape", "out", 0.75, 0.05, 0.05, Is::Old), // Flash: white in the middle. ("flash", "", 0.3, 0.5, 0.5, Is::White), // Clock: a hand sweeping from 12 o'clock. ("clock", "clockwise", 0.25, 0.6, 0.15, Is::New), ("clock", "clockwise", 0.25, 0.4, 0.15, Is::Old), ("clock", "counterClockwise", 0.25, 0.4, 0.15, Is::New), ("clock", "counterClockwise", 0.25, 0.6, 0.15, Is::Old), ("clock", "wedge", 0.5, 0.5, 0.05, Is::New), ("clock", "wedge", 0.5, 0.5, 0.95, Is::Old), // Ripple: from the centre, or from the corner opposite the option. ("ripple", "center", 0.5, 0.5, 0.5, Is::New), ("ripple", "center", 0.5, 0.02, 0.03, Is::Old), ("ripple", "ru", 0.4, 0.03, 0.97, Is::New), ("ripple", "ru", 0.4, 0.97, 0.03, Is::Old), // Comb: the old slide's bands slide off, the bottom band first. ("comb", "horz", 0.5, 0.5, 0.97, Is::New), ("comb", "horz", 0.5, 0.5, 0.03, Is::Old), // Glitter "l": the sweep starts on the left. ("glitter", "l", 0.45, 0.03, 0.5, Is::New), ("glitter", "l", 0.45, 0.97, 0.5, Is::Old), ("glitter", "r", 0.45, 0.97, 0.5, Is::New), // Vortex "l": the old slide whirls off toward the left, its right side first. ("vortex", "l", 0.25, 0.03, 0.5, Is::Old), ("vortex", "l", 0.97, 0.03, 0.5, Is::New), // Cube / Box "l": the new face comes in from the right. ("cube", "l", 0.5, 0.85, 0.5, Is::New), ("cube", "l", 0.5, 0.15, 0.5, Is::Old), ("cube", "r", 0.5, 0.15, 0.5, Is::New), ("box", "l", 0.25, 0.97, 0.5, Is::New), ("box", "l", 0.25, 0.3, 0.5, Is::Old), ("cube", "u", 0.5, 0.5, 0.9, Is::New), ("cube", "d", 0.5, 0.5, 0.1, Is::New), // Switch "l": the old card is pulled out to the left, the new one to the right. ("switch", "l", 0.25, 0.97, 0.5, Is::New), ("switch", "l", 0.25, 0.3, 0.5, Is::Old), ("switch", "r", 0.25, 0.03, 0.5, Is::New), // Gallery "l": the slides travel left along the wall. ("gallery", "l", 0.6, 0.9, 0.5, Is::New), // Flip: the old face, then the new one. ("flip", "l", 0.2, 0.5, 0.5, Is::Old), ("flip", "l", 0.8, 0.5, 0.5, Is::New), // Doors open in the middle, the new slide coming forward between them. ("doors", "vert", 0.9, 0.5, 0.5, Is::New), // Fall Over: the old slide tips backward, uncovering the new one at the top first. ("fallOver", "l", 0.6, 0.5, 0.03, Is::New), ("fallOver", "l", 0.6, 0.5, 0.97, Is::Old), // Peel Off "l": from the bottom-right corner. ("peelOff", "l", 0.4, 0.98, 0.98, Is::New), ("peelOff", "l", 0.4, 0.03, 0.03, Is::Old), ("peelOff", "r", 0.4, 0.02, 0.98, Is::New), // Page Curl: the far half rolls up first. ("pageCurlDouble", "l", 0.5, 0.9, 0.5, Is::New), ("pageCurlDouble", "l", 0.5, 0.1, 0.5, Is::Old), ("pageCurlDouble", "r", 0.5, 0.1, 0.5, Is::New), ("pageCurlDouble", "singleL", 0.6, 0.95, 0.5, Is::New), ("pageCurlDouble", "singleL", 0.6, 0.05, 0.5, Is::Old), // Airplane "l": a dart with its nose to the right, hanging low. ("airplane", "l", 0.5, 0.15, 0.1, Is::New), ("airplane", "l", 0.5, 0.8, 0.68, Is::Old), ("airplane", "r", 0.5, 0.2, 0.68, Is::Old), // Origami "l": the bird flies off to the left. ("origami", "l", 0.75, 0.97, 0.5, Is::New), // Fracture: shards fall, the top first. ("fracture", "", 0.6, 0.5, 0.05, Is::New), // Crush: the old slide crumples toward the centre. ("crush", "", 0.5, 0.03, 0.03, Is::New), ("crush", "", 0.5, 0.45, 0.5, Is::Old), // Curtains part in the middle. ("curtains", "", 0.4, 0.5, 0.5, Is::New), ("curtains", "", 0.4, 0.02, 0.5, Is::Old), // Zoom and Rotate: the new slide spins in from the centre over the old one. ("zoom", "rotate", 0.2, 0.03, 0.03, Is::Old), ("zoom", "out", 0.8, 0.5, 0.5, Is::New), ("dissolve", "", 0.02, 0.5, 0.5, Is::Old), ("dissolve", "", 0.99, 0.5, 0.5, Is::New), ]; #[test] fn frames_match_powerpoint_probes() { let mut bad = Vec::new(); for (kind, option, t, x, y, want) in PROBES { let got = at(&frame(kind, option, *t), *x, *y); if got != *want { bad.push(format!("{kind}/{option} t={t} ({x},{y}): want {want:?}, got {got:?}")); } } assert!(bad.is_empty(), "{}", bad.join("\n")); } #[test] fn every_transition_starts_old_and_ends_new() { for (kind, _, _, _, opts) in deckcraft_model::anim::TRANSITIONS { let mut options: Vec<&str> = opts.to_vec(); options.push(""); for o in options { assert_eq!(at(&frame(kind, o, 0.0), 0.5, 0.5), Is::Old, "{kind}/{o}"); assert_eq!(at(&frame(kind, o, 1.0), 0.5, 0.5), Is::New, "{kind}/{o}"); for t in [0.25, 0.5, 0.75] { let img = frame(kind, o, t); assert_eq!((img.width, img.height), (W, H)); } } } } #[test] fn dynamic_content_moves_contents_over_backgrounds() { // Old: red background with a white content square in the middle; new: blue background // with a yellow square. let content = |c: [u8; 4]| { let mut img = solid(W, H, [0, 0, 0, 0]); for y in 35..55u32 { for x in 70..90u32 { let i = ((y * W + x) * 4) as usize; if let Some(p) = img.pixels.get_mut(i..i + 4) { p.copy_from_slice(&c); } } } img }; let parts = SlideParts { old_back: Some(solid(W, H, OLD)), old_content: Some(content([255, 255, 255, 255])), new_back: solid(W, H, NEW), new_content: content([255, 220, 0, 255]), }; let old = solid(W, H, OLD); let new = solid(W, H, NEW); let f = |kind: &str, o: &str, t: f64| composite_transition(Some(&old), &new, Some(&parts), &transition_frame(kind, o, t, 16.0 / 9.0)); // Pan left at the middle: the backgrounds blend, the old square has moved left. let img = f("pan", "l", 0.5); let bg = img.pixel(5, 5); assert!(bg[0] > 60 && bg[2] > 60, "{bg:?}"); assert_eq!(img.pixel(80, 45), bg, "the middle shows background only"); // Pan right: the new square arrives from the left. let img = f("pan", "r", 0.6); assert!((0..80).any(|x| { let p = img.pixel(x, 45); p[0] > 200 && p[1] > 180 && p[2] < 80 })); // Reveal: the new content is in place near the end. let p = f("reveal", "smoothlyLeft", 0.97).pixel(80, 45); assert!(p[0] > 200 && p[1] > 180, "{p:?}"); // Without parts the dynamic transitions still draw whole slides. let img = composite_transition(Some(&old), &new, None, &transition_frame("pan", "l", 0.5, 16.0 / 9.0)); assert_ne!(img.pixel(80, 45), [0, 0, 0, 255]); } }