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)]
21pub struct Caption {
22 pub words: Vec<CaptionWord>,
23 #[serde(default = "default_active_color")]
24 pub active_color: String,
25 #[serde(default)]
26 pub mode: CaptionStyle,
27 #[serde(default)]
28 pub max_width: Option<f32>,
29 #[serde(default)]
32 pub pill_color: Option<String>,
33 #[serde(default)]
34 pub style: CssStyle,
35 #[serde(flatten)]
36 pub timing: TimingConfig,
37 #[serde(default)]
38 pub timeline: Vec<TimelineStep>,
39 #[serde(default)]
40 pub stagger: Option<f32>,
41}
42
43rustmotion_core::impl_traits!(Caption {
44 Animatable => animation,
45 Timed => timing,
46 Styled => style,
47});
48
49impl Caption {
50 fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) {
51 let time = ctx.time;
52 let base_ctx = crate::intrinsic::font_size_ctx(
60 ctx.video_width as f32,
61 ctx.video_height as f32,
62 layout_width.max(0.0),
63 );
64 let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
65 let color = self.style.color_str_or("#FFFFFF");
66 let font_family = self.style.font_family_or("Inter");
67
68 let type_ctx = LengthContext {
73 font_size,
74 ..base_ctx
75 };
76
77 let font_style = Self::resolve_font_style(&self.style);
83
84 let Ok(typeface) = typeface_with_fallback(font_family, font_style) else {
85 return;
86 };
87
88 let font = Font::from_typeface(typeface, font_size);
89 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
90
91 let top_offset = font_size * 1.2;
103 canvas.save();
104 canvas.translate((0.0, top_offset));
105 if layout_height > 0.0 {
114 const HALF_PLANE: f32 = 1_000_000.0;
115 canvas.clip_rect(
116 Rect::from_xywh(-HALF_PLANE, -top_offset, HALF_PLANE * 2.0, layout_height),
117 skia_safe::ClipOp::Intersect,
118 true,
119 );
120 }
121
122 match self.mode {
123 CaptionStyle::WordByWord => {
124 for word in &self.words {
125 if time >= word.start && time < word.end {
126 let paint = paint_from_hex(&self.active_color);
127 let text_width =
128 measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
129
130 let cx = layout_width / 2.0;
131
132 if let Some(bg_color) = self.style.background_color_str() {
133 let padding = font_size * 0.3;
134 let bg_rect = Rect::from_xywh(
135 cx - text_width / 2.0 - padding,
136 -font_size - padding / 2.0,
137 text_width + padding * 2.0,
138 font_size * 1.4 + padding,
139 );
140 let bg_paint = paint_from_hex(bg_color);
141 let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
142 canvas.draw_rrect(rrect, &bg_paint);
143 }
144
145 let x = cx - text_width / 2.0;
146 draw_text_with_fallback(
147 canvas,
148 &word.text,
149 &font,
150 &emoji_font,
151 0.0,
152 x,
153 0.0,
154 &paint,
155 );
156 break;
157 }
158 }
159 }
160 CaptionStyle::WordPop => {
161 for word in &self.words {
162 if time >= word.start && time < word.end {
163 let text_width =
164 measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
165 let cx = layout_width / 2.0;
166
167 let t = (((time - word.start) / POP_DURATION).clamp(0.0, 1.0)) as f32;
170 let scale = ease_out_back(t).max(0.01);
171
172 let cy = -font_size * 0.35;
175 canvas.save();
176 canvas.translate((cx, cy));
177 canvas.scale((scale, scale));
178 canvas.translate((-cx, -cy));
179
180 let padding = font_size * 0.35;
181 self.draw_pill(
182 canvas,
183 Rect::from_xywh(
184 cx - text_width / 2.0 - padding,
185 -font_size - padding / 2.0,
186 text_width + padding * 2.0,
187 font_size * 1.4 + padding,
188 ),
189 );
190
191 let paint = paint_from_hex(&self.active_color);
192 draw_text_with_fallback(
193 canvas,
194 &word.text,
195 &font,
196 &emoji_font,
197 0.0,
198 cx - text_width / 2.0,
199 0.0,
200 &paint,
201 );
202 canvas.restore();
203 break;
204 }
205 }
206 }
207 CaptionStyle::Highlight | CaptionStyle::Karaoke | CaptionStyle::KaraokePop => {
208 let nowrap = matches!(
215 self.style.white_space,
216 Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
217 );
218 let max_width = if nowrap {
228 f32::MAX
229 } else if layout_width.is_finite() && layout_width > 0.0 {
230 self.max_width
231 .map_or(layout_width, |mw| mw.min(layout_width))
232 } else {
233 self.max_width.unwrap_or(f32::MAX)
234 };
235 let space_width = measure_text_with_fallback(" ", &font, &emoji_font, 0.0);
236
237 let mut lines: Vec<Vec<(usize, f32)>> = vec![vec![]];
238 let mut current_x = 0.0f32;
239
240 for (i, word) in self.words.iter().enumerate() {
241 let word_width =
242 measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0);
243 if current_x + word_width > max_width && !lines.last().unwrap().is_empty() {
244 lines.push(vec![]);
245 current_x = 0.0;
246 }
247 lines.last_mut().unwrap().push((i, word_width));
248 current_x += word_width + space_width;
249 }
250
251 let line_height = self.style.line_height_for_ctx(font_size, &type_ctx);
261 let cx = layout_width / 2.0;
262
263 if let Some(bg_color) = self.style.background_color_str() {
264 let padding = font_size * 0.3;
265 let total_height = lines.len() as f32 * line_height;
266 let max_line_width = lines
267 .iter()
268 .map(|line| {
269 line.iter().map(|(_, w)| w).sum::<f32>()
270 + (line.len().saturating_sub(1)) as f32 * space_width
271 })
272 .fold(0.0f32, f32::max);
273 let bg_rect = Rect::from_xywh(
274 cx - max_line_width / 2.0 - padding,
275 -font_size - padding / 2.0,
276 max_line_width + padding * 2.0,
277 total_height + padding,
278 );
279 let bg_paint = paint_from_hex(bg_color);
280 let rrect = skia_safe::RRect::new_rect_xy(bg_rect, padding, padding);
281 canvas.draw_rrect(rrect, &bg_paint);
282 }
283
284 for (line_idx, line) in lines.iter().enumerate() {
285 let line_width: f32 = line.iter().map(|(_, w)| w).sum::<f32>()
286 + (line.len().saturating_sub(1)) as f32 * space_width;
287 let mut x = cx - line_width / 2.0;
288 let y = line_idx as f32 * line_height;
289
290 for (word_idx, word_width) in line {
291 let word = &self.words[*word_idx];
292 let is_active = time >= word.start && time < word.end;
293 let pop = is_active && matches!(self.mode, CaptionStyle::KaraokePop);
294 let word_color = if is_active { &self.active_color } else { color };
295 let paint = paint_from_hex(word_color);
296
297 if pop {
298 let wcx = x + word_width / 2.0;
301 let wcy = y - font_size * 0.35;
302 canvas.save();
303 canvas.translate((wcx, wcy));
304 canvas.scale((KARAOKE_POP_SCALE, KARAOKE_POP_SCALE));
305 canvas.translate((-wcx, -wcy));
306
307 let padding = font_size * 0.18;
308 self.draw_pill(
309 canvas,
310 Rect::from_xywh(
311 x - padding,
312 y - font_size - padding / 2.0,
313 word_width + padding * 2.0,
314 font_size * 1.4 + padding,
315 ),
316 );
317 }
318
319 draw_text_with_fallback(
320 canvas,
321 &word.text,
322 &font,
323 &emoji_font,
324 0.0,
325 x,
326 y,
327 &paint,
328 );
329 if pop {
330 canvas.restore();
331 }
332 x += word_width + space_width;
333 }
334 }
335 }
336 }
337 canvas.restore();
338 }
339}
340
341impl Caption {
342 fn draw_pill(&self, canvas: &Canvas, rect: Rect) {
344 let radius = rect.height() / 2.0;
345 let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR));
346 canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint);
347 }
348
349 fn resolve_font_style(style: &CssStyle) -> FontStyle {
356 let weight = match &style.font_weight {
357 Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
358 skia_safe::font_style::Weight::BOLD
359 }
360 Some(CssFontWeight::Number(n)) if *n >= 600 => skia_safe::font_style::Weight::BOLD,
361 Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32),
362 _ => skia_safe::font_style::Weight::NORMAL,
363 };
364 let slant = match style.font_style {
365 Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
366 Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
367 _ => skia_safe::font_style::Slant::Upright,
368 };
369 FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant)
370 }
371}
372
373impl Painter for Caption {
374 fn paint_content(
375 &self,
376 canvas: &Canvas,
377 layout: &BoxLayout,
378 _props: &AnimatedProperties,
379 ctx: &PaintCtx,
380 ) {
381 self.paint(canvas, layout.width, layout.height, ctx);
382 }
383}
384
385fn default_active_color() -> String {
386 "#FFFF00".to_string()
387}
388
389const DEFAULT_PILL_COLOR: &str = "#000000B3";
391
392const POP_DURATION: f64 = 0.18;
394
395const KARAOKE_POP_SCALE: f32 = 1.15;
397
398fn ease_out_back(t: f32) -> f32 {
400 const C1: f32 = 1.70158;
401 const C3: f32 = C1 + 1.0;
402 let p = t - 1.0;
403 1.0 + C3 * p * p * p + C1 * p * p
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use rustmotion_core::css::style::CssStyle;
410 use rustmotion_core::css::Length;
411 use rustmotion_core::schema::CaptionWord;
412
413 fn test_ctx(time: f64) -> PaintCtx {
417 PaintCtx {
418 time,
419 scenario_time: time,
420 scene_duration: 2.0,
421 frame_index: (time * 30.0) as u32,
422 fps: 30,
423 video_width: 1920,
424 video_height: 1080,
425 stagger_offset: 0.0,
426 }
427 }
428
429 fn make_caption(text: &str, white_space: Option<CssWhiteSpace>) -> Caption {
430 make_caption_with_max_width(text, white_space, Some(80.0))
431 }
432
433 fn make_caption_with_max_width(
434 text: &str,
435 white_space: Option<CssWhiteSpace>,
436 max_width: Option<f32>,
437 ) -> Caption {
438 let words = text
439 .split_whitespace()
440 .map(|w| CaptionWord {
441 text: w.to_string(),
442 start: 0.0,
443 end: 1000.0,
444 })
445 .collect();
446 Caption {
447 words,
448 active_color: default_active_color(),
449 mode: CaptionStyle::Highlight,
450 max_width,
451 pill_color: None,
452 style: CssStyle {
453 font_size: Some(Length::Px(28.0)),
454 white_space,
455 ..Default::default()
456 },
457 timing: Default::default(),
458 timeline: Vec::new(),
459 stagger: None,
460 }
461 }
462
463 fn ink_bounds(
466 surface: &mut skia_safe::Surface,
467 w: i32,
468 h: i32,
469 ) -> Option<(i32, i32, i32, i32)> {
470 let snapshot = surface.image_snapshot();
471 let info = skia_safe::ImageInfo::new(
472 (w, h),
473 skia_safe::ColorType::RGBA8888,
474 skia_safe::AlphaType::Premul,
475 None,
476 );
477 let mut buf = vec![0u8; (w * h * 4) as usize];
478 let ok = snapshot.read_pixels(
479 &info,
480 &mut buf,
481 (w * 4) as usize,
482 skia_safe::IPoint::new(0, 0),
483 skia_safe::image::CachingHint::Disallow,
484 );
485 assert!(ok, "pixel read should succeed");
486 let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
487 for y in 0..h {
488 for x in 0..w {
489 if buf[((y * w + x) * 4 + 3) as usize] > 0 {
490 minx = minx.min(x);
491 maxx = maxx.max(x);
492 miny = miny.min(y);
493 maxy = maxy.max(y);
494 }
495 }
496 }
497 (minx <= maxx).then_some((minx, maxx, miny, maxy))
498 }
499
500 #[test]
501 fn ink_never_starts_above_the_box_top() {
502 let caption = make_caption("Hello world", None);
509 const W: i32 = 400;
510 const H: i32 = 200;
511 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
512 {
513 let canvas = surface.canvas();
514 caption.paint(canvas, W as f32, H as f32, &test_ctx(0.5));
515 }
516 let (_minx, _maxx, miny, _maxy) =
517 ink_bounds(&mut surface, W, H).expect("caption must paint something");
518 assert!(miny >= 0, "ink starts above the box top at y={miny}");
519 }
520
521 #[test]
522 fn word_pop_pill_never_starts_above_the_box_top() {
523 let mut caption = make_caption("Hello", None);
527 caption.mode = CaptionStyle::WordPop;
528 caption.words[0].start = 0.0;
529 caption.words[0].end = 10.0;
530 const W: i32 = 400;
531 const H: i32 = 200;
532 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
533 {
534 let canvas = surface.canvas();
535 caption.paint(canvas, W as f32, H as f32, &test_ctx(0.1));
536 }
537 let (_minx, _maxx, miny, _maxy) =
538 ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something");
539 assert!(miny >= 0, "pill starts above the box top at y={miny}");
540 }
541
542 #[test]
543 fn nowrap_paints_one_wide_line_instead_of_wrapping_at_max_width() {
544 let caption = make_caption(
548 "the quick brown fox jumps over the lazy dog",
549 Some(CssWhiteSpace::Nowrap),
550 );
551 const W: i32 = 1600;
552 const H: i32 = 400;
553 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
554 {
555 let canvas = surface.canvas();
556 canvas.translate((800.0, 250.0));
557 caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
558 }
559 let (minx, maxx, miny, maxy) =
560 ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something");
561
562 assert!(
563 maxx - minx > 240,
564 "nowrap caption must bleed far past its 80px max_width, got ink width {}",
565 maxx - minx
566 );
567 assert!(
568 maxy - miny < 50,
569 "nowrap caption must stay on one line, got ink height {}",
570 maxy - miny
571 );
572 }
573
574 #[test]
575 fn normal_white_space_wraps_at_max_width() {
576 let caption = make_caption("the quick brown fox jumps over the lazy dog", None);
577 const W: i32 = 1600;
578 const H: i32 = 400;
579 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
580 {
581 let canvas = surface.canvas();
582 canvas.translate((800.0, 250.0));
583 caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
584 }
585 let (minx, maxx, miny, maxy) =
586 ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something");
587
588 assert!(
589 maxx - minx < 200,
590 "wrapped caption must pack close to its 80px max_width, got ink width {}",
591 maxx - minx
592 );
593 assert!(
594 maxy - miny > 50,
595 "wrapped caption must spread across multiple lines, got ink height {}",
596 maxy - miny
597 );
598 }
599
600 #[test]
603 fn wraps_at_layout_width_when_max_width_is_unset() {
604 let caption = make_caption_with_max_width(
612 "the quick brown fox jumps over the lazy dog again",
613 None,
614 None, );
616 const W: i32 = 1600;
617 const H: i32 = 400;
618 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
619 {
620 let canvas = surface.canvas();
621 canvas.translate((800.0, 200.0));
622 caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
625 }
626 let (minx, maxx, miny, maxy) =
627 ink_bounds(&mut surface, W, H).expect("caption must paint something");
628
629 assert!(
630 maxx - minx < 320,
631 "must wrap within ~layout_width (300px), got ink width {}",
632 maxx - minx
633 );
634 assert!(
635 maxy - miny > 50,
636 "must spread across multiple lines when max_width is unset, got ink height {}",
637 maxy - miny
638 );
639 }
640
641 #[test]
642 fn nowrap_still_ignores_layout_width_when_max_width_is_unset() {
643 let caption = make_caption_with_max_width(
646 "the quick brown fox jumps over the lazy dog",
647 Some(CssWhiteSpace::Nowrap),
648 None,
649 );
650 const W: i32 = 1600;
651 const H: i32 = 400;
652 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
653 {
654 let canvas = surface.canvas();
655 canvas.translate((800.0, 200.0));
656 caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
657 }
658 let (minx, maxx, miny, maxy) =
659 ink_bounds(&mut surface, W, H).expect("caption must paint something");
660
661 assert!(
662 maxx - minx > 400,
663 "nowrap must still bleed past layout_width, got ink width {}",
664 maxx - minx
665 );
666 assert!(
667 maxy - miny < 50,
668 "nowrap must stay on one line, got ink height {}",
669 maxy - miny
670 );
671 }
672
673 #[test]
676 fn honours_style_line_height_instead_of_hardcoded_1_4() {
677 let mut tight = make_caption_with_max_width(
685 "one two three four five six seven eight",
686 None,
687 Some(80.0),
688 );
689 tight.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(0.9));
690 let mut loose = make_caption_with_max_width(
691 "one two three four five six seven eight",
692 None,
693 Some(80.0),
694 );
695 loose.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(2.0));
696
697 const W: i32 = 1600;
698 const H: i32 = 800;
699
700 let mut surf_tight =
701 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
702 {
703 let canvas = surf_tight.canvas();
704 canvas.translate((800.0, 50.0));
705 tight.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
706 }
707 let (_, _, _, tight_maxy) =
708 ink_bounds(&mut surf_tight, W, H).expect("tight caption must paint something");
709
710 let mut surf_loose =
711 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
712 {
713 let canvas = surf_loose.canvas();
714 canvas.translate((800.0, 50.0));
715 loose.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
716 }
717 let (_, _, _, loose_maxy) =
718 ink_bounds(&mut surf_loose, W, H).expect("loose caption must paint something");
719
720 assert!(
721 loose_maxy > tight_maxy + 50,
722 "line-height: 2.0 must spread lines much further than 0.9 \
723 (tight bottom={tight_maxy}, loose bottom={loose_maxy})"
724 );
725 }
726
727 #[test]
730 fn rem_font_size_paints_visible_ink() {
731 let mut caption = make_caption_with_max_width("hello world", None, Some(300.0));
735 caption.style.font_size = Some(Length::String("2rem".into()));
736 const W: i32 = 400;
737 const H: i32 = 200;
738 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
739 {
740 let canvas = surface.canvas();
741 caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
742 }
743 let bounds = ink_bounds(&mut surface, W, H);
744 assert!(
745 bounds.is_some(),
746 "caption at font-size: 2rem must paint visible ink"
747 );
748 }
749
750 #[test]
759 fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() {
760 let style = CssStyle::default();
767 let resolved = Caption::resolve_font_style(&style);
768 assert_eq!(
769 *resolved.weight(),
770 400,
771 "unset font-weight must resolve to normal (400), not a hardcoded bold"
772 );
773 }
774
775 #[test]
776 fn resolve_font_style_honours_explicit_bold_and_numeric_weight() {
777 let bold = CssStyle {
778 font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bold)),
779 ..Default::default()
780 };
781 assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700);
782
783 let numeric = CssStyle {
787 font_weight: Some(CssFontWeight::Number(350)),
788 ..Default::default()
789 };
790 assert_eq!(*Caption::resolve_font_style(&numeric).weight(), 350);
791 }
792
793 #[test]
794 fn resolve_font_style_honours_italic() {
795 let italic = CssStyle {
796 font_style: Some(CssFontStyle::Italic),
797 ..Default::default()
798 };
799 assert_eq!(
800 Caption::resolve_font_style(&italic).slant(),
801 skia_safe::font_style::Slant::Italic
802 );
803 }
804}