1use oxideav_core::{Segment, SubtitleCue, Transform2D};
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum KaraokeKind {
123 Fill,
125 Sweep,
128 Outline,
130}
131
132#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct KaraokeSpan {
141 pub kind: KaraokeKind,
143 pub start_ms: u32,
145 pub end_ms: u32,
148}
149
150impl KaraokeSpan {
151 pub fn progress(&self, t_in_cue_ms: i32) -> f32 {
161 if t_in_cue_ms <= self.start_ms as i32 {
162 return 0.0;
163 }
164 if t_in_cue_ms >= self.end_ms as i32 || self.end_ms <= self.start_ms {
165 return 1.0;
166 }
167 (t_in_cue_ms - self.start_ms as i32) as f32 / (self.end_ms - self.start_ms) as f32
168 }
169}
170
171#[derive(Clone, Debug, PartialEq)]
173pub enum AnimatedTag {
174 Fad { t1_ms: u32, t2_ms: u32 },
178 Fade {
180 a1: u8,
181 a2: u8,
182 a3: u8,
183 t1_ms: i32,
184 t2_ms: i32,
185 t3_ms: i32,
186 t4_ms: i32,
187 },
188 Pos { x: f32, y: f32 },
195 Move {
198 x1: f32,
199 y1: f32,
200 x2: f32,
201 y2: f32,
202 t1_ms: Option<i32>,
203 t2_ms: Option<i32>,
204 },
205 Frz(f32),
208 Blur(f32),
210 Fscx(f32),
212 Fscy(f32),
214 Color1((u8, u8, u8)),
216 Fs(f32),
219 ClipRect { x1: f32, y1: f32, x2: f32, y2: f32 },
221 ClipDrawing(String),
226 Frx(f32),
230 Fry(f32),
232 Org { x: f32, y: f32 },
235 Bord(f32),
237 Xbord(f32),
239 Ybord(f32),
241 Shad(f32),
244 Xshad(f32),
246 Yshad(f32),
248 Be(u8),
251 Fax(f32),
253 Fay(f32),
255 IClipRect { x1: f32, y1: f32, x2: f32, y2: f32 },
258 IClipDrawing(String),
262 Color2((u8, u8, u8)),
264 Color3((u8, u8, u8)),
266 Color4((u8, u8, u8)),
268 Alpha(u8),
272 Alpha1(u8),
274 Alpha2(u8),
276 Alpha3(u8),
278 Alpha4(u8),
280 Fsp(f32),
284 Q(u8),
289 An(u8),
300 A(u8),
307 Karaoke { kind: KaraokeKind, cs: u32 },
318 T {
322 t1_ms: Option<i32>,
323 t2_ms: Option<i32>,
324 accel: f32,
325 inner: Vec<AnimatedTag>,
326 },
327}
328
329#[derive(Clone, Debug, Default, PartialEq)]
331pub struct CueAnimation {
332 pub tags: Vec<AnimatedTag>,
333}
334
335impl CueAnimation {
336 pub fn is_empty(&self) -> bool {
338 self.tags.is_empty()
339 }
340
341 pub fn karaoke_spans(&self) -> Vec<KaraokeSpan> {
351 let mut spans = Vec::new();
352 let mut cursor_ms: u32 = 0;
353 for tag in &self.tags {
354 if let AnimatedTag::Karaoke { kind, cs } = tag {
355 let end_ms = cursor_ms.saturating_add(cs.saturating_mul(10));
356 spans.push(KaraokeSpan {
357 kind: *kind,
358 start_ms: cursor_ms,
359 end_ms,
360 });
361 cursor_ms = end_ms;
362 }
363 }
364 spans
365 }
366}
367
368#[derive(Clone, Debug, PartialEq)]
375pub struct RenderState {
376 pub alpha_mul: f32,
378 pub transform: Transform2D,
381 pub rotate_radians: f32,
384 pub rotate_x_radians: f32,
387 pub rotate_y_radians: f32,
389 pub scale: (f32, f32),
391 pub translate: Option<(f32, f32)>,
395 pub blur_sigma: f32,
397 pub clip_rect: Option<ClipRect>,
399 pub clip_drawing: Option<String>,
402 pub primary_color: Option<(u8, u8, u8)>,
404 pub font_size: Option<f32>,
406 pub pivot: Option<(f32, f32)>,
409 pub border: Option<(f32, f32)>,
412 pub shadow: Option<(f32, f32)>,
417 pub be_strength: u8,
420 pub shear: (f32, f32),
423 pub iclip_rect: Option<ClipRect>,
426 pub iclip_drawing: Option<String>,
429 pub secondary_color: Option<(u8, u8, u8)>,
431 pub outline_color: Option<(u8, u8, u8)>,
433 pub shadow_color: Option<(u8, u8, u8)>,
435 pub primary_alpha: Option<u8>,
441 pub secondary_alpha: Option<u8>,
443 pub outline_alpha: Option<u8>,
445 pub shadow_alpha: Option<u8>,
447 pub letter_spacing: Option<f32>,
451 pub wrap_style: Option<u8>,
456 pub alignment: Option<u8>,
470}
471
472impl RenderState {
473 pub fn identity() -> Self {
475 Self {
476 alpha_mul: 1.0,
477 transform: Transform2D::identity(),
478 rotate_radians: 0.0,
479 rotate_x_radians: 0.0,
480 rotate_y_radians: 0.0,
481 scale: (1.0, 1.0),
482 translate: None,
483 blur_sigma: 0.0,
484 clip_rect: None,
485 clip_drawing: None,
486 primary_color: None,
487 font_size: None,
488 pivot: None,
489 border: None,
490 shadow: None,
491 be_strength: 0,
492 shear: (0.0, 0.0),
493 iclip_rect: None,
494 iclip_drawing: None,
495 secondary_color: None,
496 outline_color: None,
497 shadow_color: None,
498 primary_alpha: None,
499 secondary_alpha: None,
500 outline_alpha: None,
501 shadow_alpha: None,
502 letter_spacing: None,
503 wrap_style: None,
504 alignment: None,
505 }
506 }
507}
508
509impl Default for RenderState {
510 fn default() -> Self {
511 Self::identity()
512 }
513}
514
515#[derive(Clone, Copy, Debug, PartialEq)]
518pub struct ClipRect {
519 pub x1: f32,
520 pub y1: f32,
521 pub x2: f32,
522 pub y2: f32,
523}
524
525impl CueAnimation {
526 pub fn evaluate_at(&self, t_in_cue_ms: i32, cue_duration_ms: i32) -> RenderState {
531 let mut st = RenderState::identity();
532 for tag in &self.tags {
533 apply_tag(&mut st, tag, t_in_cue_ms, cue_duration_ms);
534 }
535 st.transform = compose_transform(&st);
536 st
537 }
538}
539
540fn compose_transform(st: &RenderState) -> Transform2D {
541 let (sx, sy) = st.scale;
542 let mut t = Transform2D::identity();
543 if (sx - 1.0).abs() > f32::EPSILON || (sy - 1.0).abs() > f32::EPSILON {
544 t = t.compose(&Transform2D::scale(sx, sy));
545 }
546 if st.rotate_radians.abs() > f32::EPSILON {
547 t = Transform2D::rotate(st.rotate_radians).compose(&t);
548 }
549 if let Some((tx, ty)) = st.translate {
550 t = Transform2D::translate(tx, ty).compose(&t);
551 }
552 t
553}
554
555fn apply_tag(st: &mut RenderState, tag: &AnimatedTag, t_ms: i32, dur_ms: i32) {
556 match tag {
557 AnimatedTag::Fad { t1_ms, t2_ms } => {
558 st.alpha_mul *= fad_alpha(*t1_ms as i32, *t2_ms as i32, t_ms, dur_ms);
559 }
560 AnimatedTag::Fade {
561 a1,
562 a2,
563 a3,
564 t1_ms,
565 t2_ms,
566 t3_ms,
567 t4_ms,
568 } => {
569 let a = fade_alpha(*a1, *a2, *a3, *t1_ms, *t2_ms, *t3_ms, *t4_ms, t_ms);
570 st.alpha_mul *= ass_alpha_to_mul(a);
571 }
572 AnimatedTag::Pos { x, y } => {
573 st.translate = Some((*x, *y));
578 }
579 AnimatedTag::Move {
580 x1,
581 y1,
582 x2,
583 y2,
584 t1_ms,
585 t2_ms,
586 } => {
587 let t1 = t1_ms.unwrap_or(0);
588 let t2 = t2_ms.unwrap_or(dur_ms);
589 let p = lerp_xy((*x1, *y1), (*x2, *y2), t1, t2, t_ms);
590 st.translate = Some(p);
591 }
592 AnimatedTag::Frz(deg) => {
593 st.rotate_radians = deg.to_radians();
594 }
595 AnimatedTag::Blur(sigma) => {
596 st.blur_sigma = sigma.max(0.0);
597 }
598 AnimatedTag::Fscx(pct) => {
599 st.scale.0 = pct / 100.0;
600 }
601 AnimatedTag::Fscy(pct) => {
602 st.scale.1 = pct / 100.0;
603 }
604 AnimatedTag::Color1(rgb) => {
605 st.primary_color = Some(*rgb);
606 }
607 AnimatedTag::Fs(size) => {
608 st.font_size = Some(*size);
609 }
610 AnimatedTag::ClipRect { x1, y1, x2, y2 } => {
611 let (lo_x, hi_x) = if x1 <= x2 { (*x1, *x2) } else { (*x2, *x1) };
612 let (lo_y, hi_y) = if y1 <= y2 { (*y1, *y2) } else { (*y2, *y1) };
613 st.clip_rect = Some(ClipRect {
614 x1: lo_x,
615 y1: lo_y,
616 x2: hi_x,
617 y2: hi_y,
618 });
619 }
620 AnimatedTag::ClipDrawing(s) => {
621 st.clip_drawing = Some(s.clone());
622 }
623 AnimatedTag::Frx(deg) => {
624 st.rotate_x_radians = deg.to_radians();
625 }
626 AnimatedTag::Fry(deg) => {
627 st.rotate_y_radians = deg.to_radians();
628 }
629 AnimatedTag::Org { x, y } => {
630 st.pivot = Some((*x, *y));
631 }
632 AnimatedTag::Bord(w) => {
633 let w = w.max(0.0);
636 st.border = Some((w, w));
637 }
638 AnimatedTag::Xbord(w) => {
639 let w = w.max(0.0);
640 let (_, y) = st.border.unwrap_or((0.0, 0.0));
641 st.border = Some((w, y));
642 }
643 AnimatedTag::Ybord(w) => {
644 let w = w.max(0.0);
645 let (x, _) = st.border.unwrap_or((0.0, 0.0));
646 st.border = Some((x, w));
647 }
648 AnimatedTag::Shad(d) => {
649 let d = d.max(0.0);
651 st.shadow = Some((d, d));
652 }
653 AnimatedTag::Xshad(d) => {
654 let (_, y) = st.shadow.unwrap_or((0.0, 0.0));
656 st.shadow = Some((*d, y));
657 }
658 AnimatedTag::Yshad(d) => {
659 let (x, _) = st.shadow.unwrap_or((0.0, 0.0));
660 st.shadow = Some((x, *d));
661 }
662 AnimatedTag::Be(n) => {
663 st.be_strength = *n;
664 }
665 AnimatedTag::Fax(f) => {
666 st.shear.0 = *f;
667 }
668 AnimatedTag::Fay(f) => {
669 st.shear.1 = *f;
670 }
671 AnimatedTag::IClipRect { x1, y1, x2, y2 } => {
672 let (lo_x, hi_x) = if x1 <= x2 { (*x1, *x2) } else { (*x2, *x1) };
673 let (lo_y, hi_y) = if y1 <= y2 { (*y1, *y2) } else { (*y2, *y1) };
674 st.iclip_rect = Some(ClipRect {
675 x1: lo_x,
676 y1: lo_y,
677 x2: hi_x,
678 y2: hi_y,
679 });
680 }
681 AnimatedTag::IClipDrawing(s) => {
682 st.iclip_drawing = Some(s.clone());
683 }
684 AnimatedTag::Color2(rgb) => {
685 st.secondary_color = Some(*rgb);
686 }
687 AnimatedTag::Color3(rgb) => {
688 st.outline_color = Some(*rgb);
689 }
690 AnimatedTag::Color4(rgb) => {
691 st.shadow_color = Some(*rgb);
692 }
693 AnimatedTag::Alpha(a) => {
694 st.primary_alpha = Some(*a);
696 st.secondary_alpha = Some(*a);
697 st.outline_alpha = Some(*a);
698 st.shadow_alpha = Some(*a);
699 }
700 AnimatedTag::Alpha1(a) => {
701 st.primary_alpha = Some(*a);
702 }
703 AnimatedTag::Alpha2(a) => {
704 st.secondary_alpha = Some(*a);
705 }
706 AnimatedTag::Alpha3(a) => {
707 st.outline_alpha = Some(*a);
708 }
709 AnimatedTag::Alpha4(a) => {
710 st.shadow_alpha = Some(*a);
711 }
712 AnimatedTag::Fsp(s) => {
713 st.letter_spacing = Some(*s);
714 }
715 AnimatedTag::Q(mode) => {
716 if *mode <= 3 {
719 st.wrap_style = Some(*mode);
720 }
721 }
722 AnimatedTag::An(n) => {
723 if (1..=9).contains(n) {
726 st.alignment = Some(*n);
727 }
728 }
729 AnimatedTag::A(n) => {
730 if let Some(numpad) = ssa_alignment_to_numpad(*n) {
735 st.alignment = Some(numpad);
736 }
737 }
738 AnimatedTag::Karaoke { .. } => {
739 }
745 AnimatedTag::T {
746 t1_ms,
747 t2_ms,
748 accel,
749 inner,
750 } => {
751 apply_t(st, *t1_ms, *t2_ms, *accel, inner, t_ms, dur_ms);
752 }
753 }
754}
755
756fn apply_t(
757 st: &mut RenderState,
758 t1: Option<i32>,
759 t2: Option<i32>,
760 accel: f32,
761 inner: &[AnimatedTag],
762 t_ms: i32,
763 dur_ms: i32,
764) {
765 let start = t1.unwrap_or(0);
766 let end = t2.unwrap_or(dur_ms);
767 let pre = st.clone();
769 let mut post = pre.clone();
771 for tag in inner {
772 apply_tag(&mut post, tag, t_ms, dur_ms);
773 }
774 let raw = if end <= start {
776 if t_ms >= end {
777 1.0
778 } else {
779 0.0
780 }
781 } else if t_ms <= start {
782 0.0
783 } else if t_ms >= end {
784 1.0
785 } else {
786 (t_ms - start) as f32 / (end - start) as f32
787 };
788 let k = if accel.abs() < f32::EPSILON {
789 raw
790 } else {
791 raw.powf(accel)
792 };
793 st.scale.0 = lerp_f32(pre.scale.0, post.scale.0, k);
795 st.scale.1 = lerp_f32(pre.scale.1, post.scale.1, k);
796 st.rotate_radians = lerp_f32(pre.rotate_radians, post.rotate_radians, k);
797 st.rotate_x_radians = lerp_f32(pre.rotate_x_radians, post.rotate_x_radians, k);
798 st.rotate_y_radians = lerp_f32(pre.rotate_y_radians, post.rotate_y_radians, k);
799 st.blur_sigma = lerp_f32(pre.blur_sigma, post.blur_sigma, k).max(0.0);
800 st.alpha_mul = lerp_f32(pre.alpha_mul, post.alpha_mul, k);
801 if let Some(c) = post.primary_color {
802 let from = pre.primary_color.unwrap_or(c);
803 st.primary_color = Some(lerp_rgb(from, c, k));
804 }
805 if let Some(s) = post.font_size {
806 let from = pre.font_size.unwrap_or(s);
807 st.font_size = Some(lerp_f32(from, s, k));
808 }
809 if let Some((px, py)) = post.translate {
810 let (fx, fy) = pre.translate.unwrap_or((px, py));
811 st.translate = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
812 }
813 if let Some((px, py)) = post.border {
817 let (fx, fy) = pre.border.unwrap_or((px, py));
818 st.border = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
819 }
820 if let Some((px, py)) = post.shadow {
821 let (fx, fy) = pre.shadow.unwrap_or((px, py));
822 st.shadow = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
823 }
824 if post.be_strength != pre.be_strength {
825 let from = pre.be_strength as f32;
826 let to = post.be_strength as f32;
827 st.be_strength = lerp_f32(from, to, k).clamp(0.0, 255.0).round() as u8;
828 }
829 st.shear.0 = lerp_f32(pre.shear.0, post.shear.0, k);
830 st.shear.1 = lerp_f32(pre.shear.1, post.shear.1, k);
831 if let Some(c) = post.secondary_color {
833 let from = pre.secondary_color.unwrap_or(c);
834 st.secondary_color = Some(lerp_rgb(from, c, k));
835 }
836 if let Some(c) = post.outline_color {
837 let from = pre.outline_color.unwrap_or(c);
838 st.outline_color = Some(lerp_rgb(from, c, k));
839 }
840 if let Some(c) = post.shadow_color {
841 let from = pre.shadow_color.unwrap_or(c);
842 st.shadow_color = Some(lerp_rgb(from, c, k));
843 }
844 if let Some(a) = post.primary_alpha {
846 let from = pre.primary_alpha.unwrap_or(a);
847 st.primary_alpha = Some(lerp_u8(from, a, k));
848 }
849 if let Some(a) = post.secondary_alpha {
850 let from = pre.secondary_alpha.unwrap_or(a);
851 st.secondary_alpha = Some(lerp_u8(from, a, k));
852 }
853 if let Some(a) = post.outline_alpha {
854 let from = pre.outline_alpha.unwrap_or(a);
855 st.outline_alpha = Some(lerp_u8(from, a, k));
856 }
857 if let Some(a) = post.shadow_alpha {
858 let from = pre.shadow_alpha.unwrap_or(a);
859 st.shadow_alpha = Some(lerp_u8(from, a, k));
860 }
861 if let Some(s) = post.letter_spacing {
864 let from = pre.letter_spacing.unwrap_or(s);
865 st.letter_spacing = Some(lerp_f32(from, s, k));
866 }
867 if post.wrap_style != pre.wrap_style {
870 st.wrap_style = if k > 0.0 {
871 post.wrap_style
872 } else {
873 pre.wrap_style
874 };
875 }
876 if post.alignment != pre.alignment {
879 st.alignment = if k > 0.0 {
880 post.alignment
881 } else {
882 pre.alignment
883 };
884 }
885}
886
887fn ssa_alignment_to_numpad(n: u8) -> Option<u8> {
897 match n {
901 1 => Some(1),
902 2 => Some(2),
903 3 => Some(3),
904 5 => Some(7),
905 6 => Some(8),
906 7 => Some(9),
907 9 => Some(4),
908 10 => Some(5),
909 11 => Some(6),
910 _ => None,
911 }
912}
913
914fn lerp_u8(a: u8, b: u8, k: f32) -> u8 {
915 let v = a as f32 + (b as f32 - a as f32) * k;
916 v.clamp(0.0, 255.0).round() as u8
917}
918
919fn fad_alpha(t1: i32, t2: i32, t: i32, dur: i32) -> f32 {
920 let t = t.max(0);
921 let dur = dur.max(0);
922 let mul_in = if t1 <= 0 {
923 1.0
924 } else if t < t1 {
925 t as f32 / t1 as f32
926 } else {
927 1.0
928 };
929 let fade_out_start = (dur - t2).max(0);
930 let mul_out = if t2 <= 0 {
931 1.0
932 } else if t >= dur {
933 0.0
934 } else if t > fade_out_start {
935 ((dur - t) as f32 / t2 as f32).clamp(0.0, 1.0)
936 } else {
937 1.0
938 };
939 (mul_in * mul_out).clamp(0.0, 1.0)
940}
941
942#[allow(clippy::too_many_arguments)]
943fn fade_alpha(a1: u8, a2: u8, a3: u8, t1: i32, t2: i32, t3: i32, t4: i32, t: i32) -> u8 {
944 let lerp_u8 = |from: u8, to: u8, k: f32| -> u8 {
945 let v = from as f32 + (to as f32 - from as f32) * k;
946 v.clamp(0.0, 255.0) as u8
947 };
948 if t < t1 {
949 a1
950 } else if t < t2 {
951 let span = (t2 - t1).max(1);
952 lerp_u8(a1, a2, (t - t1) as f32 / span as f32)
953 } else if t < t3 {
954 a2
955 } else if t < t4 {
956 let span = (t4 - t3).max(1);
957 lerp_u8(a2, a3, (t - t3) as f32 / span as f32)
958 } else {
959 a3
960 }
961}
962
963fn ass_alpha_to_mul(a: u8) -> f32 {
964 1.0 - (a as f32 / 255.0)
966}
967
968fn lerp_f32(a: f32, b: f32, k: f32) -> f32 {
969 a + (b - a) * k
970}
971
972fn lerp_rgb(a: (u8, u8, u8), b: (u8, u8, u8), k: f32) -> (u8, u8, u8) {
973 let lerp_c = |from: u8, to: u8| -> u8 {
974 let v = from as f32 + (to as f32 - from as f32) * k;
975 v.clamp(0.0, 255.0) as u8
976 };
977 (lerp_c(a.0, b.0), lerp_c(a.1, b.1), lerp_c(a.2, b.2))
978}
979
980fn lerp_xy(a: (f32, f32), b: (f32, f32), t1: i32, t2: i32, t: i32) -> (f32, f32) {
981 let k = if t2 <= t1 {
982 if t >= t2 {
983 1.0
984 } else {
985 0.0
986 }
987 } else if t <= t1 {
988 0.0
989 } else if t >= t2 {
990 1.0
991 } else {
992 (t - t1) as f32 / (t2 - t1) as f32
993 };
994 (lerp_f32(a.0, b.0, k), lerp_f32(a.1, b.1, k))
995}
996
997pub fn extract_cue_animation(cue: &SubtitleCue) -> CueAnimation {
1008 let mut tags: Vec<AnimatedTag> = Vec::new();
1009 walk_segments(&cue.segments, &mut tags);
1010 CueAnimation { tags }
1011}
1012
1013fn walk_segments(segs: &[Segment], out: &mut Vec<AnimatedTag>) {
1014 for s in segs {
1015 match s {
1016 Segment::Raw(raw) => parse_raw_block(raw, out),
1017 Segment::Bold(c) | Segment::Italic(c) | Segment::Underline(c) | Segment::Strike(c) => {
1018 walk_segments(c, out)
1019 }
1020 Segment::Color { children, .. }
1021 | Segment::Font { children, .. }
1022 | Segment::Voice { children, .. }
1023 | Segment::Class { children, .. } => walk_segments(children, out),
1024 Segment::Karaoke { cs, children } => {
1025 out.push(AnimatedTag::Karaoke {
1031 kind: KaraokeKind::Fill,
1032 cs: *cs,
1033 });
1034 walk_segments(children, out);
1035 }
1036 _ => {}
1037 }
1038 }
1039}
1040
1041fn parse_raw_block(raw: &str, out: &mut Vec<AnimatedTag>) {
1042 let inner = raw.trim();
1045 let inner = inner.strip_prefix('{').unwrap_or(inner);
1046 let inner = inner.strip_suffix('}').unwrap_or(inner);
1047 parse_overrides(inner, out);
1048}
1049
1050pub fn parse_overrides(block: &str, out: &mut Vec<AnimatedTag>) {
1057 let bytes = block.as_bytes();
1058 let mut i = 0;
1059 while i < bytes.len() {
1060 if bytes[i] != b'\\' {
1061 i += 1;
1062 continue;
1063 }
1064 i += 1;
1065 let name_start = i;
1067 if i < bytes.len() && bytes[i].is_ascii_digit() {
1068 i += 1;
1069 while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
1070 i += 1;
1071 }
1072 } else {
1073 while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
1074 i += 1;
1075 }
1076 }
1077 let name = &block[name_start..i];
1078 if name.is_empty() {
1079 continue;
1080 }
1081 let (param, advance) = read_param(&block[i..]);
1082 i += advance;
1083 let name_lc = name.to_ascii_lowercase();
1084 if let Some(t) = parse_one(&name_lc, name, ¶m) {
1089 out.push(t);
1090 }
1091 }
1092}
1093
1094fn read_param(s: &str) -> (String, usize) {
1098 let bytes = s.as_bytes();
1099 if bytes.first() == Some(&b'(') {
1100 let mut depth: i32 = 0;
1103 let mut idx = 0;
1104 for (k, &b) in bytes.iter().enumerate() {
1105 if b == b'(' {
1106 depth += 1;
1107 } else if b == b')' {
1108 depth -= 1;
1109 if depth == 0 {
1110 idx = k;
1111 break;
1112 }
1113 }
1114 }
1115 if idx == 0 {
1116 return (s[1..].to_string(), bytes.len());
1118 }
1119 (s[1..idx].to_string(), idx + 1)
1120 } else {
1121 let mut k = 0;
1123 while k < bytes.len() && bytes[k] != b'\\' {
1124 k += 1;
1125 }
1126 (s[..k].to_string(), k)
1127 }
1128}
1129
1130fn parse_one(name_lc: &str, name_orig: &str, param: &str) -> Option<AnimatedTag> {
1131 match name_lc {
1132 "fad" => {
1133 let nums = parse_int_list(param);
1134 if nums.len() >= 2 {
1135 Some(AnimatedTag::Fad {
1136 t1_ms: nums[0].max(0) as u32,
1137 t2_ms: nums[1].max(0) as u32,
1138 })
1139 } else {
1140 None
1141 }
1142 }
1143 "fade" => {
1144 let nums = parse_int_list(param);
1145 if nums.len() >= 7 {
1146 Some(AnimatedTag::Fade {
1147 a1: nums[0].clamp(0, 255) as u8,
1148 a2: nums[1].clamp(0, 255) as u8,
1149 a3: nums[2].clamp(0, 255) as u8,
1150 t1_ms: nums[3],
1151 t2_ms: nums[4],
1152 t3_ms: nums[5],
1153 t4_ms: nums[6],
1154 })
1155 } else {
1156 None
1157 }
1158 }
1159 "move" => {
1160 let nums = parse_float_list(param);
1161 match nums.len() {
1162 4 => Some(AnimatedTag::Move {
1163 x1: nums[0],
1164 y1: nums[1],
1165 x2: nums[2],
1166 y2: nums[3],
1167 t1_ms: None,
1168 t2_ms: None,
1169 }),
1170 6 => Some(AnimatedTag::Move {
1171 x1: nums[0],
1172 y1: nums[1],
1173 x2: nums[2],
1174 y2: nums[3],
1175 t1_ms: Some(nums[4] as i32),
1176 t2_ms: Some(nums[5] as i32),
1177 }),
1178 _ => None,
1179 }
1180 }
1181 "frz" | "fr" => param.trim().parse::<f32>().ok().map(AnimatedTag::Frz),
1182 "frx" => param.trim().parse::<f32>().ok().map(AnimatedTag::Frx),
1183 "fry" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fry),
1184 "pos" => {
1185 let n = parse_float_list(param);
1189 if n.len() == 2 {
1190 Some(AnimatedTag::Pos { x: n[0], y: n[1] })
1191 } else {
1192 None
1193 }
1194 }
1195 "org" => {
1196 let n = parse_float_list(param);
1197 if n.len() == 2 {
1198 Some(AnimatedTag::Org { x: n[0], y: n[1] })
1199 } else {
1200 None
1201 }
1202 }
1203 "blur" => param.trim().parse::<f32>().ok().map(AnimatedTag::Blur),
1204 "be" => {
1205 let n = param.trim().parse::<f32>().ok()?;
1208 let n = n.clamp(0.0, 255.0).round() as u8;
1209 Some(AnimatedTag::Be(n))
1210 }
1211 "bord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Bord),
1212 "xbord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Xbord),
1213 "ybord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Ybord),
1214 "shad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Shad),
1215 "xshad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Xshad),
1216 "yshad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Yshad),
1217 "fax" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fax),
1218 "fay" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fay),
1219 "fscx" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fscx),
1220 "fscy" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fscy),
1221 "fs" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fs),
1222 "fsp" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fsp),
1223 "q" => {
1224 let n: i32 = param.trim().parse().ok()?;
1228 if (0..=3).contains(&n) {
1229 Some(AnimatedTag::Q(n as u8))
1230 } else {
1231 None
1232 }
1233 }
1234 "an" => {
1235 let n: i32 = param.trim().parse().ok()?;
1239 if (1..=9).contains(&n) {
1240 Some(AnimatedTag::An(n as u8))
1241 } else {
1242 None
1243 }
1244 }
1245 "a" => {
1246 let n: i32 = param.trim().parse().ok()?;
1253 if (0..=255).contains(&n) {
1254 Some(AnimatedTag::A(n as u8))
1255 } else {
1256 None
1257 }
1258 }
1259 "k" | "kf" | "ko" => {
1260 let cs = param.trim().parse::<f32>().ok()?;
1267 let cs = cs.max(0.0).round() as u32;
1268 let kind = match name_lc {
1269 "kf" => KaraokeKind::Sweep,
1270 "ko" => KaraokeKind::Outline,
1271 _ if name_orig == "K" => KaraokeKind::Sweep,
1273 _ => KaraokeKind::Fill,
1274 };
1275 Some(AnimatedTag::Karaoke { kind, cs })
1276 }
1277 "c" | "1c" => parse_color_rgb(param).map(AnimatedTag::Color1),
1278 "2c" => parse_color_rgb(param).map(AnimatedTag::Color2),
1279 "3c" => parse_color_rgb(param).map(AnimatedTag::Color3),
1280 "4c" => parse_color_rgb(param).map(AnimatedTag::Color4),
1281 "alpha" => parse_alpha_byte(param).map(AnimatedTag::Alpha),
1282 "1a" => parse_alpha_byte(param).map(AnimatedTag::Alpha1),
1283 "2a" => parse_alpha_byte(param).map(AnimatedTag::Alpha2),
1284 "3a" => parse_alpha_byte(param).map(AnimatedTag::Alpha3),
1285 "4a" => parse_alpha_byte(param).map(AnimatedTag::Alpha4),
1286 "clip" => parse_clip(param, false),
1287 "iclip" => parse_clip(param, true),
1288 "t" => parse_t(param),
1289 _ => None,
1290 }
1291}
1292
1293fn parse_alpha_byte(s: &str) -> Option<u8> {
1301 let mut t = s.trim();
1302 t = t.trim_matches('&');
1303 t = t.trim_start_matches(['H', 'h']);
1304 t = t.trim_start_matches("0x");
1305 t = t.trim_matches('&').trim();
1306 if t.is_empty() {
1307 return None;
1308 }
1309 let v = u32::from_str_radix(t, 16).ok()?;
1310 Some(v.clamp(0, 255) as u8)
1311}
1312
1313fn parse_int_list(s: &str) -> Vec<i32> {
1314 s.split(',')
1315 .map(|p| p.trim().parse::<i32>().ok())
1316 .collect::<Option<Vec<_>>>()
1317 .unwrap_or_default()
1318}
1319
1320fn parse_float_list(s: &str) -> Vec<f32> {
1321 s.split(',')
1322 .map(|p| p.trim().parse::<f32>().ok())
1323 .collect::<Option<Vec<_>>>()
1324 .unwrap_or_default()
1325}
1326
1327fn parse_color_rgb(s: &str) -> Option<(u8, u8, u8)> {
1328 let s = s.trim().trim_matches('&');
1330 let s = s.trim_start_matches(['H', 'h']);
1331 let s = s.trim_start_matches("0x");
1332 let s = s.trim_end_matches('&').trim();
1333 if s.is_empty() {
1334 return None;
1335 }
1336 let v: u32 = u32::from_str_radix(s, 16).ok()?;
1337 let b = ((v >> 16) & 0xFF) as u8;
1338 let g = ((v >> 8) & 0xFF) as u8;
1339 let r = (v & 0xFF) as u8;
1340 Some((r, g, b))
1341}
1342
1343fn parse_clip(param: &str, inverse: bool) -> Option<AnimatedTag> {
1344 let parts: Vec<&str> = param.split(',').map(|s| s.trim()).collect();
1348 if parts.len() == 4 {
1349 let n: Vec<Option<f32>> = parts.iter().map(|p| p.parse::<f32>().ok()).collect();
1350 if n.iter().all(|x| x.is_some()) {
1351 let n: Vec<f32> = n.into_iter().map(|x| x.unwrap()).collect();
1352 return Some(if inverse {
1353 AnimatedTag::IClipRect {
1354 x1: n[0],
1355 y1: n[1],
1356 x2: n[2],
1357 y2: n[3],
1358 }
1359 } else {
1360 AnimatedTag::ClipRect {
1361 x1: n[0],
1362 y1: n[1],
1363 x2: n[2],
1364 y2: n[3],
1365 }
1366 });
1367 }
1368 }
1369 Some(if inverse {
1370 AnimatedTag::IClipDrawing(param.to_string())
1371 } else {
1372 AnimatedTag::ClipDrawing(param.to_string())
1373 })
1374}
1375
1376fn parse_t(param: &str) -> Option<AnimatedTag> {
1377 let (nums, tags_str) = peel_leading_numbers(param);
1387 let mut inner: Vec<AnimatedTag> = Vec::new();
1388 parse_overrides(tags_str, &mut inner);
1389 let (t1, t2, accel) = match nums.len() {
1390 0 => (None, None, 1.0_f32),
1391 1 => (None, None, nums[0]),
1392 2 => (Some(nums[0] as i32), Some(nums[1] as i32), 1.0),
1393 _ => (Some(nums[0] as i32), Some(nums[1] as i32), nums[2]),
1394 };
1395 Some(AnimatedTag::T {
1396 t1_ms: t1,
1397 t2_ms: t2,
1398 accel,
1399 inner,
1400 })
1401}
1402
1403fn peel_leading_numbers(s: &str) -> (Vec<f32>, &str) {
1409 let mut nums = Vec::new();
1410 let mut cursor = s.trim_start();
1411 loop {
1412 let bytes = cursor.as_bytes();
1414 let mut k = 0;
1415 while k < bytes.len() && bytes[k] != b',' && bytes[k] != b'\\' {
1416 k += 1;
1417 }
1418 let head = cursor[..k].trim();
1419 if head.is_empty() {
1421 if k == 0 {
1423 break;
1424 }
1425 }
1426 match head.parse::<f32>() {
1427 Ok(n) => {
1428 nums.push(n);
1429 if k >= bytes.len() {
1430 cursor = "";
1431 break;
1432 }
1433 if bytes[k] == b'\\' {
1434 cursor = &cursor[k..];
1435 break;
1436 }
1437 cursor = &cursor[k + 1..];
1439 cursor = cursor.trim_start();
1440 }
1441 Err(_) => break,
1442 }
1443 }
1444 (nums, cursor)
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450
1451 fn parse_block(s: &str) -> Vec<AnimatedTag> {
1452 let mut out = Vec::new();
1453 parse_overrides(s, &mut out);
1454 out
1455 }
1456
1457 #[test]
1458 fn parses_fad() {
1459 let v = parse_block(r"\fad(200,300)");
1460 assert_eq!(
1461 v,
1462 vec![AnimatedTag::Fad {
1463 t1_ms: 200,
1464 t2_ms: 300,
1465 }]
1466 );
1467 }
1468
1469 #[test]
1470 fn parses_fade7() {
1471 let v = parse_block(r"\fade(255,0,255,0,500,1500,2000)");
1472 assert_eq!(
1473 v,
1474 vec![AnimatedTag::Fade {
1475 a1: 255,
1476 a2: 0,
1477 a3: 255,
1478 t1_ms: 0,
1479 t2_ms: 500,
1480 t3_ms: 1500,
1481 t4_ms: 2000,
1482 }]
1483 );
1484 }
1485
1486 #[test]
1487 fn parses_move4_and_move6() {
1488 let v = parse_block(r"\move(10,20,100,200)");
1489 assert_eq!(v.len(), 1);
1490 match &v[0] {
1491 AnimatedTag::Move {
1492 x1,
1493 y1,
1494 x2,
1495 y2,
1496 t1_ms,
1497 t2_ms,
1498 } => {
1499 assert_eq!(*x1, 10.0);
1500 assert_eq!(*y1, 20.0);
1501 assert_eq!(*x2, 100.0);
1502 assert_eq!(*y2, 200.0);
1503 assert!(t1_ms.is_none());
1504 assert!(t2_ms.is_none());
1505 }
1506 _ => panic!(),
1507 }
1508
1509 let v = parse_block(r"\move(10,20,100,200,500,1500)");
1510 match &v[0] {
1511 AnimatedTag::Move { t1_ms, t2_ms, .. } => {
1512 assert_eq!(*t1_ms, Some(500));
1513 assert_eq!(*t2_ms, Some(1500));
1514 }
1515 _ => panic!(),
1516 }
1517 }
1518
1519 #[test]
1520 fn parses_frz_blur_fscx_fscy() {
1521 let v = parse_block(r"\frz45\blur2.5\fscx150\fscy75");
1522 assert_eq!(v.len(), 4);
1523 assert!(matches!(v[0], AnimatedTag::Frz(45.0)));
1524 assert!(matches!(v[1], AnimatedTag::Blur(b) if (b - 2.5).abs() < 1e-6));
1525 assert!(matches!(v[2], AnimatedTag::Fscx(150.0)));
1526 assert!(matches!(v[3], AnimatedTag::Fscy(75.0)));
1527 }
1528
1529 #[test]
1530 fn parses_clip_rect() {
1531 let v = parse_block(r"\clip(10,20,100,200)");
1532 assert_eq!(
1533 v,
1534 vec![AnimatedTag::ClipRect {
1535 x1: 10.0,
1536 y1: 20.0,
1537 x2: 100.0,
1538 y2: 200.0,
1539 }]
1540 );
1541 }
1542
1543 #[test]
1544 fn parses_clip_drawing_passthrough() {
1545 let v = parse_block(r"\clip(m 0 0 l 100 0 l 100 100 l 0 100)");
1546 assert_eq!(v.len(), 1);
1547 assert!(matches!(v[0], AnimatedTag::ClipDrawing(_)));
1548 }
1549
1550 #[test]
1551 fn parses_t_full() {
1552 let v = parse_block(r"\t(0,1000,1.5,\fscx200\frz90)");
1553 assert_eq!(v.len(), 1);
1554 match &v[0] {
1555 AnimatedTag::T {
1556 t1_ms,
1557 t2_ms,
1558 accel,
1559 inner,
1560 } => {
1561 assert_eq!(*t1_ms, Some(0));
1562 assert_eq!(*t2_ms, Some(1000));
1563 assert!((accel - 1.5).abs() < 1e-6);
1564 assert_eq!(inner.len(), 2);
1565 assert!(matches!(inner[0], AnimatedTag::Fscx(200.0)));
1566 assert!(matches!(inner[1], AnimatedTag::Frz(90.0)));
1567 }
1568 _ => panic!(),
1569 }
1570 }
1571
1572 #[test]
1573 fn parses_t_no_times() {
1574 let v = parse_block(r"\t(\frz360)");
1575 match &v[0] {
1576 AnimatedTag::T {
1577 t1_ms,
1578 t2_ms,
1579 accel,
1580 inner,
1581 } => {
1582 assert!(t1_ms.is_none());
1583 assert!(t2_ms.is_none());
1584 assert!((accel - 1.0).abs() < 1e-6);
1585 assert_eq!(inner.len(), 1);
1586 }
1587 _ => panic!(),
1588 }
1589 }
1590
1591 #[test]
1592 fn parses_t_two_times_no_accel() {
1593 let v = parse_block(r"\t(0,500,\frz45)");
1594 match &v[0] {
1595 AnimatedTag::T {
1596 t1_ms,
1597 t2_ms,
1598 accel,
1599 inner,
1600 } => {
1601 assert_eq!(*t1_ms, Some(0));
1602 assert_eq!(*t2_ms, Some(500));
1603 assert!((accel - 1.0).abs() < 1e-6);
1604 assert_eq!(inner.len(), 1);
1605 }
1606 _ => panic!(),
1607 }
1608 }
1609
1610 #[test]
1611 fn parses_color() {
1612 let v = parse_block(r"\c&H0000FF&");
1613 assert_eq!(v, vec![AnimatedTag::Color1((255, 0, 0))]);
1614 let v = parse_block(r"\1c&HFF00FF&");
1615 assert_eq!(v, vec![AnimatedTag::Color1((255, 0, 255))]);
1616 }
1617
1618 #[test]
1619 fn fad_alpha_curve() {
1620 let dur = 2000;
1622 assert!((fad_alpha(200, 300, 0, dur) - 0.0).abs() < 1e-6);
1623 assert!((fad_alpha(200, 300, 100, dur) - 0.5).abs() < 1e-6);
1624 assert!((fad_alpha(200, 300, 200, dur) - 1.0).abs() < 1e-6);
1625 assert!((fad_alpha(200, 300, 1000, dur) - 1.0).abs() < 1e-6);
1626 assert!((fad_alpha(200, 300, 1700, dur) - 1.0).abs() < 1e-6);
1627 assert!((fad_alpha(200, 300, 1850, dur) - 0.5).abs() < 1e-6);
1629 assert!((fad_alpha(200, 300, 2000, dur) - 0.0).abs() < 1e-6);
1630 }
1631
1632 #[test]
1633 fn evaluate_static_overrides() {
1634 let cue_anim = CueAnimation {
1635 tags: vec![
1636 AnimatedTag::Fscx(200.0),
1637 AnimatedTag::Fscy(50.0),
1638 AnimatedTag::Frz(90.0),
1639 AnimatedTag::Blur(3.0),
1640 ],
1641 };
1642 let st = cue_anim.evaluate_at(500, 1000);
1643 assert_eq!(st.scale, (2.0, 0.5));
1644 assert!((st.rotate_radians - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
1645 assert_eq!(st.blur_sigma, 3.0);
1646 }
1647
1648 #[test]
1649 fn evaluate_move() {
1650 let cue_anim = CueAnimation {
1651 tags: vec![AnimatedTag::Move {
1652 x1: 0.0,
1653 y1: 0.0,
1654 x2: 100.0,
1655 y2: 200.0,
1656 t1_ms: Some(0),
1657 t2_ms: Some(1000),
1658 }],
1659 };
1660 let st0 = cue_anim.evaluate_at(0, 1000);
1661 assert_eq!(st0.translate, Some((0.0, 0.0)));
1662 let st_mid = cue_anim.evaluate_at(500, 1000);
1663 assert_eq!(st_mid.translate, Some((50.0, 100.0)));
1664 let st_end = cue_anim.evaluate_at(1000, 1000);
1665 assert_eq!(st_end.translate, Some((100.0, 200.0)));
1666 let st_after = cue_anim.evaluate_at(2000, 1000);
1668 assert_eq!(st_after.translate, Some((100.0, 200.0)));
1669 }
1670
1671 #[test]
1672 fn evaluate_move_default_times() {
1673 let cue_anim = CueAnimation {
1675 tags: vec![AnimatedTag::Move {
1676 x1: 0.0,
1677 y1: 0.0,
1678 x2: 100.0,
1679 y2: 100.0,
1680 t1_ms: None,
1681 t2_ms: None,
1682 }],
1683 };
1684 let st = cue_anim.evaluate_at(500, 1000);
1685 assert_eq!(st.translate, Some((50.0, 50.0)));
1686 }
1687
1688 #[test]
1689 fn parses_pos() {
1690 let v = parse_block(r"\pos(320,240)");
1691 assert_eq!(v, vec![AnimatedTag::Pos { x: 320.0, y: 240.0 }]);
1692 let v = parse_block(r"\pos(12.5,-3.0)");
1694 assert_eq!(v, vec![AnimatedTag::Pos { x: 12.5, y: -3.0 }]);
1695 assert!(parse_block(r"\pos(320)").is_empty());
1697 assert!(parse_block(r"\pos(1,2,3)").is_empty());
1698 }
1699
1700 #[test]
1701 fn evaluate_pos_is_static() {
1702 let cue_anim = CueAnimation {
1705 tags: vec![AnimatedTag::Pos { x: 320.0, y: 240.0 }],
1706 };
1707 assert_eq!(
1708 cue_anim.evaluate_at(0, 1000).translate,
1709 Some((320.0, 240.0))
1710 );
1711 assert_eq!(
1712 cue_anim.evaluate_at(500, 1000).translate,
1713 Some((320.0, 240.0))
1714 );
1715 assert_eq!(
1716 cue_anim.evaluate_at(1000, 1000).translate,
1717 Some((320.0, 240.0))
1718 );
1719 }
1720
1721 #[test]
1722 fn move_after_pos_overrides() {
1723 let cue_anim = CueAnimation {
1726 tags: vec![
1727 AnimatedTag::Pos { x: 10.0, y: 10.0 },
1728 AnimatedTag::Move {
1729 x1: 0.0,
1730 y1: 0.0,
1731 x2: 100.0,
1732 y2: 100.0,
1733 t1_ms: Some(0),
1734 t2_ms: Some(1000),
1735 },
1736 ],
1737 };
1738 assert_eq!(
1740 cue_anim.evaluate_at(500, 1000).translate,
1741 Some((50.0, 50.0))
1742 );
1743 }
1744
1745 #[test]
1746 fn evaluate_fad() {
1747 let cue_anim = CueAnimation {
1748 tags: vec![AnimatedTag::Fad {
1749 t1_ms: 200,
1750 t2_ms: 300,
1751 }],
1752 };
1753 let dur = 2000;
1754 assert!((cue_anim.evaluate_at(0, dur).alpha_mul - 0.0).abs() < 1e-6);
1755 assert!((cue_anim.evaluate_at(100, dur).alpha_mul - 0.5).abs() < 1e-6);
1756 assert!((cue_anim.evaluate_at(1000, dur).alpha_mul - 1.0).abs() < 1e-6);
1757 assert!((cue_anim.evaluate_at(1850, dur).alpha_mul - 0.5).abs() < 1e-6);
1758 }
1759
1760 #[test]
1761 fn evaluate_t_interpolates_scale() {
1762 let cue_anim = CueAnimation {
1765 tags: vec![AnimatedTag::T {
1766 t1_ms: Some(0),
1767 t2_ms: Some(1000),
1768 accel: 1.0,
1769 inner: vec![AnimatedTag::Fscx(200.0)],
1770 }],
1771 };
1772 assert_eq!(cue_anim.evaluate_at(0, 1000).scale.0, 1.0);
1773 assert!((cue_anim.evaluate_at(500, 1000).scale.0 - 1.5).abs() < 1e-6);
1774 assert_eq!(cue_anim.evaluate_at(1000, 1000).scale.0, 2.0);
1775 assert_eq!(cue_anim.evaluate_at(1500, 1000).scale.0, 2.0);
1776 }
1777
1778 #[test]
1779 fn evaluate_t_interpolates_rotate() {
1780 let cue_anim = CueAnimation {
1781 tags: vec![AnimatedTag::T {
1782 t1_ms: Some(0),
1783 t2_ms: Some(1000),
1784 accel: 1.0,
1785 inner: vec![AnimatedTag::Frz(90.0)],
1786 }],
1787 };
1788 let st_mid = cue_anim.evaluate_at(500, 1000);
1789 assert!((st_mid.rotate_radians - std::f32::consts::FRAC_PI_4).abs() < 1e-5);
1791 }
1792
1793 #[test]
1794 fn evaluate_t_interpolates_color() {
1795 let cue_anim = CueAnimation {
1796 tags: vec![
1797 AnimatedTag::Color1((255, 0, 0)), AnimatedTag::T {
1799 t1_ms: Some(0),
1800 t2_ms: Some(1000),
1801 accel: 1.0,
1802 inner: vec![AnimatedTag::Color1((0, 0, 255))], },
1804 ],
1805 };
1806 let st = cue_anim.evaluate_at(500, 1000);
1807 let rgb = st.primary_color.unwrap();
1808 assert!((rgb.0 as i32 - 127).abs() <= 1);
1810 assert_eq!(rgb.1, 0);
1811 assert!((rgb.2 as i32 - 127).abs() <= 1);
1812 }
1813
1814 #[test]
1815 fn evaluate_t_no_times_uses_cue_span() {
1816 let cue_anim = CueAnimation {
1817 tags: vec![AnimatedTag::T {
1818 t1_ms: None,
1819 t2_ms: None,
1820 accel: 1.0,
1821 inner: vec![AnimatedTag::Fscy(200.0)],
1822 }],
1823 };
1824 let st = cue_anim.evaluate_at(1000, 2000);
1826 assert!((st.scale.1 - 1.5).abs() < 1e-6);
1827 }
1828
1829 #[test]
1830 fn clip_rect_applies() {
1831 let cue_anim = CueAnimation {
1832 tags: vec![AnimatedTag::ClipRect {
1833 x1: 10.0,
1834 y1: 20.0,
1835 x2: 100.0,
1836 y2: 200.0,
1837 }],
1838 };
1839 let st = cue_anim.evaluate_at(0, 1000);
1840 let c = st.clip_rect.unwrap();
1841 assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
1842 }
1843
1844 #[test]
1845 fn clip_rect_normalises_swapped_corners() {
1846 let cue_anim = CueAnimation {
1847 tags: vec![AnimatedTag::ClipRect {
1848 x1: 100.0,
1849 y1: 200.0,
1850 x2: 10.0,
1851 y2: 20.0,
1852 }],
1853 };
1854 let st = cue_anim.evaluate_at(0, 1000);
1855 let c = st.clip_rect.unwrap();
1856 assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
1857 }
1858
1859 #[test]
1860 fn extract_from_cue_segments() {
1861 let cue = SubtitleCue {
1863 start_us: 0,
1864 end_us: 1_000_000,
1865 style_ref: None,
1866 positioning: None,
1867 segments: vec![
1868 Segment::Raw(r"{\fad(100,200)\frz30}".into()),
1869 Segment::Text("hello".into()),
1870 Segment::Raw(r"{\move(0,0,100,100)}".into()),
1871 ],
1872 };
1873 let anim = extract_cue_animation(&cue);
1874 assert_eq!(anim.tags.len(), 3);
1875 assert!(matches!(
1876 anim.tags[0],
1877 AnimatedTag::Fad {
1878 t1_ms: 100,
1879 t2_ms: 200
1880 }
1881 ));
1882 assert!(matches!(anim.tags[1], AnimatedTag::Frz(30.0)));
1883 assert!(matches!(anim.tags[2], AnimatedTag::Move { .. }));
1884 }
1885
1886 #[test]
1887 fn extract_skips_non_animated_raw() {
1888 let cue = SubtitleCue {
1890 start_us: 0,
1891 end_us: 1_000_000,
1892 style_ref: None,
1893 positioning: None,
1894 segments: vec![Segment::Raw(r"{\xyz(1,2)}".into())],
1895 };
1896 let anim = extract_cue_animation(&cue);
1897 assert!(anim.is_empty());
1898 }
1899
1900 #[test]
1901 fn extract_recurses_into_color_children() {
1902 let cue = SubtitleCue {
1903 start_us: 0,
1904 end_us: 0,
1905 style_ref: None,
1906 positioning: None,
1907 segments: vec![Segment::Color {
1908 rgb: (1, 2, 3),
1909 children: vec![Segment::Raw(r"{\fad(50,50)}".into())],
1910 }],
1911 };
1912 let anim = extract_cue_animation(&cue);
1913 assert_eq!(anim.tags.len(), 1);
1914 assert!(matches!(
1915 anim.tags[0],
1916 AnimatedTag::Fad {
1917 t1_ms: 50,
1918 t2_ms: 50
1919 }
1920 ));
1921 }
1922
1923 #[test]
1924 fn transform_composition_includes_translate() {
1925 let cue_anim = CueAnimation {
1926 tags: vec![
1927 AnimatedTag::Move {
1928 x1: 100.0,
1929 y1: 200.0,
1930 x2: 100.0,
1931 y2: 200.0,
1932 t1_ms: None,
1933 t2_ms: None,
1934 },
1935 AnimatedTag::Fscx(200.0),
1936 ],
1937 };
1938 let st = cue_anim.evaluate_at(0, 1000);
1939 let p = st.transform.apply(oxideav_core::Point { x: 0.0, y: 0.0 });
1941 assert!((p.x - 100.0).abs() < 1e-5);
1942 assert!((p.y - 200.0).abs() < 1e-5);
1943 let p1 = st.transform.apply(oxideav_core::Point { x: 1.0, y: 0.0 });
1945 assert!((p1.x - 102.0).abs() < 1e-5);
1946 assert!((p1.y - 200.0).abs() < 1e-5);
1947 }
1948
1949 #[test]
1954 fn parses_bord_uniform() {
1955 let v = parse_block(r"\bord3.5");
1956 assert_eq!(v, vec![AnimatedTag::Bord(3.5)]);
1957 }
1958
1959 #[test]
1960 fn parses_xbord_ybord_pair() {
1961 let v = parse_block(r"\xbord2\ybord4");
1962 assert_eq!(v, vec![AnimatedTag::Xbord(2.0), AnimatedTag::Ybord(4.0)]);
1963 }
1964
1965 #[test]
1966 fn parses_shad_uniform_and_per_axis() {
1967 let v = parse_block(r"\shad5\xshad-2.5\yshad3");
1968 assert_eq!(
1969 v,
1970 vec![
1971 AnimatedTag::Shad(5.0),
1972 AnimatedTag::Xshad(-2.5),
1973 AnimatedTag::Yshad(3.0),
1974 ]
1975 );
1976 }
1977
1978 #[test]
1979 fn parses_blur_and_be_are_separate_variants() {
1980 let v = parse_block(r"\blur2.5\be3");
1983 assert_eq!(v.len(), 2);
1984 assert!(matches!(v[0], AnimatedTag::Blur(b) if (b - 2.5).abs() < 1e-6));
1985 assert!(matches!(v[1], AnimatedTag::Be(3)));
1986 }
1987
1988 #[test]
1989 fn be_rounds_non_integer_strengths() {
1990 let v = parse_block(r"\be2.7");
1992 assert!(matches!(v[0], AnimatedTag::Be(3)));
1993 }
1994
1995 #[test]
1996 fn parses_fax_fay() {
1997 let v = parse_block(r"\fax0.5\fay-0.25");
1998 assert_eq!(v, vec![AnimatedTag::Fax(0.5), AnimatedTag::Fay(-0.25)]);
1999 }
2000
2001 #[test]
2002 fn parses_iclip_rect() {
2003 let v = parse_block(r"\iclip(10,20,100,200)");
2004 assert_eq!(
2005 v,
2006 vec![AnimatedTag::IClipRect {
2007 x1: 10.0,
2008 y1: 20.0,
2009 x2: 100.0,
2010 y2: 200.0,
2011 }]
2012 );
2013 }
2014
2015 #[test]
2016 fn parses_iclip_drawing_passthrough() {
2017 let v = parse_block(r"\iclip(m 0 0 l 100 0 l 100 100 l 0 100)");
2018 assert_eq!(v.len(), 1);
2019 assert!(matches!(v[0], AnimatedTag::IClipDrawing(_)));
2020 }
2021
2022 #[test]
2023 fn parses_iclip_with_scale_prefix_is_drawing_form() {
2024 let v = parse_block(r"\iclip(2,m 0 0 l 50 50)");
2027 assert!(matches!(v[0], AnimatedTag::IClipDrawing(_)));
2028 }
2029
2030 #[test]
2031 fn evaluate_bord_sets_both_axes() {
2032 let cue_anim = CueAnimation {
2033 tags: vec![AnimatedTag::Bord(2.5)],
2034 };
2035 let st = cue_anim.evaluate_at(0, 1000);
2036 assert_eq!(st.border, Some((2.5, 2.5)));
2037 }
2038
2039 #[test]
2040 fn evaluate_xbord_then_ybord_combines() {
2041 let cue_anim = CueAnimation {
2042 tags: vec![AnimatedTag::Xbord(2.0), AnimatedTag::Ybord(4.0)],
2043 };
2044 let st = cue_anim.evaluate_at(0, 1000);
2045 assert_eq!(st.border, Some((2.0, 4.0)));
2046 }
2047
2048 #[test]
2049 fn evaluate_bord_after_xbord_ybord_overrides_both() {
2050 let cue_anim = CueAnimation {
2053 tags: vec![
2054 AnimatedTag::Xbord(2.0),
2055 AnimatedTag::Ybord(4.0),
2056 AnimatedTag::Bord(1.0),
2057 ],
2058 };
2059 let st = cue_anim.evaluate_at(0, 1000);
2060 assert_eq!(st.border, Some((1.0, 1.0)));
2061 }
2062
2063 #[test]
2064 fn evaluate_bord_clamps_negative_to_zero() {
2065 let cue_anim = CueAnimation {
2067 tags: vec![AnimatedTag::Bord(-3.0)],
2068 };
2069 let st = cue_anim.evaluate_at(0, 1000);
2070 assert_eq!(st.border, Some((0.0, 0.0)));
2071 }
2072
2073 #[test]
2074 fn evaluate_shad_uniform_and_xshad_yshad_negative() {
2075 let cue_anim = CueAnimation {
2078 tags: vec![AnimatedTag::Shad(2.0)],
2079 };
2080 let st = cue_anim.evaluate_at(0, 1000);
2081 assert_eq!(st.shadow, Some((2.0, 2.0)));
2082
2083 let cue_anim2 = CueAnimation {
2084 tags: vec![AnimatedTag::Xshad(-3.5), AnimatedTag::Yshad(1.5)],
2085 };
2086 let st2 = cue_anim2.evaluate_at(0, 1000);
2087 assert_eq!(st2.shadow, Some((-3.5, 1.5)));
2088
2089 let cue_anim3 = CueAnimation {
2091 tags: vec![AnimatedTag::Shad(-2.0)],
2092 };
2093 let st3 = cue_anim3.evaluate_at(0, 1000);
2094 assert_eq!(st3.shadow, Some((0.0, 0.0)));
2095 }
2096
2097 #[test]
2098 fn evaluate_be_strength() {
2099 let cue_anim = CueAnimation {
2100 tags: vec![AnimatedTag::Be(5)],
2101 };
2102 let st = cue_anim.evaluate_at(0, 1000);
2103 assert_eq!(st.be_strength, 5);
2104 assert_eq!(st.blur_sigma, 0.0);
2106 }
2107
2108 #[test]
2109 fn evaluate_fax_fay_writes_shear() {
2110 let cue_anim = CueAnimation {
2111 tags: vec![AnimatedTag::Fax(0.5), AnimatedTag::Fay(-0.3)],
2112 };
2113 let st = cue_anim.evaluate_at(0, 1000);
2114 assert!((st.shear.0 - 0.5).abs() < 1e-6);
2115 assert!((st.shear.1 + 0.3).abs() < 1e-6);
2116 }
2117
2118 #[test]
2119 fn evaluate_iclip_rect_normalises() {
2120 let cue_anim = CueAnimation {
2121 tags: vec![AnimatedTag::IClipRect {
2122 x1: 100.0,
2123 y1: 200.0,
2124 x2: 10.0,
2125 y2: 20.0,
2126 }],
2127 };
2128 let st = cue_anim.evaluate_at(0, 1000);
2129 let c = st.iclip_rect.unwrap();
2130 assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
2131 assert!(st.clip_rect.is_none());
2134 }
2135
2136 #[test]
2137 fn evaluate_iclip_drawing_stored() {
2138 let cue_anim = CueAnimation {
2139 tags: vec![AnimatedTag::IClipDrawing("m 0 0 l 10 10".into())],
2140 };
2141 let st = cue_anim.evaluate_at(0, 1000);
2142 assert_eq!(st.iclip_drawing.as_deref(), Some("m 0 0 l 10 10"));
2143 assert!(st.clip_drawing.is_none());
2144 }
2145
2146 #[test]
2147 fn t_interpolates_bord() {
2148 let cue_anim = CueAnimation {
2150 tags: vec![
2151 AnimatedTag::Bord(0.0),
2152 AnimatedTag::T {
2153 t1_ms: Some(0),
2154 t2_ms: Some(1000),
2155 accel: 1.0,
2156 inner: vec![AnimatedTag::Bord(4.0)],
2157 },
2158 ],
2159 };
2160 let st_mid = cue_anim.evaluate_at(500, 1000);
2161 let (bx, by) = st_mid.border.unwrap();
2162 assert!((bx - 2.0).abs() < 1e-5, "bx = {}", bx);
2163 assert!((by - 2.0).abs() < 1e-5);
2164 let st_end = cue_anim.evaluate_at(1000, 1000);
2165 let (bx2, by2) = st_end.border.unwrap();
2166 assert!((bx2 - 4.0).abs() < 1e-5);
2167 assert!((by2 - 4.0).abs() < 1e-5);
2168 }
2169
2170 #[test]
2171 fn t_interpolates_shad_per_axis() {
2172 let cue_anim = CueAnimation {
2173 tags: vec![
2174 AnimatedTag::Xshad(0.0),
2175 AnimatedTag::Yshad(0.0),
2176 AnimatedTag::T {
2177 t1_ms: Some(0),
2178 t2_ms: Some(1000),
2179 accel: 1.0,
2180 inner: vec![AnimatedTag::Xshad(6.0), AnimatedTag::Yshad(-2.0)],
2181 },
2182 ],
2183 };
2184 let st = cue_anim.evaluate_at(500, 1000);
2185 let (sx, sy) = st.shadow.unwrap();
2186 assert!((sx - 3.0).abs() < 1e-5);
2187 assert!((sy + 1.0).abs() < 1e-5);
2188 }
2189
2190 #[test]
2191 fn t_interpolates_fax_fay() {
2192 let cue_anim = CueAnimation {
2193 tags: vec![AnimatedTag::T {
2194 t1_ms: Some(0),
2195 t2_ms: Some(1000),
2196 accel: 1.0,
2197 inner: vec![AnimatedTag::Fax(1.0)],
2198 }],
2199 };
2200 let st = cue_anim.evaluate_at(500, 1000);
2201 assert!((st.shear.0 - 0.5).abs() < 1e-5);
2203 }
2204
2205 #[test]
2206 fn t_interpolates_be_rounds_to_integer() {
2207 let cue_anim = CueAnimation {
2208 tags: vec![
2209 AnimatedTag::Be(0),
2210 AnimatedTag::T {
2211 t1_ms: Some(0),
2212 t2_ms: Some(1000),
2213 accel: 1.0,
2214 inner: vec![AnimatedTag::Be(10)],
2215 },
2216 ],
2217 };
2218 let st = cue_anim.evaluate_at(500, 1000);
2219 assert_eq!(st.be_strength, 5);
2221 let st_q = cue_anim.evaluate_at(250, 1000);
2222 assert!(st_q.be_strength == 2 || st_q.be_strength == 3);
2224 }
2225
2226 #[test]
2227 fn extract_typed_tags_from_real_world_cue() {
2228 let cue = SubtitleCue {
2231 start_us: 0,
2232 end_us: 5_000_000,
2233 style_ref: None,
2234 positioning: None,
2235 segments: vec![
2236 Segment::Raw(
2237 r"{\bord2\xbord3\ybord4\shad1\xshad-2\yshad2\blur1.5\be2\fax0.1\fay-0.1\iclip(0,0,640,480)}"
2238 .into(),
2239 ),
2240 Segment::Text("text".into()),
2241 ],
2242 };
2243 let anim = extract_cue_animation(&cue);
2244 assert_eq!(anim.tags.len(), 11, "got {:?}", anim.tags);
2245 let st = anim.evaluate_at(0, 5000);
2246 assert_eq!(st.border, Some((3.0, 4.0)));
2248 assert_eq!(st.shadow, Some((-2.0, 2.0)));
2250 assert!((st.blur_sigma - 1.5).abs() < 1e-6);
2251 assert_eq!(st.be_strength, 2);
2252 assert!((st.shear.0 - 0.1).abs() < 1e-6);
2253 assert!((st.shear.1 + 0.1).abs() < 1e-6);
2254 let c = st.iclip_rect.unwrap();
2255 assert_eq!((c.x1, c.y1, c.x2, c.y2), (0.0, 0.0, 640.0, 480.0));
2256 }
2257
2258 #[test]
2263 fn parses_color2_color3_color4() {
2264 let v = parse_block(r"\2c&H0000FF&\3c&H00FF00&\4c&HFF0000&");
2265 assert_eq!(
2266 v,
2267 vec![
2268 AnimatedTag::Color2((255, 0, 0)),
2269 AnimatedTag::Color3((0, 255, 0)),
2270 AnimatedTag::Color4((0, 0, 255)),
2271 ]
2272 );
2273 }
2274
2275 #[test]
2276 fn parses_alpha_all_and_per_component() {
2277 let v = parse_block(r"\alpha&H80&\1a&HFF&\2a&H00&\3a&H40&\4a&HC0&");
2278 assert_eq!(
2279 v,
2280 vec![
2281 AnimatedTag::Alpha(0x80),
2282 AnimatedTag::Alpha1(0xFF),
2283 AnimatedTag::Alpha2(0x00),
2284 AnimatedTag::Alpha3(0x40),
2285 AnimatedTag::Alpha4(0xC0),
2286 ]
2287 );
2288 }
2289
2290 #[test]
2291 fn parses_alpha_tolerates_envelope_variants() {
2292 assert_eq!(parse_alpha_byte("&HFF&"), Some(0xFF));
2294 assert_eq!(parse_alpha_byte("&HFF"), Some(0xFF));
2295 assert_eq!(parse_alpha_byte("HFF"), Some(0xFF));
2296 assert_eq!(parse_alpha_byte("0xFF"), Some(0xFF));
2297 assert_eq!(parse_alpha_byte("ff"), Some(0xFF));
2298 assert_eq!(parse_alpha_byte(""), None);
2299 }
2300
2301 #[test]
2302 fn evaluate_color2_color3_color4_writes_separate_fields() {
2303 let cue_anim = CueAnimation {
2304 tags: vec![
2305 AnimatedTag::Color2((10, 20, 30)),
2306 AnimatedTag::Color3((40, 50, 60)),
2307 AnimatedTag::Color4((70, 80, 90)),
2308 ],
2309 };
2310 let st = cue_anim.evaluate_at(0, 1000);
2311 assert_eq!(st.secondary_color, Some((10, 20, 30)));
2312 assert_eq!(st.outline_color, Some((40, 50, 60)));
2313 assert_eq!(st.shadow_color, Some((70, 80, 90)));
2314 assert_eq!(st.primary_color, None);
2316 }
2317
2318 #[test]
2319 fn evaluate_alpha_global_sets_all_four_channels() {
2320 let cue_anim = CueAnimation {
2321 tags: vec![AnimatedTag::Alpha(0x80)],
2322 };
2323 let st = cue_anim.evaluate_at(0, 1000);
2324 assert_eq!(st.primary_alpha, Some(0x80));
2325 assert_eq!(st.secondary_alpha, Some(0x80));
2326 assert_eq!(st.outline_alpha, Some(0x80));
2327 assert_eq!(st.shadow_alpha, Some(0x80));
2328 }
2329
2330 #[test]
2331 fn evaluate_per_component_alpha_overrides_global() {
2332 let cue_anim = CueAnimation {
2334 tags: vec![AnimatedTag::Alpha(0x40), AnimatedTag::Alpha3(0xFF)],
2335 };
2336 let st = cue_anim.evaluate_at(0, 1000);
2337 assert_eq!(st.primary_alpha, Some(0x40));
2338 assert_eq!(st.secondary_alpha, Some(0x40));
2339 assert_eq!(st.outline_alpha, Some(0xFF));
2340 assert_eq!(st.shadow_alpha, Some(0x40));
2341 }
2342
2343 #[test]
2344 fn alpha_per_component_does_not_touch_alpha_mul() {
2345 let cue_anim = CueAnimation {
2348 tags: vec![AnimatedTag::Alpha1(0x80), AnimatedTag::Alpha3(0xC0)],
2349 };
2350 let st = cue_anim.evaluate_at(0, 1000);
2351 assert_eq!(st.alpha_mul, 1.0);
2352 assert_eq!(st.primary_alpha, Some(0x80));
2353 assert_eq!(st.outline_alpha, Some(0xC0));
2354 }
2355
2356 #[test]
2357 fn t_interpolates_color3() {
2358 let cue_anim = CueAnimation {
2360 tags: vec![
2361 AnimatedTag::Color3((255, 0, 0)),
2362 AnimatedTag::T {
2363 t1_ms: Some(0),
2364 t2_ms: Some(1000),
2365 accel: 1.0,
2366 inner: vec![AnimatedTag::Color3((0, 0, 255))],
2367 },
2368 ],
2369 };
2370 let st = cue_anim.evaluate_at(500, 1000);
2371 let rgb = st.outline_color.unwrap();
2372 assert!((rgb.0 as i32 - 127).abs() <= 1);
2373 assert_eq!(rgb.1, 0);
2374 assert!((rgb.2 as i32 - 127).abs() <= 1);
2375 }
2376
2377 #[test]
2378 fn t_interpolates_alpha1() {
2379 let cue_anim = CueAnimation {
2381 tags: vec![
2382 AnimatedTag::Alpha1(0x00),
2383 AnimatedTag::T {
2384 t1_ms: Some(0),
2385 t2_ms: Some(1000),
2386 accel: 1.0,
2387 inner: vec![AnimatedTag::Alpha1(0xFF)],
2388 },
2389 ],
2390 };
2391 let st = cue_anim.evaluate_at(500, 1000);
2392 let a = st.primary_alpha.unwrap();
2393 assert!((a as i32 - 0x80).abs() <= 1, "got {:#x}", a);
2394 let st_end = cue_anim.evaluate_at(1000, 1000);
2396 assert_eq!(st_end.primary_alpha, Some(0xFF));
2397 }
2398
2399 #[test]
2400 fn t_interpolates_alpha_global_writes_all_four() {
2401 let cue_anim = CueAnimation {
2403 tags: vec![
2404 AnimatedTag::Alpha(0x00),
2405 AnimatedTag::T {
2406 t1_ms: Some(0),
2407 t2_ms: Some(1000),
2408 accel: 1.0,
2409 inner: vec![AnimatedTag::Alpha(0xFF)],
2410 },
2411 ],
2412 };
2413 let st = cue_anim.evaluate_at(500, 1000);
2414 for ch in [
2415 st.primary_alpha,
2416 st.secondary_alpha,
2417 st.outline_alpha,
2418 st.shadow_alpha,
2419 ] {
2420 let a = ch.unwrap();
2421 assert!((a as i32 - 0x80).abs() <= 1);
2422 }
2423 }
2424
2425 #[test]
2426 fn extract_full_alpha_and_color_cue() {
2427 let cue = SubtitleCue {
2430 start_us: 0,
2431 end_us: 2_000_000,
2432 style_ref: None,
2433 positioning: None,
2434 segments: vec![
2435 Segment::Raw(
2436 r"{\1c&H0000FF&\2c&H00FF00&\3c&HFF0000&\4c&H808080&\alpha&H80&\3a&HFF&}".into(),
2437 ),
2438 Segment::Text("text".into()),
2439 ],
2440 };
2441 let anim = extract_cue_animation(&cue);
2442 assert_eq!(anim.tags.len(), 6, "got {:?}", anim.tags);
2443 let st = anim.evaluate_at(0, 2000);
2444 assert_eq!(st.primary_color, Some((255, 0, 0)));
2445 assert_eq!(st.secondary_color, Some((0, 255, 0)));
2446 assert_eq!(st.outline_color, Some((0, 0, 255)));
2447 assert_eq!(st.shadow_color, Some((128, 128, 128)));
2448 assert_eq!(st.primary_alpha, Some(0x80));
2451 assert_eq!(st.secondary_alpha, Some(0x80));
2452 assert_eq!(st.outline_alpha, Some(0xFF));
2453 assert_eq!(st.shadow_alpha, Some(0x80));
2454 }
2455
2456 #[test]
2457 fn unrecognised_color_or_alpha_payload_is_skipped() {
2458 assert!(parse_block(r"\2c&Hgggggg&").is_empty());
2460 assert!(parse_block(r"\1a").is_empty());
2461 assert!(parse_block(r"\3c").is_empty());
2462 }
2463
2464 #[test]
2465 fn capital_k_karaoke_tag_is_recognised_as_kf() {
2466 use crate::parse;
2470 let src = "[Script Info]\n\
2471ScriptType: v4.00+\n\
2472\n\
2473[V4+ Styles]\n\
2474Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n\
2475Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n\
2476\n\
2477[Events]\n\
2478Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
2479Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\K50}sweep{\\K30}done\n";
2480 let t = parse(src.as_bytes()).unwrap();
2481 let segs = &t.cues[0].segments;
2482 let karaoke_count = segs
2484 .iter()
2485 .filter(|s| matches!(s, Segment::Karaoke { .. }))
2486 .count();
2487 assert_eq!(karaoke_count, 2, "got segs = {:?}", segs);
2488 }
2489
2490 #[test]
2494 fn parses_fsp_static() {
2495 let v = parse_block(r"\fsp3");
2496 assert_eq!(v, vec![AnimatedTag::Fsp(3.0)]);
2497 let v = parse_block(r"\fsp-1.5");
2499 assert_eq!(v, vec![AnimatedTag::Fsp(-1.5)]);
2500 }
2501
2502 #[test]
2503 fn parses_q_in_range() {
2504 for mode in 0..=3 {
2505 let src = format!(r"\q{mode}");
2506 let v = parse_block(&src);
2507 assert_eq!(v, vec![AnimatedTag::Q(mode as u8)]);
2508 }
2509 }
2510
2511 #[test]
2512 fn parses_q_out_of_range_dropped() {
2513 assert!(parse_block(r"\q4").is_empty());
2516 assert!(parse_block(r"\q-1").is_empty());
2517 }
2518
2519 #[test]
2520 fn evaluate_fsp_static_override() {
2521 let cue_anim = CueAnimation {
2522 tags: vec![AnimatedTag::Fsp(2.5)],
2523 };
2524 let st = cue_anim.evaluate_at(0, 1000);
2525 assert_eq!(st.letter_spacing, Some(2.5));
2526 assert!(RenderState::identity().letter_spacing.is_none());
2528 }
2529
2530 #[test]
2531 fn evaluate_q_static_override() {
2532 let cue_anim = CueAnimation {
2533 tags: vec![AnimatedTag::Q(2)],
2534 };
2535 let st = cue_anim.evaluate_at(0, 1000);
2536 assert_eq!(st.wrap_style, Some(2));
2537 assert!(RenderState::identity().wrap_style.is_none());
2538 }
2539
2540 #[test]
2541 fn fsp_animatable_via_t() {
2542 let v = parse_block(r"\fsp0\t(0,1000,\fsp4)");
2547 assert_eq!(v.len(), 2);
2548 let cue_anim = CueAnimation { tags: v };
2549 let st0 = cue_anim.evaluate_at(0, 1000);
2550 assert_eq!(st0.letter_spacing, Some(0.0));
2551 let st_mid = cue_anim.evaluate_at(500, 1000);
2552 let mid = st_mid.letter_spacing.expect("set");
2553 assert!(
2554 (mid - 2.0).abs() < 1e-3,
2555 "expected 2.0 at midpoint, got {mid}"
2556 );
2557 let st_end = cue_anim.evaluate_at(1000, 1000);
2558 assert_eq!(st_end.letter_spacing, Some(4.0));
2559 }
2560
2561 #[test]
2562 fn q_static_inside_t_snaps_post() {
2563 let v = parse_block(r"\q0\t(500,1000,\q2)");
2566 assert_eq!(v.len(), 2);
2567 let cue_anim = CueAnimation { tags: v };
2568 let st_before = cue_anim.evaluate_at(0, 1000);
2570 assert_eq!(st_before.wrap_style, Some(0));
2571 let st_mid = cue_anim.evaluate_at(750, 1000);
2573 assert_eq!(st_mid.wrap_style, Some(2));
2574 let st_end = cue_anim.evaluate_at(1000, 1000);
2575 assert_eq!(st_end.wrap_style, Some(2));
2576 }
2577
2578 #[test]
2579 fn extract_fsp_q_from_cue_segment() {
2580 let cue = SubtitleCue {
2581 start_us: 0,
2582 end_us: 1_000_000,
2583 style_ref: None,
2584 positioning: None,
2585 segments: vec![
2586 Segment::Raw(r"{\fsp2\q1}".into()),
2587 Segment::Text("spaced".into()),
2588 ],
2589 };
2590 let anim = extract_cue_animation(&cue);
2591 assert_eq!(anim.tags.len(), 2);
2592 let st = anim.evaluate_at(0, 1000);
2593 assert_eq!(st.letter_spacing, Some(2.0));
2594 assert_eq!(st.wrap_style, Some(1));
2595 }
2596
2597 #[test]
2598 fn parses_an_in_range() {
2599 for pos in 1..=9 {
2602 let src = format!(r"\an{pos}");
2603 let v = parse_block(&src);
2604 assert_eq!(v, vec![AnimatedTag::An(pos as u8)]);
2605 }
2606 }
2607
2608 #[test]
2609 fn parses_an_out_of_range_dropped() {
2610 assert!(parse_block(r"\an0").is_empty());
2614 assert!(parse_block(r"\an10").is_empty());
2615 assert!(parse_block(r"\an-1").is_empty());
2616 }
2617
2618 #[test]
2619 fn parses_legacy_a_known_codes() {
2620 let cases: &[(u8, u8)] = &[
2624 (1, 1),
2625 (2, 2),
2626 (3, 3),
2627 (5, 7),
2628 (6, 8),
2629 (7, 9),
2630 (9, 4),
2631 (10, 5),
2632 (11, 6),
2633 ];
2634 for (legacy, numpad) in cases {
2635 let src = format!(r"\a{legacy}");
2636 let v = parse_block(&src);
2637 assert_eq!(
2638 v,
2639 vec![AnimatedTag::A(*legacy)],
2640 "legacy code {} should parse",
2641 legacy
2642 );
2643 let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
2646 assert_eq!(
2647 st.alignment,
2648 Some(*numpad),
2649 "legacy {} should map to numpad {}",
2650 legacy,
2651 numpad
2652 );
2653 }
2654 }
2655
2656 #[test]
2657 fn parses_legacy_a_unknown_codes_drop_override() {
2658 for legacy in [4_u8, 8, 12, 20, 255] {
2662 let src = format!(r"\a{legacy}");
2663 let v = parse_block(&src);
2664 assert_eq!(v, vec![AnimatedTag::A(legacy)]);
2665 let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
2666 assert!(
2667 st.alignment.is_none(),
2668 "legacy {} should not override alignment",
2669 legacy
2670 );
2671 }
2672 }
2673
2674 #[test]
2675 fn evaluate_an_static_override() {
2676 let cue_anim = CueAnimation {
2677 tags: vec![AnimatedTag::An(7)],
2678 };
2679 let st = cue_anim.evaluate_at(0, 1000);
2680 assert_eq!(st.alignment, Some(7));
2681 assert!(RenderState::identity().alignment.is_none());
2683 let st_mid = cue_anim.evaluate_at(500, 1000);
2685 let st_end = cue_anim.evaluate_at(1000, 1000);
2686 assert_eq!(st_mid.alignment, Some(7));
2687 assert_eq!(st_end.alignment, Some(7));
2688 }
2689
2690 #[test]
2691 fn an_static_inside_t_snaps_post() {
2692 let v = parse_block(r"\an2\t(500,1000,\an8)");
2696 assert_eq!(v.len(), 2);
2697 let cue_anim = CueAnimation { tags: v };
2698 let st_before = cue_anim.evaluate_at(0, 1000);
2700 assert_eq!(st_before.alignment, Some(2));
2701 let st_mid = cue_anim.evaluate_at(750, 1000);
2703 assert_eq!(st_mid.alignment, Some(8));
2704 let st_end = cue_anim.evaluate_at(1000, 1000);
2705 assert_eq!(st_end.alignment, Some(8));
2706 }
2707
2708 #[test]
2709 fn an_later_overrides_earlier_legacy_a() {
2710 let v = parse_block(r"\a6\an1");
2712 assert_eq!(v.len(), 2);
2713 let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
2714 assert_eq!(st.alignment, Some(1));
2715 }
2716
2717 #[test]
2718 fn extract_an_from_cue_segment() {
2719 let cue = SubtitleCue {
2720 start_us: 0,
2721 end_us: 1_000_000,
2722 style_ref: None,
2723 positioning: None,
2724 segments: vec![
2725 Segment::Raw(r"{\an5}".into()),
2726 Segment::Text("centered".into()),
2727 ],
2728 };
2729 let anim = extract_cue_animation(&cue);
2730 assert_eq!(anim.tags, vec![AnimatedTag::An(5)]);
2731 let st = anim.evaluate_at(0, 1000);
2732 assert_eq!(st.alignment, Some(5));
2733 }
2734
2735 #[test]
2739 fn parses_k_family_kinds() {
2740 assert_eq!(
2742 parse_block(r"\k50"),
2743 vec![AnimatedTag::Karaoke {
2744 kind: KaraokeKind::Fill,
2745 cs: 50,
2746 }]
2747 );
2748 assert_eq!(
2749 parse_block(r"\kf30"),
2750 vec![AnimatedTag::Karaoke {
2751 kind: KaraokeKind::Sweep,
2752 cs: 30,
2753 }]
2754 );
2755 assert_eq!(
2756 parse_block(r"\ko20"),
2757 vec![AnimatedTag::Karaoke {
2758 kind: KaraokeKind::Outline,
2759 cs: 20,
2760 }]
2761 );
2762 }
2763
2764 #[test]
2765 fn capital_k_is_sweep_identical_to_kf() {
2766 let cap = parse_block(r"\K40");
2769 let kf = parse_block(r"\kf40");
2770 assert_eq!(
2771 cap,
2772 vec![AnimatedTag::Karaoke {
2773 kind: KaraokeKind::Sweep,
2774 cs: 40,
2775 }]
2776 );
2777 assert_eq!(cap, kf);
2778 }
2779
2780 #[test]
2781 fn k_negative_duration_clamps_to_zero() {
2782 assert_eq!(
2783 parse_block(r"\k-10"),
2784 vec![AnimatedTag::Karaoke {
2785 kind: KaraokeKind::Fill,
2786 cs: 0,
2787 }]
2788 );
2789 }
2790
2791 #[test]
2792 fn kt_is_not_handled() {
2793 assert!(parse_block(r"\kt100").is_empty());
2796 }
2797
2798 #[test]
2799 fn karaoke_spans_are_cumulative() {
2800 let v = parse_block(r"\k50\kf30");
2802 let anim = CueAnimation { tags: v };
2803 let spans = anim.karaoke_spans();
2804 assert_eq!(
2805 spans,
2806 vec![
2807 KaraokeSpan {
2808 kind: KaraokeKind::Fill,
2809 start_ms: 0,
2810 end_ms: 500,
2811 },
2812 KaraokeSpan {
2813 kind: KaraokeKind::Sweep,
2814 start_ms: 500,
2815 end_ms: 800,
2816 },
2817 ]
2818 );
2819 }
2820
2821 #[test]
2822 fn karaoke_span_progress() {
2823 let span = KaraokeSpan {
2824 kind: KaraokeKind::Sweep,
2825 start_ms: 500,
2826 end_ms: 800,
2827 };
2828 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); }
2834
2835 #[test]
2836 fn karaoke_zero_length_span_progress_is_one_past_start() {
2837 let span = KaraokeSpan {
2838 kind: KaraokeKind::Fill,
2839 start_ms: 100,
2840 end_ms: 100,
2841 };
2842 assert_eq!(span.progress(50), 0.0);
2843 assert_eq!(span.progress(150), 1.0);
2844 }
2845
2846 #[test]
2847 fn karaoke_is_noop_on_render_state() {
2848 let v = parse_block(r"\k50\kf30");
2851 let st = CueAnimation { tags: v }.evaluate_at(250, 1000);
2852 assert_eq!(st, RenderState::identity());
2853 }
2854
2855 #[test]
2856 fn extract_karaoke_from_cue_segments() {
2857 use crate::parse;
2862 let src = "[Script Info]\n\
2863ScriptType: v4.00+\n\
2864\n\
2865[V4+ Styles]\n\
2866Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n\
2867Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n\
2868\n\
2869[Events]\n\
2870Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
2871Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\k50}la{\\kf30}la\n";
2872 let t = parse(src.as_bytes()).unwrap();
2873 let anim = extract_cue_animation(&t.cues[0]);
2874 let spans = anim.karaoke_spans();
2875 assert_eq!(spans.len(), 2);
2876 assert_eq!(spans[0].start_ms, 0);
2877 assert_eq!(spans[0].end_ms, 500);
2878 assert_eq!(spans[1].start_ms, 500);
2879 assert_eq!(spans[1].end_ms, 800);
2880 }
2881}