Skip to main content

alpha2_model/
text_animation.rs

1//! Text content as runs, and text animators: per-character motion and color held by a text
2//! style.
3//!
4//! Runs never change layout; the joined text is shaped and broken into lines like the plain
5//! string. An animator gives each grapheme cluster a selection in `[0, 1]` and moves, scales,
6//! turns, fades or recolors it in proportion, as After Effects' text animators do.
7
8use 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
16/// Most runs one text holds.
17pub const MAX_TEXT_RUNS: usize = 512;
18
19/// Most classes one run carries.
20pub const MAX_RUN_CLASSES: usize = 8;
21
22/// Most bytes of one class name.
23pub const MAX_CLASS_NAME_BYTES: usize = 64;
24
25/// Most animators one text style holds.
26pub const MAX_TEXT_ANIMATORS: usize = 8;
27
28/// How far a Range selector in index units reaches past either end: `[-N, 2N]` units for
29/// `N` of this.
30pub const MAX_RANGE_INDEX: f32 = 4_096.0;
31
32/// The range a percent Range selector's start, end and offset take.
33pub const RANGE_PERCENT: std::ops::RangeInclusive<f32> = -100.0..=200.0;
34
35/// The range an animator's scale takes on each axis.
36pub const ANIMATOR_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.0..=10.0;
37
38/// A text: one string, or runs whose texts joined are the text.
39///
40/// Written as `"Hello"` or `[{ "text": "Hel" }, { "text": "lo", "class": ["key"] }]`.
41#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[serde(untagged)]
44pub enum TextContent {
45    /// One string; `\n` starts a line.
46    Plain(String),
47    /// Runs in order; their texts joined are the text.
48    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
69/// Compares the whole text, runs joined, with a string.
70impl 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    /// The whole text: the string, or the runs' texts joined.
84    #[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    /// The runs, none for a plain string.
93    #[must_use]
94    pub fn runs(&self) -> &[TextRun] {
95        match self {
96            Self::Plain(_) => &[],
97            Self::Runs(runs) => runs,
98        }
99    }
100
101    /// Bytes of the whole text.
102    #[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    /// Whether the whole text is empty.
111    #[must_use]
112    pub fn is_empty(&self) -> bool {
113        self.len() == 0
114    }
115
116    /// Whether the text is non-empty and within `max_bytes`, has at most [`MAX_TEXT_RUNS`] runs,
117    /// and every run has non-empty text, at most [`MAX_RUN_CLASSES`] valid class names, and a
118    /// time span that starts at or after zero and before it ends.
119    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/// A piece of a text that animators' Class and Timing selectors can pick.
128#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(deny_unknown_fields)]
131pub struct TextRun {
132    /// Non-empty; `\n` starts a line. A grapheme cluster spanning two runs belongs to the run
133    /// holding its first byte.
134    pub text: String,
135    /// Names Class selectors match, each 1 to 64 bytes of `A-Z`, `a-z`, `0-9`, `_` and `-`; at
136    /// most [`MAX_RUN_CLASSES`].
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub class: Vec<String>,
139    /// When the run is said, from the holder's start (a caption cue's start, or a text
140    /// generator's picture time zero).
141    #[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/// Whether `name` is a class name a run or a Class selector may carry.
157#[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/// A run's time span.
167#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[serde(deny_unknown_fields)]
170pub struct RunTime {
171    /// When it starts.
172    pub start: MediaTime,
173    /// When it ends; after `start`.
174    pub end: MediaTime,
175}
176
177/// One animator: what it selects, and what it does to what it selects.
178#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180#[serde(deny_unknown_fields)]
181pub struct TextAnimator {
182    /// Gives each character its selection, in `[0, 1]`.
183    pub selector: TextSelector,
184    /// Applied to each character in proportion to its selection.
185    #[serde(default)]
186    pub properties: TextAnimatorProperties,
187}
188
189/// How an animator selects characters.
190#[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    /// A range over the text's units, shaped at its ends, like After Effects' range selector.
195    Range {
196        /// What the range counts: characters (the default), non-space characters, words, or lines.
197        #[serde(default)]
198        based_on: TextUnit,
199        /// Whether start, end and offset are percents of the units (the default) or unit indices.
200        #[serde(default)]
201        units: RangeUnits,
202        /// Where the range starts: `[-100, 200]` percent, or `[-N, 2N]` units for `N` of
203        /// [`MAX_RANGE_INDEX`]. Default 0.
204        #[serde(default = "zero")]
205        start: Animated<f32>,
206        /// Where it ends, in the same range. Default 100.
207        #[serde(default = "hundred")]
208        end: Animated<f32>,
209        /// Added to start and end, in the same range. Default 0.
210        #[serde(default = "zero")]
211        offset: Animated<f32>,
212        /// How selection rises and falls across the range.
213        #[serde(default)]
214        shape: RangeShape,
215        /// The selection's scale, in `[0, 1]`. Default 1.
216        #[serde(default = "one")]
217        amount: Animated<f32>,
218    },
219    /// Characters of timed runs, by the run's time against the current time.
220    Timing {
221        /// Which runs it selects: those not yet said, being said, or said.
222        state: RunState,
223        /// How long the state takes to fade in and out; zero switches.
224        #[serde(default)]
225        ramp: MediaTime,
226        /// Splits each run's span over its characters in order, sweeping the run character by
227        /// character.
228        #[serde(default)]
229        sweep: bool,
230    },
231    /// Characters of runs that carry a class.
232    Class {
233        /// The class name.
234        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/// The units a Range selector counts.
251#[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    /// Grapheme clusters, spaces included.
256    #[default]
257    Characters,
258    /// Grapheme clusters that are not white space.
259    CharactersExcludingSpaces,
260    /// UAX #29 words that hold a letter or digit; a CJK ideograph is a word.
261    Words,
262    /// Laid-out lines.
263    Lines,
264}
265
266/// What a Range selector's start, end and offset count in.
267#[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    /// Percents of the units.
272    #[default]
273    Percent,
274    /// Unit indices.
275    Index,
276}
277
278/// How selection rises and falls across a Range selector's range.
279#[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    /// Fully selected inside the range, partly at fractional ends.
284    #[default]
285    Square,
286    /// Rising from the start to the end.
287    RampUp,
288    /// Falling from the start to the end.
289    RampDown,
290    /// Rising to the middle, falling after it.
291    Triangle,
292    /// A half circle over the range.
293    Round,
294    /// The triangle eased by smoothstep.
295    Smooth,
296}
297
298/// Which runs a Timing selector selects.
299#[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    /// Runs whose time has not started.
304    Upcoming,
305    /// Runs whose time holds the current time.
306    Active,
307    /// Runs whose time has ended.
308    Spoken,
309}
310
311/// What an animator does to each character, in proportion to its selection. A property left
312/// out does nothing.
313#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315#[serde(deny_unknown_fields)]
316pub struct TextAnimatorProperties {
317    /// Moves the character, in font sizes, +y down.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub offset: Option<Animated<Vec2>>,
320    /// Scales the character about its centre; 1 is none. Each axis in `[0, 10]`.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub scale: Option<Animated<Vec2>>,
323    /// Turns the character about its centre, clockwise degrees.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub rotation_degrees: Option<Animated<f32>>,
326    /// The opacity a fully selected character takes, in `[0, 1]`.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub opacity: Option<Animated<f32>>,
329    /// The fill color a fully selected character takes; a gradient fill is mixed toward it.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub fill: Option<Animated<Rgba>>,
332    /// The color every stroke of a fully selected character takes.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub stroke: Option<Animated<Rgba>>,
335    /// Space added after the character, in font sizes, in `[-0.5, 1]`, as letter spacing adds
336    /// it. Lines are not re-broken.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub tracking: Option<Animated<f32>>,
339}
340
341impl TextAnimatorProperties {
342    /// Whether no property is set: the animator does nothing.
343    #[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
421/// Whether `animators` are at most [`MAX_TEXT_ANIMATORS`] valid animators, using only Range
422/// selectors when `range_only`.
423pub(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
432/// Whether any value of `animators` has keyframes.
433pub(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
439/// Calls `visit` with each keyed value of `animators` under `at`, for the lint's keyframe
440/// walk.
441pub(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}