use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{CubicBezier, clamp01, interpolate, interpolate_multi, normalised_speed};
use crate::ui::{Mode, UiTheme, blur, text};
pub const REF_W: f32 = 712.0;
pub const FONT_SIZE: f32 = 19.22;
pub const LETTER_SPACING_EM: f32 = -0.068;
pub const CAPTION_WEIGHT: u16 = 500;
pub const BOX_W: f32 = 282.08;
pub const CAPTION_OPEN: [f32; 2] = [-110.07, 125.53];
pub const CAPTION_SHUT: [f32; 2] = [-5.44, 13.75];
pub const SWAP_DX: f32 = -1.9;
pub const SWAP_REST: [[f32; 2]; 2] = [[-71.0, -94.6], [41.0, 104.6]];
pub const SWAP_AT: f32 = 46.0;
pub const CUTS: [f32; 8] = [0.0, 3.0, 6.0, 10.0, 14.0, 19.0, 25.0, 29.0];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Shot {
pub aspect: f32,
pub k: f32,
pub dx: f32,
pub dy: f32,
}
pub const SHOTS: [Shot; 8] = [
Shot {
aspect: 1.505,
k: 1.0004,
dx: 0.47,
dy: 0.1,
},
Shot {
aspect: 1.4788,
k: 0.9982,
dx: 0.78,
dy: 0.09,
},
Shot {
aspect: 1.4832,
k: 1.0038,
dx: 1.67,
dy: 1.16,
},
Shot {
aspect: 1.4561,
k: 1.0009,
dx: 1.59,
dy: 0.93,
},
Shot {
aspect: 1.4573,
k: 0.9999,
dx: 1.8,
dy: 0.84,
},
Shot {
aspect: 1.4505,
k: 0.9988,
dx: 1.92,
dy: 1.3,
},
Shot {
aspect: 1.4465,
k: 0.9984,
dx: 1.8,
dy: 1.14,
},
Shot {
aspect: 1.4465,
k: 1.0,
dx: 1.3,
dy: -0.1,
},
];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CollageSlot {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub from: f32,
}
pub const COLLAGE: [CollageSlot; 6] = [
CollageSlot {
x: -195.5,
y: -38.5,
w: 105.0,
h: 117.0,
from: 0.616,
},
CollageSlot {
x: -66.5,
y: -1.0,
w: 141.0,
h: 96.0,
from: 0.425,
},
CollageSlot {
x: 106.5,
y: -91.5,
w: 161.0,
h: 107.0,
from: 0.554,
},
CollageSlot {
x: -153.0,
y: 108.0,
w: 150.0,
h: 102.0,
from: 0.644,
},
CollageSlot {
x: 200.0,
y: 97.5,
w: 142.0,
h: 97.0,
from: 0.61,
},
CollageSlot {
x: 81.5,
y: 27.5,
w: 139.0,
h: 97.0,
from: 0.601,
},
];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Blob {
pub dx: f32,
pub dy: f32,
pub hw: f32,
pub hh: f32,
pub r: f32,
pub blur: f32,
}
pub const GLOW: Blob = Blob {
dx: 10.9,
dy: 6.4,
hw: 261.6,
hh: 147.0,
r: 128.4,
blur: 36.2,
};
pub const FLUSH: Blob = Blob {
dx: 4.4,
dy: -4.2,
hw: 301.8,
hh: 151.5,
r: 2.0,
blur: 58.6,
};
pub const TEXT_ZOOM: [f32; 2] = [7.59, 37.0];
pub const BOX_ZOOM: [f32; 2] = [0.42, 54.55];
pub const REEL_OPEN: [f32; 2] = [18.71, 37.47];
pub const CAPTIONS_OPEN: [f32; 2] = [18.4, 38.0];
pub const SPLIT_TOP: [f32; 2] = [43.69, 69.24];
pub const SPLIT_BOTTOM: [f32; 2] = [40.33, 69.32];
pub const COLLAGE_PAN: [f32; 2] = [46.66, 75.15];
pub const COLLAGE_BLOOM: [f32; 2] = [48.92, 73.18];
pub const COLLAGE_FADE: [f32; 2] = [50.14, 62.3];
pub const COLLAGE_FADE_TRAILING: [f32; 2] = [50.74, 65.08];
pub const FLUSH_KEYFRAMES: [f32; 4] = [48.0, 56.0, 57.5, 73.5];
pub const MOTION_ENDS: [f32; 11] = [
TEXT_ZOOM[1],
BOX_ZOOM[1],
REEL_OPEN[1],
CAPTIONS_OPEN[1],
SPLIT_TOP[1],
SPLIT_BOTTOM[1],
COLLAGE_PAN[1],
COLLAGE_BLOOM[1],
COLLAGE_FADE[1],
COLLAGE_FADE_TRAILING[1],
FLUSH_KEYFRAMES[3],
];
const EASE_TEXT_ZOOM: CubicBezier = CubicBezier::new(0.9062, 0.1924, 0.4104, 1.0142);
const EASE_BOX_ZOOM: CubicBezier = CubicBezier::new(0.8548, 0.3903, 0.0817, 1.1691);
const EASE_REEL_OPEN: CubicBezier = CubicBezier::new(0.8703, 0.0, 0.078, 1.0);
const EASE_CAPTIONS_OPEN: CubicBezier = CubicBezier::new(0.8766, 0.0, 0.0413, 1.0);
const EASE_SPLIT_TOP: CubicBezier = CubicBezier::new(0.9243, 0.1575, 0.1018, 0.781);
const EASE_SPLIT_BOTTOM: CubicBezier = CubicBezier::new(0.9897, 0.0, 0.1683, 0.7764);
const EASE_COLLAGE_PAN: CubicBezier = CubicBezier::new(0.8112, 0.3372, 0.0414, 0.6655);
const EASE_COLLAGE_BLOOM: CubicBezier = CubicBezier::new(0.5174, -0.3347, 0.0854, 1.0);
pub const BLUR_LAYERS: [(f32, f32); 11] = [
(2.5, 0.006_210),
(2.0, 0.016_644),
(1.5, 0.045_083),
(1.0, 0.098_423),
(0.5, 0.178_146),
(0.0, 0.276_895),
(-0.5, 0.382_925),
(-1.0, 0.485_783),
(-1.5, 0.578_916),
(-2.0, 0.659_466),
(-2.5, 0.727_049),
];
pub const BLUR_CORE_SHOW_THROUGH: f32 = 0.006_210;
#[must_use]
pub fn scene_unit(canvas_width: f32) -> f32 {
canvas_width / REF_W
}
#[must_use]
pub fn seg(frame: f32, window: [f32; 2], out: [f32; 2], ease: CubicBezier) -> f32 {
let span = window[1] - window[0];
let progress = if span.abs() < f32::EPSILON {
1.0
} else {
clamp01((frame - window[0]) / span)
};
out[0] + (out[1] - out[0]) * ease.ease(progress)
}
#[must_use]
pub fn text_zoom(frame: f32) -> f32 {
seg(frame, TEXT_ZOOM, [1.0, 1.1741], EASE_TEXT_ZOOM)
}
#[must_use]
pub fn box_zoom(frame: f32) -> f32 {
seg(frame, BOX_ZOOM, [1.0, 1.1852], EASE_BOX_ZOOM)
}
#[must_use]
pub fn reel_open(frame: f32) -> f32 {
seg(frame, REEL_OPEN, [1.0, 0.0], EASE_REEL_OPEN)
}
#[must_use]
pub fn captions_open(frame: f32) -> f32 {
seg(frame, CAPTIONS_OPEN, [1.0, 0.0], EASE_CAPTIONS_OPEN)
}
#[must_use]
pub fn split_top(frame: f32) -> f32 {
seg(frame, SPLIT_TOP, [0.0, 1.0], EASE_SPLIT_TOP)
}
#[must_use]
pub fn split_bottom(frame: f32) -> f32 {
seg(frame, SPLIT_BOTTOM, [0.0, 1.0], EASE_SPLIT_BOTTOM)
}
#[must_use]
pub fn collage_pan(frame: f32) -> f32 {
seg(frame, COLLAGE_PAN, [57.0, 0.0], EASE_COLLAGE_PAN)
}
#[must_use]
pub fn collage_bloom(frame: f32) -> f32 {
seg(frame, COLLAGE_BLOOM, [0.0, 1.0], EASE_COLLAGE_BLOOM)
}
#[must_use]
pub fn collage_opacity(frame: f32, slot: usize) -> f32 {
let window = if slot == 4 {
COLLAGE_FADE_TRAILING
} else {
COLLAGE_FADE
};
interpolate(frame, window, [0.0, 1.0])
}
#[must_use]
pub fn flush_opacity(frame: f32) -> f32 {
interpolate_multi(frame, &FLUSH_KEYFRAMES, &[0.0, 1.0, 1.0, 0.0])
}
#[must_use]
pub fn slide_at(frame: f32) -> usize {
let mut index = 0;
for (i, cut) in CUTS.iter().enumerate() {
if frame >= *cut {
index = i;
}
}
index
}
#[must_use]
pub fn shot(index: usize) -> Shot {
SHOTS.get(index).copied().unwrap_or(SHOTS[0])
}
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn settle_frame() -> usize {
let last = MOTION_ENDS.iter().copied().fold(0.0_f32, f32::max);
last.ceil() as usize
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReelGeometry {
pub box_w: f32,
pub box_h: f32,
pub full_h: f32,
pub dx: f32,
pub dy: f32,
}
impl ReelGeometry {
#[must_use]
pub fn at(frame: f32, index: usize) -> Self {
let shot = shot(index);
let box_w = BOX_W * shot.k * box_zoom(frame);
let full_h = box_w / shot.aspect;
Self {
box_w,
box_h: full_h * reel_open(frame),
full_h,
dx: shot.dx,
dy: shot.dy,
}
}
#[must_use]
pub fn is_visible(&self, unit: f32) -> bool {
self.box_h * unit > 0.01
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CaptionPlacement {
pub x: f32,
pub y: f32,
pub zoom: f32,
pub swapped: bool,
}
#[must_use]
pub fn caption_placement(frame: f32, line: usize) -> CaptionPlacement {
let index = if line == 0 { 0 } else { 1 };
let zoom = text_zoom(frame);
let apart = captions_open(frame);
let swapped = frame >= SWAP_AT;
let split = if index == 0 {
split_top(frame)
} else {
split_bottom(frame)
};
let open_y = CAPTION_OPEN[index];
let shut_y = CAPTION_SHUT[index];
let baseline = zoom * (open_y * apart + shut_y * (1.0 - apart));
let drift = if swapped {
SWAP_DX * (1.0 - split)
} else {
0.0
};
CaptionPlacement {
x: drift + SWAP_REST[index][0] * split,
y: baseline + (SWAP_REST[index][1] - zoom * shut_y) * split,
zoom,
swapped,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CollageMotion {
pub opacity: f32,
pub scale: f32,
pub pan: f32,
}
#[must_use]
pub fn collage_motion(frame: f32, slot: usize) -> CollageMotion {
let from = COLLAGE.get(slot).map_or(1.0, |s| s.from);
let bloom = collage_bloom(frame);
CollageMotion {
opacity: collage_opacity(frame, slot),
scale: from + (1.0 - from) * bloom,
pan: collage_pan(frame),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl Rect {
#[must_use]
pub fn centre_x(&self) -> f32 {
self.x + self.w / 2.0
}
#[must_use]
pub fn centre_y(&self) -> f32 {
self.y + self.h / 2.0
}
#[must_use]
pub fn grown(&self, offset: f32) -> Option<Self> {
let w = self.w + 2.0 * offset;
let h = self.h + 2.0 * offset;
if w <= 0.0 || h <= 0.0 {
return None;
}
Some(Self {
x: self.x - offset,
y: self.y - offset,
w,
h,
})
}
}
#[must_use]
pub fn reel_window(centre: (f32, f32), unit: f32, geometry: &ReelGeometry) -> Rect {
let (centre_x, centre_y) = centre;
Rect {
x: centre_x + geometry.dx * unit - geometry.box_w * unit / 2.0,
y: centre_y + geometry.dy * unit - geometry.box_h * unit / 2.0,
w: geometry.box_w * unit,
h: geometry.box_h * unit,
}
}
#[must_use]
pub fn reel_picture(centre: (f32, f32), unit: f32, geometry: &ReelGeometry) -> Rect {
let window = reel_window(centre, unit, geometry);
Rect {
x: window.x,
y: window.centre_y() - geometry.full_h * unit / 2.0,
w: window.w,
h: geometry.full_h * unit,
}
}
#[must_use]
pub fn collage_box(centre: (f32, f32), unit: f32, slot: &CollageSlot, pan: f32) -> Rect {
let (centre_x, centre_y) = centre;
Rect {
x: centre_x + (slot.x + pan - slot.w / 2.0) * unit,
y: centre_y + (slot.y - slot.h / 2.0) * unit,
w: slot.w * unit,
h: slot.h * unit,
}
}
#[must_use]
pub fn caption_anchor(centre: (f32, f32), unit: f32, placement: &CaptionPlacement) -> (f32, f32) {
(centre.0 + placement.x * unit, centre.1 + placement.y * unit)
}
#[must_use]
pub fn blob_box(centre: (f32, f32), unit: f32, blob: &Blob) -> Rect {
Rect {
x: centre.0 + (blob.dx - blob.hw) * unit,
y: centre.1 + (blob.dy - blob.hh) * unit,
w: 2.0 * blob.hw * unit,
h: 2.0 * blob.hh * unit,
}
}
#[must_use]
pub fn blur_sigma(blob: &Blob) -> f32 {
blob.blur / 2.0
}
pub const DEFAULT_SLIDES: [&str; 8] = [
"placeholder-01.png",
"placeholder-02.png",
"placeholder-03.png",
"placeholder-04.png",
"placeholder-05.png",
"placeholder-06.png",
"placeholder-07.png",
"placeholder-08.png",
];
pub const DEFAULT_COLLAGE: [&str; 6] = [
"placeholder-03.png",
"placeholder-01.png",
"placeholder-06.png",
"placeholder-02.png",
"placeholder-08.png",
"placeholder-05.png",
];
#[must_use]
pub fn pool_entry(pool: &[String], index: usize) -> Option<&str> {
if pool.is_empty() {
return None;
}
pool.get(index % pool.len()).map(String::as_str)
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReelCollageConfig {
pub top_line: String,
pub bottom_line: String,
pub swap_top: String,
pub swap_bottom: String,
pub slides: Vec<String>,
pub collage: Vec<String>,
pub background: String,
pub glow_color: String,
pub flush_color: String,
pub text_color: Option<String>,
pub mode: Mode,
pub theme: Option<UiTheme>,
pub font_family: Option<String>,
pub speed: f32,
pub duration: usize,
pub simulate_blur: bool,
}
impl Default for ReelCollageConfig {
fn default() -> Self {
Self {
top_line: "The videos".to_owned(),
bottom_line: "That you ship".to_owned(),
swap_top: "Especially on".to_owned(),
swap_bottom: "Launch day".to_owned(),
slides: DEFAULT_SLIDES
.iter()
.map(|name| (*name).to_owned())
.collect(),
collage: DEFAULT_COLLAGE
.iter()
.map(|name| (*name).to_owned())
.collect(),
background: "#f5f4f2".to_owned(),
glow_color: "#dfe7e3".to_owned(),
flush_color: "#fdc7bc".to_owned(),
text_color: None,
mode: Mode::Light,
theme: None,
font_family: None,
speed: 1.0,
duration: 96,
simulate_blur: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ReelCollage {
pub config: ReelCollageConfig,
}
impl ReelCollage {
#[must_use]
pub fn new(config: ReelCollageConfig) -> Self {
Self { config }
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "reel-collage",
name: "Reel Collage",
summary: "A photo reel shuts like an eyelid, the copy swaps and a collage blooms in",
feature: "reel-collage",
}
}
fn theme(&self) -> UiTheme {
self.config
.theme
.clone()
.unwrap_or_else(|| UiTheme::for_mode(self.config.mode))
}
}
fn blob_nodes(
centre: (f32, f32),
unit: f32,
blob: &Blob,
colour: &str,
opacity: f32,
simulate_blur: bool,
) -> Svgr<'static> {
let shape = blob_box(centre, unit, blob);
let radius = blob.r * unit;
let sigma = blur_sigma(blob) * unit;
let mut layers: Vec<Svgr<'static>> = Vec::new();
if simulate_blur && blur::is_visible(blob.blur) {
for (offset, alpha) in BLUR_LAYERS {
let grown = offset * sigma;
let Some(layer) = shape.grown(grown) else {
continue;
};
let r = (radius + grown).max(0.0);
layers.push(fframes::svgr!(
<rect x={layer.x} y={layer.y} width={layer.w} height={layer.h}
rx={r} ry={r} fill={colour.to_owned()} opacity={alpha} />
));
}
} else {
layers.push(fframes::svgr!(
<rect x={shape.x} y={shape.y} width={shape.w} height={shape.h}
rx={radius} ry={radius} fill={colour.to_owned()} opacity={1.0} />
));
}
let layers = Svgr::from(layers);
fframes::svgr!(<g opacity={opacity}>{layers}</g>)
}
impl Component for ReelCollage {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.duration
}
fn background(&self) -> Option<&'static str> {
Some("#f5f4f2")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let theme = self.theme();
let family =
text::resolve_face(config.font_family.as_ref(), theme.font_family.as_ref(), ctx);
let ink = config
.text_color
.clone()
.unwrap_or_else(|| theme.foreground.clone());
let width = ctx.width();
let height = ctx.height();
let centre = (width / 2.0, height / 2.0);
let unit = scene_unit(width);
let frame = ctx.frame() * normalised_speed(config.speed);
let mut defs: Vec<Svgr<'static>> = Vec::new();
let mut nodes: Vec<Svgr<'static>> = Vec::new();
nodes.push(fframes::svgr!(
<rect x={0.0} y={0.0} width={width} height={height} fill={config.background.clone()} />
));
let flush = flush_opacity(frame);
if flush > 0.0 {
nodes.push(blob_nodes(
centre,
unit,
&FLUSH,
&config.flush_color,
flush,
config.simulate_blur,
));
}
nodes.push(blob_nodes(
centre,
unit,
&GLOW,
&config.glow_color,
1.0,
config.simulate_blur,
));
let slide = slide_at(frame);
let geometry = ReelGeometry::at(frame, slide);
if geometry.is_visible(unit)
&& let Some(src) = pool_entry(&config.slides, slide)
{
let window = reel_window(centre, unit, &geometry);
let picture = reel_picture(centre, unit, &geometry);
let clip_id = "reel-collage-reel".to_owned();
defs.push(fframes::svgr!(
<clipPath id={clip_id.clone()}>
<rect x={window.x} y={window.y} width={window.w} height={window.h} />
</clipPath>
));
let clip = format!("url(#{clip_id})");
nodes.push(fframes::svgr!(
<g clip-path={clip}>
<rect x={picture.x} y={picture.y} width={picture.w} height={picture.h}
fill="#242424" />
<image href={src.to_owned()} x={picture.x} y={picture.y}
width={picture.w} height={picture.h}
preserveAspectRatio="xMidYMid slice" />
</g>
));
}
for (slot, place) in COLLAGE.iter().enumerate() {
let motion = collage_motion(frame, slot);
if motion.opacity <= 0.0 {
continue;
}
let Some(src) = pool_entry(&config.collage, slot) else {
continue;
};
let panel = collage_box(centre, unit, place, motion.pan);
let transform = format!(
"translate({:.5} {:.5}) scale({:.6})",
panel.centre_x(),
panel.centre_y(),
motion.scale
);
nodes.push(fframes::svgr!(
<g transform={transform} opacity={motion.opacity}>
<rect x={-panel.w / 2.0} y={-panel.h / 2.0} width={panel.w} height={panel.h}
fill="#242424" />
<image href={src.to_owned()} x={-panel.w / 2.0} y={-panel.h / 2.0}
width={panel.w} height={panel.h}
preserveAspectRatio="xMidYMid slice" />
</g>
));
}
for line in 0..2 {
let place = caption_placement(frame, line);
let content = match (place.swapped, line) {
(false, 0) => &config.top_line,
(false, _) => &config.bottom_line,
(true, 0) => &config.swap_top,
(true, _) => &config.swap_bottom,
};
if content.is_empty() {
continue;
}
let (anchor_x, baseline) = caption_anchor(centre, unit, &place);
let size = FONT_SIZE * unit * place.zoom;
let tracking = text::tracking_px_from_em(LETTER_SPACING_EM, size);
nodes.push(text::text_node(
ctx,
content,
anchor_x,
baseline,
family.clone(),
size,
CAPTION_WEIGHT,
tracking,
ink.clone(),
1.0,
));
}
let defs = Svgr::from(defs);
let nodes = Svgr::from(nodes);
fframes::svgr!(
<g>
<defs>{defs}</defs>
{nodes}
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const UNIT: f32 = 2.696_629_2;
const CENTRE: (f32, f32) = (960.0, 540.0);
fn close(a: f32, b: f32, tolerance: f32) -> bool {
(a - b).abs() <= tolerance
}
#[test]
fn the_scene_scales_by_width_not_by_the_family_unit() {
assert!(close(scene_unit(1920.0), 2.696_629_2, 1e-5));
assert!(close(scene_unit(1280.0), 1.797_752_8, 1e-5));
assert!(close(scene_unit(960.0), 1.348_314_6, 1e-5));
assert!(close(scene_unit(1920.0) / scene_unit(1280.0), 1.5, 1e-5));
}
#[test]
fn a_segment_eases_between_its_ends_and_clamps_outside_them() {
let ease = EASE_COLLAGE_PAN;
assert!(close(
seg(0.0, [46.66, 75.15], [57.0, 0.0], ease),
57.0,
1e-4
));
assert!(close(
seg(46.66, [46.66, 75.15], [57.0, 0.0], ease),
57.0,
1e-4
));
assert!(close(
seg(75.15, [46.66, 75.15], [57.0, 0.0], ease),
0.0,
1e-4
));
assert!(close(
seg(1000.0, [46.66, 75.15], [57.0, 0.0], ease),
0.0,
1e-4
));
let middle = seg(60.905, [46.66, 75.15], [57.0, 0.0], ease);
assert!(middle < 57.0 && middle > 0.0);
assert!(close(seg(0.0, [10.0, 10.0], [1.0, 0.0], ease), 0.0, 1e-4));
}
#[test]
fn the_fitted_curves_start_and_end_on_the_sources_own_numbers() {
assert!(close(text_zoom(0.0), 1.0, 1e-4));
assert!(close(text_zoom(37.0), 1.1741, 1e-4));
assert!(close(box_zoom(0.0), 1.0, 1e-4));
assert!(close(box_zoom(54.55), 1.1852, 1e-4));
assert!(close(reel_open(0.0), 1.0, 1e-4));
assert!(close(reel_open(37.47), 0.0, 1e-4));
assert!(close(captions_open(0.0), 1.0, 1e-4));
assert!(close(captions_open(38.0), 0.0, 1e-4));
assert!(close(split_top(43.69), 0.0, 1e-4));
assert!(close(split_top(69.24), 1.0, 1e-4));
assert!(close(split_bottom(40.33), 0.0, 1e-4));
assert!(close(split_bottom(69.32), 1.0, 1e-4));
assert!(close(collage_pan(46.66), 57.0, 1e-4));
assert!(close(collage_pan(75.15), 0.0, 1e-4));
assert!(close(collage_bloom(48.92), 0.0, 1e-4));
assert!(close(collage_bloom(73.18), 1.0, 1e-4));
}
#[test]
fn the_flush_keys_on_its_four_frames() {
assert!(close(flush_opacity(0.0), 0.0, 1e-6));
assert!(close(flush_opacity(48.0), 0.0, 1e-6));
assert!(close(flush_opacity(52.0), 0.5, 1e-6));
assert!(close(flush_opacity(56.0), 1.0, 1e-6));
assert!(close(flush_opacity(57.5), 1.0, 1e-6));
assert!(close(flush_opacity(65.5), 0.5, 1e-6));
assert!(close(flush_opacity(73.5), 0.0, 1e-6));
assert!(close(flush_opacity(200.0), 0.0, 1e-6));
}
#[test]
fn the_bottom_right_picture_fades_in_last() {
for frame in [0.0, 50.0, 50.14, 55.0, 62.3, 70.0, 96.0] {
if frame < COLLAGE_FADE_TRAILING[0] {
assert!(
close(collage_opacity(frame, 4), 0.0, 1e-6),
"slot 4 at {frame}"
);
}
assert!(
collage_opacity(frame, 4) <= collage_opacity(frame, 0) + 1e-6,
"slot 4 should never lead slot 0 (frame {frame})"
);
}
assert!(close(
collage_opacity(56.0, 4),
(56.0 - 50.74) / (65.08 - 50.74),
1e-5
));
assert!(close(
collage_opacity(56.0, 0),
(56.0 - 50.14) / (62.3 - 50.14),
1e-5
));
assert!(close(collage_opacity(90.0, 0), 1.0, 1e-6));
assert!(close(collage_opacity(0.0, 0), 0.0, 1e-6));
}
#[test]
fn every_slot_blooms_from_its_own_scale_to_one() {
let cold = collage_motion(0.0, 1);
assert!(close(cold.opacity, 0.0, 1e-6));
assert!(close(cold.scale, COLLAGE[1].from, 1e-6));
assert!(close(cold.pan, 57.0, 1e-4));
let landed = collage_motion(96.0, 1);
assert!(close(landed.opacity, 1.0, 1e-6));
assert!(close(landed.scale, 1.0, 1e-6));
assert!(close(landed.pan, 0.0, 1e-4));
let mid = collage_motion(65.0, 5);
assert!(
mid.scale > COLLAGE[5].from && mid.scale < 1.0,
"{}",
mid.scale
);
assert!(close(collage_motion(65.0, 99).scale, 1.0, 1e-6));
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn the_bloom_undershoots_exactly_as_the_source_curve_does() {
let lowest = (49..=60)
.map(|frame| collage_bloom(frame as f32))
.fold(f32::MAX, f32::min);
assert!(lowest < 0.0 && lowest > -0.06, "deepest bloom {lowest}");
assert!(collage_bloom(52.0) < 0.0);
let rising = (56..=96)
.map(|frame| collage_bloom(frame as f32))
.collect::<Vec<_>>();
for pair in rising.windows(2) {
assert!(pair[1] >= pair[0] - 1e-6);
}
}
#[test]
fn the_reel_cuts_on_the_sources_own_frames() {
assert_eq!(slide_at(-1.0), 0);
assert_eq!(slide_at(0.0), 0);
assert_eq!(slide_at(2.99), 0);
assert_eq!(slide_at(3.0), 1);
assert_eq!(slide_at(9.0), 2);
assert_eq!(slide_at(10.0), 3);
assert_eq!(slide_at(19.0), 5);
assert_eq!(slide_at(29.0), 7);
assert_eq!(slide_at(96.0), 7);
assert_eq!(SHOTS.len(), CUTS.len());
assert_eq!(shot(99), SHOTS[0]);
}
#[test]
fn the_settle_frame_reproduces_the_registry_measurement() {
let last = MOTION_ENDS.iter().copied().fold(0.0_f32, f32::max);
assert!(close(last, COLLAGE_PAN[1], 1e-6));
assert!(last > 75.0 && last < 76.0);
assert_eq!(settle_frame(), 76);
assert_eq!(ReelCollage::default().duration(), 96);
}
#[test]
fn the_reel_window_opens_on_the_shot_and_shuts_on_the_eyelid() {
let open = ReelGeometry::at(0.0, 0);
assert!(close(open.box_w, 282.08 * 1.0004, 1e-3));
assert!(close(open.full_h, open.box_w / 1.505, 1e-3));
assert!(close(open.box_h, open.full_h, 1e-3));
assert!(open.is_visible(UNIT));
let shut = ReelGeometry::at(37.47, 0);
assert!(close(shut.box_h, 0.0, 1e-4));
assert!(!shut.is_visible(UNIT));
let zoomed = ReelGeometry::at(54.55, 7);
assert!(close(zoomed.box_w, BOX_W * 1.0 * 1.1852, 1e-3));
}
#[test]
fn the_drawn_reel_window_matches_the_demos_own_ink() {
let geometry = ReelGeometry::at(0.0, 0);
let window = reel_window(CENTRE, UNIT, &geometry);
assert!(close(window.x, 580.8, 1.0), "left {}", window.x);
assert!(close(window.w, 761.0, 1.0), "width {}", window.w);
assert!(close(window.y, 287.5, 1.0), "top {}", window.y);
assert!(close(window.h, 505.6, 1.0), "height {}", window.h);
assert!(close(window.x, 289.5 * 2.0, 2.0), "left against the demo");
assert!(
close(window.x + window.w, 670.5 * 2.0, 2.0),
"right against the demo"
);
assert!(close(window.y, 143.5 * 2.0, 1.5), "top against the demo");
assert!(
close(window.y + window.h, 396.5 * 2.0, 1.5),
"bottom against the demo"
);
let picture = reel_picture(CENTRE, UNIT, &geometry);
assert!(close(picture.centre_y(), window.centre_y(), 1e-6));
assert!(close(picture.h, geometry.full_h * UNIT, 1e-6));
assert!(close(picture.w, window.w, 1e-6));
}
#[test]
fn the_drawn_caption_anchor_matches_the_demos_own_ink() {
let place = caption_placement(0.0, 0);
assert!(!place.swapped);
assert!(close(place.x, 0.0, 1e-6));
assert!(close(place.y, -110.07, 1e-6));
assert!(close(place.zoom, 1.0, 1e-6));
let (anchor_x, baseline) = caption_anchor(CENTRE, UNIT, &place);
assert!(close(anchor_x, 960.0, 1e-3), "anchor x {anchor_x}");
assert!(close(baseline, 243.2, 0.5), "baseline {baseline}");
assert!(close(baseline, 121.6 * 2.0, 1.0));
let (_, bottom) = caption_anchor(CENTRE, UNIT, &caption_placement(0.0, 1));
assert!(close(bottom, 540.0 + 125.53 * UNIT, 1e-3));
assert!(close(bottom, 439.3 * 2.0, 1.0));
assert!(close(
FONT_SIZE * UNIT * caption_placement(0.0, 0).zoom,
51.83,
0.05
));
}
#[test]
fn the_captions_fold_then_swap_then_split() {
let early = caption_placement(5.0, 0);
assert!(close(early.zoom, 1.0, 1e-6));
assert!(close(early.y, -110.07, 1e-4));
assert!(caption_placement(10.0, 0).zoom > 1.0);
let folded = caption_placement(38.0, 0);
let zoom = text_zoom(38.0);
assert!(close(folded.y, zoom * CAPTION_SHUT[0], 1e-4));
assert!(close(folded.y, -6.387, 0.01));
assert!(!caption_placement(45.9, 0).swapped);
assert!(caption_placement(46.0, 0).swapped);
let top = caption_placement(96.0, 0);
let bottom = caption_placement(96.0, 1);
assert!(close(top.x, SWAP_REST[0][0], 1e-4));
assert!(close(top.y, SWAP_REST[0][1], 1e-4));
assert!(close(bottom.x, SWAP_REST[1][0], 1e-4));
assert!(close(bottom.y, SWAP_REST[1][1], 1e-4));
assert!(close(text_zoom(96.0), 1.1741, 1e-4));
assert!(close(
caption_anchor(CENTRE, UNIT, &top).0,
960.0 - 71.0 * UNIT,
1e-3
));
assert!(close(
caption_anchor(CENTRE, UNIT, &top).1,
540.0 - 94.6 * UNIT,
1e-3
));
assert!(split_bottom(42.0) > 0.0);
assert!(close(split_top(42.0), 0.0, 1e-6));
}
#[test]
fn the_drawn_collage_box_matches_the_demos_own_ink() {
let pan = collage_pan(96.0);
let left = collage_box(CENTRE, UNIT, &COLLAGE[0], pan);
assert!(close(left.x, 145.0 * 2.0, 3.0), "left {}", left.x);
assert!(close(left.w, 105.0 * UNIT, 1e-3));
assert!(close(left.h, 117.0 * UNIT, 1e-3));
let right = collage_box(CENTRE, UNIT, &COLLAGE[4], pan);
assert!(close(right.x + right.w, 844.5 * 2.0, 3.0));
let bottom = collage_box(CENTRE, UNIT, &COLLAGE[3], pan);
assert!(close(bottom.y + bottom.h, 484.5 * 2.0, 3.0));
let centre = collage_box(CENTRE, UNIT, &COLLAGE[1], 0.0);
assert!(close(centre.centre_x(), 960.0 + COLLAGE[1].x * UNIT, 1e-3));
assert!(close(centre.centre_y(), 540.0 + COLLAGE[1].y * UNIT, 1e-3));
}
#[test]
fn the_drawn_blob_is_the_sources_own_rounded_box() {
let glow = blob_box(CENTRE, UNIT, &GLOW);
assert!(close(glow.w, 2.0 * 261.6 * UNIT, 1e-3));
assert!(close(glow.h, 2.0 * 147.0 * UNIT, 1e-3));
assert!(close(glow.centre_x(), 960.0 + 10.9 * UNIT, 1e-3));
assert!(close(glow.centre_y(), 540.0 + 6.4 * UNIT, 1e-3));
assert!(close(blur_sigma(&GLOW), 18.1, 1e-4));
let layer = glow.grown(18.1).expect("a grown layer");
assert!(close(layer.w, glow.w + 2.0 * 18.1, 1e-3));
assert!(close(layer.centre_x(), glow.centre_x(), 1e-3));
assert!(glow.grown(-glow.h / 2.0).is_none());
}
#[test]
fn the_blur_ladder_reproduces_a_gaussian_edges_coverage() {
let mut show_through = 1.0_f32;
for (_, alpha) in BLUR_LAYERS {
assert!(alpha > 0.0 && alpha < 1.0, "alpha {alpha}");
show_through *= 1.0 - alpha;
}
assert!(close(show_through, BLUR_CORE_SHOW_THROUGH, 1e-6));
assert!(close(1.0 - show_through, 0.993_79, 1e-5));
assert!(BLUR_LAYERS[10].1 > BLUR_LAYERS[0].1);
for pair in BLUR_LAYERS.windows(2) {
assert!(
pair[1].1 > pair[0].1,
"{:?} should be above {:?}",
pair[1],
pair[0]
);
assert!(pair[1].0 < pair[0].0);
}
let mut edge_show_through = 1.0_f32;
for (offset, alpha) in BLUR_LAYERS {
if offset >= 0.0 {
edge_show_through *= 1.0 - alpha;
}
}
assert!(
close(edge_show_through, 0.5, 1e-5),
"edge coverage {}",
1.0 - edge_show_through
);
}
#[test]
fn a_pool_wraps_and_an_empty_pool_draws_nothing() {
let pool = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
assert_eq!(pool_entry(&pool, 0), Some("a"));
assert_eq!(pool_entry(&pool, 2), Some("c"));
assert_eq!(pool_entry(&pool, 3), Some("a"));
assert_eq!(pool_entry(&pool, 8), Some("c"));
let empty: Vec<String> = Vec::new();
assert_eq!(pool_entry(&empty, 0), None);
}
#[test]
fn the_embedded_placeholder_images_are_the_shipped_defaults() {
let config = ReelCollageConfig::default();
assert_eq!(config.top_line, "The videos");
assert_eq!(config.bottom_line, "That you ship");
assert_eq!(config.swap_top, "Especially on");
assert_eq!(config.swap_bottom, "Launch day");
assert_eq!(
config.slides,
DEFAULT_SLIDES
.iter()
.map(|name| (*name).to_owned())
.collect::<Vec<_>>()
);
assert_eq!(
config.collage,
DEFAULT_COLLAGE
.iter()
.map(|name| (*name).to_owned())
.collect::<Vec<_>>()
);
assert_eq!(config.background, "#f5f4f2");
assert_eq!(config.glow_color, "#dfe7e3");
assert_eq!(config.flush_color, "#fdc7bc");
assert_eq!(config.text_color, None);
assert_eq!(config.mode, Mode::Light);
assert_eq!(config.theme, None);
assert_eq!(config.font_family, None);
assert!(close(config.speed, 1.0, 1e-6));
assert_eq!(config.duration, 96);
assert!(config.simulate_blur);
}
#[test]
fn the_geometry_constants_are_the_sources_own() {
assert!(close(REF_W, 712.0, 1e-6));
assert!(close(FONT_SIZE, 19.22, 1e-6));
assert!(close(LETTER_SPACING_EM, -0.068, 1e-6));
assert!(close(BOX_W, 282.08, 1e-6));
assert_eq!(CAPTION_OPEN, [-110.07, 125.53]);
assert_eq!(CAPTION_SHUT, [-5.44, 13.75]);
assert!(close(SWAP_DX, -1.9, 1e-6));
assert_eq!(SWAP_REST, [[-71.0, -94.6], [41.0, 104.6]]);
assert!(close(SWAP_AT, 46.0, 1e-6));
assert_eq!(CUTS, [0.0, 3.0, 6.0, 10.0, 14.0, 19.0, 25.0, 29.0]);
assert_eq!(SHOTS[0].aspect, 1.505);
assert_eq!(SHOTS[7].dx, 1.3);
assert_eq!(COLLAGE[1].from, 0.425);
assert_eq!(GLOW.blur, 36.2);
assert_eq!(FLUSH.blur, 58.6);
assert_eq!(CAPTION_WEIGHT, 500);
}
#[test]
fn the_component_reports_its_own_identity() {
let component = ReelCollage::default();
assert_eq!(ReelCollage::meta_static().id, "reel-collage");
assert_eq!(ReelCollage::meta_static().feature, "reel-collage");
assert_eq!(component.meta().name, "Reel Collage");
assert_eq!(component.background(), Some("#f5f4f2"));
let custom = ReelCollage::new(ReelCollageConfig {
duration: 120,
..Default::default()
});
assert_eq!(custom.duration(), 120);
}
}