mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
The renderer lays every slide background over white (as PowerPoint does),
so a PNG export was always opaque. A new transparentBackground option leaves
the slide background out (both the white underlay and the background fill),
so only what is on the slide is drawn over transparency. Shapes filled with
the background (useBgFill) still show it.
- render: RenderOpts::transparent_background
- engine: file.export {transparentBackground} (PNG only; other formats
refuse it and write nothing), file.render {transparentBackground}
- UI: Transparent background checkbox in the Export dialog (PNG) and in
File > Export > Change File Type > PNG; the web download honours it
- CLI: render --transparent
520 lines
23 KiB
Rust
520 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,
|
|
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::<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]);
|
|
}
|
|
}
|