use oxideav_core::{Segment, SubtitleCue, Transform2D};
#[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,
},
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 },
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()
}
}
#[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)>,
}
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,
}
}
}
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::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::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)));
}
}
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, .. }
| Segment::Karaoke { children, .. } => 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, ¶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, 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),
"org" => {
let n = parse_float_list(param);
if n.len() == 2 {
Some(AnimatedTag::Org { x: n[0], y: n[1] })
} else {
None
}
}
"blur" | "be" => {
param.trim().parse::<f32>().ok().map(AnimatedTag::Blur)
}
"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),
"c" | "1c" => parse_color_rgb(param).map(AnimatedTag::Color1),
"clip" => parse_clip(param),
"t" => parse_t(param),
_ => None,
}
}
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) -> 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(AnimatedTag::ClipRect {
x1: n[0],
y1: n[1],
x2: n[2],
y2: n[3],
});
}
}
Some(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 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);
}
}