1use crate::{
4 CaptionCue, CaptionTrack, FontLibrary, ParagraphLayout, ParagraphStyle, Text, TextAlignment,
5 TextSpan,
6};
7use motion_core::{Color, Node, NodeModifiers, Rect, Shape, Size, Time, ValidationError};
8use std::sync::Arc;
9use unicode_segmentation::UnicodeSegmentation;
10
11fn invalid(message: &str) -> ValidationError {
12 ValidationError::invalid("captions", "input", message)
13}
14
15#[derive(Clone, Debug)]
17pub struct CaptionStyle {
18 safe: Rect,
19 size: f64,
20 color: Color,
21 background: Color,
22 padding: (f64, f64),
23 gap: f64,
24 lines: usize,
25 keywords: Vec<(String, Color)>,
26}
27
28impl CaptionStyle {
29 pub fn new(safe: Rect) -> Self {
31 Self {
32 safe,
33 size: 30.0,
34 color: Color::WHITE,
35 background: Color::rgba(0, 0, 0, 210),
36 padding: (12.0, 8.0),
37 gap: 8.0,
38 lines: 2,
39 keywords: Vec::new(),
40 }
41 }
42
43 pub fn font_size(mut self, size: f64) -> Self {
45 self.size = size;
46 self
47 }
48
49 pub fn color(mut self, color: Color) -> Self {
51 self.color = color;
52 self
53 }
54
55 pub fn background(mut self, color: Color) -> Self {
57 self.background = color;
58 self
59 }
60
61 pub fn padding(mut self, horizontal: f64, vertical: f64) -> Self {
63 self.padding = (horizontal, vertical);
64 self
65 }
66
67 pub fn gap(mut self, gap: f64) -> Self {
69 self.gap = gap;
70 self
71 }
72
73 pub fn max_lines(mut self, lines: usize) -> Self {
75 self.lines = lines;
76 self
77 }
78
79 pub fn keyword(mut self, word: impl Into<String>, color: Color) -> Self {
81 self.keywords.push((word.into(), color));
82 self
83 }
84
85 fn runs(&self, source: &str) -> Result<Vec<(String, Color)>, ValidationError> {
86 let text = source.replace('\t', " ");
87 if text.len() > 32768 {
88 return Err(invalid("Subtitle exceeds expanded UTF-8 byte limit"));
89 }
90 let mut boundaries = text
91 .grapheme_indices(true)
92 .map(|(index, _)| index)
93 .collect::<Vec<_>>();
94 boundaries.push(text.len());
95 let mut runs = Vec::new();
96 let mut cursor = 0;
97 let mut plain = 0;
98 for &offset in &boundaries {
99 if offset < cursor || offset == text.len() {
100 continue;
101 }
102 if let Some((word, color)) = self
103 .keywords
104 .iter()
105 .filter(|(word, _)| {
106 text[offset..].starts_with(word)
107 && boundaries.binary_search(&(offset + word.len())).is_ok()
108 })
109 .min_by_key(|(word, _)| std::cmp::Reverse(word.len()))
110 {
111 if offset > plain {
112 runs.push((text[plain..offset].to_owned(), self.color));
113 }
114 runs.push((word.clone(), *color));
115 cursor = offset + word.len();
116 plain = cursor;
117 if runs.len() > 256 {
118 return Err(invalid("Subtitle keyword styling exceeds 256 runs"));
119 }
120 }
121 }
122 if plain < text.len() {
123 runs.push((text[plain..].to_owned(), self.color));
124 }
125 if runs.len() > 256 {
126 return Err(invalid("Subtitle keyword styling exceeds 256 runs"));
127 }
128 Ok(runs)
129 }
130
131 pub fn layout(
134 &self,
135 track: &CaptionTrack,
136 canvas: Size,
137 fonts: &FontLibrary,
138 ) -> motion_core::Result<CaptionLayout> {
139 let safe = self.safe;
140 if ![
141 canvas.width,
142 canvas.height,
143 safe.left,
144 safe.top,
145 safe.right,
146 safe.bottom,
147 self.size,
148 self.padding.0,
149 self.padding.1,
150 self.gap,
151 ]
152 .iter()
153 .all(|value| value.is_finite())
154 || canvas.width <= 0.0
155 || canvas.height <= 0.0
156 || canvas.width > 65536.0
157 || canvas.height > 65536.0
158 || safe.is_empty()
159 || safe.left < 0.0
160 || safe.top < 0.0
161 || safe.right > canvas.width
162 || safe.bottom > canvas.height
163 || self.size <= 0.0
164 || self.size > 4096.0
165 || !(1..=8).contains(&self.lines)
166 || [self.padding.0, self.padding.1, self.gap]
167 .iter()
168 .any(|value| !(0.0..=4096.0).contains(value))
169 || safe.width() <= 2.0 * self.padding.0
170 || safe.height() <= 2.0 * self.padding.1
171 || self.keywords.len() > 16
172 || self.keywords.iter().any(|(word, _)| {
173 word.is_empty() || word.len() > 128 || word.chars().any(char::is_control)
174 })
175 || track.cues().len() > 1024
176 || track
177 .cues()
178 .iter()
179 .map(|cue| cue.text().len())
180 .sum::<usize>()
181 > 131072
182 {
183 return Err(invalid("Invalid subtitle style, safe area or layout budget").into());
184 }
185 let mut entries = Vec::new();
186 let mut events = Vec::new();
187 for (index, cue) in track.cues().iter().enumerate() {
188 let spans = self
189 .runs(cue.text())?
190 .into_iter()
191 .map(|(text, color)| TextSpan::new(text).size(self.size).fill(color));
192 let paragraph = ParagraphStyle::rich(spans)
193 .size(self.size)
194 .line_height(1.0 + 6.0 / self.size)
195 .alignment(TextAlignment::Center)
196 .max_width(safe.width() - 2.0 * self.padding.0)
197 .fonts(fonts);
198 let layout = paragraph.layout(fonts)?;
199 if layout.lines().len() > self.lines {
200 return Err(
201 invalid("Subtitle exceeds its line limit; edit or retime the cue").into(),
202 );
203 }
204 let node = Text::from_paragraph(paragraph)
205 .vector_tree_of(&layout)?
206 .node();
207 entries.push((cue.clone(), layout, node));
208 events.push((cue.start(), true, index));
209 events.push((cue.end(), false, index));
210 }
211 events.sort_unstable();
212 let mut active = Vec::new();
213 for (_, entering, index) in events {
214 if entering {
215 active.push(index);
216 } else {
217 active.retain(|other| *other != index);
218 }
219 let height = active
220 .iter()
221 .map(|index| entries[*index].1.bounds().height())
222 .sum::<f64>()
223 + self.gap * active.len().saturating_sub(1) as f64
224 + 2.0 * self.padding.1;
225 if height > safe.height() {
226 return Err(invalid("Simultaneous subtitles exceed safe-area height").into());
227 }
228 }
229 Ok(CaptionLayout {
230 style: self.clone(),
231 entries,
232 })
233 }
234}
235
236#[derive(Clone, Debug)]
238pub struct CaptionLayout {
239 style: CaptionStyle,
240 entries: Vec<(CaptionCue, Arc<ParagraphLayout>, Node)>,
241}
242
243impl CaptionLayout {
244 pub fn cues(&self) -> impl Iterator<Item = (&CaptionCue, &ParagraphLayout)> {
246 self.entries
247 .iter()
248 .map(|(cue, layout, _)| (cue, layout.as_ref()))
249 }
250
251 pub fn safe_area(&self) -> Rect {
253 self.style.safe
254 }
255
256 fn active(
257 &self,
258 time: Time,
259 ) -> impl Iterator<Item = &(CaptionCue, Arc<ParagraphLayout>, Node)> {
260 let ticks = u128::try_from(time.ticks()).ok();
261 self.entries.iter().filter(move |(cue, _, _)| {
262 ticks.is_some_and(|ticks| {
263 let position = ticks * 1_000_000_000;
264 let rate = motion_core::TICKS_PER_SECOND as u128;
265 cue.start().as_nanos() * rate <= position && position < cue.end().as_nanos() * rate
266 })
267 })
268 }
269
270 pub fn node(&self, time: Time) -> Node {
272 let mut bottom = self.style.safe.bottom - self.style.padding.1;
273 let mut nodes = Vec::new();
274 for (_, layout, node) in self.active(time) {
275 if !nodes.is_empty() {
276 bottom -= self.style.gap;
277 }
278 bottom -= layout.bounds().height();
279 nodes.push(
280 node.clone()
281 .translate(self.style.safe.left + self.style.padding.0, bottom),
282 );
283 }
284 if nodes.is_empty() {
285 return Node::empty();
286 }
287 let background = Node::from(
288 Rect::new(
289 self.style.safe.left,
290 bottom - self.style.padding.1,
291 self.style.safe.right,
292 self.style.safe.bottom,
293 )
294 .fill(self.style.background),
295 );
296 Node::group([background, Node::group(nodes)])
297 .clip_with(self.style.safe, motion_core::ClipMode::Geometric)
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use motion_core::{PixelSize, TimeLiteral, ValidationLimits};
305 use std::time::Duration;
306
307 #[test]
308 fn keyword_runs_remain_grapheme_safe_longest_first_and_tab_expanded() -> motion_core::Result {
309 let style = CaptionStyle::new(Rect::new(0.0, 0.0, 600.0, 200.0))
310 .keyword("Moon", Color::BLACK)
311 .keyword("Moonlight", Color::WHITE);
312 let runs = style.runs("Earth\tMoonlight Moon")?;
313 assert_eq!(
314 runs.iter()
315 .map(|(text, _)| text.as_str())
316 .collect::<String>(),
317 "Earth Moonlight Moon"
318 );
319 assert_eq!(runs[1], ("Moonlight".into(), Color::WHITE));
320 let style = CaptionStyle::new(Rect::new(0.0, 0.0, 600.0, 200.0)).keyword("e", Color::BLACK);
321 assert_eq!(style.runs("e\u{301}")?.len(), 1);
322 Ok(())
323 }
324
325 #[test]
326 #[ignore = "requires explicit MOTION_TEST_FONT; no system discovery"]
327 fn subtitles_keep_safe_area_half_open_overlaps_and_overflow_errors() -> motion_core::Result {
328 let mut fonts = FontLibrary::new();
329 fonts.load_file(
330 std::env::var_os("MOTION_TEST_FONT").ok_or_else(|| invalid("Set MOTION_TEST_FONT"))?,
331 )?;
332 let track = CaptionTrack::new([
333 CaptionCue::new(Duration::ZERO, Duration::from_secs(2), "Earth\tMoon")?,
334 CaptionCue::new(Duration::from_secs(1), Duration::from_secs(3), "Sun")?,
335 ])?;
336 let style = CaptionStyle::new(Rect::new(20.0, 100.0, 620.0, 340.0))
337 .keyword("Moon", Color::from_hex(0x118677));
338 let layout = style.layout(&track, Size::new(640.0, 360.0), &fonts)?;
339 for (index, count) in [(0, 1), (29, 1), (30, 2), (59, 2), (60, 1), (89, 1), (90, 0)] {
340 let time = Time::from_frame(index, motion_core::fps!(30));
341 assert_eq!(layout.active(time).count(), count);
342 assert_eq!(track.active_at_time(time).count(), count);
343 layout.node(time).compile(
344 Size::new(640.0, 360.0),
345 PixelSize::new(320, 180),
346 ValidationLimits::default(),
347 )?;
348 }
349 let height = layout.entries[0].1.bounds().height();
350 assert!(
351 CaptionStyle::new(Rect::new(0.0, 0.0, 600.0, height + 16.0))
352 .layout(&track, Size::new(640.0, 360.0), &fonts)
353 .is_err()
354 );
355 let multiline = CaptionTrack::new([CaptionCue::new(
356 Duration::ZERO,
357 Duration::from_secs(1),
358 "one\ntwo",
359 )?])?;
360 assert!(
361 style
362 .clone()
363 .max_lines(1)
364 .layout(&multiline, Size::new(640.0, 360.0), &fonts)
365 .is_err()
366 );
367 assert!(
368 style
369 .layout(&track, Size::new(320.0, 180.0), &fonts)
370 .is_err()
371 );
372 assert!(
373 layout
374 .node(500.milliseconds().after_origin())
375 .measure(ValidationLimits::default())
376 .is_ok()
377 );
378 Ok(())
379 }
380}