lib.rsannotatedlib.rssource228 lines · 8.4 KB · raw

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;
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};

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.

23pub fn chrome() -> Chrome {
24    Chrome::new(2)
25}

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.

32pub fn site() -> String {
33    SITE_ORIGIN.split("://").nth(1).unwrap_or(SITE_ORIGIN).to_owned()
34}

The dot field and band as a whole SVG, for tools/preview to rasterise.

37pub fn background() -> String {
38    chrome().background_svg(&site())
39}

The cat is drawn this many pixels to a grid unit.

42const UNIT: f32 = 17.0;

Space between the cat and the words.

44const GUTTER: u32 = 32;
45const PLUM: &str = "#8a2456";

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}

Where the words begin.

54fn column_x() -> u32 {
55    chrome().content().x + (VIEW.2 * UNIT).ceil() as u32 + GUTTER
56}

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.

102pub fn card() -> String {
103    picture(Mood::Idle, 1.0, (0.9, 0.1), "hello")
104}

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.

122pub fn drift(t: f32) -> (f32, f32) {
123    ((t * 0.8).sin() * 0.9, (t * 1.3).sin() * 0.35)
124}

How many frames the loop has.

127pub fn frame_count() -> u32 {
128    TOUR.iter().map(|b| b.ms * FPS / 1000).sum()
129}

Seconds the loop lasts.

132pub fn seconds() -> f32 {
133    frame_count() as f32 / FPS as f32
134}

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}
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}