The link preview, drawn: one still card and the frames of a looping video, as SVG, from the
same cat data the page draws (cat) and the same tour the page plays (site::demo).
tools/preview rasterises them with resvg and encodes the MP4 with ffmpeg.
The look is lmjtfy's card (jev_ui::card): the dot field, the pink band with the site's address,
and a window with a black title bar, with the words set a pixel at a time in an 8 by 8 face, so
no font is needed and the result is the same everywhere.
9use std::fmt::Write;
Frames a second of the video.
19pub const FPS: u32 = 20;
The sheet: lmjtfy's card, at twice its size (jev_ui::card). The page's own look, so the
picture a link unfurls with looks like the page it opens.
The file the dot field and the pink band are rasterised to, beside the frames (tools/preview).
They never change, so they are drawn once rather than into every frame.
29pub const BACKGROUND: &str = "background.png";
The words on the pink band: the site's address.
The dot field and band as a whole SVG, for tools/preview to rasterise.
The cat is drawn this many pixels to a grid unit.
42const UNIT: f32 = 17.0;
Where the cat is drawn: the left of the window's content, centred up and down.
Where the words begin.
The words in the right-hand column: (offset down from the content's top, scale, colour, words), 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}
Where the label's box begins, down from the content's top.
72const LABEL_Y: u32 = 276;
One 1200 by 630 picture: the cat in mood at t seconds, looking gaze, and label in the
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}
The still card: the cat waiting, looking at the words.
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}
Where the eyes look at time t: slowly round, as if watching something.
How many frames the loop has.
The tour begins here; kept so a test can tell the video starts where the page does.
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}