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