use std::borrow::Cow;
use std::collections::BTreeMap;
use crate::{
MAX_TEXT_CONTENT_BYTES, Paint, TextAlign, TextAnimator, TextBackground, TextShadow, TextStroke,
TextStyle, Vec2,
};
use alpha2_time::TimeRange;
use serde::{Deserialize, Serialize};
pub const MAX_CAPTION_STYLES: usize = 32;
pub const MAX_CAPTION_NAME_BYTES: usize = 64;
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Captions {
#[serde(default)]
pub style: TextStyle,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub styles: BTreeMap<String, TextStylePatch>,
pub cues: Vec<CaptionCue>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct CaptionCue {
pub timeline_range: TimeRange,
pub text: crate::TextContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
}
impl CaptionCue {
#[must_use]
pub fn new(timeline_range: TimeRange, text: impl Into<crate::TextContent>) -> Self {
Self {
timeline_range,
text: text.into(),
speaker: None,
style: None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TextStylePatch {
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "double_option"
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
pub font_family: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<Paint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_height: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub letter_spacing: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_weight: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub align: Option<TextAlign>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_width: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<Vec2>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<Vec2>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strokes: Option<Vec<TextStroke>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shadows: Option<Vec<TextShadow>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "double_option"
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<TextBackground>"))]
pub background: Option<Option<TextBackground>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub animators: Option<Vec<TextAnimator>>,
}
impl TextStylePatch {
#[must_use]
pub fn apply(&self, base: &TextStyle) -> TextStyle {
let mut style = base.clone();
if let Some(family) = &self.font_family {
style.font_family.clone_from(family);
}
if let Some(size) = self.font_size {
style.font_size = size;
}
if let Some(fill) = &self.fill {
style.fill = fill.clone();
}
if let Some(height) = self.line_height {
style.line_height = height;
}
if let Some(spacing) = self.letter_spacing {
style.letter_spacing = spacing;
}
if let Some(weight) = self.font_weight {
style.font_weight = weight;
}
if let Some(align) = self.align {
style.align = align;
}
if let Some(width) = self.max_width {
style.max_width = width;
}
if let Some(position) = self.position {
style.position = position;
}
if let Some(anchor) = self.anchor {
style.anchor = anchor;
}
if let Some(strokes) = &self.strokes {
style.strokes.clone_from(strokes);
}
if let Some(shadows) = &self.shadows {
style.shadows.clone_from(shadows);
}
if let Some(background) = self.background {
style.background = background;
}
if let Some(animators) = &self.animators {
style.animators.clone_from(animators);
}
style
}
}
pub const MAX_CAPTION_FONT_SIZE: f32 = 0.25;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CaptionIssue {
Invalid,
UnknownStyle { cue: usize, name: String },
CueStyle { cue: usize },
}
impl Captions {
#[must_use]
pub fn cue_patch(&self, cue: &CaptionCue) -> Option<&TextStylePatch> {
match &cue.style {
Some(name) => self.styles.get(name),
None => cue
.speaker
.as_ref()
.and_then(|speaker| self.styles.get(speaker)),
}
}
#[must_use]
pub fn cue_style(&self, cue: &CaptionCue) -> Cow<'_, TextStyle> {
match self.cue_patch(cue) {
Some(patch) => Cow::Owned(patch.apply(&self.style)),
None => Cow::Borrowed(&self.style),
}
}
pub(crate) fn validate(&self) -> Result<(), CaptionIssue> {
let name = |name: &str| !name.is_empty() && name.len() <= MAX_CAPTION_NAME_BYTES;
let valid = self.style.is_valid(MAX_CAPTION_FONT_SIZE)
&& self.styles.len() <= MAX_CAPTION_STYLES
&& self.styles.keys().all(|key| name(key))
&& self.cues.iter().all(|cue| {
!cue.timeline_range.is_empty()
&& cue.timeline_range.start().ticks() >= 0
&& cue.text.is_valid(MAX_TEXT_CONTENT_BYTES)
&& cue.speaker.as_deref().is_none_or(name)
&& run_times_lie_in(cue)
})
&& self
.cues
.windows(2)
.all(|pair| pair[0].timeline_range.end() <= pair[1].timeline_range.start());
if !valid {
return Err(CaptionIssue::Invalid);
}
for (index, cue) in self.cues.iter().enumerate() {
if let Some(style) = &cue.style
&& !self.styles.contains_key(style)
{
return Err(CaptionIssue::UnknownStyle {
cue: index,
name: style.clone(),
});
}
if self.cue_patch(cue).is_some() && !self.cue_style(cue).is_valid(MAX_CAPTION_FONT_SIZE)
{
return Err(CaptionIssue::CueStyle { cue: index });
}
}
Ok(())
}
}
fn run_times_lie_in(cue: &CaptionCue) -> bool {
let duration = cue
.timeline_range
.duration()
.map_or(0, alpha2_time::MediaTime::ticks);
cue.text.runs().iter().all(|run| {
run.time
.is_none_or(|time| time.start.ticks() >= 0 && time.end.ticks() <= duration)
})
}
#[allow(clippy::option_option, clippy::ref_option)]
mod double_option {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub(super) fn serialize<T: Serialize, S: Serializer>(
value: &Option<Option<T>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match value {
Some(inner) => inner.serialize(serializer),
None => serializer.serialize_none(),
}
}
pub(super) fn deserialize<'de, T: Deserialize<'de>, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<T>>, D::Error> {
Option::<T>::deserialize(deserializer).map(Some)
}
}