1//! The link preview, drawn: one still card and the frames of a looping video, as SVG, from the 2//! same cat data the page draws (`cat`) and the same tour the page plays (`site::demo`). 3//! `tools/preview` rasterises them with resvg and encodes the MP4 with ffmpeg. 4//! 5//! The look is lmjtfy's card (`jev_ui::card`): the dot field, the pink band with the site's address, 6//! and a window with a black title bar, with the words set a pixel at a time in an 8 by 8 face, so 7//! no font is needed and the result is the same everywhere. 8 9use std::fmt::Write; 10 11use cat::svg::{Mode, fragment}; 12use cat::{Mascot, Mood}; 13use cat::{scene, scene::VIEW}; 14use jev_ui::card::{Chrome, INK, PAPER, text, width}; 15use site::config::SITE_ORIGIN; 16use site::demo::{Step, TOUR}; 17 18/// Frames a second of the video. 19pub const FPS: u32 = 20; 20 21/// The sheet: lmjtfy's card, at twice its size (`jev_ui::card`). The page's own look, so the 22/// picture a link unfurls with looks like the page it opens. 23pub fn chrome() -> Chrome { 24 Chrome::new(2) 25} 26 27/// The file the dot field and the pink band are rasterised to, beside the frames (`tools/preview`). 28/// They never change, so they are drawn once rather than into every frame. 29pub const BACKGROUND: &str = "background.png"; 30 31/// The words on the pink band: the site's address. 32pub fn site() -> String { 33 SITE_ORIGIN.split("://").nth(1).unwrap_or(SITE_ORIGIN).to_owned() 34} 35 36/// The dot field and band as a whole SVG, for `tools/preview` to rasterise. 37pub fn background() -> String { 38 chrome().background_svg(&site()) 39} 40 41/// The cat is drawn this many pixels to a grid unit. 42const UNIT: f32 = 17.0; 43/// Space between the cat and the words. 44const GUTTER: u32 = 32; 45const PLUM: &str = "#8a2456"; 46 47/// Where the cat is drawn: the left of the window's content, centred up and down. 48fn cat_origin() -> (f32, f32) { 49 let content = chrome().content(); 50 (content.x as f32, content.y as f32 + (content.h as f32 - VIEW.3 * UNIT) / 2.0) 51} 52 53/// Where the words begin. 54fn column_x() -> u32 { 55 chrome().content().x + (VIEW.2 * UNIT).ceil() as u32 + GUTTER 56} 57 58/// The words in the right-hand column: (offset down from the content's top, scale, colour, words), 59/// then the label of the moment in a black box. Every one must fit inside the window. 60fn lines() -> Vec<(u32, u32, &'static str, String)> { 61 vec![ 62 (0, 8, INK, "Whiskers".to_owned()), 63 (82, 4, INK, "A talking cat for".to_owned()), 64 (118, 4, INK, "a young child".to_owned()), 65 (180, 2, INK, "Parents see every conversation".to_owned()), 66 (202, 2, INK, "Jev checks every message".to_owned()), 67 (232, 2, PLUM, "! A personal project, not a product".to_owned()), 68 ] 69} 70 71/// Where the label's box begins, down from the content's top. 72const LABEL_Y: u32 = 276; 73 74/// One 1200 by 630 picture: the cat in `mood` at `t` seconds, looking `gaze`, and `label` in the 75/// black box under the words. A whole SVG document; the dot field is `BACKGROUND`, beside it. 76pub fn picture(mood: Mood, t: f32, gaze: (f32, f32), label: &str) -> String { 77 let chrome = chrome(); 78 let (x0, y0, ..) = VIEW; 79 let content = chrome.content(); 80 let mut out = format!( 81 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" shape-rendering=\"crispEdges\">", 82 w = chrome.width(), 83 h = chrome.height() 84 ); 85 out.push_str(&chrome.background_image(BACKGROUND)); 86 out.push_str(&chrome.window_svg("Whiskers")); 87 let (cx, cy) = cat_origin(); 88 let _ = write!(out, "<g transform=\"translate({cx} {cy}) scale({UNIT}) translate({} {})\">", -x0, -y0); 89 out.push_str(&fragment(&scene(Mascot::DEFAULT, mood).nodes, &Mode::Frame { t, gaze })); 90 out.push_str("</g>"); 91 for (down, scale, colour, words) in lines() { 92 out.push_str(&text(column_x(), content.y + down, scale, colour, &words)); 93 } 94 let (bx, by, scale) = (column_x(), content.y + LABEL_Y, 4); 95 let _ = write!(out, "<rect x=\"{bx}\" y=\"{by}\" width=\"{}\" height=\"{}\" fill=\"{INK}\"/>", width(scale, label) + 16, 8 * scale + 16); 96 out.push_str(&text(bx + 8, by + 8, scale, PAPER, label)); 97 out.push_str("</svg>"); 98 out 99} 100 101/// The still card: the cat waiting, looking at the words. 102pub fn card() -> String { 103 picture(Mood::Idle, 1.0, (0.9, 0.1), "hello") 104} 105 106/// The frames of the loop: the whole tour once. Frame `i` of `frames().len()`. 107pub fn frames() -> Vec<String> { 108 let mut out = Vec::new(); 109 let mut start_ms = 0u32; 110 for beat in TOUR { 111 let count = beat.ms * FPS / 1000; 112 for k in 0..count { 113 let t = (start_ms as f32 / 1000.0) + k as f32 / FPS as f32; 114 out.push(picture(beat.mood, t, drift(t), beat.mood.name())); 115 } 116 start_ms += beat.ms; 117 } 118 out 119} 120 121/// Where the eyes look at time `t`: slowly round, as if watching something. 122pub fn drift(t: f32) -> (f32, f32) { 123 ((t * 0.8).sin() * 0.9, (t * 1.3).sin() * 0.35) 124} 125 126/// How many frames the loop has. 127pub fn frame_count() -> u32 { 128 TOUR.iter().map(|b| b.ms * FPS / 1000).sum() 129} 130 131/// Seconds the loop lasts. 132pub fn seconds() -> f32 { 133 frame_count() as f32 / FPS as f32 134} 135 136/// The tour begins here; kept so a test can tell the video starts where the page does. 137pub fn first_mood() -> Mood { 138 Step::new(0).beat().mood 139} 140 141#[cfg(test)] 142mod tests { 143 use super::*; 144 use site::config::{PREVIEW_HEIGHT, PREVIEW_WIDTH}; 145 146 #[test] 147 fn the_sheet_is_the_size_the_page_declares() { 148 assert_eq!((chrome().width(), chrome().height()), (PREVIEW_WIDTH, PREVIEW_HEIGHT)); 149 } 150 151 #[test] 152 fn every_line_fits_inside_the_window() { 153 let content = chrome().content(); 154 for label in ["hello", "listening", "thinking", "speaking", "dizzy", "sleeping"] { 155 for (down, scale, _, words) in lines() { 156 assert!(column_x() + width(scale, &words) <= content.x + content.w, "{words}"); 157 assert!(down + 8 * scale <= content.h, "{words}"); 158 } 159 let box_w = width(4, label) + 16; 160 assert!(column_x() + box_w <= content.x + content.w, "{label}"); 161 assert!(LABEL_Y + 8 * 4 + 16 <= content.h, "{label}'s box"); 162 } 163 } 164 165 #[test] 166 fn the_lines_do_not_overlap() { 167 let all = lines(); 168 for pair in all.windows(2) { 169 assert!(pair[0].0 + 8 * pair[0].1 <= pair[1].0, "{} runs into {}", pair[0].3, pair[1].3); 170 } 171 let last = all.last().unwrap(); 172 assert!(last.0 + 8 * last.1 <= LABEL_Y); 173 } 174 175 #[test] 176 fn the_cat_fits_to_the_left_of_the_words_and_inside_the_window() { 177 let content = chrome().content(); 178 let (cx, cy) = cat_origin(); 179 assert!(cx + VIEW.2 * UNIT <= column_x() as f32); 180 assert!(cy >= content.y as f32 && cy + VIEW.3 * UNIT <= (content.y + content.h) as f32); 181 } 182 183 #[test] 184 fn the_card_is_one_svg_of_the_size_the_page_declares_over_the_shared_background() { 185 let card = card(); 186 assert!(card.starts_with("<svg") && card.ends_with("</svg>")); 187 assert!(card.contains("width=\"1200\" height=\"630\"")); 188 assert!(card.contains(&format!("href=\"{BACKGROUND}\""))); 189 assert_eq!(card.matches("<g ").count(), card.matches("</g>").count()); 190 assert!(!card.contains("NaN")); 191 } 192 193 #[test] 194 fn the_background_says_the_sites_address() { 195 let bg = background(); 196 assert_eq!(site(), "whiskers.lmjtfy.fun"); 197 let name = site().to_uppercase(); 198 let at = (1200 - width(4, &name)) / 2; 199 assert!(bg.contains(&text(at, 630 - 80 + 24, 4, INK, &name)), "the address is on the band, in capitals"); 200 } 201 202 #[test] 203 fn the_loop_plays_the_tour_the_page_plays() { 204 let frames = frames(); 205 assert_eq!(frames.len() as u32, frame_count()); 206 assert_eq!(first_mood(), Mood::Idle); 207 // Every mood is on screen at some point: its beat's first frame is its own picture. 208 let mut at = 0; 209 for beat in TOUR { 210 let t = at as f32 / FPS as f32; 211 assert_eq!(frames[at], picture(beat.mood, t, drift(t), beat.mood.name())); 212 at += (beat.ms * FPS / 1000) as usize; 213 } 214 for mood in Mood::ALL { 215 assert!(TOUR.iter().any(|b| b.mood == mood)); 216 } 217 // The cat moves: successive frames differ. 218 assert_ne!(frames[0], frames[1]); 219 } 220 221 #[test] 222 fn the_eyes_stay_in_range() { 223 for i in 0..400 { 224 let (x, y) = drift(i as f32 * 0.1); 225 assert!(x.abs() <= 1.0 && y.abs() <= 1.0); 226 } 227 } 228}