Skip to main content

motion_content/captions/
layout.rs

1//! Caption styling and frozen per-cue paragraph layout for subtitle tracks.
2
3use 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/// production subtitle style with an explicit safe area and frozen font environment.
16#[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    /// Defaults to 30-unit white text, translucent black, padding 12/8 and two lines.
30    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    /// Replaces finite font size in (0,4096].
44    pub fn font_size(mut self, size: f64) -> Self {
45        self.size = size;
46        self
47    }
48
49    /// Replaces the base text color.
50    pub fn color(mut self, color: Color) -> Self {
51        self.color = color;
52        self
53    }
54
55    /// Replaces the shared backplate color.
56    pub fn background(mut self, color: Color) -> Self {
57        self.background = color;
58        self
59    }
60
61    /// Replaces nonnegative horizontal/vertical padding, each at most 4096.
62    pub fn padding(mut self, horizontal: f64, vertical: f64) -> Self {
63        self.padding = (horizontal, vertical);
64        self
65    }
66
67    /// Replaces the nonnegative gap between simultaneous cues, at most 4096.
68    pub fn gap(mut self, gap: f64) -> Self {
69        self.gap = gap;
70        self
71    }
72
73    /// Replaces the per-cue line limit in 1..=8; excess text is rejected.
74    pub fn max_lines(mut self, lines: usize) -> Self {
75        self.lines = lines;
76        self
77    }
78
79    /// Adds a grapheme-safe literal keyword; longest match and then insertion order win.
80    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    /// Measures with supplied fonts and freezes vector artwork before frame evaluation.
132    /// At most 1024 cues/128 KiB source and explicit per-cue line/safe-area limits apply.
133    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/// Cached subtitle vectors; frame sampling never reshapes or discovers fonts.
237#[derive(Clone, Debug)]
238pub struct CaptionLayout {
239    style: CaptionStyle,
240    entries: Vec<(CaptionCue, Arc<ParagraphLayout>, Node)>,
241}
242
243impl CaptionLayout {
244    /// Returns immutable cue/paragraph pairs in input order.
245    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    /// Returns the validated safe rectangle.
252    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    /// Samples half-open cues and stacks overlaps bottom-up in input order.
271    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}