use oxideav_core::{Segment, SubtitleCue, Transform2D};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KaraokeKind {
Fill,
Sweep,
Outline,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KaraokeSpan {
pub kind: KaraokeKind,
pub start_ms: u32,
pub end_ms: u32,
}
impl KaraokeSpan {
pub fn progress(&self, t_in_cue_ms: i32) -> f32 {
if t_in_cue_ms <= self.start_ms as i32 {
return 0.0;
}
if t_in_cue_ms >= self.end_ms as i32 || self.end_ms <= self.start_ms {
return 1.0;
}
(t_in_cue_ms - self.start_ms as i32) as f32 / (self.end_ms - self.start_ms) as f32
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum AnimatedTag {
Fad { t1_ms: u32, t2_ms: u32 },
Fade {
a1: u8,
a2: u8,
a3: u8,
t1_ms: i32,
t2_ms: i32,
t3_ms: i32,
t4_ms: i32,
},
Pos { x: f32, y: f32 },
Move {
x1: f32,
y1: f32,
x2: f32,
y2: f32,
t1_ms: Option<i32>,
t2_ms: Option<i32>,
},
Frz(f32),
Blur(f32),
Fscx(f32),
Fscy(f32),
Color1((u8, u8, u8)),
Fs(f32),
ClipRect { x1: f32, y1: f32, x2: f32, y2: f32 },
ClipDrawing(String),
Frx(f32),
Fry(f32),
Org { x: f32, y: f32 },
Bord(f32),
Xbord(f32),
Ybord(f32),
Shad(f32),
Xshad(f32),
Yshad(f32),
Be(u8),
Fax(f32),
Fay(f32),
IClipRect { x1: f32, y1: f32, x2: f32, y2: f32 },
IClipDrawing(String),
Color2((u8, u8, u8)),
Color3((u8, u8, u8)),
Color4((u8, u8, u8)),
Alpha(u8),
Alpha1(u8),
Alpha2(u8),
Alpha3(u8),
Alpha4(u8),
Fsp(f32),
Q(u8),
An(u8),
A(u8),
Karaoke { kind: KaraokeKind, cs: u32 },
T {
t1_ms: Option<i32>,
t2_ms: Option<i32>,
accel: f32,
inner: Vec<AnimatedTag>,
},
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CueAnimation {
pub tags: Vec<AnimatedTag>,
}
impl CueAnimation {
pub fn is_empty(&self) -> bool {
self.tags.is_empty()
}
pub fn karaoke_spans(&self) -> Vec<KaraokeSpan> {
let mut spans = Vec::new();
let mut cursor_ms: u32 = 0;
for tag in &self.tags {
if let AnimatedTag::Karaoke { kind, cs } = tag {
let end_ms = cursor_ms.saturating_add(cs.saturating_mul(10));
spans.push(KaraokeSpan {
kind: *kind,
start_ms: cursor_ms,
end_ms,
});
cursor_ms = end_ms;
}
}
spans
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RenderState {
pub alpha_mul: f32,
pub transform: Transform2D,
pub rotate_radians: f32,
pub rotate_x_radians: f32,
pub rotate_y_radians: f32,
pub scale: (f32, f32),
pub translate: Option<(f32, f32)>,
pub blur_sigma: f32,
pub clip_rect: Option<ClipRect>,
pub clip_drawing: Option<String>,
pub primary_color: Option<(u8, u8, u8)>,
pub font_size: Option<f32>,
pub pivot: Option<(f32, f32)>,
pub border: Option<(f32, f32)>,
pub shadow: Option<(f32, f32)>,
pub be_strength: u8,
pub shear: (f32, f32),
pub iclip_rect: Option<ClipRect>,
pub iclip_drawing: Option<String>,
pub secondary_color: Option<(u8, u8, u8)>,
pub outline_color: Option<(u8, u8, u8)>,
pub shadow_color: Option<(u8, u8, u8)>,
pub primary_alpha: Option<u8>,
pub secondary_alpha: Option<u8>,
pub outline_alpha: Option<u8>,
pub shadow_alpha: Option<u8>,
pub letter_spacing: Option<f32>,
pub wrap_style: Option<u8>,
pub alignment: Option<u8>,
}
impl RenderState {
pub fn identity() -> Self {
Self {
alpha_mul: 1.0,
transform: Transform2D::identity(),
rotate_radians: 0.0,
rotate_x_radians: 0.0,
rotate_y_radians: 0.0,
scale: (1.0, 1.0),
translate: None,
blur_sigma: 0.0,
clip_rect: None,
clip_drawing: None,
primary_color: None,
font_size: None,
pivot: None,
border: None,
shadow: None,
be_strength: 0,
shear: (0.0, 0.0),
iclip_rect: None,
iclip_drawing: None,
secondary_color: None,
outline_color: None,
shadow_color: None,
primary_alpha: None,
secondary_alpha: None,
outline_alpha: None,
shadow_alpha: None,
letter_spacing: None,
wrap_style: None,
alignment: None,
}
}
}
impl Default for RenderState {
fn default() -> Self {
Self::identity()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClipRect {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
}
impl CueAnimation {
pub fn evaluate_at(&self, t_in_cue_ms: i32, cue_duration_ms: i32) -> RenderState {
let mut st = RenderState::identity();
for tag in &self.tags {
apply_tag(&mut st, tag, t_in_cue_ms, cue_duration_ms);
}
st.transform = compose_transform(&st);
st
}
}
fn compose_transform(st: &RenderState) -> Transform2D {
let (sx, sy) = st.scale;
let mut t = Transform2D::identity();
if (sx - 1.0).abs() > f32::EPSILON || (sy - 1.0).abs() > f32::EPSILON {
t = t.compose(&Transform2D::scale(sx, sy));
}
if st.rotate_radians.abs() > f32::EPSILON {
t = Transform2D::rotate(st.rotate_radians).compose(&t);
}
if let Some((tx, ty)) = st.translate {
t = Transform2D::translate(tx, ty).compose(&t);
}
t
}
fn apply_tag(st: &mut RenderState, tag: &AnimatedTag, t_ms: i32, dur_ms: i32) {
match tag {
AnimatedTag::Fad { t1_ms, t2_ms } => {
st.alpha_mul *= fad_alpha(*t1_ms as i32, *t2_ms as i32, t_ms, dur_ms);
}
AnimatedTag::Fade {
a1,
a2,
a3,
t1_ms,
t2_ms,
t3_ms,
t4_ms,
} => {
let a = fade_alpha(*a1, *a2, *a3, *t1_ms, *t2_ms, *t3_ms, *t4_ms, t_ms);
st.alpha_mul *= ass_alpha_to_mul(a);
}
AnimatedTag::Pos { x, y } => {
st.translate = Some((*x, *y));
}
AnimatedTag::Move {
x1,
y1,
x2,
y2,
t1_ms,
t2_ms,
} => {
let t1 = t1_ms.unwrap_or(0);
let t2 = t2_ms.unwrap_or(dur_ms);
let p = lerp_xy((*x1, *y1), (*x2, *y2), t1, t2, t_ms);
st.translate = Some(p);
}
AnimatedTag::Frz(deg) => {
st.rotate_radians = deg.to_radians();
}
AnimatedTag::Blur(sigma) => {
st.blur_sigma = sigma.max(0.0);
}
AnimatedTag::Fscx(pct) => {
st.scale.0 = pct / 100.0;
}
AnimatedTag::Fscy(pct) => {
st.scale.1 = pct / 100.0;
}
AnimatedTag::Color1(rgb) => {
st.primary_color = Some(*rgb);
}
AnimatedTag::Fs(size) => {
st.font_size = Some(*size);
}
AnimatedTag::ClipRect { x1, y1, x2, y2 } => {
let (lo_x, hi_x) = if x1 <= x2 { (*x1, *x2) } else { (*x2, *x1) };
let (lo_y, hi_y) = if y1 <= y2 { (*y1, *y2) } else { (*y2, *y1) };
st.clip_rect = Some(ClipRect {
x1: lo_x,
y1: lo_y,
x2: hi_x,
y2: hi_y,
});
}
AnimatedTag::ClipDrawing(s) => {
st.clip_drawing = Some(s.clone());
}
AnimatedTag::Frx(deg) => {
st.rotate_x_radians = deg.to_radians();
}
AnimatedTag::Fry(deg) => {
st.rotate_y_radians = deg.to_radians();
}
AnimatedTag::Org { x, y } => {
st.pivot = Some((*x, *y));
}
AnimatedTag::Bord(w) => {
let w = w.max(0.0);
st.border = Some((w, w));
}
AnimatedTag::Xbord(w) => {
let w = w.max(0.0);
let (_, y) = st.border.unwrap_or((0.0, 0.0));
st.border = Some((w, y));
}
AnimatedTag::Ybord(w) => {
let w = w.max(0.0);
let (x, _) = st.border.unwrap_or((0.0, 0.0));
st.border = Some((x, w));
}
AnimatedTag::Shad(d) => {
let d = d.max(0.0);
st.shadow = Some((d, d));
}
AnimatedTag::Xshad(d) => {
let (_, y) = st.shadow.unwrap_or((0.0, 0.0));
st.shadow = Some((*d, y));
}
AnimatedTag::Yshad(d) => {
let (x, _) = st.shadow.unwrap_or((0.0, 0.0));
st.shadow = Some((x, *d));
}
AnimatedTag::Be(n) => {
st.be_strength = *n;
}
AnimatedTag::Fax(f) => {
st.shear.0 = *f;
}
AnimatedTag::Fay(f) => {
st.shear.1 = *f;
}
AnimatedTag::IClipRect { x1, y1, x2, y2 } => {
let (lo_x, hi_x) = if x1 <= x2 { (*x1, *x2) } else { (*x2, *x1) };
let (lo_y, hi_y) = if y1 <= y2 { (*y1, *y2) } else { (*y2, *y1) };
st.iclip_rect = Some(ClipRect {
x1: lo_x,
y1: lo_y,
x2: hi_x,
y2: hi_y,
});
}
AnimatedTag::IClipDrawing(s) => {
st.iclip_drawing = Some(s.clone());
}
AnimatedTag::Color2(rgb) => {
st.secondary_color = Some(*rgb);
}
AnimatedTag::Color3(rgb) => {
st.outline_color = Some(*rgb);
}
AnimatedTag::Color4(rgb) => {
st.shadow_color = Some(*rgb);
}
AnimatedTag::Alpha(a) => {
st.primary_alpha = Some(*a);
st.secondary_alpha = Some(*a);
st.outline_alpha = Some(*a);
st.shadow_alpha = Some(*a);
}
AnimatedTag::Alpha1(a) => {
st.primary_alpha = Some(*a);
}
AnimatedTag::Alpha2(a) => {
st.secondary_alpha = Some(*a);
}
AnimatedTag::Alpha3(a) => {
st.outline_alpha = Some(*a);
}
AnimatedTag::Alpha4(a) => {
st.shadow_alpha = Some(*a);
}
AnimatedTag::Fsp(s) => {
st.letter_spacing = Some(*s);
}
AnimatedTag::Q(mode) => {
if *mode <= 3 {
st.wrap_style = Some(*mode);
}
}
AnimatedTag::An(n) => {
if (1..=9).contains(n) {
st.alignment = Some(*n);
}
}
AnimatedTag::A(n) => {
if let Some(numpad) = ssa_alignment_to_numpad(*n) {
st.alignment = Some(numpad);
}
}
AnimatedTag::Karaoke { .. } => {
}
AnimatedTag::T {
t1_ms,
t2_ms,
accel,
inner,
} => {
apply_t(st, *t1_ms, *t2_ms, *accel, inner, t_ms, dur_ms);
}
}
}
fn apply_t(
st: &mut RenderState,
t1: Option<i32>,
t2: Option<i32>,
accel: f32,
inner: &[AnimatedTag],
t_ms: i32,
dur_ms: i32,
) {
let start = t1.unwrap_or(0);
let end = t2.unwrap_or(dur_ms);
let pre = st.clone();
let mut post = pre.clone();
for tag in inner {
apply_tag(&mut post, tag, t_ms, dur_ms);
}
let raw = if end <= start {
if t_ms >= end {
1.0
} else {
0.0
}
} else if t_ms <= start {
0.0
} else if t_ms >= end {
1.0
} else {
(t_ms - start) as f32 / (end - start) as f32
};
let k = if accel.abs() < f32::EPSILON {
raw
} else {
raw.powf(accel)
};
st.scale.0 = lerp_f32(pre.scale.0, post.scale.0, k);
st.scale.1 = lerp_f32(pre.scale.1, post.scale.1, k);
st.rotate_radians = lerp_f32(pre.rotate_radians, post.rotate_radians, k);
st.rotate_x_radians = lerp_f32(pre.rotate_x_radians, post.rotate_x_radians, k);
st.rotate_y_radians = lerp_f32(pre.rotate_y_radians, post.rotate_y_radians, k);
st.blur_sigma = lerp_f32(pre.blur_sigma, post.blur_sigma, k).max(0.0);
st.alpha_mul = lerp_f32(pre.alpha_mul, post.alpha_mul, k);
if let Some(c) = post.primary_color {
let from = pre.primary_color.unwrap_or(c);
st.primary_color = Some(lerp_rgb(from, c, k));
}
if let Some(s) = post.font_size {
let from = pre.font_size.unwrap_or(s);
st.font_size = Some(lerp_f32(from, s, k));
}
if let Some((px, py)) = post.translate {
let (fx, fy) = pre.translate.unwrap_or((px, py));
st.translate = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
}
if let Some((px, py)) = post.border {
let (fx, fy) = pre.border.unwrap_or((px, py));
st.border = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
}
if let Some((px, py)) = post.shadow {
let (fx, fy) = pre.shadow.unwrap_or((px, py));
st.shadow = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
}
if post.be_strength != pre.be_strength {
let from = pre.be_strength as f32;
let to = post.be_strength as f32;
st.be_strength = lerp_f32(from, to, k).clamp(0.0, 255.0).round() as u8;
}
st.shear.0 = lerp_f32(pre.shear.0, post.shear.0, k);
st.shear.1 = lerp_f32(pre.shear.1, post.shear.1, k);
if let Some(c) = post.secondary_color {
let from = pre.secondary_color.unwrap_or(c);
st.secondary_color = Some(lerp_rgb(from, c, k));
}
if let Some(c) = post.outline_color {
let from = pre.outline_color.unwrap_or(c);
st.outline_color = Some(lerp_rgb(from, c, k));
}
if let Some(c) = post.shadow_color {
let from = pre.shadow_color.unwrap_or(c);
st.shadow_color = Some(lerp_rgb(from, c, k));
}
if let Some(a) = post.primary_alpha {
let from = pre.primary_alpha.unwrap_or(a);
st.primary_alpha = Some(lerp_u8(from, a, k));
}
if let Some(a) = post.secondary_alpha {
let from = pre.secondary_alpha.unwrap_or(a);
st.secondary_alpha = Some(lerp_u8(from, a, k));
}
if let Some(a) = post.outline_alpha {
let from = pre.outline_alpha.unwrap_or(a);
st.outline_alpha = Some(lerp_u8(from, a, k));
}
if let Some(a) = post.shadow_alpha {
let from = pre.shadow_alpha.unwrap_or(a);
st.shadow_alpha = Some(lerp_u8(from, a, k));
}
if let Some(s) = post.letter_spacing {
let from = pre.letter_spacing.unwrap_or(s);
st.letter_spacing = Some(lerp_f32(from, s, k));
}
if post.wrap_style != pre.wrap_style {
st.wrap_style = if k > 0.0 {
post.wrap_style
} else {
pre.wrap_style
};
}
if post.alignment != pre.alignment {
st.alignment = if k > 0.0 {
post.alignment
} else {
pre.alignment
};
}
}
fn ssa_alignment_to_numpad(n: u8) -> Option<u8> {
match n {
1 => Some(1),
2 => Some(2),
3 => Some(3),
5 => Some(7),
6 => Some(8),
7 => Some(9),
9 => Some(4),
10 => Some(5),
11 => Some(6),
_ => None,
}
}
fn lerp_u8(a: u8, b: u8, k: f32) -> u8 {
let v = a as f32 + (b as f32 - a as f32) * k;
v.clamp(0.0, 255.0).round() as u8
}
fn fad_alpha(t1: i32, t2: i32, t: i32, dur: i32) -> f32 {
let t = t.max(0);
let dur = dur.max(0);
let mul_in = if t1 <= 0 {
1.0
} else if t < t1 {
t as f32 / t1 as f32
} else {
1.0
};
let fade_out_start = (dur - t2).max(0);
let mul_out = if t2 <= 0 {
1.0
} else if t >= dur {
0.0
} else if t > fade_out_start {
((dur - t) as f32 / t2 as f32).clamp(0.0, 1.0)
} else {
1.0
};
(mul_in * mul_out).clamp(0.0, 1.0)
}
#[allow(clippy::too_many_arguments)]
fn fade_alpha(a1: u8, a2: u8, a3: u8, t1: i32, t2: i32, t3: i32, t4: i32, t: i32) -> u8 {
let lerp_u8 = |from: u8, to: u8, k: f32| -> u8 {
let v = from as f32 + (to as f32 - from as f32) * k;
v.clamp(0.0, 255.0) as u8
};
if t < t1 {
a1
} else if t < t2 {
let span = (t2 - t1).max(1);
lerp_u8(a1, a2, (t - t1) as f32 / span as f32)
} else if t < t3 {
a2
} else if t < t4 {
let span = (t4 - t3).max(1);
lerp_u8(a2, a3, (t - t3) as f32 / span as f32)
} else {
a3
}
}
fn ass_alpha_to_mul(a: u8) -> f32 {
1.0 - (a as f32 / 255.0)
}
fn lerp_f32(a: f32, b: f32, k: f32) -> f32 {
a + (b - a) * k
}
fn lerp_rgb(a: (u8, u8, u8), b: (u8, u8, u8), k: f32) -> (u8, u8, u8) {
let lerp_c = |from: u8, to: u8| -> u8 {
let v = from as f32 + (to as f32 - from as f32) * k;
v.clamp(0.0, 255.0) as u8
};
(lerp_c(a.0, b.0), lerp_c(a.1, b.1), lerp_c(a.2, b.2))
}
fn lerp_xy(a: (f32, f32), b: (f32, f32), t1: i32, t2: i32, t: i32) -> (f32, f32) {
let k = if t2 <= t1 {
if t >= t2 {
1.0
} else {
0.0
}
} else if t <= t1 {
0.0
} else if t >= t2 {
1.0
} else {
(t - t1) as f32 / (t2 - t1) as f32
};
(lerp_f32(a.0, b.0, k), lerp_f32(a.1, b.1, k))
}
pub fn extract_cue_animation(cue: &SubtitleCue) -> CueAnimation {
let mut tags: Vec<AnimatedTag> = Vec::new();
walk_segments(&cue.segments, &mut tags);
CueAnimation { tags }
}
fn walk_segments(segs: &[Segment], out: &mut Vec<AnimatedTag>) {
for s in segs {
match s {
Segment::Raw(raw) => parse_raw_block(raw, out),
Segment::Bold(c) | Segment::Italic(c) | Segment::Underline(c) | Segment::Strike(c) => {
walk_segments(c, out)
}
Segment::Color { children, .. }
| Segment::Font { children, .. }
| Segment::Voice { children, .. }
| Segment::Class { children, .. } => walk_segments(children, out),
Segment::Karaoke { cs, children } => {
out.push(AnimatedTag::Karaoke {
kind: KaraokeKind::Fill,
cs: *cs,
});
walk_segments(children, out);
}
_ => {}
}
}
}
fn parse_raw_block(raw: &str, out: &mut Vec<AnimatedTag>) {
let inner = raw.trim();
let inner = inner.strip_prefix('{').unwrap_or(inner);
let inner = inner.strip_suffix('}').unwrap_or(inner);
parse_overrides(inner, out);
}
pub fn parse_overrides(block: &str, out: &mut Vec<AnimatedTag>) {
let bytes = block.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'\\' {
i += 1;
continue;
}
i += 1;
let name_start = i;
if i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
} else {
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
}
let name = &block[name_start..i];
if name.is_empty() {
continue;
}
let (param, advance) = read_param(&block[i..]);
i += advance;
let name_lc = name.to_ascii_lowercase();
if let Some(t) = parse_one(&name_lc, name, ¶m) {
out.push(t);
}
}
}
fn read_param(s: &str) -> (String, usize) {
let bytes = s.as_bytes();
if bytes.first() == Some(&b'(') {
let mut depth: i32 = 0;
let mut idx = 0;
for (k, &b) in bytes.iter().enumerate() {
if b == b'(' {
depth += 1;
} else if b == b')' {
depth -= 1;
if depth == 0 {
idx = k;
break;
}
}
}
if idx == 0 {
return (s[1..].to_string(), bytes.len());
}
(s[1..idx].to_string(), idx + 1)
} else {
let mut k = 0;
while k < bytes.len() && bytes[k] != b'\\' {
k += 1;
}
(s[..k].to_string(), k)
}
}
fn parse_one(name_lc: &str, name_orig: &str, param: &str) -> Option<AnimatedTag> {
match name_lc {
"fad" => {
let nums = parse_int_list(param);
if nums.len() >= 2 {
Some(AnimatedTag::Fad {
t1_ms: nums[0].max(0) as u32,
t2_ms: nums[1].max(0) as u32,
})
} else {
None
}
}
"fade" => {
let nums = parse_int_list(param);
if nums.len() >= 7 {
Some(AnimatedTag::Fade {
a1: nums[0].clamp(0, 255) as u8,
a2: nums[1].clamp(0, 255) as u8,
a3: nums[2].clamp(0, 255) as u8,
t1_ms: nums[3],
t2_ms: nums[4],
t3_ms: nums[5],
t4_ms: nums[6],
})
} else {
None
}
}
"move" => {
let nums = parse_float_list(param);
match nums.len() {
4 => Some(AnimatedTag::Move {
x1: nums[0],
y1: nums[1],
x2: nums[2],
y2: nums[3],
t1_ms: None,
t2_ms: None,
}),
6 => Some(AnimatedTag::Move {
x1: nums[0],
y1: nums[1],
x2: nums[2],
y2: nums[3],
t1_ms: Some(nums[4] as i32),
t2_ms: Some(nums[5] as i32),
}),
_ => None,
}
}
"frz" | "fr" => param.trim().parse::<f32>().ok().map(AnimatedTag::Frz),
"frx" => param.trim().parse::<f32>().ok().map(AnimatedTag::Frx),
"fry" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fry),
"pos" => {
let n = parse_float_list(param);
if n.len() == 2 {
Some(AnimatedTag::Pos { x: n[0], y: n[1] })
} else {
None
}
}
"org" => {
let n = parse_float_list(param);
if n.len() == 2 {
Some(AnimatedTag::Org { x: n[0], y: n[1] })
} else {
None
}
}
"blur" => param.trim().parse::<f32>().ok().map(AnimatedTag::Blur),
"be" => {
let n = param.trim().parse::<f32>().ok()?;
let n = n.clamp(0.0, 255.0).round() as u8;
Some(AnimatedTag::Be(n))
}
"bord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Bord),
"xbord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Xbord),
"ybord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Ybord),
"shad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Shad),
"xshad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Xshad),
"yshad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Yshad),
"fax" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fax),
"fay" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fay),
"fscx" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fscx),
"fscy" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fscy),
"fs" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fs),
"fsp" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fsp),
"q" => {
let n: i32 = param.trim().parse().ok()?;
if (0..=3).contains(&n) {
Some(AnimatedTag::Q(n as u8))
} else {
None
}
}
"an" => {
let n: i32 = param.trim().parse().ok()?;
if (1..=9).contains(&n) {
Some(AnimatedTag::An(n as u8))
} else {
None
}
}
"a" => {
let n: i32 = param.trim().parse().ok()?;
if (0..=255).contains(&n) {
Some(AnimatedTag::A(n as u8))
} else {
None
}
}
"k" | "kf" | "ko" => {
let cs = param.trim().parse::<f32>().ok()?;
let cs = cs.max(0.0).round() as u32;
let kind = match name_lc {
"kf" => KaraokeKind::Sweep,
"ko" => KaraokeKind::Outline,
_ if name_orig == "K" => KaraokeKind::Sweep,
_ => KaraokeKind::Fill,
};
Some(AnimatedTag::Karaoke { kind, cs })
}
"c" | "1c" => parse_color_rgb(param).map(AnimatedTag::Color1),
"2c" => parse_color_rgb(param).map(AnimatedTag::Color2),
"3c" => parse_color_rgb(param).map(AnimatedTag::Color3),
"4c" => parse_color_rgb(param).map(AnimatedTag::Color4),
"alpha" => parse_alpha_byte(param).map(AnimatedTag::Alpha),
"1a" => parse_alpha_byte(param).map(AnimatedTag::Alpha1),
"2a" => parse_alpha_byte(param).map(AnimatedTag::Alpha2),
"3a" => parse_alpha_byte(param).map(AnimatedTag::Alpha3),
"4a" => parse_alpha_byte(param).map(AnimatedTag::Alpha4),
"clip" => parse_clip(param, false),
"iclip" => parse_clip(param, true),
"t" => parse_t(param),
_ => None,
}
}
fn parse_alpha_byte(s: &str) -> Option<u8> {
let mut t = s.trim();
t = t.trim_matches('&');
t = t.trim_start_matches(['H', 'h']);
t = t.trim_start_matches("0x");
t = t.trim_matches('&').trim();
if t.is_empty() {
return None;
}
let v = u32::from_str_radix(t, 16).ok()?;
Some(v.clamp(0, 255) as u8)
}
fn parse_int_list(s: &str) -> Vec<i32> {
s.split(',')
.map(|p| p.trim().parse::<i32>().ok())
.collect::<Option<Vec<_>>>()
.unwrap_or_default()
}
fn parse_float_list(s: &str) -> Vec<f32> {
s.split(',')
.map(|p| p.trim().parse::<f32>().ok())
.collect::<Option<Vec<_>>>()
.unwrap_or_default()
}
fn parse_color_rgb(s: &str) -> Option<(u8, u8, u8)> {
let s = s.trim().trim_matches('&');
let s = s.trim_start_matches(['H', 'h']);
let s = s.trim_start_matches("0x");
let s = s.trim_end_matches('&').trim();
if s.is_empty() {
return None;
}
let v: u32 = u32::from_str_radix(s, 16).ok()?;
let b = ((v >> 16) & 0xFF) as u8;
let g = ((v >> 8) & 0xFF) as u8;
let r = (v & 0xFF) as u8;
Some((r, g, b))
}
fn parse_clip(param: &str, inverse: bool) -> Option<AnimatedTag> {
let parts: Vec<&str> = param.split(',').map(|s| s.trim()).collect();
if parts.len() == 4 {
let n: Vec<Option<f32>> = parts.iter().map(|p| p.parse::<f32>().ok()).collect();
if n.iter().all(|x| x.is_some()) {
let n: Vec<f32> = n.into_iter().map(|x| x.unwrap()).collect();
return Some(if inverse {
AnimatedTag::IClipRect {
x1: n[0],
y1: n[1],
x2: n[2],
y2: n[3],
}
} else {
AnimatedTag::ClipRect {
x1: n[0],
y1: n[1],
x2: n[2],
y2: n[3],
}
});
}
}
Some(if inverse {
AnimatedTag::IClipDrawing(param.to_string())
} else {
AnimatedTag::ClipDrawing(param.to_string())
})
}
fn parse_t(param: &str) -> Option<AnimatedTag> {
let (nums, tags_str) = peel_leading_numbers(param);
let mut inner: Vec<AnimatedTag> = Vec::new();
parse_overrides(tags_str, &mut inner);
let (t1, t2, accel) = match nums.len() {
0 => (None, None, 1.0_f32),
1 => (None, None, nums[0]),
2 => (Some(nums[0] as i32), Some(nums[1] as i32), 1.0),
_ => (Some(nums[0] as i32), Some(nums[1] as i32), nums[2]),
};
Some(AnimatedTag::T {
t1_ms: t1,
t2_ms: t2,
accel,
inner,
})
}
fn peel_leading_numbers(s: &str) -> (Vec<f32>, &str) {
let mut nums = Vec::new();
let mut cursor = s.trim_start();
loop {
let bytes = cursor.as_bytes();
let mut k = 0;
while k < bytes.len() && bytes[k] != b',' && bytes[k] != b'\\' {
k += 1;
}
let head = cursor[..k].trim();
if head.is_empty() {
if k == 0 {
break;
}
}
match head.parse::<f32>() {
Ok(n) => {
nums.push(n);
if k >= bytes.len() {
cursor = "";
break;
}
if bytes[k] == b'\\' {
cursor = &cursor[k..];
break;
}
cursor = &cursor[k + 1..];
cursor = cursor.trim_start();
}
Err(_) => break,
}
}
(nums, cursor)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_block(s: &str) -> Vec<AnimatedTag> {
let mut out = Vec::new();
parse_overrides(s, &mut out);
out
}
#[test]
fn parses_fad() {
let v = parse_block(r"\fad(200,300)");
assert_eq!(
v,
vec![AnimatedTag::Fad {
t1_ms: 200,
t2_ms: 300,
}]
);
}
#[test]
fn parses_fade7() {
let v = parse_block(r"\fade(255,0,255,0,500,1500,2000)");
assert_eq!(
v,
vec![AnimatedTag::Fade {
a1: 255,
a2: 0,
a3: 255,
t1_ms: 0,
t2_ms: 500,
t3_ms: 1500,
t4_ms: 2000,
}]
);
}
#[test]
fn parses_move4_and_move6() {
let v = parse_block(r"\move(10,20,100,200)");
assert_eq!(v.len(), 1);
match &v[0] {
AnimatedTag::Move {
x1,
y1,
x2,
y2,
t1_ms,
t2_ms,
} => {
assert_eq!(*x1, 10.0);
assert_eq!(*y1, 20.0);
assert_eq!(*x2, 100.0);
assert_eq!(*y2, 200.0);
assert!(t1_ms.is_none());
assert!(t2_ms.is_none());
}
_ => panic!(),
}
let v = parse_block(r"\move(10,20,100,200,500,1500)");
match &v[0] {
AnimatedTag::Move { t1_ms, t2_ms, .. } => {
assert_eq!(*t1_ms, Some(500));
assert_eq!(*t2_ms, Some(1500));
}
_ => panic!(),
}
}
#[test]
fn parses_frz_blur_fscx_fscy() {
let v = parse_block(r"\frz45\blur2.5\fscx150\fscy75");
assert_eq!(v.len(), 4);
assert!(matches!(v[0], AnimatedTag::Frz(45.0)));
assert!(matches!(v[1], AnimatedTag::Blur(b) if (b - 2.5).abs() < 1e-6));
assert!(matches!(v[2], AnimatedTag::Fscx(150.0)));
assert!(matches!(v[3], AnimatedTag::Fscy(75.0)));
}
#[test]
fn parses_clip_rect() {
let v = parse_block(r"\clip(10,20,100,200)");
assert_eq!(
v,
vec![AnimatedTag::ClipRect {
x1: 10.0,
y1: 20.0,
x2: 100.0,
y2: 200.0,
}]
);
}
#[test]
fn parses_clip_drawing_passthrough() {
let v = parse_block(r"\clip(m 0 0 l 100 0 l 100 100 l 0 100)");
assert_eq!(v.len(), 1);
assert!(matches!(v[0], AnimatedTag::ClipDrawing(_)));
}
#[test]
fn parses_t_full() {
let v = parse_block(r"\t(0,1000,1.5,\fscx200\frz90)");
assert_eq!(v.len(), 1);
match &v[0] {
AnimatedTag::T {
t1_ms,
t2_ms,
accel,
inner,
} => {
assert_eq!(*t1_ms, Some(0));
assert_eq!(*t2_ms, Some(1000));
assert!((accel - 1.5).abs() < 1e-6);
assert_eq!(inner.len(), 2);
assert!(matches!(inner[0], AnimatedTag::Fscx(200.0)));
assert!(matches!(inner[1], AnimatedTag::Frz(90.0)));
}
_ => panic!(),
}
}
#[test]
fn parses_t_no_times() {
let v = parse_block(r"\t(\frz360)");
match &v[0] {
AnimatedTag::T {
t1_ms,
t2_ms,
accel,
inner,
} => {
assert!(t1_ms.is_none());
assert!(t2_ms.is_none());
assert!((accel - 1.0).abs() < 1e-6);
assert_eq!(inner.len(), 1);
}
_ => panic!(),
}
}
#[test]
fn parses_t_two_times_no_accel() {
let v = parse_block(r"\t(0,500,\frz45)");
match &v[0] {
AnimatedTag::T {
t1_ms,
t2_ms,
accel,
inner,
} => {
assert_eq!(*t1_ms, Some(0));
assert_eq!(*t2_ms, Some(500));
assert!((accel - 1.0).abs() < 1e-6);
assert_eq!(inner.len(), 1);
}
_ => panic!(),
}
}
#[test]
fn parses_color() {
let v = parse_block(r"\c&H0000FF&");
assert_eq!(v, vec![AnimatedTag::Color1((255, 0, 0))]);
let v = parse_block(r"\1c&HFF00FF&");
assert_eq!(v, vec![AnimatedTag::Color1((255, 0, 255))]);
}
#[test]
fn fad_alpha_curve() {
let dur = 2000;
assert!((fad_alpha(200, 300, 0, dur) - 0.0).abs() < 1e-6);
assert!((fad_alpha(200, 300, 100, dur) - 0.5).abs() < 1e-6);
assert!((fad_alpha(200, 300, 200, dur) - 1.0).abs() < 1e-6);
assert!((fad_alpha(200, 300, 1000, dur) - 1.0).abs() < 1e-6);
assert!((fad_alpha(200, 300, 1700, dur) - 1.0).abs() < 1e-6);
assert!((fad_alpha(200, 300, 1850, dur) - 0.5).abs() < 1e-6);
assert!((fad_alpha(200, 300, 2000, dur) - 0.0).abs() < 1e-6);
}
#[test]
fn evaluate_static_overrides() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Fscx(200.0),
AnimatedTag::Fscy(50.0),
AnimatedTag::Frz(90.0),
AnimatedTag::Blur(3.0),
],
};
let st = cue_anim.evaluate_at(500, 1000);
assert_eq!(st.scale, (2.0, 0.5));
assert!((st.rotate_radians - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
assert_eq!(st.blur_sigma, 3.0);
}
#[test]
fn evaluate_move() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Move {
x1: 0.0,
y1: 0.0,
x2: 100.0,
y2: 200.0,
t1_ms: Some(0),
t2_ms: Some(1000),
}],
};
let st0 = cue_anim.evaluate_at(0, 1000);
assert_eq!(st0.translate, Some((0.0, 0.0)));
let st_mid = cue_anim.evaluate_at(500, 1000);
assert_eq!(st_mid.translate, Some((50.0, 100.0)));
let st_end = cue_anim.evaluate_at(1000, 1000);
assert_eq!(st_end.translate, Some((100.0, 200.0)));
let st_after = cue_anim.evaluate_at(2000, 1000);
assert_eq!(st_after.translate, Some((100.0, 200.0)));
}
#[test]
fn evaluate_move_default_times() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Move {
x1: 0.0,
y1: 0.0,
x2: 100.0,
y2: 100.0,
t1_ms: None,
t2_ms: None,
}],
};
let st = cue_anim.evaluate_at(500, 1000);
assert_eq!(st.translate, Some((50.0, 50.0)));
}
#[test]
fn parses_pos() {
let v = parse_block(r"\pos(320,240)");
assert_eq!(v, vec![AnimatedTag::Pos { x: 320.0, y: 240.0 }]);
let v = parse_block(r"\pos(12.5,-3.0)");
assert_eq!(v, vec![AnimatedTag::Pos { x: 12.5, y: -3.0 }]);
assert!(parse_block(r"\pos(320)").is_empty());
assert!(parse_block(r"\pos(1,2,3)").is_empty());
}
#[test]
fn evaluate_pos_is_static() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Pos { x: 320.0, y: 240.0 }],
};
assert_eq!(
cue_anim.evaluate_at(0, 1000).translate,
Some((320.0, 240.0))
);
assert_eq!(
cue_anim.evaluate_at(500, 1000).translate,
Some((320.0, 240.0))
);
assert_eq!(
cue_anim.evaluate_at(1000, 1000).translate,
Some((320.0, 240.0))
);
}
#[test]
fn move_after_pos_overrides() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Pos { x: 10.0, y: 10.0 },
AnimatedTag::Move {
x1: 0.0,
y1: 0.0,
x2: 100.0,
y2: 100.0,
t1_ms: Some(0),
t2_ms: Some(1000),
},
],
};
assert_eq!(
cue_anim.evaluate_at(500, 1000).translate,
Some((50.0, 50.0))
);
}
#[test]
fn evaluate_fad() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Fad {
t1_ms: 200,
t2_ms: 300,
}],
};
let dur = 2000;
assert!((cue_anim.evaluate_at(0, dur).alpha_mul - 0.0).abs() < 1e-6);
assert!((cue_anim.evaluate_at(100, dur).alpha_mul - 0.5).abs() < 1e-6);
assert!((cue_anim.evaluate_at(1000, dur).alpha_mul - 1.0).abs() < 1e-6);
assert!((cue_anim.evaluate_at(1850, dur).alpha_mul - 0.5).abs() < 1e-6);
}
#[test]
fn evaluate_t_interpolates_scale() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Fscx(200.0)],
}],
};
assert_eq!(cue_anim.evaluate_at(0, 1000).scale.0, 1.0);
assert!((cue_anim.evaluate_at(500, 1000).scale.0 - 1.5).abs() < 1e-6);
assert_eq!(cue_anim.evaluate_at(1000, 1000).scale.0, 2.0);
assert_eq!(cue_anim.evaluate_at(1500, 1000).scale.0, 2.0);
}
#[test]
fn evaluate_t_interpolates_rotate() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Frz(90.0)],
}],
};
let st_mid = cue_anim.evaluate_at(500, 1000);
assert!((st_mid.rotate_radians - std::f32::consts::FRAC_PI_4).abs() < 1e-5);
}
#[test]
fn evaluate_t_interpolates_color() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Color1((255, 0, 0)), AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Color1((0, 0, 255))], },
],
};
let st = cue_anim.evaluate_at(500, 1000);
let rgb = st.primary_color.unwrap();
assert!((rgb.0 as i32 - 127).abs() <= 1);
assert_eq!(rgb.1, 0);
assert!((rgb.2 as i32 - 127).abs() <= 1);
}
#[test]
fn evaluate_t_no_times_uses_cue_span() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::T {
t1_ms: None,
t2_ms: None,
accel: 1.0,
inner: vec![AnimatedTag::Fscy(200.0)],
}],
};
let st = cue_anim.evaluate_at(1000, 2000);
assert!((st.scale.1 - 1.5).abs() < 1e-6);
}
#[test]
fn clip_rect_applies() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::ClipRect {
x1: 10.0,
y1: 20.0,
x2: 100.0,
y2: 200.0,
}],
};
let st = cue_anim.evaluate_at(0, 1000);
let c = st.clip_rect.unwrap();
assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
}
#[test]
fn clip_rect_normalises_swapped_corners() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::ClipRect {
x1: 100.0,
y1: 200.0,
x2: 10.0,
y2: 20.0,
}],
};
let st = cue_anim.evaluate_at(0, 1000);
let c = st.clip_rect.unwrap();
assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
}
#[test]
fn extract_from_cue_segments() {
let cue = SubtitleCue {
start_us: 0,
end_us: 1_000_000,
style_ref: None,
positioning: None,
segments: vec![
Segment::Raw(r"{\fad(100,200)\frz30}".into()),
Segment::Text("hello".into()),
Segment::Raw(r"{\move(0,0,100,100)}".into()),
],
};
let anim = extract_cue_animation(&cue);
assert_eq!(anim.tags.len(), 3);
assert!(matches!(
anim.tags[0],
AnimatedTag::Fad {
t1_ms: 100,
t2_ms: 200
}
));
assert!(matches!(anim.tags[1], AnimatedTag::Frz(30.0)));
assert!(matches!(anim.tags[2], AnimatedTag::Move { .. }));
}
#[test]
fn extract_skips_non_animated_raw() {
let cue = SubtitleCue {
start_us: 0,
end_us: 1_000_000,
style_ref: None,
positioning: None,
segments: vec![Segment::Raw(r"{\xyz(1,2)}".into())],
};
let anim = extract_cue_animation(&cue);
assert!(anim.is_empty());
}
#[test]
fn extract_recurses_into_color_children() {
let cue = SubtitleCue {
start_us: 0,
end_us: 0,
style_ref: None,
positioning: None,
segments: vec![Segment::Color {
rgb: (1, 2, 3),
children: vec![Segment::Raw(r"{\fad(50,50)}".into())],
}],
};
let anim = extract_cue_animation(&cue);
assert_eq!(anim.tags.len(), 1);
assert!(matches!(
anim.tags[0],
AnimatedTag::Fad {
t1_ms: 50,
t2_ms: 50
}
));
}
#[test]
fn transform_composition_includes_translate() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Move {
x1: 100.0,
y1: 200.0,
x2: 100.0,
y2: 200.0,
t1_ms: None,
t2_ms: None,
},
AnimatedTag::Fscx(200.0),
],
};
let st = cue_anim.evaluate_at(0, 1000);
let p = st.transform.apply(oxideav_core::Point { x: 0.0, y: 0.0 });
assert!((p.x - 100.0).abs() < 1e-5);
assert!((p.y - 200.0).abs() < 1e-5);
let p1 = st.transform.apply(oxideav_core::Point { x: 1.0, y: 0.0 });
assert!((p1.x - 102.0).abs() < 1e-5);
assert!((p1.y - 200.0).abs() < 1e-5);
}
#[test]
fn parses_bord_uniform() {
let v = parse_block(r"\bord3.5");
assert_eq!(v, vec![AnimatedTag::Bord(3.5)]);
}
#[test]
fn parses_xbord_ybord_pair() {
let v = parse_block(r"\xbord2\ybord4");
assert_eq!(v, vec![AnimatedTag::Xbord(2.0), AnimatedTag::Ybord(4.0)]);
}
#[test]
fn parses_shad_uniform_and_per_axis() {
let v = parse_block(r"\shad5\xshad-2.5\yshad3");
assert_eq!(
v,
vec![
AnimatedTag::Shad(5.0),
AnimatedTag::Xshad(-2.5),
AnimatedTag::Yshad(3.0),
]
);
}
#[test]
fn parses_blur_and_be_are_separate_variants() {
let v = parse_block(r"\blur2.5\be3");
assert_eq!(v.len(), 2);
assert!(matches!(v[0], AnimatedTag::Blur(b) if (b - 2.5).abs() < 1e-6));
assert!(matches!(v[1], AnimatedTag::Be(3)));
}
#[test]
fn be_rounds_non_integer_strengths() {
let v = parse_block(r"\be2.7");
assert!(matches!(v[0], AnimatedTag::Be(3)));
}
#[test]
fn parses_fax_fay() {
let v = parse_block(r"\fax0.5\fay-0.25");
assert_eq!(v, vec![AnimatedTag::Fax(0.5), AnimatedTag::Fay(-0.25)]);
}
#[test]
fn parses_iclip_rect() {
let v = parse_block(r"\iclip(10,20,100,200)");
assert_eq!(
v,
vec![AnimatedTag::IClipRect {
x1: 10.0,
y1: 20.0,
x2: 100.0,
y2: 200.0,
}]
);
}
#[test]
fn parses_iclip_drawing_passthrough() {
let v = parse_block(r"\iclip(m 0 0 l 100 0 l 100 100 l 0 100)");
assert_eq!(v.len(), 1);
assert!(matches!(v[0], AnimatedTag::IClipDrawing(_)));
}
#[test]
fn parses_iclip_with_scale_prefix_is_drawing_form() {
let v = parse_block(r"\iclip(2,m 0 0 l 50 50)");
assert!(matches!(v[0], AnimatedTag::IClipDrawing(_)));
}
#[test]
fn evaluate_bord_sets_both_axes() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Bord(2.5)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.border, Some((2.5, 2.5)));
}
#[test]
fn evaluate_xbord_then_ybord_combines() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Xbord(2.0), AnimatedTag::Ybord(4.0)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.border, Some((2.0, 4.0)));
}
#[test]
fn evaluate_bord_after_xbord_ybord_overrides_both() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Xbord(2.0),
AnimatedTag::Ybord(4.0),
AnimatedTag::Bord(1.0),
],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.border, Some((1.0, 1.0)));
}
#[test]
fn evaluate_bord_clamps_negative_to_zero() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Bord(-3.0)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.border, Some((0.0, 0.0)));
}
#[test]
fn evaluate_shad_uniform_and_xshad_yshad_negative() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Shad(2.0)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.shadow, Some((2.0, 2.0)));
let cue_anim2 = CueAnimation {
tags: vec![AnimatedTag::Xshad(-3.5), AnimatedTag::Yshad(1.5)],
};
let st2 = cue_anim2.evaluate_at(0, 1000);
assert_eq!(st2.shadow, Some((-3.5, 1.5)));
let cue_anim3 = CueAnimation {
tags: vec![AnimatedTag::Shad(-2.0)],
};
let st3 = cue_anim3.evaluate_at(0, 1000);
assert_eq!(st3.shadow, Some((0.0, 0.0)));
}
#[test]
fn evaluate_be_strength() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Be(5)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.be_strength, 5);
assert_eq!(st.blur_sigma, 0.0);
}
#[test]
fn evaluate_fax_fay_writes_shear() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Fax(0.5), AnimatedTag::Fay(-0.3)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert!((st.shear.0 - 0.5).abs() < 1e-6);
assert!((st.shear.1 + 0.3).abs() < 1e-6);
}
#[test]
fn evaluate_iclip_rect_normalises() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::IClipRect {
x1: 100.0,
y1: 200.0,
x2: 10.0,
y2: 20.0,
}],
};
let st = cue_anim.evaluate_at(0, 1000);
let c = st.iclip_rect.unwrap();
assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
assert!(st.clip_rect.is_none());
}
#[test]
fn evaluate_iclip_drawing_stored() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::IClipDrawing("m 0 0 l 10 10".into())],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.iclip_drawing.as_deref(), Some("m 0 0 l 10 10"));
assert!(st.clip_drawing.is_none());
}
#[test]
fn t_interpolates_bord() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Bord(0.0),
AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Bord(4.0)],
},
],
};
let st_mid = cue_anim.evaluate_at(500, 1000);
let (bx, by) = st_mid.border.unwrap();
assert!((bx - 2.0).abs() < 1e-5, "bx = {}", bx);
assert!((by - 2.0).abs() < 1e-5);
let st_end = cue_anim.evaluate_at(1000, 1000);
let (bx2, by2) = st_end.border.unwrap();
assert!((bx2 - 4.0).abs() < 1e-5);
assert!((by2 - 4.0).abs() < 1e-5);
}
#[test]
fn t_interpolates_shad_per_axis() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Xshad(0.0),
AnimatedTag::Yshad(0.0),
AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Xshad(6.0), AnimatedTag::Yshad(-2.0)],
},
],
};
let st = cue_anim.evaluate_at(500, 1000);
let (sx, sy) = st.shadow.unwrap();
assert!((sx - 3.0).abs() < 1e-5);
assert!((sy + 1.0).abs() < 1e-5);
}
#[test]
fn t_interpolates_fax_fay() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Fax(1.0)],
}],
};
let st = cue_anim.evaluate_at(500, 1000);
assert!((st.shear.0 - 0.5).abs() < 1e-5);
}
#[test]
fn t_interpolates_be_rounds_to_integer() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Be(0),
AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Be(10)],
},
],
};
let st = cue_anim.evaluate_at(500, 1000);
assert_eq!(st.be_strength, 5);
let st_q = cue_anim.evaluate_at(250, 1000);
assert!(st_q.be_strength == 2 || st_q.be_strength == 3);
}
#[test]
fn extract_typed_tags_from_real_world_cue() {
let cue = SubtitleCue {
start_us: 0,
end_us: 5_000_000,
style_ref: None,
positioning: None,
segments: vec![
Segment::Raw(
r"{\bord2\xbord3\ybord4\shad1\xshad-2\yshad2\blur1.5\be2\fax0.1\fay-0.1\iclip(0,0,640,480)}"
.into(),
),
Segment::Text("text".into()),
],
};
let anim = extract_cue_animation(&cue);
assert_eq!(anim.tags.len(), 11, "got {:?}", anim.tags);
let st = anim.evaluate_at(0, 5000);
assert_eq!(st.border, Some((3.0, 4.0)));
assert_eq!(st.shadow, Some((-2.0, 2.0)));
assert!((st.blur_sigma - 1.5).abs() < 1e-6);
assert_eq!(st.be_strength, 2);
assert!((st.shear.0 - 0.1).abs() < 1e-6);
assert!((st.shear.1 + 0.1).abs() < 1e-6);
let c = st.iclip_rect.unwrap();
assert_eq!((c.x1, c.y1, c.x2, c.y2), (0.0, 0.0, 640.0, 480.0));
}
#[test]
fn parses_color2_color3_color4() {
let v = parse_block(r"\2c&H0000FF&\3c&H00FF00&\4c&HFF0000&");
assert_eq!(
v,
vec![
AnimatedTag::Color2((255, 0, 0)),
AnimatedTag::Color3((0, 255, 0)),
AnimatedTag::Color4((0, 0, 255)),
]
);
}
#[test]
fn parses_alpha_all_and_per_component() {
let v = parse_block(r"\alpha&H80&\1a&HFF&\2a&H00&\3a&H40&\4a&HC0&");
assert_eq!(
v,
vec![
AnimatedTag::Alpha(0x80),
AnimatedTag::Alpha1(0xFF),
AnimatedTag::Alpha2(0x00),
AnimatedTag::Alpha3(0x40),
AnimatedTag::Alpha4(0xC0),
]
);
}
#[test]
fn parses_alpha_tolerates_envelope_variants() {
assert_eq!(parse_alpha_byte("&HFF&"), Some(0xFF));
assert_eq!(parse_alpha_byte("&HFF"), Some(0xFF));
assert_eq!(parse_alpha_byte("HFF"), Some(0xFF));
assert_eq!(parse_alpha_byte("0xFF"), Some(0xFF));
assert_eq!(parse_alpha_byte("ff"), Some(0xFF));
assert_eq!(parse_alpha_byte(""), None);
}
#[test]
fn evaluate_color2_color3_color4_writes_separate_fields() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Color2((10, 20, 30)),
AnimatedTag::Color3((40, 50, 60)),
AnimatedTag::Color4((70, 80, 90)),
],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.secondary_color, Some((10, 20, 30)));
assert_eq!(st.outline_color, Some((40, 50, 60)));
assert_eq!(st.shadow_color, Some((70, 80, 90)));
assert_eq!(st.primary_color, None);
}
#[test]
fn evaluate_alpha_global_sets_all_four_channels() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Alpha(0x80)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.primary_alpha, Some(0x80));
assert_eq!(st.secondary_alpha, Some(0x80));
assert_eq!(st.outline_alpha, Some(0x80));
assert_eq!(st.shadow_alpha, Some(0x80));
}
#[test]
fn evaluate_per_component_alpha_overrides_global() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Alpha(0x40), AnimatedTag::Alpha3(0xFF)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.primary_alpha, Some(0x40));
assert_eq!(st.secondary_alpha, Some(0x40));
assert_eq!(st.outline_alpha, Some(0xFF));
assert_eq!(st.shadow_alpha, Some(0x40));
}
#[test]
fn alpha_per_component_does_not_touch_alpha_mul() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Alpha1(0x80), AnimatedTag::Alpha3(0xC0)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.alpha_mul, 1.0);
assert_eq!(st.primary_alpha, Some(0x80));
assert_eq!(st.outline_alpha, Some(0xC0));
}
#[test]
fn t_interpolates_color3() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Color3((255, 0, 0)),
AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Color3((0, 0, 255))],
},
],
};
let st = cue_anim.evaluate_at(500, 1000);
let rgb = st.outline_color.unwrap();
assert!((rgb.0 as i32 - 127).abs() <= 1);
assert_eq!(rgb.1, 0);
assert!((rgb.2 as i32 - 127).abs() <= 1);
}
#[test]
fn t_interpolates_alpha1() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Alpha1(0x00),
AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Alpha1(0xFF)],
},
],
};
let st = cue_anim.evaluate_at(500, 1000);
let a = st.primary_alpha.unwrap();
assert!((a as i32 - 0x80).abs() <= 1, "got {:#x}", a);
let st_end = cue_anim.evaluate_at(1000, 1000);
assert_eq!(st_end.primary_alpha, Some(0xFF));
}
#[test]
fn t_interpolates_alpha_global_writes_all_four() {
let cue_anim = CueAnimation {
tags: vec![
AnimatedTag::Alpha(0x00),
AnimatedTag::T {
t1_ms: Some(0),
t2_ms: Some(1000),
accel: 1.0,
inner: vec![AnimatedTag::Alpha(0xFF)],
},
],
};
let st = cue_anim.evaluate_at(500, 1000);
for ch in [
st.primary_alpha,
st.secondary_alpha,
st.outline_alpha,
st.shadow_alpha,
] {
let a = ch.unwrap();
assert!((a as i32 - 0x80).abs() <= 1);
}
}
#[test]
fn extract_full_alpha_and_color_cue() {
let cue = SubtitleCue {
start_us: 0,
end_us: 2_000_000,
style_ref: None,
positioning: None,
segments: vec![
Segment::Raw(
r"{\1c&H0000FF&\2c&H00FF00&\3c&HFF0000&\4c&H808080&\alpha&H80&\3a&HFF&}".into(),
),
Segment::Text("text".into()),
],
};
let anim = extract_cue_animation(&cue);
assert_eq!(anim.tags.len(), 6, "got {:?}", anim.tags);
let st = anim.evaluate_at(0, 2000);
assert_eq!(st.primary_color, Some((255, 0, 0)));
assert_eq!(st.secondary_color, Some((0, 255, 0)));
assert_eq!(st.outline_color, Some((0, 0, 255)));
assert_eq!(st.shadow_color, Some((128, 128, 128)));
assert_eq!(st.primary_alpha, Some(0x80));
assert_eq!(st.secondary_alpha, Some(0x80));
assert_eq!(st.outline_alpha, Some(0xFF));
assert_eq!(st.shadow_alpha, Some(0x80));
}
#[test]
fn unrecognised_color_or_alpha_payload_is_skipped() {
assert!(parse_block(r"\2c&Hgggggg&").is_empty());
assert!(parse_block(r"\1a").is_empty());
assert!(parse_block(r"\3c").is_empty());
}
#[test]
fn capital_k_karaoke_tag_is_recognised_as_kf() {
use crate::parse;
let src = "[Script Info]\n\
ScriptType: v4.00+\n\
\n\
[V4+ Styles]\n\
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n\
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\K50}sweep{\\K30}done\n";
let t = parse(src.as_bytes()).unwrap();
let segs = &t.cues[0].segments;
let karaoke_count = segs
.iter()
.filter(|s| matches!(s, Segment::Karaoke { .. }))
.count();
assert_eq!(karaoke_count, 2, "got segs = {:?}", segs);
}
#[test]
fn parses_fsp_static() {
let v = parse_block(r"\fsp3");
assert_eq!(v, vec![AnimatedTag::Fsp(3.0)]);
let v = parse_block(r"\fsp-1.5");
assert_eq!(v, vec![AnimatedTag::Fsp(-1.5)]);
}
#[test]
fn parses_q_in_range() {
for mode in 0..=3 {
let src = format!(r"\q{mode}");
let v = parse_block(&src);
assert_eq!(v, vec![AnimatedTag::Q(mode as u8)]);
}
}
#[test]
fn parses_q_out_of_range_dropped() {
assert!(parse_block(r"\q4").is_empty());
assert!(parse_block(r"\q-1").is_empty());
}
#[test]
fn evaluate_fsp_static_override() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Fsp(2.5)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.letter_spacing, Some(2.5));
assert!(RenderState::identity().letter_spacing.is_none());
}
#[test]
fn evaluate_q_static_override() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::Q(2)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.wrap_style, Some(2));
assert!(RenderState::identity().wrap_style.is_none());
}
#[test]
fn fsp_animatable_via_t() {
let v = parse_block(r"\fsp0\t(0,1000,\fsp4)");
assert_eq!(v.len(), 2);
let cue_anim = CueAnimation { tags: v };
let st0 = cue_anim.evaluate_at(0, 1000);
assert_eq!(st0.letter_spacing, Some(0.0));
let st_mid = cue_anim.evaluate_at(500, 1000);
let mid = st_mid.letter_spacing.expect("set");
assert!(
(mid - 2.0).abs() < 1e-3,
"expected 2.0 at midpoint, got {mid}"
);
let st_end = cue_anim.evaluate_at(1000, 1000);
assert_eq!(st_end.letter_spacing, Some(4.0));
}
#[test]
fn q_static_inside_t_snaps_post() {
let v = parse_block(r"\q0\t(500,1000,\q2)");
assert_eq!(v.len(), 2);
let cue_anim = CueAnimation { tags: v };
let st_before = cue_anim.evaluate_at(0, 1000);
assert_eq!(st_before.wrap_style, Some(0));
let st_mid = cue_anim.evaluate_at(750, 1000);
assert_eq!(st_mid.wrap_style, Some(2));
let st_end = cue_anim.evaluate_at(1000, 1000);
assert_eq!(st_end.wrap_style, Some(2));
}
#[test]
fn extract_fsp_q_from_cue_segment() {
let cue = SubtitleCue {
start_us: 0,
end_us: 1_000_000,
style_ref: None,
positioning: None,
segments: vec![
Segment::Raw(r"{\fsp2\q1}".into()),
Segment::Text("spaced".into()),
],
};
let anim = extract_cue_animation(&cue);
assert_eq!(anim.tags.len(), 2);
let st = anim.evaluate_at(0, 1000);
assert_eq!(st.letter_spacing, Some(2.0));
assert_eq!(st.wrap_style, Some(1));
}
#[test]
fn parses_an_in_range() {
for pos in 1..=9 {
let src = format!(r"\an{pos}");
let v = parse_block(&src);
assert_eq!(v, vec![AnimatedTag::An(pos as u8)]);
}
}
#[test]
fn parses_an_out_of_range_dropped() {
assert!(parse_block(r"\an0").is_empty());
assert!(parse_block(r"\an10").is_empty());
assert!(parse_block(r"\an-1").is_empty());
}
#[test]
fn parses_legacy_a_known_codes() {
let cases: &[(u8, u8)] = &[
(1, 1),
(2, 2),
(3, 3),
(5, 7),
(6, 8),
(7, 9),
(9, 4),
(10, 5),
(11, 6),
];
for (legacy, numpad) in cases {
let src = format!(r"\a{legacy}");
let v = parse_block(&src);
assert_eq!(
v,
vec![AnimatedTag::A(*legacy)],
"legacy code {} should parse",
legacy
);
let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
assert_eq!(
st.alignment,
Some(*numpad),
"legacy {} should map to numpad {}",
legacy,
numpad
);
}
}
#[test]
fn parses_legacy_a_unknown_codes_drop_override() {
for legacy in [4_u8, 8, 12, 20, 255] {
let src = format!(r"\a{legacy}");
let v = parse_block(&src);
assert_eq!(v, vec![AnimatedTag::A(legacy)]);
let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
assert!(
st.alignment.is_none(),
"legacy {} should not override alignment",
legacy
);
}
}
#[test]
fn evaluate_an_static_override() {
let cue_anim = CueAnimation {
tags: vec![AnimatedTag::An(7)],
};
let st = cue_anim.evaluate_at(0, 1000);
assert_eq!(st.alignment, Some(7));
assert!(RenderState::identity().alignment.is_none());
let st_mid = cue_anim.evaluate_at(500, 1000);
let st_end = cue_anim.evaluate_at(1000, 1000);
assert_eq!(st_mid.alignment, Some(7));
assert_eq!(st_end.alignment, Some(7));
}
#[test]
fn an_static_inside_t_snaps_post() {
let v = parse_block(r"\an2\t(500,1000,\an8)");
assert_eq!(v.len(), 2);
let cue_anim = CueAnimation { tags: v };
let st_before = cue_anim.evaluate_at(0, 1000);
assert_eq!(st_before.alignment, Some(2));
let st_mid = cue_anim.evaluate_at(750, 1000);
assert_eq!(st_mid.alignment, Some(8));
let st_end = cue_anim.evaluate_at(1000, 1000);
assert_eq!(st_end.alignment, Some(8));
}
#[test]
fn an_later_overrides_earlier_legacy_a() {
let v = parse_block(r"\a6\an1");
assert_eq!(v.len(), 2);
let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
assert_eq!(st.alignment, Some(1));
}
#[test]
fn extract_an_from_cue_segment() {
let cue = SubtitleCue {
start_us: 0,
end_us: 1_000_000,
style_ref: None,
positioning: None,
segments: vec![
Segment::Raw(r"{\an5}".into()),
Segment::Text("centered".into()),
],
};
let anim = extract_cue_animation(&cue);
assert_eq!(anim.tags, vec![AnimatedTag::An(5)]);
let st = anim.evaluate_at(0, 1000);
assert_eq!(st.alignment, Some(5));
}
#[test]
fn parses_k_family_kinds() {
assert_eq!(
parse_block(r"\k50"),
vec![AnimatedTag::Karaoke {
kind: KaraokeKind::Fill,
cs: 50,
}]
);
assert_eq!(
parse_block(r"\kf30"),
vec![AnimatedTag::Karaoke {
kind: KaraokeKind::Sweep,
cs: 30,
}]
);
assert_eq!(
parse_block(r"\ko20"),
vec![AnimatedTag::Karaoke {
kind: KaraokeKind::Outline,
cs: 20,
}]
);
}
#[test]
fn capital_k_is_sweep_identical_to_kf() {
let cap = parse_block(r"\K40");
let kf = parse_block(r"\kf40");
assert_eq!(
cap,
vec![AnimatedTag::Karaoke {
kind: KaraokeKind::Sweep,
cs: 40,
}]
);
assert_eq!(cap, kf);
}
#[test]
fn k_negative_duration_clamps_to_zero() {
assert_eq!(
parse_block(r"\k-10"),
vec![AnimatedTag::Karaoke {
kind: KaraokeKind::Fill,
cs: 0,
}]
);
}
#[test]
fn kt_is_not_handled() {
assert!(parse_block(r"\kt100").is_empty());
}
#[test]
fn karaoke_spans_are_cumulative() {
let v = parse_block(r"\k50\kf30");
let anim = CueAnimation { tags: v };
let spans = anim.karaoke_spans();
assert_eq!(
spans,
vec![
KaraokeSpan {
kind: KaraokeKind::Fill,
start_ms: 0,
end_ms: 500,
},
KaraokeSpan {
kind: KaraokeKind::Sweep,
start_ms: 500,
end_ms: 800,
},
]
);
}
#[test]
fn karaoke_span_progress() {
let span = KaraokeSpan {
kind: KaraokeKind::Sweep,
start_ms: 500,
end_ms: 800,
};
assert_eq!(span.progress(400), 0.0); assert_eq!(span.progress(500), 0.0); assert!((span.progress(650) - 0.5).abs() < 1e-6); assert_eq!(span.progress(800), 1.0); assert_eq!(span.progress(900), 1.0); }
#[test]
fn karaoke_zero_length_span_progress_is_one_past_start() {
let span = KaraokeSpan {
kind: KaraokeKind::Fill,
start_ms: 100,
end_ms: 100,
};
assert_eq!(span.progress(50), 0.0);
assert_eq!(span.progress(150), 1.0);
}
#[test]
fn karaoke_is_noop_on_render_state() {
let v = parse_block(r"\k50\kf30");
let st = CueAnimation { tags: v }.evaluate_at(250, 1000);
assert_eq!(st, RenderState::identity());
}
#[test]
fn extract_karaoke_from_cue_segments() {
use crate::parse;
let src = "[Script Info]\n\
ScriptType: v4.00+\n\
\n\
[V4+ Styles]\n\
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n\
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\k50}la{\\kf30}la\n";
let t = parse(src.as_bytes()).unwrap();
let anim = extract_cue_animation(&t.cues[0]);
let spans = anim.karaoke_spans();
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].start_ms, 0);
assert_eq!(spans[0].end_ms, 500);
assert_eq!(spans[1].start_ms, 500);
assert_eq!(spans[1].end_ms, 800);
}
}