fframes-components 0.1.2

Isolated, mix-and-match animated video components for fframes, ported from the remocn Remotion library
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
//! A YouTube end screen: a Subscribe call to action, social handles, and two thumbnail frames.
//!
//! Ported from Remotion Elements' `YouTubeEndCard`. The source lays the card out with flex boxes
//! on a 1920 x 1080 canvas; here every box is resolved to fixed scene coordinates in that same
//! box, which one group then fits to the canvas.
//!
//! Deliberate differences from the source:
//!
//! - **Avatar.** The default picture is the embedded `avatar1.png`, not the remote Remotion logo.
//! - **Handles.** The defaults are this crate's own channel handles, not `remotion`.
//! - **Website row.** The source always shows `remotion.dev`; here the row is omitted unless
//!   [`YouTubeEndCardConfig::website`] is set.
//! - **Springs.** `Easing.spring({ damping: 200 })` and `spring({ durationInFrames })` are both
//!   modelled as the damping-200 spring stretched so that it settles at the segment's end.
//! - **Duration.** The source reads `durationInFrames` from its host and has no fixed length, so
//!   the default here is 150 frames.
//! - **Shadows.** SVG filters are not used in this crate, so the avatar's `box-shadow` is stacked
//!   translucent circles.
//!
//! The glyphs are Font Awesome Free brand icons, as in the source.

use fframes::Svgr;

use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{Spring, clamp01, lerp};
use crate::ui::text;

/// The scene's width, in source px.
pub const SCENE_W: f32 = 1920.0;
/// The scene's height, in source px.
pub const SCENE_H: f32 = 1080.0;
/// The page behind the scene.
pub const BACKGROUND: &str = "#FAFAFA";
/// The colour of text, borders and the button.
pub const INK: &str = "#000000";
/// The colour of the button's label.
pub const BUTTON_INK: &str = "#ffffff";

/// The left column's left edge.
pub const COLUMN_X: f32 = 80.0;
/// The avatar's diameter.
pub const AVATAR_D: f32 = 140.0;
/// The gap between the avatar and the button.
pub const CTA_GAP: f32 = 30.0;
/// The button's width.
pub const BUTTON_W: f32 = 400.0;
/// The button's height.
pub const BUTTON_H: f32 = 140.0;
/// The button's corner radius.
pub const BUTTON_RADIUS: f32 = 70.0;
/// The font size of every text run.
pub const TEXT_SIZE: f32 = 50.0;
/// The font weight of every text run.
pub const TEXT_WEIGHT: u16 = 500;

/// The gap between the call to action and the first social row.
pub const LIST_MARGIN_TOP: f32 = 80.0;
/// A social row's vertical padding, top and bottom.
pub const ROW_PAD: f32 = 20.0;
/// The icon box's width.
pub const ICON_BOX_W: f32 = 140.0;
/// The icon box's height.
pub const ICON_BOX_H: f32 = 60.0;
/// How far the icon box is shifted down.
pub const ICON_BOX_DROP: f32 = 4.0;
/// The gap between the icon box and the text.
pub const ICON_TEXT_GAP: f32 = 30.0;
/// The text's left margin.
pub const TEXT_MARGIN: f32 = 20.0;
/// The gap above the optional website row.
pub const WEBSITE_GAP: f32 = 80.0;

/// The Instagram icon's rendered height.
pub const INSTAGRAM_H: f32 = 70.0;
/// The LinkedIn icon's rendered height. The source's extra `-4px` cancels the box's drop.
pub const LINKEDIN_H: f32 = 60.0;
/// The X icon's rendered height.
pub const X_H: f32 = 60.0;
/// The link icon's rendered height.
pub const LINK_H: f32 = 60.0;
/// The Instagram icon's view box (width, height).
pub const INSTAGRAM_VIEW: (f32, f32) = (448.0, 512.0);
/// The LinkedIn icon's view box (width, height).
pub const LINKEDIN_VIEW: (f32, f32) = (448.0, 512.0);
/// The X icon's view box (width, height).
pub const X_VIEW: (f32, f32) = (512.0, 512.0);
/// The link icon's view box (width, height).
pub const LINK_VIEW: (f32, f32) = (640.0, 512.0);

/// The frame the call to action starts sliding up.
pub const CTA_START: f32 = 35.0;
/// How many frames the slide takes.
pub const CTA_FRAMES: f32 = 30.0;
/// How far below its rest position the call to action starts.
pub const CTA_DROP: f32 = 281.0;
/// The frame the first row starts fading in.
pub const ROW_START: f32 = 35.0;
/// How many frames a row's fade takes.
pub const ROW_FRAMES: f32 = 15.0;
/// The spring every fade and slide uses.
pub const EASE_SPRING: Spring = Spring {
    damping: 200.0,
    mass: 1.0,
    stiffness: 100.0,
};

/// A thumbnail frame's content width.
pub const THUMB_W: f32 = 631.0;
/// A thumbnail frame's content height.
pub const THUMB_H: f32 = 361.0;
/// A thumbnail frame's border width. The source's boxes are content-box, so it adds to the size.
pub const THUMB_BORDER: f32 = 6.0;
/// The gap between the top thumbnail's top edge and the canvas top.
pub const THUMB_TOP: f32 = 135.0;
/// The gap between the bottom thumbnail's bottom edge and the canvas bottom.
pub const THUMB_BOTTOM: f32 = 155.0;
/// The gap between the thumbnails and the canvas right edge.
pub const THUMB_RIGHT: f32 = 100.0;

/// The Font Awesome `linkedin-in` glyph (448 x 512 view box).
pub const LINKEDIN_PATH: &str =
    "M100.28 448H7.4V148.9h92.88zM53.79 108.1C24.09 108.1 0 83.5 0 53.8a53.79 53.79 0 0 1 107.58 0c0 29.7-24.1 54.3-53.79 54.3zM447.9 448h-92.68V302.4c0-34.7-.7-79.2-48.29-79.2-48.29 0-55.69 37.7-55.69 76.7V448h-92.78V148.9h89.08v40.8h1.3c12.4-23.5 42.69-48.3 87.88-48.3 94 0 111.28 61.9 111.28 142.3V448z";

/// The Font Awesome `x-twitter` glyph (512 x 512 view box).
pub const X_PATH: &str =
    "M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z";

/// The Font Awesome `link` glyph (640 x 512 view box).
pub const LINK_PATH: &str =
    "M580.2 267.3c56.2-56.2 56.2-147.4 0-203.6S432.8 7.4 376.6 63.7L365.3 75l45.3 45.3 11.3-11.3c31.2-31.2 81.9-31.2 113.1 0s31.2 81.9 0 113.1L421.8 335.2c-31.2 31.2-81.9 31.2-113.1 0c-25.6-25.6-30.3-64.3-13.8-94.6c1.8-3.4 3.9-6.7 6.3-9.8l-51.2-38.4c-4.3 5.7-8.1 11.6-11.4 17.8c-29.5 54.6-21.3 124.2 24.9 170.3c56.2 56.2 147.4 56.2 203.6 0L580.2 267.3zM59.8 244.7c-56.2 56.2-56.2 147.4 0 203.6s147.4 56.2 203.6 0L274.7 437l-45.3-45.3-11.3 11.3c-31.2 31.2-81.9 31.2-113.1 0s-31.2-81.9 0-113.1L218.2 176.8c31.2-31.2 81.9-31.2 113.1 0c25.6 25.6 30.3 64.3 13.8 94.6c-1.8 3.4-3.9 6.7-6.3 9.8l51.2 38.4c4.3-5.7 8.1-11.6 11.4-17.8c29.5-54.6 21.3-124.2-24.9-170.3c-56.2-56.2-147.4-56.2-203.6 0L59.8 244.7z";

/// The Font Awesome `instagram` glyph (448 x 512 view box).
pub const INSTAGRAM_PATH: &str =
    "M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7 2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z";

/// The spring's value at `t` of a segment, stretched so that it settles at `t = 1`.
///
/// Remotion's `durationInFrames` rescales the natural spring to the requested length. The natural
/// length is the last frame at which the spring is still 0.005 or more from rest, plus one.
#[must_use]
pub fn stretched_spring(t: f32) -> f32 {
    if t <= 0.0 {
        return 0.0;
    }
    if t >= 1.0 {
        return 1.0;
    }
    let mut natural = 1.0;
    for frame in 0..1000 {
        if (EASE_SPRING.value(frame as f32) - 1.0).abs() >= 0.005 {
            natural = frame as f32 + 1.0;
        }
    }
    EASE_SPRING.value(t * natural)
}

/// A fade from `start` over `frames`, as a stretched spring clamped to 0..1.
#[must_use]
pub fn spring_fade(frame: f32, start: f32, frames: f32) -> f32 {
    stretched_spring(clamp01((frame - start) / frames))
}

/// The start frame of the social row at `index_from_last` (Instagram 4, LinkedIn 3, X 2).
#[must_use]
pub fn row_start(index_from_last: f32) -> f32 {
    ROW_START + ((index_from_last - 1.0) / 4.0) * (30.0 - 15.0)
}

/// A social row's opacity at `frame`.
#[must_use]
pub fn row_opacity(frame: f32, index_from_last: f32) -> f32 {
    spring_fade(frame, row_start(index_from_last), ROW_FRAMES)
}

/// The website row's opacity at `frame`.
#[must_use]
pub fn website_opacity(frame: f32) -> f32 {
    spring_fade(frame, ROW_START, ROW_FRAMES)
}

/// How far below its rest position the call to action sits at `frame`.
#[must_use]
pub fn cta_offset(frame: f32) -> f32 {
    lerp(CTA_DROP, 0.0, spring_fade(frame, CTA_START, CTA_FRAMES))
}

/// The scale that fits the scene to the canvas.
#[must_use]
pub fn scene_scale(canvas_width: f32, canvas_height: f32) -> f32 {
    (canvas_width / SCENE_W).min(canvas_height / SCENE_H)
}

/// What a [`YouTubeEndCard`] shows.
#[derive(Debug, Clone, PartialEq)]
pub struct YouTubeEndCardConfig {
    /// The Instagram handle, shown as given.
    pub instagram: String,
    /// The LinkedIn name, shown as given.
    pub linkedin: String,
    /// The X handle, shown as given.
    pub x: String,
    /// The button's label.
    pub subscribe_label: String,
    /// The website shown in a fourth row, or `None` to omit the row.
    pub website: Option<String>,
    /// The avatar's picture name. Empty draws an initial instead.
    pub avatar: String,
    /// The font family, or `None` for the embedded Inter.
    pub font_family: Option<String>,
    /// The length in frames.
    pub duration: usize,
}

impl Default for YouTubeEndCardConfig {
    fn default() -> Self {
        Self {
            instagram: "@Bilaal0x".to_owned(),
            linkedin: "Bilaal0x".to_owned(),
            x: "@Bilaal0x".to_owned(),
            subscribe_label: "Subscribe".to_owned(),
            website: None,
            avatar: "avatar1.png".to_owned(),
            font_family: None,
            duration: 150,
        }
    }
}

/// The YouTube end card component.
#[derive(Debug, Clone, Default)]
pub struct YouTubeEndCard {
    /// What the card shows.
    pub config: YouTubeEndCardConfig,
}

impl YouTubeEndCard {
    /// A card with `config`.
    #[must_use]
    pub fn new(config: YouTubeEndCardConfig) -> Self {
        Self { config }
    }

    /// The component's catalog entry, without an instance.
    #[must_use]
    pub fn meta_static() -> ComponentMeta {
        ComponentMeta {
            id: "youtube-end-card",
            name: "YouTube End Card",
            summary: "A Subscribe call to action and social handles beside two thumbnail frames",
            feature: "youtube-end-card",
        }
    }
}

/// An icon centred on (`cx`, `cy`) at `height`, filled black.
fn icon(path: &str, view: (f32, f32), height: f32, cx: f32, cy: f32) -> Svgr<'static> {
    let scale = height / view.1;
    let transform = format!(
        "translate({} {}) scale({scale})",
        cx - view.0 * scale / 2.0,
        cy - height / 2.0
    );
    fframes::svgr!(<path transform={transform} d={path.to_owned()} fill={INK} />)
}

/// The avatar's shadow: stacked translucent circles standing in for a 20 px blur.
fn avatar_shadow(cx: f32, cy: f32) -> Svgr<'static> {
    let mut layers: Vec<Svgr<'static>> = Vec::new();
    for step in 0..10 {
        let grow = step as f32 * 1.6;
        layers.push(fframes::svgr!(
            <circle cx={cx} cy={cy} r={AVATAR_D / 2.0 + grow} fill="#000000" opacity={0.014} />
        ));
    }
    Svgr::from(layers)
}

/// The avatar circle centred on (`cx`, `cy`).
fn avatar(
    ctx: &RenderCtx<'_>,
    config: &YouTubeEndCardConfig,
    family: &str,
    cx: f32,
    cy: f32,
) -> Svgr<'static> {
    let r = AVATAR_D / 2.0;
    let picture = if config.avatar.is_empty() {
        let initial: String = config
            .instagram
            .trim_start_matches('@')
            .chars()
            .take(1)
            .collect();
        fframes::svgr!(
            <g>
                <circle cx={cx} cy={cy} r={r} fill={INK} />
                {text::text_node(
                    ctx,
                    &initial.to_uppercase(),
                    cx,
                    text::baseline_for_centred_box(ctx, family, TEXT_SIZE, cy),
                    family.to_owned(),
                    TEXT_SIZE,
                    TEXT_WEIGHT,
                    0.0,
                    BUTTON_INK.to_owned(),
                    1.0,
                )}
            </g>
        )
    } else {
        // The circle is a clip because it rounds a picture.
        fframes::svgr!(
            <g>
                <defs>
                    <clipPath id="youtube-end-card-avatar">
                        <circle cx={cx} cy={cy} r={r} />
                    </clipPath>
                </defs>
                <image href={config.avatar.clone()} x={cx - r} y={cy - r}
                       width={AVATAR_D} height={AVATAR_D}
                       preserveAspectRatio="xMidYMid slice"
                       clip-path="url(#youtube-end-card-avatar)" />
            </g>
        )
    };
    fframes::svgr!(<g>{avatar_shadow(cx, cy)}{picture}</g>)
}

/// The call to action: avatar, gap and button, with the avatar's left edge at `COLUMN_X`.
fn call_to_action(
    ctx: &RenderCtx<'_>,
    config: &YouTubeEndCardConfig,
    family: &str,
    top: f32,
) -> Svgr<'static> {
    let cy = top + BUTTON_H / 2.0;
    let button_x = COLUMN_X + AVATAR_D + CTA_GAP;
    fframes::svgr!(
        <g>
            {avatar(ctx, config, family, COLUMN_X + AVATAR_D / 2.0, cy)}
            <rect x={button_x} y={top} width={BUTTON_W} height={BUTTON_H} rx={BUTTON_RADIUS}
                  fill={INK} />
            {text::text_node(
                ctx,
                &config.subscribe_label,
                button_x + BUTTON_W / 2.0,
                text::baseline_for_centred_box(ctx, family, TEXT_SIZE, cy),
                family.to_owned(),
                TEXT_SIZE,
                TEXT_WEIGHT,
                0.0,
                BUTTON_INK.to_owned(),
                1.0,
            )}
        </g>
    )
}

/// The height of a social row's content: the taller of the icon box and the text line.
fn content_height(ctx: &RenderCtx<'_>, family: &str) -> f32 {
    let line = ctx
        .meter(family)
        .map_or(TEXT_SIZE * 1.2, |meter| meter.line_height(TEXT_SIZE));
    ICON_BOX_H.max(line)
}

/// One social row whose padding box starts at `top`.
#[allow(clippy::too_many_arguments)]
fn social_row(
    ctx: &RenderCtx<'_>,
    family: &str,
    top: f32,
    path: &str,
    view: (f32, f32),
    icon_height: f32,
    label: &str,
    opacity: f32,
) -> Svgr<'static> {
    let content = content_height(ctx, family);
    let cy = top + ROW_PAD + content / 2.0;
    let icon_cx = COLUMN_X + ICON_BOX_W / 2.0;
    let text_x = COLUMN_X + ICON_BOX_W + ICON_TEXT_GAP + TEXT_MARGIN;
    fframes::svgr!(
        <g opacity={opacity}>
            {icon(path, view, icon_height, icon_cx, cy + ICON_BOX_DROP)}
            {text::text_node_left(
                label,
                text_x,
                text::baseline_for_centred_box(ctx, family, TEXT_SIZE, cy),
                family.to_owned(),
                TEXT_SIZE,
                TEXT_WEIGHT,
                0.0,
                INK.to_owned(),
                1.0,
            )}
        </g>
    )
}

/// A bordered, empty thumbnail frame whose outer box has its top-left at (`x`, `y`).
fn thumbnail(x: f32, y: f32) -> Svgr<'static> {
    // The stroke is centred on the rect's edge, so the rect is inset by half the border.
    let half = THUMB_BORDER / 2.0;
    fframes::svgr!(
        <rect x={x + half} y={y + half} width={THUMB_W + THUMB_BORDER}
              height={THUMB_H + THUMB_BORDER} fill="none" stroke={INK}
              stroke-width={THUMB_BORDER} />
    )
}

impl Component for YouTubeEndCard {
    fn meta(&self) -> ComponentMeta {
        Self::meta_static()
    }

    fn duration(&self) -> usize {
        self.config.duration
    }

    fn background(&self) -> Option<&'static str> {
        Some(BACKGROUND)
    }

    fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
        let config = &self.config;
        let family = config
            .font_family
            .clone()
            .unwrap_or_else(|| crate::INTER_FAMILY.to_owned());
        let frame = ctx.frame();

        // The source's box, fitted to the canvas centre.
        let scale = scene_scale(ctx.width(), ctx.height());
        let fit = format!(
            "translate({} {}) scale({scale})",
            (ctx.width() - SCENE_W * scale) / 2.0,
            (ctx.height() - SCENE_H * scale) / 2.0
        );

        let row_h = ROW_PAD * 2.0 + content_height(ctx, &family);
        let mut column_h = BUTTON_H + LIST_MARGIN_TOP + 3.0 * row_h;
        if config.website.is_some() {
            column_h += WEBSITE_GAP + row_h;
        }
        let top = (SCENE_H - column_h) / 2.0;
        let list_top = top + BUTTON_H + LIST_MARGIN_TOP;

        let cta = call_to_action(ctx, config, &family, top);
        let cta_transform = format!("translate(0 {})", cta_offset(frame));

        let mut rows: Vec<Svgr<'static>> = vec![
            social_row(
                ctx,
                &family,
                list_top,
                INSTAGRAM_PATH,
                INSTAGRAM_VIEW,
                INSTAGRAM_H,
                &config.instagram,
                row_opacity(frame, 4.0),
            ),
            social_row(
                ctx,
                &family,
                list_top + row_h,
                LINKEDIN_PATH,
                LINKEDIN_VIEW,
                LINKEDIN_H,
                &config.linkedin,
                row_opacity(frame, 3.0),
            ),
            social_row(
                ctx,
                &family,
                list_top + 2.0 * row_h,
                X_PATH,
                X_VIEW,
                X_H,
                &config.x,
                row_opacity(frame, 2.0),
            ),
        ];
        if let Some(website) = &config.website {
            rows.push(social_row(
                ctx,
                &family,
                list_top + 3.0 * row_h + WEBSITE_GAP,
                LINK_PATH,
                LINK_VIEW,
                LINK_H,
                website,
                website_opacity(frame),
            ));
        }

        let outer_w = THUMB_W + 2.0 * THUMB_BORDER;
        let outer_h = THUMB_H + 2.0 * THUMB_BORDER;
        let thumb_x = SCENE_W - THUMB_RIGHT - outer_w;
        let top_thumb = thumbnail(thumb_x, THUMB_TOP);
        let bottom_thumb = thumbnail(thumb_x, SCENE_H - THUMB_BOTTOM - outer_h);

        fframes::svgr!(
            <g transform={fit}>
                <rect x={0} y={0} width={SCENE_W} height={SCENE_H} fill={BACKGROUND} />
                <g transform={cta_transform}>{cta}</g>
                {Svgr::from(rows)}
                {top_thumb}
                {bottom_thumb}
            </g>
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_defaults_are_the_documented_config() {
        let config = YouTubeEndCardConfig::default();
        assert_eq!(config.instagram, "@Bilaal0x");
        assert_eq!(config.linkedin, "Bilaal0x");
        assert_eq!(config.x, "@Bilaal0x");
        assert_eq!(config.subscribe_label, "Subscribe");
        assert_eq!(config.website, None);
        assert_eq!(config.avatar, "avatar1.png");
        assert_eq!(config.font_family, None);
        assert_eq!(config.duration, 150);
    }

    #[test]
    fn the_meta_names_the_component_and_its_feature() {
        let component = YouTubeEndCard::default();
        assert_eq!(component.meta().id, "youtube-end-card");
        assert_eq!(YouTubeEndCard::meta_static().feature, "youtube-end-card");
        assert!(component.duration() > 0);
        assert_eq!(component.background(), Some(BACKGROUND));
    }

    #[test]
    fn the_rows_start_staggered_from_the_instagram_row_down() {
        assert!((row_start(4.0) - 46.25).abs() < 1e-4);
        assert!((row_start(3.0) - 42.5).abs() < 1e-4);
        assert!((row_start(2.0) - 38.75).abs() < 1e-4);
    }

    #[test]
    fn a_fade_is_clamped_and_settles() {
        assert_eq!(row_opacity(0.0, 4.0), 0.0);
        assert_eq!(row_opacity(46.25, 4.0), 0.0);
        assert_eq!(row_opacity(61.25, 4.0), 1.0);
        assert_eq!(row_opacity(149.0, 4.0), 1.0);
        let mid = row_opacity(53.0, 4.0);
        assert!(mid > 0.0 && mid < 1.0);
        assert_eq!(website_opacity(34.0), 0.0);
        assert_eq!(website_opacity(50.0), 1.0);
    }

    #[test]
    fn the_stretched_spring_rises_monotonically() {
        let mut last = 0.0;
        for step in 0..=20 {
            let value = stretched_spring(step as f32 / 20.0);
            assert!(value >= last - 1e-6);
            last = value;
        }
        assert_eq!(stretched_spring(1.0), 1.0);
    }

    #[test]
    fn the_call_to_action_rises_from_281_to_rest() {
        assert!((cta_offset(0.0) - 281.0).abs() < 1e-4);
        assert!((cta_offset(35.0) - 281.0).abs() < 1e-4);
        assert!(cta_offset(50.0) < 281.0 && cta_offset(50.0) > 0.0);
        assert!(cta_offset(65.0).abs() < 1e-4);
        assert!(cta_offset(120.0).abs() < 1e-4);
    }

    #[test]
    fn the_thumbnails_sit_inside_the_canvas() {
        let outer_w = THUMB_W + 2.0 * THUMB_BORDER;
        assert!((SCENE_W - THUMB_RIGHT - outer_w - 1177.0).abs() < 1e-4);
        let bottom_y = SCENE_H - THUMB_BOTTOM - (THUMB_H + 2.0 * THUMB_BORDER);
        assert!((bottom_y - 552.0).abs() < 1e-4);
        assert!(THUMB_TOP + THUMB_H + 2.0 * THUMB_BORDER < bottom_y);
    }

    #[test]
    fn the_scene_fits_landscape_and_portrait_canvases() {
        assert!((scene_scale(1920.0, 1080.0) - 1.0).abs() < 1e-6);
        assert!((scene_scale(1080.0, 1920.0) - 0.5625).abs() < 1e-6);
    }
}