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