use std::borrow::Cow;
use alpha2_time::MediaTime;
use serde::{Deserialize, Serialize};
use crate::animation::{valid_keyframes, valid_scalar_values};
use crate::{Animated, Rgba, Vec2};
pub const MAX_TEXT_RUNS: usize = 512;
pub const MAX_RUN_CLASSES: usize = 8;
pub const MAX_CLASS_NAME_BYTES: usize = 64;
pub const MAX_TEXT_ANIMATORS: usize = 8;
pub const MAX_RANGE_INDEX: f32 = 4_096.0;
pub const RANGE_PERCENT: std::ops::RangeInclusive<f32> = -100.0..=200.0;
pub const ANIMATOR_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.0..=10.0;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum TextContent {
Plain(String),
Runs(Vec<TextRun>),
}
impl Default for TextContent {
fn default() -> Self {
Self::Plain(String::new())
}
}
impl From<String> for TextContent {
fn from(text: String) -> Self {
Self::Plain(text)
}
}
impl From<&str> for TextContent {
fn from(text: &str) -> Self {
Self::Plain(text.to_owned())
}
}
impl PartialEq<str> for TextContent {
fn eq(&self, other: &str) -> bool {
self.text() == other
}
}
impl PartialEq<&str> for TextContent {
fn eq(&self, other: &&str) -> bool {
self.text() == *other
}
}
impl TextContent {
#[must_use]
pub fn text(&self) -> Cow<'_, str> {
match self {
Self::Plain(text) => Cow::Borrowed(text),
Self::Runs(runs) => Cow::Owned(runs.iter().map(|run| run.text.as_str()).collect()),
}
}
#[must_use]
pub fn runs(&self) -> &[TextRun] {
match self {
Self::Plain(_) => &[],
Self::Runs(runs) => runs,
}
}
#[must_use]
pub fn len(&self) -> usize {
match self {
Self::Plain(text) => text.len(),
Self::Runs(runs) => runs.iter().map(|run| run.text.len()).sum(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub(crate) fn is_valid(&self, max_bytes: usize) -> bool {
!self.is_empty()
&& self.len() <= max_bytes
&& self.runs().len() <= MAX_TEXT_RUNS
&& self.runs().iter().all(TextRun::is_valid)
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TextRun {
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub class: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time: Option<RunTime>,
}
impl TextRun {
fn is_valid(&self) -> bool {
!self.text.is_empty()
&& self.class.len() <= MAX_RUN_CLASSES
&& self.class.iter().all(|name| valid_class_name(name))
&& self
.time
.is_none_or(|time| time.start.ticks() >= 0 && time.start < time.end)
}
}
#[must_use]
pub fn valid_class_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= MAX_CLASS_NAME_BYTES
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
}
#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RunTime {
pub start: MediaTime,
pub end: MediaTime,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TextAnimator {
pub selector: TextSelector,
#[serde(default)]
pub properties: TextAnimatorProperties,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum TextSelector {
Range {
#[serde(default)]
based_on: TextUnit,
#[serde(default)]
units: RangeUnits,
#[serde(default = "zero")]
start: Animated<f32>,
#[serde(default = "hundred")]
end: Animated<f32>,
#[serde(default = "zero")]
offset: Animated<f32>,
#[serde(default)]
shape: RangeShape,
#[serde(default = "one")]
amount: Animated<f32>,
},
Timing {
state: RunState,
#[serde(default)]
ramp: MediaTime,
#[serde(default)]
sweep: bool,
},
Class {
class: String,
},
}
const fn zero() -> Animated<f32> {
Animated::Constant { value: 0.0 }
}
const fn hundred() -> Animated<f32> {
Animated::Constant { value: 100.0 }
}
const fn one() -> Animated<f32> {
Animated::Constant { value: 1.0 }
}
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum TextUnit {
#[default]
Characters,
CharactersExcludingSpaces,
Words,
Lines,
}
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RangeUnits {
#[default]
Percent,
Index,
}
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RangeShape {
#[default]
Square,
RampUp,
RampDown,
Triangle,
Round,
Smooth,
}
#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RunState {
Upcoming,
Active,
Spoken,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TextAnimatorProperties {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<Animated<Vec2>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scale: Option<Animated<Vec2>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotation_degrees: Option<Animated<f32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opacity: Option<Animated<f32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<Animated<Rgba>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke: Option<Animated<Rgba>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tracking: Option<Animated<f32>>,
}
impl TextAnimatorProperties {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.offset.is_none()
&& self.scale.is_none()
&& self.rotation_degrees.is_none()
&& self.opacity.is_none()
&& self.fill.is_none()
&& self.stroke.is_none()
&& self.tracking.is_none()
}
fn is_valid(&self) -> bool {
let vec2 = |animation: &Animated<Vec2>, check: &dyn Fn(f32) -> bool| match animation {
Animated::Constant { value } => check(value.x) && check(value.y),
Animated::Keyframes { keyframes } => {
valid_keyframes(keyframes)
&& keyframes
.iter()
.all(|keyframe| check(keyframe.value.x) && check(keyframe.value.y))
}
};
let color = |animation: &Animated<Rgba>| match animation {
Animated::Constant { value } => value.is_valid(),
Animated::Keyframes { keyframes } => {
valid_keyframes(keyframes) && keyframes.iter().all(|key| key.value.is_valid())
}
};
self.offset
.as_ref()
.is_none_or(|offset| vec2(offset, &|value| value.is_finite()))
&& self.scale.as_ref().is_none_or(|scale| {
vec2(scale, &|value| {
value.is_finite() && ANIMATOR_SCALE_RANGE.contains(&value)
})
})
&& self
.rotation_degrees
.as_ref()
.is_none_or(|rotation| valid_scalar_values(rotation, |_| true))
&& self.opacity.as_ref().is_none_or(|opacity| {
valid_scalar_values(opacity, |value| (0.0..=1.0).contains(&value))
})
&& self.fill.as_ref().is_none_or(color)
&& self.stroke.as_ref().is_none_or(color)
&& self.tracking.as_ref().is_none_or(|tracking| {
valid_scalar_values(tracking, |value| {
crate::LETTER_SPACING_RANGE.contains(&value)
})
})
}
}
impl TextSelector {
fn is_valid(&self) -> bool {
match self {
Self::Range {
units,
start,
end,
offset,
amount,
..
} => {
let range = match units {
RangeUnits::Percent => RANGE_PERCENT,
RangeUnits::Index => -MAX_RANGE_INDEX..=2.0 * MAX_RANGE_INDEX,
};
[start, end, offset]
.into_iter()
.all(|value| valid_scalar_values(value, |value| range.contains(&value)))
&& valid_scalar_values(amount, |value| (0.0..=1.0).contains(&value))
}
Self::Timing { ramp, .. } => ramp.ticks() >= 0,
Self::Class { class } => valid_class_name(class),
}
}
}
pub(crate) fn valid_animators(animators: &[TextAnimator], range_only: bool) -> bool {
animators.len() <= MAX_TEXT_ANIMATORS
&& animators.iter().all(|animator| {
animator.selector.is_valid()
&& animator.properties.is_valid()
&& (!range_only || matches!(animator.selector, TextSelector::Range { .. }))
})
}
pub(crate) fn animators_have_keyframes(animators: &[TextAnimator]) -> bool {
let mut keyed = false;
for_each_keyed_animator(animators, "", &mut |_, _| keyed = true);
keyed
}
pub(crate) fn for_each_keyed_animator(
animators: &[TextAnimator],
at: &str,
visit: &mut impl FnMut(&str, &[MediaTime]),
) {
use crate::animation::visit_keyed;
for (index, animator) in animators.iter().enumerate() {
let at = format!("{at}[{index}]");
if let TextSelector::Range {
start,
end,
offset,
amount,
..
} = &animator.selector
{
visit_keyed(&format!("{at}.selector.start"), start, visit);
visit_keyed(&format!("{at}.selector.end"), end, visit);
visit_keyed(&format!("{at}.selector.offset"), offset, visit);
visit_keyed(&format!("{at}.selector.amount"), amount, visit);
}
let properties = &animator.properties;
let at = format!("{at}.properties");
if let Some(value) = &properties.offset {
visit_keyed(&format!("{at}.offset"), value, visit);
}
if let Some(value) = &properties.scale {
visit_keyed(&format!("{at}.scale"), value, visit);
}
if let Some(value) = &properties.rotation_degrees {
visit_keyed(&format!("{at}.rotation_degrees"), value, visit);
}
if let Some(value) = &properties.opacity {
visit_keyed(&format!("{at}.opacity"), value, visit);
}
if let Some(value) = &properties.fill {
visit_keyed(&format!("{at}.fill"), value, visit);
}
if let Some(value) = &properties.stroke {
visit_keyed(&format!("{at}.stroke"), value, visit);
}
if let Some(value) = &properties.tracking {
visit_keyed(&format!("{at}.tracking"), value, visit);
}
}
}