Skip to main content

oximedia_timecode/
lib.rs

1//! OxiMedia Timecode - LTC and VITC reading and writing
2//!
3//! This crate provides SMPTE 12M compliant timecode reading and writing for:
4//! - LTC (Linear Timecode) - audio-based timecode
5//! - VITC (Vertical Interval Timecode) - video line-based timecode
6//!
7//! # Features
8//! - All standard frame rates (23.976, 24, 25, 29.97, 30, 47.952, 50, 59.94, 60, 120)
9//! - Drop frame and non-drop frame support
10//! - User bits encoding/decoding
11//! - Real-time capable
12//! - No unsafe code
13#![allow(
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::cast_sign_loss,
17    dead_code,
18    clippy::pedantic
19)]
20
21pub mod burn_in;
22pub mod continuity;
23pub mod drop_frame;
24pub mod duration;
25pub mod frame_offset;
26pub mod frame_rate;
27pub mod jam_sync;
28pub mod ltc;
29pub mod ltc_encoder;
30pub mod ltc_parser;
31pub mod midi_timecode;
32pub mod reader;
33pub mod sync;
34pub mod sync_map;
35pub mod tc_calculator;
36pub mod tc_compare;
37pub mod tc_convert;
38pub mod tc_drift;
39pub mod tc_interpolate;
40pub mod tc_math;
41pub mod tc_metadata;
42pub mod tc_offset_table;
43pub mod tc_range;
44pub mod tc_sequence;
45pub mod tc_smpte_ranges;
46pub mod tc_subtitle_sync;
47pub mod tc_validator;
48pub mod timecode_calculator;
49pub mod timecode_event;
50pub mod timecode_format;
51pub mod timecode_generator;
52pub mod timecode_range;
53pub mod vitc;
54
55use std::fmt;
56
57/// SMPTE timecode frame rates
58#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub enum FrameRate {
60    /// 23.976 fps (film transferred to NTSC, non-drop frame)
61    Fps23976,
62    /// 23.976 fps drop frame (drops 2 frames every 10 minutes)
63    Fps23976DF,
64    /// 24 fps (film)
65    Fps24,
66    /// 25 fps (PAL)
67    Fps25,
68    /// 29.97 fps (NTSC drop frame)
69    Fps2997DF,
70    /// 29.97 fps (NTSC non-drop frame)
71    Fps2997NDF,
72    /// 30 fps
73    Fps30,
74    /// 47.952 fps (cinema HFR, pulled-down from 48fps, non-drop frame)
75    Fps47952,
76    /// 47.952 fps drop frame (drops 4 frames every 10 minutes)
77    Fps47952DF,
78    /// 50 fps (PAL progressive)
79    Fps50,
80    /// 59.94 fps (NTSC progressive, non-drop frame)
81    Fps5994,
82    /// 59.94 fps drop frame (drops 4 frames every 10 minutes)
83    Fps5994DF,
84    /// 60 fps
85    Fps60,
86    /// 120 fps (high frame rate display / VR)
87    Fps120,
88}
89
90impl FrameRate {
91    /// Get the nominal frame rate as a float
92    pub fn as_float(&self) -> f64 {
93        match self {
94            FrameRate::Fps23976 | FrameRate::Fps23976DF => 24000.0 / 1001.0,
95            FrameRate::Fps24 => 24.0,
96            FrameRate::Fps25 => 25.0,
97            FrameRate::Fps2997DF | FrameRate::Fps2997NDF => 30000.0 / 1001.0,
98            FrameRate::Fps30 => 30.0,
99            FrameRate::Fps47952 | FrameRate::Fps47952DF => 48000.0 / 1001.0,
100            FrameRate::Fps50 => 50.0,
101            FrameRate::Fps5994 | FrameRate::Fps5994DF => 60000.0 / 1001.0,
102            FrameRate::Fps60 => 60.0,
103            FrameRate::Fps120 => 120.0,
104        }
105    }
106
107    /// Get the exact frame rate as a rational (numerator, denominator)
108    pub fn as_rational(&self) -> (u32, u32) {
109        match self {
110            FrameRate::Fps23976 | FrameRate::Fps23976DF => (24000, 1001),
111            FrameRate::Fps24 => (24, 1),
112            FrameRate::Fps25 => (25, 1),
113            FrameRate::Fps2997DF | FrameRate::Fps2997NDF => (30000, 1001),
114            FrameRate::Fps30 => (30, 1),
115            FrameRate::Fps47952 | FrameRate::Fps47952DF => (48000, 1001),
116            FrameRate::Fps50 => (50, 1),
117            FrameRate::Fps5994 | FrameRate::Fps5994DF => (60000, 1001),
118            FrameRate::Fps60 => (60, 1),
119            FrameRate::Fps120 => (120, 1),
120        }
121    }
122
123    /// Check if this is a drop frame rate
124    pub fn is_drop_frame(&self) -> bool {
125        matches!(
126            self,
127            FrameRate::Fps2997DF
128                | FrameRate::Fps23976DF
129                | FrameRate::Fps5994DF
130                | FrameRate::Fps47952DF
131        )
132    }
133
134    /// The number of frames dropped per discontinuity point (every non-10th minute boundary).
135    ///
136    /// For 29.97 DF: 2 frames dropped per minute.
137    /// For 23.976 DF: 2 frames dropped per minute (scaled from 29.97 × 24/30).
138    /// For 47.952 DF: 4 frames dropped per minute (scaled from 29.97 × 48/30).
139    /// For 59.94 DF: 4 frames dropped per minute (scaled from 29.97 × 60/30).
140    pub fn drop_frames_per_minute(&self) -> u64 {
141        match self {
142            FrameRate::Fps23976DF => 2,
143            FrameRate::Fps2997DF => 2,
144            FrameRate::Fps47952DF => 4,
145            FrameRate::Fps5994DF => 4,
146            _ => 0,
147        }
148    }
149
150    /// Get the number of frames per second (rounded)
151    pub fn frames_per_second(&self) -> u32 {
152        match self {
153            FrameRate::Fps23976 | FrameRate::Fps23976DF => 24,
154            FrameRate::Fps24 => 24,
155            FrameRate::Fps25 => 25,
156            FrameRate::Fps2997DF | FrameRate::Fps2997NDF => 30,
157            FrameRate::Fps30 => 30,
158            FrameRate::Fps47952 | FrameRate::Fps47952DF => 48,
159            FrameRate::Fps50 => 50,
160            FrameRate::Fps5994 | FrameRate::Fps5994DF => 60,
161            FrameRate::Fps60 => 60,
162            FrameRate::Fps120 => 120,
163        }
164    }
165}
166
167/// Frame rate information for timecode (embedded in Timecode struct)
168#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
169pub struct FrameRateInfo {
170    /// Frames per second (rounded)
171    pub fps: u8,
172    /// Drop frame flag
173    pub drop_frame: bool,
174}
175
176impl PartialEq for FrameRateInfo {
177    fn eq(&self, other: &Self) -> bool {
178        self.fps == other.fps && self.drop_frame == other.drop_frame
179    }
180}
181
182impl Eq for FrameRateInfo {}
183
184/// Reconstruct a [`FrameRate`] enum from a [`FrameRateInfo`] embedded in a [`Timecode`].
185///
186/// This is a best-effort reconstruction: it cannot distinguish e.g. `Fps23976` from `Fps24`
187/// (both have nominal fps=24) without the drop-frame flag, so it uses the drop-frame flag
188/// and nominal fps to select the most common matching variant.
189pub fn frame_rate_from_info(info: &FrameRateInfo) -> FrameRate {
190    match (info.fps, info.drop_frame) {
191        (24, true) => FrameRate::Fps23976DF,
192        (24, false) => FrameRate::Fps23976, // Conservative: assume pull-down variant
193        (25, _) => FrameRate::Fps25,
194        (30, true) => FrameRate::Fps2997DF,
195        (30, false) => FrameRate::Fps2997NDF,
196        (48, true) => FrameRate::Fps47952DF,
197        (48, false) => FrameRate::Fps47952,
198        (50, _) => FrameRate::Fps50,
199        (60, true) => FrameRate::Fps5994DF,
200        (60, false) => FrameRate::Fps5994,
201        (120, _) => FrameRate::Fps120,
202        _ => FrameRate::Fps25, // Fallback
203    }
204}
205
206/// SMPTE timecode structure
207///
208/// The `frame_count_cache` field stores the pre-computed total frame count
209/// from midnight, avoiding recomputation on repeated calls to `to_frames()`.
210/// It is excluded from equality comparison and serialization so it does not
211/// affect timecode identity or wire format.
212#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
213pub struct Timecode {
214    /// Hours (0-23)
215    pub hours: u8,
216    /// Minutes (0-59)
217    pub minutes: u8,
218    /// Seconds (0-59)
219    pub seconds: u8,
220    /// Frames (0 to frame_rate - 1)
221    pub frames: u8,
222    /// Frame rate
223    pub frame_rate: FrameRateInfo,
224    /// User bits (32 bits)
225    pub user_bits: u32,
226    /// Cached total frame count from midnight (computed at construction, excluded from Eq)
227    #[serde(skip)]
228    frame_count_cache: u64,
229}
230
231impl PartialEq for Timecode {
232    fn eq(&self, other: &Self) -> bool {
233        self.hours == other.hours
234            && self.minutes == other.minutes
235            && self.seconds == other.seconds
236            && self.frames == other.frames
237            && self.frame_rate == other.frame_rate
238            && self.user_bits == other.user_bits
239    }
240}
241
242impl Eq for Timecode {}
243
244impl PartialOrd for Timecode {
245    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
246        Some(self.cmp(other))
247    }
248}
249
250impl Ord for Timecode {
251    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
252        self.to_frames().cmp(&other.to_frames())
253    }
254}
255
256impl Timecode {
257    /// Compute total frames from midnight from the component fields.
258    /// This is the canonical calculation used by the constructor and cache.
259    fn compute_frames_from_fields(
260        hours: u8,
261        minutes: u8,
262        seconds: u8,
263        frames: u8,
264        fps: u64,
265        drop_frame: bool,
266    ) -> u64 {
267        let mut total = hours as u64 * 3600 * fps;
268        total += minutes as u64 * 60 * fps;
269        total += seconds as u64 * fps;
270        total += frames as u64;
271
272        if drop_frame {
273            let drop_per_min = if fps >= 60 { 4u64 } else { 2u64 };
274            let total_minutes = hours as u64 * 60 + minutes as u64;
275            let dropped_frames = drop_per_min * (total_minutes - total_minutes / 10);
276            total -= dropped_frames;
277        }
278
279        total
280    }
281
282    /// Create a new timecode
283    pub fn new(
284        hours: u8,
285        minutes: u8,
286        seconds: u8,
287        frames: u8,
288        frame_rate: FrameRate,
289    ) -> Result<Self, TimecodeError> {
290        let fps = frame_rate.frames_per_second() as u8;
291
292        if hours > 23 {
293            return Err(TimecodeError::InvalidHours);
294        }
295        if minutes > 59 {
296            return Err(TimecodeError::InvalidMinutes);
297        }
298        if seconds > 59 {
299            return Err(TimecodeError::InvalidSeconds);
300        }
301        if frames >= fps {
302            return Err(TimecodeError::InvalidFrames);
303        }
304
305        // Validate drop frame rules
306        if frame_rate.is_drop_frame() {
307            let drop_count = frame_rate.drop_frames_per_minute() as u8;
308            // drop_count frames are dropped at the start of each minute,
309            // except minutes 0, 10, 20, 30, 40, 50.
310            if seconds == 0 && frames < drop_count && !minutes.is_multiple_of(10) {
311                return Err(TimecodeError::InvalidDropFrame);
312            }
313        }
314
315        let drop_frame = frame_rate.is_drop_frame();
316        let frame_count_cache = Self::compute_frames_from_fields(
317            hours, minutes, seconds, frames, fps as u64, drop_frame,
318        );
319
320        Ok(Timecode {
321            hours,
322            minutes,
323            seconds,
324            frames,
325            frame_rate: FrameRateInfo { fps, drop_frame },
326            user_bits: 0,
327            frame_count_cache,
328        })
329    }
330
331    /// Parse a SMPTE timecode string.
332    ///
333    /// Accepts both "HH:MM:SS:FF" (non-drop frame, all colons) and
334    /// "HH:MM:SS;FF" (drop frame, semicolon before frames).
335    ///
336    /// The `frame_rate` parameter determines the frame rate; the separator
337    /// before the frame field determines whether drop-frame validation applies.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if the string format is invalid or component values
342    /// are out of range.
343    pub fn from_string(s: &str, frame_rate: FrameRate) -> Result<Self, TimecodeError> {
344        let s = s.trim();
345        // Minimum length: "00:00:00:00" = 11 chars
346        if s.len() < 11 {
347            return Err(TimecodeError::InvalidConfiguration);
348        }
349
350        // Split on colons and semicolons. Expect exactly 4 parts.
351        let parts: Vec<&str> = s.split([':', ';']).collect();
352        if parts.len() != 4 {
353            return Err(TimecodeError::InvalidConfiguration);
354        }
355
356        let hours: u8 = parts[0].parse().map_err(|_| TimecodeError::InvalidHours)?;
357        let minutes: u8 = parts[1]
358            .parse()
359            .map_err(|_| TimecodeError::InvalidMinutes)?;
360        let seconds: u8 = parts[2]
361            .parse()
362            .map_err(|_| TimecodeError::InvalidSeconds)?;
363        let frames: u8 = parts[3].parse().map_err(|_| TimecodeError::InvalidFrames)?;
364
365        Self::new(hours, minutes, seconds, frames, frame_rate)
366    }
367
368    /// Create a `Timecode` directly from raw fields without constructor validation.
369    ///
370    /// This is intended for internal use in parsers and codecs where the
371    /// component values have already been validated by the caller.
372    /// The `frame_count_cache` is computed automatically.
373    pub fn from_raw_fields(
374        hours: u8,
375        minutes: u8,
376        seconds: u8,
377        frames: u8,
378        fps: u8,
379        drop_frame: bool,
380        user_bits: u32,
381    ) -> Self {
382        let frame_count_cache = Self::compute_frames_from_fields(
383            hours, minutes, seconds, frames, fps as u64, drop_frame,
384        );
385        Self {
386            hours,
387            minutes,
388            seconds,
389            frames,
390            frame_rate: FrameRateInfo { fps, drop_frame },
391            user_bits,
392            frame_count_cache,
393        }
394    }
395
396    /// Create timecode with user bits
397    pub fn with_user_bits(mut self, user_bits: u32) -> Self {
398        self.user_bits = user_bits;
399        self
400    }
401
402    /// Convert to total frames since midnight.
403    ///
404    /// Returns the cached value computed at construction time — O(1).
405    #[inline]
406    pub fn to_frames(&self) -> u64 {
407        self.frame_count_cache
408    }
409
410    /// Convert this timecode to elapsed wall-clock seconds as f64.
411    ///
412    /// For pull-down rates (23.976, 29.97, 47.952, 59.94) the exact rational
413    /// frame rate is used so the result is frame-accurate.
414    #[allow(clippy::cast_precision_loss)]
415    pub fn to_seconds_f64(&self) -> f64 {
416        let rate = frame_rate_from_info(&self.frame_rate);
417        let (num, den) = rate.as_rational();
418        // Use the exact rational to avoid floating-point drift at pull-down rates.
419        self.frame_count_cache as f64 * den as f64 / num as f64
420    }
421
422    /// Create from total frames since midnight
423    pub fn from_frames(frames: u64, frame_rate: FrameRate) -> Result<Self, TimecodeError> {
424        let fps = frame_rate.frames_per_second() as u64;
425        let mut remaining = frames;
426
427        // Adjust for drop frame (generalised to support 2-frame and 4-frame drop rates)
428        if frame_rate.is_drop_frame() {
429            let drop_per_min = frame_rate.drop_frames_per_minute();
430            let frames_per_minute = fps * 60 - drop_per_min;
431            let frames_per_10_minutes = frames_per_minute * 9 + fps * 60;
432
433            let ten_minute_blocks = remaining / frames_per_10_minutes;
434            remaining += ten_minute_blocks * (drop_per_min * 9);
435
436            let remaining_in_block = remaining % frames_per_10_minutes;
437            if remaining_in_block >= fps * 60 {
438                let extra_minutes = (remaining_in_block - fps * 60) / frames_per_minute;
439                remaining += (extra_minutes + 1) * drop_per_min;
440            }
441        }
442
443        let hours = (remaining / (fps * 3600)) as u8;
444        remaining %= fps * 3600;
445        let minutes = (remaining / (fps * 60)) as u8;
446        remaining %= fps * 60;
447        let seconds = (remaining / fps) as u8;
448        let frame = (remaining % fps) as u8;
449
450        Self::new(hours, minutes, seconds, frame, frame_rate)
451    }
452
453    /// Increment by one frame
454    pub fn increment(&mut self) -> Result<(), TimecodeError> {
455        self.frames += 1;
456
457        if self.frames >= self.frame_rate.fps {
458            self.frames = 0;
459            self.seconds += 1;
460
461            if self.seconds >= 60 {
462                self.seconds = 0;
463                self.minutes += 1;
464
465                // Handle drop frame: skip frame numbers 0..drop_count at non-10th-minute boundaries
466                if self.frame_rate.drop_frame && !self.minutes.is_multiple_of(10) {
467                    let drop_count = if self.frame_rate.fps >= 60 { 4u8 } else { 2u8 };
468                    self.frames = drop_count;
469                }
470
471                if self.minutes >= 60 {
472                    self.minutes = 0;
473                    self.hours += 1;
474
475                    if self.hours >= 24 {
476                        self.hours = 0;
477                    }
478                }
479            }
480        }
481
482        // Recompute cache after mutation
483        self.frame_count_cache = Self::compute_frames_from_fields(
484            self.hours,
485            self.minutes,
486            self.seconds,
487            self.frames,
488            self.frame_rate.fps as u64,
489            self.frame_rate.drop_frame,
490        );
491
492        Ok(())
493    }
494
495    /// Decrement by one frame
496    pub fn decrement(&mut self) -> Result<(), TimecodeError> {
497        if self.frames > 0 {
498            self.frames -= 1;
499
500            // Check if we're in a drop frame position
501            let drop_count = if self.frame_rate.fps >= 60 { 4u8 } else { 2u8 };
502            if self.frame_rate.drop_frame
503                && self.seconds == 0
504                && self.frames < drop_count
505                && !self.minutes.is_multiple_of(10)
506            {
507                self.frames = self.frame_rate.fps - 1;
508                if self.seconds > 0 {
509                    self.seconds -= 1;
510                } else {
511                    self.seconds = 59;
512                    if self.minutes > 0 {
513                        self.minutes -= 1;
514                    } else {
515                        self.minutes = 59;
516                        if self.hours > 0 {
517                            self.hours -= 1;
518                        } else {
519                            self.hours = 23;
520                        }
521                    }
522                }
523            }
524        } else if self.seconds > 0 {
525            self.seconds -= 1;
526            self.frames = self.frame_rate.fps - 1;
527        } else {
528            self.seconds = 59;
529            self.frames = self.frame_rate.fps - 1;
530
531            if self.minutes > 0 {
532                self.minutes -= 1;
533            } else {
534                self.minutes = 59;
535                if self.hours > 0 {
536                    self.hours -= 1;
537                } else {
538                    self.hours = 23;
539                }
540            }
541        }
542
543        // Recompute cache after mutation
544        self.frame_count_cache = Self::compute_frames_from_fields(
545            self.hours,
546            self.minutes,
547            self.seconds,
548            self.frames,
549            self.frame_rate.fps as u64,
550            self.frame_rate.drop_frame,
551        );
552
553        Ok(())
554    }
555}
556
557// ---------------------------------------------------------------------------
558// Arithmetic operators
559// ---------------------------------------------------------------------------
560
561impl std::ops::Add for Timecode {
562    type Output = Result<Timecode, TimecodeError>;
563
564    /// Add two timecodes by summing their total frame counts.
565    ///
566    /// The result uses the frame rate of `self`. The frame counts wrap at a
567    /// 24-hour boundary.
568    fn add(self, rhs: Timecode) -> Self::Output {
569        let rate = frame_rate_from_info(&self.frame_rate);
570        let fps = self.frame_rate.fps as u64;
571        let frames_per_day = fps * 86_400;
572
573        let sum = if frames_per_day > 0 {
574            (self.frame_count_cache + rhs.frame_count_cache) % frames_per_day
575        } else {
576            self.frame_count_cache + rhs.frame_count_cache
577        };
578
579        Timecode::from_frames(sum, rate)
580    }
581}
582
583impl std::ops::Sub for Timecode {
584    type Output = Result<Timecode, TimecodeError>;
585
586    /// Subtract `rhs` from `self` by frame count.
587    ///
588    /// The result uses the frame rate of `self`. Underflow wraps at a
589    /// 24-hour boundary.
590    fn sub(self, rhs: Timecode) -> Self::Output {
591        let rate = frame_rate_from_info(&self.frame_rate);
592        let fps = self.frame_rate.fps as u64;
593        let frames_per_day = fps * 86_400;
594
595        let result = if frames_per_day > 0 {
596            if self.frame_count_cache >= rhs.frame_count_cache {
597                self.frame_count_cache - rhs.frame_count_cache
598            } else {
599                // Wrap: borrow one 24-hour day
600                frames_per_day - (rhs.frame_count_cache - self.frame_count_cache) % frames_per_day
601            }
602        } else {
603            self.frame_count_cache.saturating_sub(rhs.frame_count_cache)
604        };
605
606        Timecode::from_frames(result, rate)
607    }
608}
609
610impl std::ops::Add<u32> for Timecode {
611    type Output = Result<Timecode, TimecodeError>;
612
613    /// Add `rhs` frames to `self`, wrapping at a 24-hour boundary.
614    ///
615    /// The result uses the frame rate of `self`.
616    fn add(self, rhs: u32) -> Self::Output {
617        let rate = frame_rate_from_info(&self.frame_rate);
618        let fps = self.frame_rate.fps as u64;
619        let frames_per_day = fps * 86_400;
620
621        let sum = if frames_per_day > 0 {
622            (self.frame_count_cache + rhs as u64) % frames_per_day
623        } else {
624            self.frame_count_cache + rhs as u64
625        };
626
627        Timecode::from_frames(sum, rate)
628    }
629}
630
631// ---------------------------------------------------------------------------
632// Display
633// ---------------------------------------------------------------------------
634
635impl fmt::Display for Timecode {
636    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
637        let separator = if self.frame_rate.drop_frame { ';' } else { ':' };
638        write!(
639            f,
640            "{:02}:{:02}:{:02}{}{:02}",
641            self.hours, self.minutes, self.seconds, separator, self.frames
642        )
643    }
644}
645
646// ---------------------------------------------------------------------------
647// Traits
648// ---------------------------------------------------------------------------
649
650/// Timecode reader trait
651pub trait TimecodeReader {
652    /// Read the next timecode from the source
653    fn read_timecode(&mut self) -> Result<Option<Timecode>, TimecodeError>;
654
655    /// Get the current frame rate
656    fn frame_rate(&self) -> FrameRate;
657
658    /// Check if the reader is synchronized
659    fn is_synchronized(&self) -> bool;
660}
661
662/// Timecode writer trait
663pub trait TimecodeWriter {
664    /// Write a timecode to the output
665    fn write_timecode(&mut self, timecode: &Timecode) -> Result<(), TimecodeError>;
666
667    /// Get the current frame rate
668    fn frame_rate(&self) -> FrameRate;
669
670    /// Flush any buffered data
671    fn flush(&mut self) -> Result<(), TimecodeError>;
672}
673
674// ---------------------------------------------------------------------------
675// Error type
676// ---------------------------------------------------------------------------
677
678/// Timecode errors
679#[derive(Debug, Clone, PartialEq, Eq)]
680pub enum TimecodeError {
681    /// Invalid hours value
682    InvalidHours,
683    /// Invalid minutes value
684    InvalidMinutes,
685    /// Invalid seconds value
686    InvalidSeconds,
687    /// Invalid frames value
688    InvalidFrames,
689    /// Invalid drop frame timecode
690    InvalidDropFrame,
691    /// Sync word not found
692    SyncNotFound,
693    /// CRC error
694    CrcError,
695    /// Buffer too small
696    BufferTooSmall,
697    /// Invalid configuration
698    InvalidConfiguration,
699    /// IO error
700    IoError(String),
701    /// Not synchronized
702    NotSynchronized,
703}
704
705impl fmt::Display for TimecodeError {
706    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
707        match self {
708            TimecodeError::InvalidHours => write!(f, "Invalid hours value"),
709            TimecodeError::InvalidMinutes => write!(f, "Invalid minutes value"),
710            TimecodeError::InvalidSeconds => write!(f, "Invalid seconds value"),
711            TimecodeError::InvalidFrames => write!(f, "Invalid frames value"),
712            TimecodeError::InvalidDropFrame => write!(f, "Invalid drop frame timecode"),
713            TimecodeError::SyncNotFound => write!(f, "Sync word not found"),
714            TimecodeError::CrcError => write!(f, "CRC error"),
715            TimecodeError::BufferTooSmall => write!(f, "Buffer too small"),
716            TimecodeError::InvalidConfiguration => write!(f, "Invalid configuration"),
717            TimecodeError::IoError(e) => write!(f, "IO error: {}", e),
718            TimecodeError::NotSynchronized => write!(f, "Not synchronized"),
719        }
720    }
721}
722
723impl std::error::Error for TimecodeError {}
724
725// ---------------------------------------------------------------------------
726// Tests
727// ---------------------------------------------------------------------------
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn test_timecode_creation() {
735        let tc = Timecode::new(1, 2, 3, 4, FrameRate::Fps25).expect("valid timecode");
736        assert_eq!(tc.hours, 1);
737        assert_eq!(tc.minutes, 2);
738        assert_eq!(tc.seconds, 3);
739        assert_eq!(tc.frames, 4);
740    }
741
742    #[test]
743    fn test_timecode_display() {
744        let tc = Timecode::new(1, 2, 3, 4, FrameRate::Fps25).expect("valid timecode");
745        assert_eq!(tc.to_string(), "01:02:03:04");
746
747        let tc_df = Timecode::new(1, 2, 3, 4, FrameRate::Fps2997DF).expect("valid timecode");
748        assert_eq!(tc_df.to_string(), "01:02:03;04");
749    }
750
751    #[test]
752    fn test_timecode_increment() {
753        let mut tc = Timecode::new(0, 0, 0, 24, FrameRate::Fps25).expect("valid timecode");
754        tc.increment().expect("increment should succeed");
755        assert_eq!(tc.frames, 0);
756        assert_eq!(tc.seconds, 1);
757    }
758
759    #[test]
760    fn test_frame_rate() {
761        assert_eq!(FrameRate::Fps25.as_float(), 25.0);
762        assert!((FrameRate::Fps2997DF.as_float() - 29.97002997).abs() < 1e-6);
763        assert!(FrameRate::Fps2997DF.is_drop_frame());
764        assert!(!FrameRate::Fps2997NDF.is_drop_frame());
765    }
766
767    #[test]
768    fn test_framerate_47952_and_120() {
769        assert_eq!(FrameRate::Fps47952.frames_per_second(), 48);
770        assert_eq!(FrameRate::Fps47952DF.frames_per_second(), 48);
771        assert_eq!(FrameRate::Fps120.frames_per_second(), 120);
772        assert!(!FrameRate::Fps47952.is_drop_frame());
773        assert!(FrameRate::Fps47952DF.is_drop_frame());
774        assert!(!FrameRate::Fps120.is_drop_frame());
775        assert_eq!(FrameRate::Fps47952.as_rational(), (48000, 1001));
776        assert_eq!(FrameRate::Fps120.as_rational(), (120, 1));
777    }
778
779    #[test]
780    fn test_from_string_ndf() {
781        let tc = Timecode::from_string("01:02:03:04", FrameRate::Fps25).expect("should parse");
782        assert_eq!(tc.hours, 1);
783        assert_eq!(tc.minutes, 2);
784        assert_eq!(tc.seconds, 3);
785        assert_eq!(tc.frames, 4);
786    }
787
788    #[test]
789    fn test_from_string_df() {
790        // Drop frame: semicolon before frames
791        let tc = Timecode::from_string("01:02:03;04", FrameRate::Fps2997DF).expect("should parse");
792        assert_eq!(tc.frames, 4);
793        assert!(tc.frame_rate.drop_frame);
794    }
795
796    #[test]
797    fn test_from_string_invalid_too_short() {
798        assert!(Timecode::from_string("1:2:3:4", FrameRate::Fps25).is_err());
799    }
800
801    #[test]
802    fn test_from_string_invalid_parts() {
803        assert!(Timecode::from_string("01:02:03", FrameRate::Fps25).is_err());
804    }
805
806    #[test]
807    fn test_to_seconds_f64_one_hour_25fps() {
808        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid");
809        let secs = tc.to_seconds_f64();
810        assert!((secs - 3600.0).abs() < 1e-6);
811    }
812
813    #[test]
814    fn test_to_seconds_f64_pull_down() {
815        // 1 frame at 29.97 NDF = 1001/30000 seconds
816        let tc = Timecode::new(0, 0, 0, 1, FrameRate::Fps2997NDF).expect("valid");
817        let expected = 1001.0 / 30000.0;
818        assert!((tc.to_seconds_f64() - expected).abs() < 1e-12);
819    }
820
821    #[test]
822    fn test_ord_timecodes() {
823        let tc1 = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
824        let tc2 = Timecode::new(0, 0, 0, 1, FrameRate::Fps25).expect("valid");
825        let tc3 = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid");
826        assert!(tc1 < tc2);
827        assert!(tc2 < tc3);
828        assert!(tc1 < tc3);
829        assert_eq!(tc1, tc1);
830    }
831
832    #[test]
833    fn test_add_timecodes() {
834        let tc1 = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid"); // 1s
835        let tc2 = Timecode::new(0, 0, 2, 0, FrameRate::Fps25).expect("valid"); // 2s
836        let result = (tc1 + tc2).expect("add should succeed");
837        assert_eq!(result.seconds, 3);
838        assert_eq!(result.frames, 0);
839    }
840
841    #[test]
842    fn test_sub_timecodes() {
843        let tc1 = Timecode::new(0, 0, 3, 0, FrameRate::Fps25).expect("valid"); // 3s
844        let tc2 = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid"); // 1s
845        let result = (tc1 - tc2).expect("sub should succeed");
846        assert_eq!(result.seconds, 2);
847        assert_eq!(result.frames, 0);
848    }
849
850    #[test]
851    fn test_add_u32_frames() {
852        // 0:00:00:00 + 25 frames = 0:00:01:00 at 25fps
853        let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
854        let result = (tc + 25_u32).expect("add u32 should succeed");
855        assert_eq!(result.seconds, 1);
856        assert_eq!(result.frames, 0);
857
858        // 23:59:59:24 + 1 frame wraps to 0:00:00:00
859        let tc_near_end = Timecode::new(23, 59, 59, 24, FrameRate::Fps25).expect("valid");
860        let wrapped = (tc_near_end + 1_u32).expect("wrap should succeed");
861        assert_eq!(wrapped.hours, 0);
862        assert_eq!(wrapped.minutes, 0);
863        assert_eq!(wrapped.seconds, 0);
864        assert_eq!(wrapped.frames, 0);
865    }
866
867    #[test]
868    fn test_frame_count_cache_matches_recomputed() {
869        let tc = Timecode::new(1, 23, 45, 12, FrameRate::Fps25).expect("valid");
870        let expected: u64 = 1 * 3600 * 25 + 23 * 60 * 25 + 45 * 25 + 12;
871        assert_eq!(tc.to_frames(), expected);
872    }
873
874    #[test]
875    fn test_frame_count_cache_after_increment() {
876        let mut tc = Timecode::new(0, 0, 0, 24, FrameRate::Fps25).expect("valid");
877        let before = tc.to_frames();
878        tc.increment().expect("ok");
879        assert_eq!(tc.to_frames(), before + 1);
880    }
881
882    #[test]
883    fn test_frame_rate_from_info() {
884        let info = FrameRateInfo {
885            fps: 25,
886            drop_frame: false,
887        };
888        assert_eq!(frame_rate_from_info(&info), FrameRate::Fps25);
889
890        let info_df = FrameRateInfo {
891            fps: 30,
892            drop_frame: true,
893        };
894        assert_eq!(frame_rate_from_info(&info_df), FrameRate::Fps2997DF);
895
896        let info_120 = FrameRateInfo {
897            fps: 120,
898            drop_frame: false,
899        };
900        assert_eq!(frame_rate_from_info(&info_120), FrameRate::Fps120);
901    }
902}