1use std::borrow::Cow;
9
10use alpha2_time::MediaTime;
11use serde::{Deserialize, Serialize};
12
13use crate::animation::{valid_keyframes, valid_scalar_values};
14use crate::{Animated, Rgba, Vec2};
15
16pub const MAX_TEXT_RUNS: usize = 512;
18
19pub const MAX_RUN_CLASSES: usize = 8;
21
22pub const MAX_CLASS_NAME_BYTES: usize = 64;
24
25pub const MAX_TEXT_ANIMATORS: usize = 8;
27
28pub const MAX_RANGE_INDEX: f32 = 4_096.0;
31
32pub const RANGE_PERCENT: std::ops::RangeInclusive<f32> = -100.0..=200.0;
34
35pub const ANIMATOR_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.0..=10.0;
37
38#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[serde(untagged)]
44pub enum TextContent {
45 Plain(String),
47 Runs(Vec<TextRun>),
49}
50
51impl Default for TextContent {
52 fn default() -> Self {
53 Self::Plain(String::new())
54 }
55}
56
57impl From<String> for TextContent {
58 fn from(text: String) -> Self {
59 Self::Plain(text)
60 }
61}
62
63impl From<&str> for TextContent {
64 fn from(text: &str) -> Self {
65 Self::Plain(text.to_owned())
66 }
67}
68
69impl PartialEq<str> for TextContent {
71 fn eq(&self, other: &str) -> bool {
72 self.text() == other
73 }
74}
75
76impl PartialEq<&str> for TextContent {
77 fn eq(&self, other: &&str) -> bool {
78 self.text() == *other
79 }
80}
81
82impl TextContent {
83 #[must_use]
85 pub fn text(&self) -> Cow<'_, str> {
86 match self {
87 Self::Plain(text) => Cow::Borrowed(text),
88 Self::Runs(runs) => Cow::Owned(runs.iter().map(|run| run.text.as_str()).collect()),
89 }
90 }
91
92 #[must_use]
94 pub fn runs(&self) -> &[TextRun] {
95 match self {
96 Self::Plain(_) => &[],
97 Self::Runs(runs) => runs,
98 }
99 }
100
101 #[must_use]
103 pub fn len(&self) -> usize {
104 match self {
105 Self::Plain(text) => text.len(),
106 Self::Runs(runs) => runs.iter().map(|run| run.text.len()).sum(),
107 }
108 }
109
110 #[must_use]
112 pub fn is_empty(&self) -> bool {
113 self.len() == 0
114 }
115
116 pub(crate) fn is_valid(&self, max_bytes: usize) -> bool {
120 !self.is_empty()
121 && self.len() <= max_bytes
122 && self.runs().len() <= MAX_TEXT_RUNS
123 && self.runs().iter().all(TextRun::is_valid)
124 }
125}
126
127#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(deny_unknown_fields)]
131pub struct TextRun {
132 pub text: String,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub class: Vec<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub time: Option<RunTime>,
143}
144
145impl TextRun {
146 fn is_valid(&self) -> bool {
147 !self.text.is_empty()
148 && self.class.len() <= MAX_RUN_CLASSES
149 && self.class.iter().all(|name| valid_class_name(name))
150 && self
151 .time
152 .is_none_or(|time| time.start.ticks() >= 0 && time.start < time.end)
153 }
154}
155
156#[must_use]
158pub fn valid_class_name(name: &str) -> bool {
159 !name.is_empty()
160 && name.len() <= MAX_CLASS_NAME_BYTES
161 && name
162 .bytes()
163 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
164}
165
166#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[serde(deny_unknown_fields)]
170pub struct RunTime {
171 pub start: MediaTime,
173 pub end: MediaTime,
175}
176
177#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180#[serde(deny_unknown_fields)]
181pub struct TextAnimator {
182 pub selector: TextSelector,
184 #[serde(default)]
186 pub properties: TextAnimatorProperties,
187}
188
189#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
193pub enum TextSelector {
194 Range {
196 #[serde(default)]
198 based_on: TextUnit,
199 #[serde(default)]
201 units: RangeUnits,
202 #[serde(default = "zero")]
205 start: Animated<f32>,
206 #[serde(default = "hundred")]
208 end: Animated<f32>,
209 #[serde(default = "zero")]
211 offset: Animated<f32>,
212 #[serde(default)]
214 shape: RangeShape,
215 #[serde(default = "one")]
217 amount: Animated<f32>,
218 },
219 Timing {
221 state: RunState,
223 #[serde(default)]
225 ramp: MediaTime,
226 #[serde(default)]
229 sweep: bool,
230 },
231 Class {
233 class: String,
235 },
236}
237
238const fn zero() -> Animated<f32> {
239 Animated::Constant { value: 0.0 }
240}
241
242const fn hundred() -> Animated<f32> {
243 Animated::Constant { value: 100.0 }
244}
245
246const fn one() -> Animated<f32> {
247 Animated::Constant { value: 1.0 }
248}
249
250#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253#[serde(rename_all = "snake_case")]
254pub enum TextUnit {
255 #[default]
257 Characters,
258 CharactersExcludingSpaces,
260 Words,
262 Lines,
264}
265
266#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
269#[serde(rename_all = "snake_case")]
270pub enum RangeUnits {
271 #[default]
273 Percent,
274 Index,
276}
277
278#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
281#[serde(rename_all = "snake_case")]
282pub enum RangeShape {
283 #[default]
285 Square,
286 RampUp,
288 RampDown,
290 Triangle,
292 Round,
294 Smooth,
296}
297
298#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301#[serde(rename_all = "snake_case")]
302pub enum RunState {
303 Upcoming,
305 Active,
307 Spoken,
309}
310
311#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315#[serde(deny_unknown_fields)]
316pub struct TextAnimatorProperties {
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub offset: Option<Animated<Vec2>>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub scale: Option<Animated<Vec2>>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub rotation_degrees: Option<Animated<f32>>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub opacity: Option<Animated<f32>>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub fill: Option<Animated<Rgba>>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub stroke: Option<Animated<Rgba>>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub tracking: Option<Animated<f32>>,
339}
340
341impl TextAnimatorProperties {
342 #[must_use]
344 pub const fn is_empty(&self) -> bool {
345 self.offset.is_none()
346 && self.scale.is_none()
347 && self.rotation_degrees.is_none()
348 && self.opacity.is_none()
349 && self.fill.is_none()
350 && self.stroke.is_none()
351 && self.tracking.is_none()
352 }
353
354 fn is_valid(&self) -> bool {
355 let vec2 = |animation: &Animated<Vec2>, check: &dyn Fn(f32) -> bool| match animation {
356 Animated::Constant { value } => check(value.x) && check(value.y),
357 Animated::Keyframes { keyframes } => {
358 valid_keyframes(keyframes)
359 && keyframes
360 .iter()
361 .all(|keyframe| check(keyframe.value.x) && check(keyframe.value.y))
362 }
363 };
364 let color = |animation: &Animated<Rgba>| match animation {
365 Animated::Constant { value } => value.is_valid(),
366 Animated::Keyframes { keyframes } => {
367 valid_keyframes(keyframes) && keyframes.iter().all(|key| key.value.is_valid())
368 }
369 };
370 self.offset
371 .as_ref()
372 .is_none_or(|offset| vec2(offset, &|value| value.is_finite()))
373 && self.scale.as_ref().is_none_or(|scale| {
374 vec2(scale, &|value| {
375 value.is_finite() && ANIMATOR_SCALE_RANGE.contains(&value)
376 })
377 })
378 && self
379 .rotation_degrees
380 .as_ref()
381 .is_none_or(|rotation| valid_scalar_values(rotation, |_| true))
382 && self.opacity.as_ref().is_none_or(|opacity| {
383 valid_scalar_values(opacity, |value| (0.0..=1.0).contains(&value))
384 })
385 && self.fill.as_ref().is_none_or(color)
386 && self.stroke.as_ref().is_none_or(color)
387 && self.tracking.as_ref().is_none_or(|tracking| {
388 valid_scalar_values(tracking, |value| {
389 crate::LETTER_SPACING_RANGE.contains(&value)
390 })
391 })
392 }
393}
394
395impl TextSelector {
396 fn is_valid(&self) -> bool {
397 match self {
398 Self::Range {
399 units,
400 start,
401 end,
402 offset,
403 amount,
404 ..
405 } => {
406 let range = match units {
407 RangeUnits::Percent => RANGE_PERCENT,
408 RangeUnits::Index => -MAX_RANGE_INDEX..=2.0 * MAX_RANGE_INDEX,
409 };
410 [start, end, offset]
411 .into_iter()
412 .all(|value| valid_scalar_values(value, |value| range.contains(&value)))
413 && valid_scalar_values(amount, |value| (0.0..=1.0).contains(&value))
414 }
415 Self::Timing { ramp, .. } => ramp.ticks() >= 0,
416 Self::Class { class } => valid_class_name(class),
417 }
418 }
419}
420
421pub(crate) fn valid_animators(animators: &[TextAnimator], range_only: bool) -> bool {
424 animators.len() <= MAX_TEXT_ANIMATORS
425 && animators.iter().all(|animator| {
426 animator.selector.is_valid()
427 && animator.properties.is_valid()
428 && (!range_only || matches!(animator.selector, TextSelector::Range { .. }))
429 })
430}
431
432pub(crate) fn animators_have_keyframes(animators: &[TextAnimator]) -> bool {
434 let mut keyed = false;
435 for_each_keyed_animator(animators, "", &mut |_, _| keyed = true);
436 keyed
437}
438
439pub(crate) fn for_each_keyed_animator(
442 animators: &[TextAnimator],
443 at: &str,
444 visit: &mut impl FnMut(&str, &[MediaTime]),
445) {
446 use crate::animation::visit_keyed;
447 for (index, animator) in animators.iter().enumerate() {
448 let at = format!("{at}[{index}]");
449 if let TextSelector::Range {
450 start,
451 end,
452 offset,
453 amount,
454 ..
455 } = &animator.selector
456 {
457 visit_keyed(&format!("{at}.selector.start"), start, visit);
458 visit_keyed(&format!("{at}.selector.end"), end, visit);
459 visit_keyed(&format!("{at}.selector.offset"), offset, visit);
460 visit_keyed(&format!("{at}.selector.amount"), amount, visit);
461 }
462 let properties = &animator.properties;
463 let at = format!("{at}.properties");
464 if let Some(value) = &properties.offset {
465 visit_keyed(&format!("{at}.offset"), value, visit);
466 }
467 if let Some(value) = &properties.scale {
468 visit_keyed(&format!("{at}.scale"), value, visit);
469 }
470 if let Some(value) = &properties.rotation_degrees {
471 visit_keyed(&format!("{at}.rotation_degrees"), value, visit);
472 }
473 if let Some(value) = &properties.opacity {
474 visit_keyed(&format!("{at}.opacity"), value, visit);
475 }
476 if let Some(value) = &properties.fill {
477 visit_keyed(&format!("{at}.fill"), value, visit);
478 }
479 if let Some(value) = &properties.stroke {
480 visit_keyed(&format!("{at}.stroke"), value, visit);
481 }
482 if let Some(value) = &properties.tracking {
483 visit_keyed(&format!("{at}.tracking"), value, visit);
484 }
485 }
486}