518 lines
23 KiB
Rust
518 lines
23 KiB
Rust
//! 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<Image>,
|
|
pub old_content: Option<Image>,
|
|
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<usize>, 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<crate::TextMorph> =
|
|
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<crate::PathMorph> = 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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
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::<f64>();
|
|
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]);
|
|
}
|
|
}
|