1use std::collections::HashMap;
13use std::fmt;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
21pub enum FrameRate {
22 F23_976,
24 F24,
26 F25,
28 F29_97,
30 F30,
32 F48,
34 F50,
36 F60,
38}
39
40impl FrameRate {
41 #[must_use]
43 pub fn frames_per_second_int(self) -> u32 {
44 match self {
45 Self::F23_976 => 24,
46 Self::F24 => 24,
47 Self::F25 => 25,
48 Self::F29_97 => 30,
49 Self::F30 => 30,
50 Self::F48 => 48,
51 Self::F50 => 50,
52 Self::F60 => 60,
53 }
54 }
55
56 #[must_use]
58 pub fn as_f64(self) -> f64 {
59 match self {
60 Self::F23_976 => 24_000.0 / 1_001.0,
61 Self::F24 => 24.0,
62 Self::F25 => 25.0,
63 Self::F29_97 => 30_000.0 / 1_001.0,
64 Self::F30 => 30.0,
65 Self::F48 => 48.0,
66 Self::F50 => 50.0,
67 Self::F60 => 60.0,
68 }
69 }
70
71 #[must_use]
73 pub fn supports_drop_frame(self) -> bool {
74 matches!(self, Self::F29_97 | Self::F23_976)
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
87pub struct Timecode {
88 hours: u8,
89 minutes: u8,
90 seconds: u8,
91 frames: u8,
92 rate: FrameRate,
93 drop_frame: bool,
94}
95
96#[derive(Debug, Clone, PartialEq, thiserror::Error)]
98pub enum TimecodeError {
99 #[error("Invalid timecode component: {0}")]
100 InvalidComponent(String),
101 #[error("Frame number {0} out of range for rate {1} fps")]
102 FrameOutOfRange(u8, u32),
103 #[error("Drop-frame timecode requires a drop-frame-compatible rate")]
104 DropFrameRateMismatch,
105 #[error("Failed to parse timecode string: {0}")]
106 ParseError(String),
107}
108
109impl Timecode {
110 pub fn new(
115 hours: u8,
116 minutes: u8,
117 seconds: u8,
118 frames: u8,
119 rate: FrameRate,
120 drop_frame: bool,
121 ) -> Result<Self, TimecodeError> {
122 if drop_frame && !rate.supports_drop_frame() {
123 return Err(TimecodeError::DropFrameRateMismatch);
124 }
125 if hours > 23 {
126 return Err(TimecodeError::InvalidComponent(format!(
127 "hours={hours} > 23"
128 )));
129 }
130 if minutes > 59 {
131 return Err(TimecodeError::InvalidComponent(format!(
132 "minutes={minutes} > 59"
133 )));
134 }
135 if seconds > 59 {
136 return Err(TimecodeError::InvalidComponent(format!(
137 "seconds={seconds} > 59"
138 )));
139 }
140 let max_frames = rate.frames_per_second_int() as u8;
141 if frames >= max_frames {
142 return Err(TimecodeError::FrameOutOfRange(frames, max_frames as u32));
143 }
144 if drop_frame && seconds == 0 && (minutes % 10) != 0 && frames < 2 {
147 return Err(TimecodeError::InvalidComponent(format!(
148 "drop-frame: frames {frames} is a dropped frame at mm={minutes} ss=00"
149 )));
150 }
151 Ok(Self {
152 hours,
153 minutes,
154 seconds,
155 frames,
156 rate,
157 drop_frame,
158 })
159 }
160
161 #[must_use]
163 pub fn hours(&self) -> u8 {
164 self.hours
165 }
166
167 #[must_use]
169 pub fn minutes(&self) -> u8 {
170 self.minutes
171 }
172
173 #[must_use]
175 pub fn seconds(&self) -> u8 {
176 self.seconds
177 }
178
179 #[must_use]
181 pub fn frames(&self) -> u8 {
182 self.frames
183 }
184
185 #[must_use]
187 pub fn rate(&self) -> FrameRate {
188 self.rate
189 }
190
191 #[must_use]
193 pub fn is_drop_frame(&self) -> bool {
194 self.drop_frame
195 }
196
197 #[must_use]
205 #[allow(clippy::cast_possible_truncation)]
206 pub fn to_frame_count(self) -> u64 {
207 let fps = self.rate.frames_per_second_int() as u64;
208 let h = self.hours as u64;
209 let m = self.minutes as u64;
210 let s = self.seconds as u64;
211 let f = self.frames as u64;
212
213 if self.drop_frame {
214 let d = 2u64; let total_minutes = 60 * h + m;
218 let drop_frames = d * (total_minutes - total_minutes / 10);
219 fps * 3600 * h + fps * 60 * m + fps * s + f - drop_frames
220 } else {
221 fps * 3600 * h + fps * 60 * m + fps * s + f
222 }
223 }
224
225 #[allow(clippy::cast_possible_truncation)]
230 pub fn from_frame_count(
231 mut total: u64,
232 rate: FrameRate,
233 drop_frame: bool,
234 ) -> Result<Self, TimecodeError> {
235 if drop_frame && !rate.supports_drop_frame() {
236 return Err(TimecodeError::DropFrameRateMismatch);
237 }
238 let fps = rate.frames_per_second_int() as u64;
239
240 if drop_frame {
241 let d = 2u64;
243 let frames_per_10min = fps * 600 - 9 * d; let frames_per_1min = fps * 60 - d;
245
246 let ten_min_blocks = total / frames_per_10min;
247 let remaining = total % frames_per_10min;
248
249 let extra_1min = if remaining < fps * 60 {
250 0u64
251 } else {
252 ((remaining - fps * 60) / frames_per_1min + 1).min(9)
253 };
254
255 total += d * (9 * ten_min_blocks + extra_1min);
256 }
257
258 let frames = (total % fps) as u8;
259 let total_secs = total / fps;
260 let seconds = (total_secs % 60) as u8;
261 let total_mins = total_secs / 60;
262 let minutes = (total_mins % 60) as u8;
263 let hours = (total_mins / 60) as u8;
264
265 Self::new(hours, minutes, seconds, frames, rate, drop_frame)
266 }
267
268 #[must_use]
270 pub fn to_secs_f64(self) -> f64 {
271 self.to_frame_count() as f64 / self.rate.as_f64()
272 }
273
274 pub fn from_secs_f64(
279 secs: f64,
280 rate: FrameRate,
281 drop_frame: bool,
282 ) -> Result<Self, TimecodeError> {
283 let total = (secs * rate.as_f64()).round() as u64;
284 Self::from_frame_count(total, rate, drop_frame)
285 }
286
287 pub fn add_frames(self, frames: i64) -> Result<Self, TimecodeError> {
296 let current = self.to_frame_count() as i64;
297 let next = (current + frames).max(0) as u64;
298 Self::from_frame_count(next, self.rate, self.drop_frame)
299 }
300
301 #[must_use]
305 pub fn diff_frames(self, other: Self) -> Option<i64> {
306 if self.rate != other.rate || self.drop_frame != other.drop_frame {
307 return None;
308 }
309 Some(self.to_frame_count() as i64 - other.to_frame_count() as i64)
310 }
311
312 pub fn parse(s: &str, rate: FrameRate) -> Result<Self, TimecodeError> {
323 let s = s.trim();
324 let drop_frame = s.contains(';');
326 let normalised: String = s.replace(';', ":");
327 let parts: Vec<&str> = normalised.split(':').collect();
328 if parts.len() != 4 {
329 return Err(TimecodeError::ParseError(format!(
330 "expected HH:MM:SS:FF, got `{s}`"
331 )));
332 }
333 let parse_u8 = |p: &str| -> Result<u8, TimecodeError> {
334 p.parse::<u8>()
335 .map_err(|_| TimecodeError::ParseError(format!("cannot parse `{p}` as u8")))
336 };
337 let hh = parse_u8(parts[0])?;
338 let mm = parse_u8(parts[1])?;
339 let ss = parse_u8(parts[2])?;
340 let ff = parse_u8(parts[3])?;
341 Self::new(hh, mm, ss, ff, rate, drop_frame)
342 }
343}
344
345impl fmt::Display for Timecode {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 let sep = if self.drop_frame { ';' } else { ':' };
348 write!(
349 f,
350 "{:02}:{:02}:{:02}{}{:02}",
351 self.hours, self.minutes, self.seconds, sep, self.frames
352 )
353 }
354}
355
356pub type TriggerId = u64;
362
363#[derive(Debug, Clone)]
365pub struct FiredTriggers {
366 pub ids: Vec<TriggerId>,
368}
369
370#[derive(Debug, Clone)]
372struct TriggerEntry {
373 id: TriggerId,
374 target_frame: u64,
375 label: String,
376 one_shot: bool,
377 fired: bool,
378}
379
380pub struct TimecodeScheduler {
385 rate: FrameRate,
386 drop_frame: bool,
387 current_frame: u64,
388 triggers: HashMap<TriggerId, TriggerEntry>,
389 next_id: TriggerId,
390}
391
392impl TimecodeScheduler {
393 #[must_use]
395 pub fn new(rate: FrameRate, drop_frame: bool) -> Self {
396 Self {
397 rate,
398 drop_frame,
399 current_frame: 0,
400 triggers: HashMap::new(),
401 next_id: 1,
402 }
403 }
404
405 #[must_use]
409 pub fn current_timecode(&self) -> Option<Timecode> {
410 Timecode::from_frame_count(self.current_frame, self.rate, self.drop_frame).ok()
411 }
412
413 pub fn seek(&mut self, tc: Timecode) {
415 self.current_frame = tc.to_frame_count();
416 }
417
418 pub fn advance_frames(&mut self, frames: u64) -> FiredTriggers {
420 let start = self.current_frame;
421 self.current_frame += frames;
422 let end = self.current_frame;
423
424 let mut fired_ids = Vec::new();
425 for entry in self.triggers.values_mut() {
426 if entry.fired && entry.one_shot {
427 continue;
428 }
429 if entry.target_frame > start && entry.target_frame <= end {
430 fired_ids.push(entry.id);
431 if entry.one_shot {
432 entry.fired = true;
433 }
434 }
435 }
436 FiredTriggers { ids: fired_ids }
437 }
438
439 pub fn register_trigger(
443 &mut self,
444 target: Timecode,
445 label: impl Into<String>,
446 one_shot: bool,
447 ) -> TriggerId {
448 let id = self.next_id;
449 self.next_id += 1;
450 self.triggers.insert(
451 id,
452 TriggerEntry {
453 id,
454 target_frame: target.to_frame_count(),
455 label: label.into(),
456 one_shot,
457 fired: false,
458 },
459 );
460 id
461 }
462
463 pub fn unregister_trigger(&mut self, id: TriggerId) {
465 self.triggers.remove(&id);
466 }
467
468 #[must_use]
470 pub fn trigger_label(&self, id: TriggerId) -> Option<&str> {
471 self.triggers.get(&id).map(|e| e.label.as_str())
472 }
473
474 #[must_use]
476 pub fn trigger_count(&self) -> usize {
477 self.triggers.len()
478 }
479
480 pub fn reset(&mut self) {
482 self.current_frame = 0;
483 for e in self.triggers.values_mut() {
484 e.fired = false;
485 }
486 }
487}
488
489pub struct LtcDecoder {
501 bit_buf: Vec<bool>,
503 frames_decoded: u64,
505}
506
507const LTC_SYNC_WORD: u16 = 0x3FFD;
510
511impl LtcDecoder {
512 #[must_use]
514 pub fn new() -> Self {
515 Self {
516 bit_buf: Vec::with_capacity(160),
517 frames_decoded: 0,
518 }
519 }
520
521 pub fn feed(&mut self, bits: &[bool]) -> usize {
525 let mut count = 0;
526 for &bit in bits {
527 self.bit_buf.push(bit);
528 if self.bit_buf.len() >= 80 {
529 let sync_start = self.bit_buf.len() - 16;
531 let detected = self.check_sync_at(sync_start);
532 if detected {
533 count += 1;
534 self.frames_decoded += 1;
535 let drain_to = self.bit_buf.len().saturating_sub(80);
537 self.bit_buf.drain(..drain_to);
538 if self.bit_buf.len() > 160 {
540 let excess = self.bit_buf.len() - 160;
541 self.bit_buf.drain(..excess);
542 }
543 }
544 }
545 }
546 count
547 }
548
549 fn check_sync_at(&self, pos: usize) -> bool {
551 if pos + 16 > self.bit_buf.len() {
552 return false;
553 }
554 let mut word: u16 = 0;
555 for i in 0..16 {
556 if self.bit_buf[pos + i] {
557 word |= 1 << i;
558 }
559 }
560 word == LTC_SYNC_WORD
561 }
562
563 #[must_use]
565 pub fn frames_decoded(&self) -> u64 {
566 self.frames_decoded
567 }
568
569 pub fn reset(&mut self) {
571 self.bit_buf.clear();
572 self.frames_decoded = 0;
573 }
574}
575
576impl Default for LtcDecoder {
577 fn default() -> Self {
578 Self::new()
579 }
580}
581
582#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
593 fn test_frame_rate_int() {
594 assert_eq!(FrameRate::F29_97.frames_per_second_int(), 30);
595 assert_eq!(FrameRate::F25.frames_per_second_int(), 25);
596 assert_eq!(FrameRate::F60.frames_per_second_int(), 60);
597 }
598
599 #[test]
600 fn test_frame_rate_f64_29_97() {
601 let fps = FrameRate::F29_97.as_f64();
602 assert!((fps - 29.97002997).abs() < 1e-6, "fps={fps}");
603 }
604
605 #[test]
608 fn test_ndf_to_frame_count_basic() {
609 let tc = Timecode::new(0, 1, 0, 0, FrameRate::F25, false).expect("valid");
610 assert_eq!(tc.to_frame_count(), 25 * 60);
611 }
612
613 #[test]
614 fn test_ndf_round_trip() {
615 let tc = Timecode::new(1, 23, 45, 12, FrameRate::F30, false).expect("valid");
616 let frames = tc.to_frame_count();
617 let restored = Timecode::from_frame_count(frames, FrameRate::F30, false).expect("restore");
618 assert_eq!(tc, restored);
619 }
620
621 #[test]
622 fn test_ndf_to_secs_f64() {
623 let tc = Timecode::new(0, 0, 1, 0, FrameRate::F25, false).expect("valid");
624 assert!((tc.to_secs_f64() - 1.0).abs() < 1e-9);
625 }
626
627 #[test]
630 fn test_df_frame_count_at_one_minute() {
631 let tc = Timecode::new(0, 1, 0, 2, FrameRate::F29_97, true).expect("valid");
637 let frames = tc.to_frame_count();
638 let restored =
639 Timecode::from_frame_count(frames, FrameRate::F29_97, true).expect("restore");
640 assert_eq!(tc, restored, "DF round-trip at 00:01:00;02 failed");
641 let ndf = Timecode::new(0, 1, 0, 2, FrameRate::F29_97, false).expect("ndf");
644 assert_eq!(ndf.to_frame_count(), 1802, "NDF check");
645 assert_eq!(frames, 1800, "DF frame count at 00:01:00;02");
646 }
647
648 #[test]
649 fn test_df_round_trip() {
650 let tc = Timecode::new(0, 5, 30, 15, FrameRate::F29_97, true).expect("valid");
651 let frames = tc.to_frame_count();
652 let restored =
653 Timecode::from_frame_count(frames, FrameRate::F29_97, true).expect("restore");
654 assert_eq!(tc, restored, "DF round-trip failed");
655 }
656
657 #[test]
658 fn test_df_invalid_dropped_frame() {
659 assert!(Timecode::new(0, 1, 0, 0, FrameRate::F29_97, true).is_err());
661 assert!(Timecode::new(0, 1, 0, 1, FrameRate::F29_97, true).is_err());
662 }
663
664 #[test]
667 fn test_parse_ndf_string() {
668 let tc = Timecode::parse("01:23:45:06", FrameRate::F25).expect("parse");
669 assert_eq!(tc.hours(), 1);
670 assert_eq!(tc.minutes(), 23);
671 assert_eq!(tc.seconds(), 45);
672 assert_eq!(tc.frames(), 6);
673 assert!(!tc.is_drop_frame());
674 }
675
676 #[test]
677 fn test_parse_df_string() {
678 let tc = Timecode::parse("00:10:00;02", FrameRate::F29_97).expect("parse DF");
679 assert!(tc.is_drop_frame());
680 assert_eq!(tc.frames(), 2);
681 }
682
683 #[test]
684 fn test_display_ndf() {
685 let tc = Timecode::new(1, 2, 3, 4, FrameRate::F25, false).expect("valid");
686 assert_eq!(tc.to_string(), "01:02:03:04");
687 }
688
689 #[test]
690 fn test_display_df() {
691 let tc = Timecode::new(0, 10, 0, 2, FrameRate::F29_97, true).expect("valid");
692 assert!(tc.to_string().contains(';'), "DF separator missing");
693 }
694
695 #[test]
698 fn test_add_frames() {
699 let tc = Timecode::new(0, 0, 0, 0, FrameRate::F25, false).expect("valid");
700 let next = tc.add_frames(25).expect("add");
701 assert_eq!(next.seconds(), 1);
702 assert_eq!(next.frames(), 0);
703 }
704
705 #[test]
706 fn test_diff_frames() {
707 let a = Timecode::new(0, 0, 1, 0, FrameRate::F25, false).expect("valid");
708 let b = Timecode::new(0, 0, 0, 0, FrameRate::F25, false).expect("valid");
709 assert_eq!(a.diff_frames(b), Some(25));
710 }
711
712 #[test]
715 fn test_scheduler_trigger_fires() {
716 let mut sched = TimecodeScheduler::new(FrameRate::F25, false);
717 let target = Timecode::new(0, 0, 2, 0, FrameRate::F25, false).expect("valid");
718 let id = sched.register_trigger(target, "mark_in", true);
719 let fired = sched.advance_frames(30);
721 assert!(fired.ids.is_empty());
722 let fired = sched.advance_frames(25);
724 assert!(fired.ids.contains(&id));
725 }
726
727 #[test]
728 fn test_scheduler_one_shot_fires_once() {
729 let mut sched = TimecodeScheduler::new(FrameRate::F25, false);
730 let target = Timecode::new(0, 0, 0, 5, FrameRate::F25, false).expect("valid");
731 let id = sched.register_trigger(target, "once", true);
732 sched.advance_frames(6);
733 sched.reset();
734 let _fired_1 = sched.advance_frames(6);
736 sched.unregister_trigger(id);
738 assert_eq!(sched.trigger_count(), 0);
739 }
740
741 #[test]
744 fn test_ltc_sync_word_detection() {
745 let mut decoder = LtcDecoder::new();
746 let mut bits = vec![false; 64];
749 let sync: u16 = LTC_SYNC_WORD;
751 for i in 0..16 {
752 bits.push((sync >> i) & 1 == 1);
753 }
754 let count = decoder.feed(&bits);
755 assert_eq!(count, 1, "should detect exactly 1 frame boundary");
756 assert_eq!(decoder.frames_decoded(), 1);
757 }
758
759 #[test]
760 fn test_ltc_no_false_positives_on_zeros() {
761 let mut decoder = LtcDecoder::new();
762 let bits = vec![false; 100];
763 let count = decoder.feed(&bits);
764 assert_eq!(count, 0);
765 }
766}