Skip to main content

alpha2_model/
caption.rs

1//! Caption tracks: timed text drawn in a track's style, each cue in a named style or its
2//! speaker's.
3
4use std::borrow::Cow;
5use std::collections::BTreeMap;
6
7use crate::{
8    MAX_TEXT_CONTENT_BYTES, Paint, TextAlign, TextAnimator, TextBackground, TextShadow, TextStroke,
9    TextStyle, Vec2,
10};
11use alpha2_time::TimeRange;
12use serde::{Deserialize, Serialize};
13
14/// Most named styles one caption track holds.
15pub const MAX_CAPTION_STYLES: usize = 32;
16
17/// Most bytes of a caption style's name or a cue's speaker.
18pub const MAX_CAPTION_NAME_BYTES: usize = 64;
19
20/// What a caption track shows: its cues, in its style.
21#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(deny_unknown_fields)]
24pub struct Captions {
25    /// The style every cue starts from.
26    #[serde(default)]
27    pub style: TextStyle,
28    /// Named changes to `style` for cues that name one or whose speaker has one. At most
29    /// [`MAX_CAPTION_STYLES`], names 1 to 64 bytes.
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub styles: BTreeMap<String, TextStylePatch>,
32    /// Cues in timeline order, none overlapping; a track shows one cue at a time.
33    pub cues: Vec<CaptionCue>,
34}
35
36/// Text shown over one timeline interval.
37#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[serde(deny_unknown_fields)]
40pub struct CaptionCue {
41    /// When the cue shows. Non-empty and not before zero.
42    pub timeline_range: TimeRange,
43    /// The text, a string or runs; `\n` starts a line. Non-empty, at most
44    /// [`MAX_TEXT_CONTENT_BYTES`] bytes. Run times are from the cue's start and lie within it.
45    pub text: crate::TextContent,
46    /// Who says it, as the source names them (a WebVTT voice), at most 64 bytes. A track style
47    /// of that name applies when the cue names no style.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub speaker: Option<String>,
50    /// The name of one of the track's `styles`, applied over its style.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub style: Option<String>,
53}
54
55impl CaptionCue {
56    /// A cue showing `text` over `timeline_range`, with no speaker or style.
57    #[must_use]
58    pub fn new(timeline_range: TimeRange, text: impl Into<crate::TextContent>) -> Self {
59        Self {
60            timeline_range,
61            text: text.into(),
62            speaker: None,
63            style: None,
64        }
65    }
66}
67
68/// Changes to a [`TextStyle`]. Each field set replaces the base's whole value (lists are not
69/// merged); a field left out keeps the base's.
70#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(deny_unknown_fields)]
73pub struct TextStylePatch {
74    /// The family; `null` draws from the fallback order alone.
75    #[serde(
76        default,
77        skip_serializing_if = "Option::is_none",
78        with = "double_option"
79    )]
80    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
81    pub font_family: Option<Option<String>>,
82    /// See [`TextStyle::font_size`].
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub font_size: Option<f32>,
85    /// See [`TextStyle::fill`].
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub fill: Option<Paint>,
88    /// See [`TextStyle::line_height`].
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub line_height: Option<f32>,
91    /// See [`TextStyle::letter_spacing`].
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub letter_spacing: Option<f32>,
94    /// See [`TextStyle::font_weight`].
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub font_weight: Option<u16>,
97    /// See [`TextStyle::align`].
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub align: Option<TextAlign>,
100    /// See [`TextStyle::max_width`].
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub max_width: Option<f32>,
103    /// See [`TextStyle::position`].
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub position: Option<Vec2>,
106    /// See [`TextStyle::anchor`].
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub anchor: Option<Vec2>,
109    /// See [`TextStyle::strokes`].
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub strokes: Option<Vec<TextStroke>>,
112    /// See [`TextStyle::shadows`].
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub shadows: Option<Vec<TextShadow>>,
115    /// The box behind each line; `null` removes the base's.
116    #[serde(
117        default,
118        skip_serializing_if = "Option::is_none",
119        with = "double_option"
120    )]
121    #[cfg_attr(feature = "schema", schemars(with = "Option<TextBackground>"))]
122    pub background: Option<Option<TextBackground>>,
123    /// See [`TextStyle::animators`].
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub animators: Option<Vec<TextAnimator>>,
126}
127
128impl TextStylePatch {
129    /// `base` with every field this patch sets replaced.
130    #[must_use]
131    pub fn apply(&self, base: &TextStyle) -> TextStyle {
132        let mut style = base.clone();
133        if let Some(family) = &self.font_family {
134            style.font_family.clone_from(family);
135        }
136        if let Some(size) = self.font_size {
137            style.font_size = size;
138        }
139        if let Some(fill) = &self.fill {
140            style.fill = fill.clone();
141        }
142        if let Some(height) = self.line_height {
143            style.line_height = height;
144        }
145        if let Some(spacing) = self.letter_spacing {
146            style.letter_spacing = spacing;
147        }
148        if let Some(weight) = self.font_weight {
149            style.font_weight = weight;
150        }
151        if let Some(align) = self.align {
152            style.align = align;
153        }
154        if let Some(width) = self.max_width {
155            style.max_width = width;
156        }
157        if let Some(position) = self.position {
158            style.position = position;
159        }
160        if let Some(anchor) = self.anchor {
161            style.anchor = anchor;
162        }
163        if let Some(strokes) = &self.strokes {
164            style.strokes.clone_from(strokes);
165        }
166        if let Some(shadows) = &self.shadows {
167            style.shadows.clone_from(shadows);
168        }
169        if let Some(background) = self.background {
170            style.background = background;
171        }
172        if let Some(animators) = &self.animators {
173            style.animators.clone_from(animators);
174        }
175        style
176    }
177}
178
179/// Largest caption font size, as a fraction of the frame's shorter side.
180pub const MAX_CAPTION_FONT_SIZE: f32 = 0.25;
181
182/// Why a caption track's cue or style is refused.
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub(crate) enum CaptionIssue {
185    /// The track's style, a style name, a speaker, a cue's range, text or run times, or the
186    /// cues' order.
187    Invalid,
188    /// Cue `cue` names a style the track does not have.
189    UnknownStyle { cue: usize, name: String },
190    /// Cue `cue`'s style, its patch applied, is out of range.
191    CueStyle { cue: usize },
192}
193
194impl Captions {
195    /// The patch cue `cue` takes: the style it names, else its speaker's if the track has one,
196    /// else none.
197    #[must_use]
198    pub fn cue_patch(&self, cue: &CaptionCue) -> Option<&TextStylePatch> {
199        match &cue.style {
200            Some(name) => self.styles.get(name),
201            None => cue
202                .speaker
203                .as_ref()
204                .and_then(|speaker| self.styles.get(speaker)),
205        }
206    }
207
208    /// The style cue `cue` draws in: the track's, with its patch applied.
209    #[must_use]
210    pub fn cue_style(&self, cue: &CaptionCue) -> Cow<'_, TextStyle> {
211        match self.cue_patch(cue) {
212            Some(patch) => Cow::Owned(patch.apply(&self.style)),
213            None => Cow::Borrowed(&self.style),
214        }
215    }
216
217    pub(crate) fn validate(&self) -> Result<(), CaptionIssue> {
218        let name = |name: &str| !name.is_empty() && name.len() <= MAX_CAPTION_NAME_BYTES;
219        let valid = self.style.is_valid(MAX_CAPTION_FONT_SIZE)
220            && self.styles.len() <= MAX_CAPTION_STYLES
221            && self.styles.keys().all(|key| name(key))
222            && self.cues.iter().all(|cue| {
223                !cue.timeline_range.is_empty()
224                    && cue.timeline_range.start().ticks() >= 0
225                    && cue.text.is_valid(MAX_TEXT_CONTENT_BYTES)
226                    && cue.speaker.as_deref().is_none_or(name)
227                    && run_times_lie_in(cue)
228            })
229            && self
230                .cues
231                .windows(2)
232                .all(|pair| pair[0].timeline_range.end() <= pair[1].timeline_range.start());
233        if !valid {
234            return Err(CaptionIssue::Invalid);
235        }
236        for (index, cue) in self.cues.iter().enumerate() {
237            if let Some(style) = &cue.style
238                && !self.styles.contains_key(style)
239            {
240                return Err(CaptionIssue::UnknownStyle {
241                    cue: index,
242                    name: style.clone(),
243                });
244            }
245            if self.cue_patch(cue).is_some() && !self.cue_style(cue).is_valid(MAX_CAPTION_FONT_SIZE)
246            {
247                return Err(CaptionIssue::CueStyle { cue: index });
248            }
249        }
250        Ok(())
251    }
252}
253
254/// Whether every run time of `cue` lies within the cue's duration.
255fn run_times_lie_in(cue: &CaptionCue) -> bool {
256    let duration = cue
257        .timeline_range
258        .duration()
259        .map_or(0, alpha2_time::MediaTime::ticks);
260    cue.text.runs().iter().all(|run| {
261        run.time
262            .is_none_or(|time| time.start.ticks() >= 0 && time.end.ticks() <= duration)
263    })
264}
265
266/// A field that is absent (no change), `null` (clear) or a value, read as `Option<Option<T>>`
267/// with `null` kept apart from absent.
268#[allow(clippy::option_option, clippy::ref_option)]
269mod double_option {
270    use serde::{Deserialize, Deserializer, Serialize, Serializer};
271
272    pub(super) fn serialize<T: Serialize, S: Serializer>(
273        value: &Option<Option<T>>,
274        serializer: S,
275    ) -> Result<S::Ok, S::Error> {
276        match value {
277            Some(inner) => inner.serialize(serializer),
278            None => serializer.serialize_none(),
279        }
280    }
281
282    pub(super) fn deserialize<'de, T: Deserialize<'de>, D: Deserializer<'de>>(
283        deserializer: D,
284    ) -> Result<Option<Option<T>>, D::Error> {
285        Option::<T>::deserialize(deserializer).map(Some)
286    }
287}