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