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