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