1use 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
14pub const MAX_CAPTION_STYLES: usize = 32;
16
17pub const MAX_CAPTION_NAME_BYTES: usize = 64;
19
20#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(deny_unknown_fields)]
24pub struct Captions {
25 #[serde(default)]
27 pub style: TextStyle,
28 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub styles: BTreeMap<String, TextStylePatch>,
32 pub cues: Vec<CaptionCue>,
34}
35
36#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[serde(deny_unknown_fields)]
40pub struct CaptionCue {
41 pub timeline_range: TimeRange,
43 pub text: crate::TextContent,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub speaker: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub style: Option<String>,
53}
54
55impl CaptionCue {
56 #[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#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(deny_unknown_fields)]
73pub struct TextStylePatch {
74 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub font_size: Option<f32>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub fill: Option<Paint>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub line_height: Option<f32>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub letter_spacing: Option<f32>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub font_weight: Option<u16>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub align: Option<TextAlign>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub max_width: Option<f32>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub position: Option<Vec2>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub anchor: Option<Vec2>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub strokes: Option<Vec<TextStroke>>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub shadows: Option<Vec<TextShadow>>,
115 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub animators: Option<Vec<TextAnimator>>,
126}
127
128impl TextStylePatch {
129 #[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
179pub const MAX_CAPTION_FONT_SIZE: f32 = 0.25;
181
182#[derive(Clone, Debug, PartialEq, Eq)]
184pub(crate) enum CaptionIssue {
185 Invalid,
188 UnknownStyle { cue: usize, name: String },
190 CueStyle { cue: usize },
192}
193
194impl Captions {
195 #[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 #[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
254fn 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#[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}