Skip to main content

rustmotion_components/
caption.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Font, FontStyle, Rect};
4
5use rustmotion_core::css::style::{
6    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
7    WhiteSpace as CssWhiteSpace,
8};
9use rustmotion_core::css::units::LengthContext;
10use rustmotion_core::css::CssStyle;
11use rustmotion_core::engine::animator::AnimatedProperties;
12use rustmotion_core::engine::layout_pass::BoxLayout;
13use rustmotion_core::engine::renderer::{
14    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
15    typeface_with_fallback,
16};
17use rustmotion_core::schema::{CaptionStyle, CaptionWord, TimelineStep};
18use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
19
20#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21#[deprecated(
22    since = "0.7.1",
23    note = "`caption` is a frozen composition. Compose a `for-each` over the words with \
24            `start_at`/`end_at` per word and an expression picking the active one instead \
25            — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
26            compatibility; scheduled for removal in a future major version via \
27            `rustmotion migrate` (#335)."
28)]
29pub struct Caption {
30    pub words: Vec<CaptionWord>,
31    #[serde(default = "default_active_color")]
32    pub active_color: String,
33    #[serde(default)]
34    pub mode: CaptionStyle,
35    #[serde(default)]
36    pub max_width: Option<f32>,
37    /// Pill background color behind the active word (`word_pop` /
38    /// `karaoke_pop` modes). Defaults to black at 70% opacity.
39    #[serde(default)]
40    pub pill_color: Option<String>,
41    #[serde(default)]
42    pub style: CssStyle,
43    #[serde(flatten)]
44    pub timing: TimingConfig,
45    #[serde(default)]
46    pub timeline: Vec<TimelineStep>,
47    #[serde(default)]
48    pub stagger: Option<f32>,
49}
50
51rustmotion_core::impl_traits!(Caption {
52    Animatable => animation,
53    Timed => timing,
54    Styled => style,
55});
56
57impl Caption {
58    fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) {
59        let time = ctx.time;
60        let base_ctx = crate::intrinsic::font_size_ctx(
61            ctx.video_width as f32,
62            ctx.video_height as f32,
63            layout_width.max(0.0),
64        );
65        let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
66        let color = self.style.color_str_or("#FFFFFF");
67        let font_family = self.style.font_family_or("Inter");
68
69        let type_ctx = LengthContext {
70            font_size,
71            ..base_ctx
72        };
73
74        let font_style = Self::resolve_font_style(&self.style);
75
76        let Ok(typeface) = typeface_with_fallback(font_family, font_style) else {
77            return;
78        };
79
80        let font = Font::from_typeface(typeface, font_size);
81        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
82
83        let top_offset = font_size * 1.2;
84        canvas.save();
85        canvas.translate((0.0, top_offset));
86        if layout_height > 0.0 {
87            const HALF_PLANE: f32 = 1_000_000.0;
88            canvas.clip_rect(
89                Rect::from_xywh(-HALF_PLANE, -top_offset, HALF_PLANE * 2.0, layout_height),
90                skia_safe::ClipOp::Intersect,
91                true,
92            );
93        }
94
95        match self.mode {
96            CaptionStyle::WordByWord => {
97                for word in &self.words {
98                    if time >= word.start && time < word.end {
99                        let paint = paint_from_hex(&self.active_color);
100                        let text_width =
101                            measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
102
103                        let cx = layout_width / 2.0;
104
105                        if let Some(bg_color) = self.style.background_color_str() {
106                            let padding = font_size * 0.3;
107                            let bg_rect = Rect::from_xywh(
108                                cx - text_width / 2.0 - padding,
109                                -font_size - padding / 2.0,
110                                text_width + padding * 2.0,
111                                font_size * 1.4 + padding,
112                            );
113                            let bg_paint = paint_from_hex(bg_color);
114                            let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
115                            canvas.draw_rrect(rrect, &bg_paint);
116                        }
117
118                        let x = cx - text_width / 2.0;
119                        draw_text_with_fallback(
120                            canvas,
121                            &word.text,
122                            &font,
123                            &emoji_font,
124                            0.0,
125                            x,
126                            0.0,
127                            &paint,
128                        );
129                        break;
130                    }
131                }
132            }
133            CaptionStyle::WordPop => {
134                for word in &self.words {
135                    if time >= word.start && time < word.end {
136                        let text_width =
137                            measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
138                        let cx = layout_width / 2.0;
139
140                        let t = (((time - word.start) / POP_DURATION).clamp(0.0, 1.0)) as f32;
141                        let scale = ease_out_back(t).max(0.01);
142
143                        let cy = -font_size * 0.35;
144                        canvas.save();
145                        canvas.translate((cx, cy));
146                        canvas.scale((scale, scale));
147                        canvas.translate((-cx, -cy));
148
149                        let padding = font_size * 0.35;
150                        self.draw_pill(
151                            canvas,
152                            Rect::from_xywh(
153                                cx - text_width / 2.0 - padding,
154                                -font_size - padding / 2.0,
155                                text_width + padding * 2.0,
156                                font_size * 1.4 + padding,
157                            ),
158                        );
159
160                        let paint = paint_from_hex(&self.active_color);
161                        draw_text_with_fallback(
162                            canvas,
163                            &word.text,
164                            &font,
165                            &emoji_font,
166                            0.0,
167                            cx - text_width / 2.0,
168                            0.0,
169                            &paint,
170                        );
171                        canvas.restore();
172                        break;
173                    }
174                }
175            }
176            CaptionStyle::Highlight | CaptionStyle::Karaoke | CaptionStyle::KaraokePop => {
177                let nowrap = matches!(
178                    self.style.white_space,
179                    Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
180                );
181                let max_width = if nowrap {
182                    f32::MAX
183                } else if layout_width.is_finite() && layout_width > 0.0 {
184                    self.max_width
185                        .map_or(layout_width, |mw| mw.min(layout_width))
186                } else {
187                    self.max_width.unwrap_or(f32::MAX)
188                };
189                let space_width = measure_text_with_fallback(" ", &font, &emoji_font, 0.0);
190
191                let mut lines: Vec<Vec<(usize, f32)>> = vec![vec![]];
192                let mut current_x = 0.0f32;
193
194                for (i, word) in self.words.iter().enumerate() {
195                    let word_width =
196                        measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
197                    if current_x + word_width > max_width && !lines.last().unwrap().is_empty() {
198                        lines.push(vec![]);
199                        current_x = 0.0;
200                    }
201                    lines.last_mut().unwrap().push((i, word_width));
202                    current_x += word_width + space_width;
203                }
204
205                let line_height = self.style.line_height_for_ctx(font_size, &type_ctx);
206                let cx = layout_width / 2.0;
207
208                if let Some(bg_color) = self.style.background_color_str() {
209                    let padding = font_size * 0.3;
210                    let total_height = lines.len() as f32 * line_height;
211                    let max_line_width = lines
212                        .iter()
213                        .map(|line| {
214                            line.iter().map(|(_, w)| w).sum::<f32>()
215                                + (line.len().saturating_sub(1)) as f32 * space_width
216                        })
217                        .fold(0.0f32, f32::max);
218                    let bg_rect = Rect::from_xywh(
219                        cx - max_line_width / 2.0 - padding,
220                        -font_size - padding / 2.0,
221                        max_line_width + padding * 2.0,
222                        total_height + padding,
223                    );
224                    let bg_paint = paint_from_hex(bg_color);
225                    let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
226                    canvas.draw_rrect(rrect, &bg_paint);
227                }
228
229                for (line_idx, line) in lines.iter().enumerate() {
230                    let line_width: f32 = line.iter().map(|(_, w)| w).sum::<f32>()
231                        + (line.len().saturating_sub(1)) as f32 * space_width;
232                    let mut x = cx - line_width / 2.0;
233                    let y = line_idx as f32 * line_height;
234
235                    for (word_idx, word_width) in line {
236                        let word = &self.words[*word_idx];
237                        let is_active = time >= word.start && time < word.end;
238                        let pop = is_active && matches!(self.mode, CaptionStyle::KaraokePop);
239                        let word_color = if is_active { &self.active_color } else { color };
240                        let paint = paint_from_hex(word_color);
241
242                        if pop {
243                            let wcx = x + word_width / 2.0;
244                            let wcy = y - font_size * 0.35;
245                            canvas.save();
246                            canvas.translate((wcx, wcy));
247                            canvas.scale((KARAOKE_POP_SCALE, KARAOKE_POP_SCALE));
248                            canvas.translate((-wcx, -wcy));
249
250                            let padding = font_size * 0.18;
251                            self.draw_pill(
252                                canvas,
253                                Rect::from_xywh(
254                                    x - padding,
255                                    y - font_size - padding / 2.0,
256                                    word_width + padding * 2.0,
257                                    font_size * 1.4 + padding,
258                                ),
259                            );
260                        }
261
262                        draw_text_with_fallback(
263                            canvas,
264                            &word.text,
265                            &font,
266                            &emoji_font,
267                            0.0,
268                            x,
269                            y,
270                            &paint,
271                        );
272                        if pop {
273                            canvas.restore();
274                        }
275                        x += word_width + space_width;
276                    }
277                }
278            }
279        }
280        canvas.restore();
281    }
282}
283
284impl Caption {
285    fn draw_pill(&self, canvas: &Canvas, rect: Rect) {
286        let radius = rect.height() / 2.0;
287        let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR));
288        canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint);
289    }
290
291    fn resolve_font_style(style: &CssStyle) -> FontStyle {
292        let weight = match &style.font_weight {
293            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
294                skia_safe::font_style::Weight::BOLD
295            }
296            Some(CssFontWeight::Number(n)) if *n >= 600 => skia_safe::font_style::Weight::BOLD,
297            Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32),
298            _ => skia_safe::font_style::Weight::NORMAL,
299        };
300        let slant = match style.font_style {
301            Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
302            Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
303            _ => skia_safe::font_style::Slant::Upright,
304        };
305        FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant)
306    }
307}
308
309impl Painter for Caption {
310    fn paint_content(
311        &self,
312        canvas: &Canvas,
313        layout: &BoxLayout,
314        _props: &AnimatedProperties,
315        ctx: &PaintCtx,
316    ) {
317        self.paint(canvas, layout.width, layout.height, ctx);
318    }
319}
320
321fn default_active_color() -> String {
322    "#FFFF00".to_string()
323}
324
325const DEFAULT_PILL_COLOR: &str = "#000000B3";
326
327const POP_DURATION: f64 = 0.18;
328
329const KARAOKE_POP_SCALE: f32 = 1.15;
330
331fn ease_out_back(t: f32) -> f32 {
332    const C1: f32 = 1.70158;
333    const C3: f32 = C1 + 1.0;
334    let p = t - 1.0;
335    1.0 + C3 * p * p * p + C1 * p * p
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use rustmotion_core::css::style::CssStyle;
342    use rustmotion_core::css::Length;
343    use rustmotion_core::schema::CaptionWord;
344
345    fn test_ctx(time: f64) -> PaintCtx {
346        PaintCtx {
347            time,
348            scenario_time: time,
349            scene_duration: 2.0,
350            frame_index: (time * 30.0) as u32,
351            fps: 30,
352            video_width: 1920,
353            video_height: 1080,
354            stagger_offset: 0.0,
355        }
356    }
357
358    fn make_caption(text: &str, white_space: Option<CssWhiteSpace>) -> Caption {
359        make_caption_with_max_width(text, white_space, Some(80.0))
360    }
361
362    fn make_caption_with_max_width(
363        text: &str,
364        white_space: Option<CssWhiteSpace>,
365        max_width: Option<f32>,
366    ) -> Caption {
367        let words = text
368            .split_whitespace()
369            .map(|w| CaptionWord {
370                text: w.to_string(),
371                start: 0.0,
372                end: 1000.0,
373            })
374            .collect();
375        Caption {
376            words,
377            active_color: default_active_color(),
378            mode: CaptionStyle::Highlight,
379            max_width,
380            pill_color: None,
381            style: CssStyle {
382                font_size: Some(Length::Px(28.0)),
383                white_space,
384                ..Default::default()
385            },
386            timing: Default::default(),
387            timeline: Vec::new(),
388            stagger: None,
389        }
390    }
391
392    fn ink_bounds(
393        surface: &mut skia_safe::Surface,
394        w: i32,
395        h: i32,
396    ) -> Option<(i32, i32, i32, i32)> {
397        let snapshot = surface.image_snapshot();
398        let info = skia_safe::ImageInfo::new(
399            (w, h),
400            skia_safe::ColorType::RGBA8888,
401            skia_safe::AlphaType::Premul,
402            None,
403        );
404        let mut buf = vec![0u8; (w * h * 4) as usize];
405        let ok = snapshot.read_pixels(
406            &info,
407            &mut buf,
408            (w * 4) as usize,
409            skia_safe::IPoint::new(0, 0),
410            skia_safe::image::CachingHint::Disallow,
411        );
412        assert!(ok, "pixel read should succeed");
413        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
414        for y in 0..h {
415            for x in 0..w {
416                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
417                    minx = minx.min(x);
418                    maxx = maxx.max(x);
419                    miny = miny.min(y);
420                    maxy = maxy.max(y);
421                }
422            }
423        }
424        (minx <= maxx).then_some((minx, maxx, miny, maxy))
425    }
426
427    #[test]
428    fn ink_never_starts_above_the_box_top() {
429        let caption = make_caption("Hello world", None);
430        const W: i32 = 400;
431        const H: i32 = 200;
432        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
433        {
434            let canvas = surface.canvas();
435            caption.paint(canvas, W as f32, H as f32, &test_ctx(0.5));
436        }
437        let (_minx, _maxx, miny, _maxy) =
438            ink_bounds(&mut surface, W, H).expect("caption must paint something");
439        assert!(miny >= 0, "ink starts above the box top at y={miny}");
440    }
441
442    #[test]
443    fn word_pop_pill_never_starts_above_the_box_top() {
444        let mut caption = make_caption("Hello", None);
445        caption.mode = CaptionStyle::WordPop;
446        caption.words[0].start = 0.0;
447        caption.words[0].end = 10.0;
448        const W: i32 = 400;
449        const H: i32 = 200;
450        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
451        {
452            let canvas = surface.canvas();
453            caption.paint(canvas, W as f32, H as f32, &test_ctx(0.1));
454        }
455        let (_minx, _maxx, miny, _maxy) =
456            ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something");
457        assert!(miny >= 0, "pill starts above the box top at y={miny}");
458    }
459
460    #[test]
461    fn nowrap_paints_one_wide_line_instead_of_wrapping_at_max_width() {
462        let caption = make_caption(
463            "the quick brown fox jumps over the lazy dog",
464            Some(CssWhiteSpace::Nowrap),
465        );
466        const W: i32 = 1600;
467        const H: i32 = 400;
468        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
469        {
470            let canvas = surface.canvas();
471            canvas.translate((800.0, 250.0));
472            caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
473        }
474        let (minx, maxx, miny, maxy) =
475            ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something");
476
477        assert!(
478            maxx - minx > 240,
479            "nowrap caption must bleed far past its 80px max_width, got ink width {}",
480            maxx - minx
481        );
482        assert!(
483            maxy - miny < 50,
484            "nowrap caption must stay on one line, got ink height {}",
485            maxy - miny
486        );
487    }
488
489    #[test]
490    fn normal_white_space_wraps_at_max_width() {
491        let caption = make_caption("the quick brown fox jumps over the lazy dog", None);
492        const W: i32 = 1600;
493        const H: i32 = 400;
494        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
495        {
496            let canvas = surface.canvas();
497            canvas.translate((800.0, 250.0));
498            caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
499        }
500        let (minx, maxx, miny, maxy) =
501            ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something");
502
503        assert!(
504            maxx - minx < 200,
505            "wrapped caption must pack close to its 80px max_width, got ink width {}",
506            maxx - minx
507        );
508        assert!(
509            maxy - miny > 50,
510            "wrapped caption must spread across multiple lines, got ink height {}",
511            maxy - miny
512        );
513    }
514
515    #[test]
516    fn wraps_at_layout_width_when_max_width_is_unset() {
517        let caption = make_caption_with_max_width(
518            "the quick brown fox jumps over the lazy dog again",
519            None,
520            None,
521        );
522        const W: i32 = 1600;
523        const H: i32 = 400;
524        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
525        {
526            let canvas = surface.canvas();
527            canvas.translate((800.0, 200.0));
528            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
529        }
530        let (minx, maxx, miny, maxy) =
531            ink_bounds(&mut surface, W, H).expect("caption must paint something");
532
533        assert!(
534            maxx - minx < 320,
535            "must wrap within ~layout_width (300px), got ink width {}",
536            maxx - minx
537        );
538        assert!(
539            maxy - miny > 50,
540            "must spread across multiple lines when max_width is unset, got ink height {}",
541            maxy - miny
542        );
543    }
544
545    #[test]
546    fn nowrap_still_ignores_layout_width_when_max_width_is_unset() {
547        let caption = make_caption_with_max_width(
548            "the quick brown fox jumps over the lazy dog",
549            Some(CssWhiteSpace::Nowrap),
550            None,
551        );
552        const W: i32 = 1600;
553        const H: i32 = 400;
554        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
555        {
556            let canvas = surface.canvas();
557            canvas.translate((800.0, 200.0));
558            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
559        }
560        let (minx, maxx, miny, maxy) =
561            ink_bounds(&mut surface, W, H).expect("caption must paint something");
562
563        assert!(
564            maxx - minx > 400,
565            "nowrap must still bleed past layout_width, got ink width {}",
566            maxx - minx
567        );
568        assert!(
569            maxy - miny < 50,
570            "nowrap must stay on one line, got ink height {}",
571            maxy - miny
572        );
573    }
574
575    #[test]
576    fn honours_style_line_height_instead_of_hardcoded_1_4() {
577        let mut tight = make_caption_with_max_width(
578            "one two three four five six seven eight",
579            None,
580            Some(80.0),
581        );
582        tight.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(0.9));
583        let mut loose = make_caption_with_max_width(
584            "one two three four five six seven eight",
585            None,
586            Some(80.0),
587        );
588        loose.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(2.0));
589
590        const W: i32 = 1600;
591        const H: i32 = 800;
592
593        let mut surf_tight =
594            skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
595        {
596            let canvas = surf_tight.canvas();
597            canvas.translate((800.0, 50.0));
598            tight.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
599        }
600        let (_, _, _, tight_maxy) =
601            ink_bounds(&mut surf_tight, W, H).expect("tight caption must paint something");
602
603        let mut surf_loose =
604            skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
605        {
606            let canvas = surf_loose.canvas();
607            canvas.translate((800.0, 50.0));
608            loose.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
609        }
610        let (_, _, _, loose_maxy) =
611            ink_bounds(&mut surf_loose, W, H).expect("loose caption must paint something");
612
613        assert!(
614            loose_maxy > tight_maxy + 50,
615            "line-height: 2.0 must spread lines much further than 0.9 \
616             (tight bottom={tight_maxy}, loose bottom={loose_maxy})"
617        );
618    }
619
620    #[test]
621    fn rem_font_size_paints_visible_ink() {
622        let mut caption = make_caption_with_max_width("hello world", None, Some(300.0));
623        caption.style.font_size = Some(Length::String("2rem".into()));
624        const W: i32 = 400;
625        const H: i32 = 200;
626        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
627        {
628            let canvas = surface.canvas();
629            caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
630        }
631        let bounds = ink_bounds(&mut surface, W, H);
632        assert!(
633            bounds.is_some(),
634            "caption at font-size: 2rem must paint visible ink"
635        );
636    }
637
638    #[test]
639    fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() {
640        let style = CssStyle::default();
641        let resolved = Caption::resolve_font_style(&style);
642        assert_eq!(
643            *resolved.weight(),
644            400,
645            "unset font-weight must resolve to normal (400), not a hardcoded bold"
646        );
647    }
648
649    #[test]
650    fn resolve_font_style_honours_explicit_bold_and_numeric_weight() {
651        let bold = CssStyle {
652            font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bold)),
653            ..Default::default()
654        };
655        assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700);
656
657        let numeric = CssStyle {
658            font_weight: Some(CssFontWeight::Number(350)),
659            ..Default::default()
660        };
661        assert_eq!(*Caption::resolve_font_style(&numeric).weight(), 350);
662    }
663
664    #[test]
665    fn resolve_font_style_honours_italic() {
666        let italic = CssStyle {
667            font_style: Some(CssFontStyle::Italic),
668            ..Default::default()
669        };
670        assert_eq!(
671            Caption::resolve_font_style(&italic).slant(),
672            skia_safe::font_style::Slant::Italic
673        );
674    }
675}