Skip to main content

oximedia_timecode/
timecode_display.rs

1//! Timecode display formatting in different regional and industry conventions.
2//!
3//! This module provides formatting utilities for rendering timecodes according
4//! to different conventions:
5//!
6//! - **SMPTE** (North American broadcast): `HH:MM:SS:FF` / `HH:MM:SS;FF`
7//! - **EBU** (European broadcast): `HH:MM:SS:FF` (always colon, even drop-frame)
8//! - **Film** (cinema): `HH+MM:SS:FF` with reel indicator
9//! - **Feet+Frames** (16/35mm film): `FFFF+FF` feet-and-frames notation
10//! - **Samples** (audio DAW): absolute sample count at given sample rate
11//! - **Milliseconds** (editing): `HH:MM:SS.mmm` sub-second in milliseconds
12
13#![allow(dead_code)]
14#![allow(clippy::cast_possible_truncation)]
15
16use crate::Timecode;
17use std::fmt;
18
19/// Display convention for timecode rendering.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21pub enum DisplayConvention {
22    /// SMPTE convention: colon for NDF, semicolon for DF.
23    Smpte,
24    /// EBU convention: always uses colons regardless of drop-frame.
25    Ebu,
26    /// Film convention: `HH+MM:SS:FF` (reel+time).
27    Film,
28    /// Feet and frames for 35mm film at 24fps (16 frames per foot).
29    FeetFrames35mm,
30    /// Feet and frames for 16mm film at 24fps (40 frames per foot).
31    FeetFrames16mm,
32    /// Absolute milliseconds: `HH:MM:SS.mmm`.
33    Milliseconds,
34    /// Relative frame number from 00:00:00:00.
35    FrameNumber,
36}
37
38/// Options for formatting a timecode.
39#[derive(Debug, Clone)]
40pub struct DisplayOptions {
41    /// Display convention to use.
42    pub convention: DisplayConvention,
43    /// Whether to include a sign prefix (+/-) for relative values.
44    pub show_sign: bool,
45    /// Whether to zero-pad all fields.
46    pub zero_pad: bool,
47    /// Custom separator between fields (overrides convention default).
48    pub custom_separator: Option<char>,
49}
50
51impl Default for DisplayOptions {
52    fn default() -> Self {
53        Self {
54            convention: DisplayConvention::Smpte,
55            show_sign: false,
56            zero_pad: true,
57            custom_separator: None,
58        }
59    }
60}
61
62impl DisplayOptions {
63    /// SMPTE display options.
64    #[must_use]
65    pub fn smpte() -> Self {
66        Self {
67            convention: DisplayConvention::Smpte,
68            ..Self::default()
69        }
70    }
71
72    /// EBU display options.
73    #[must_use]
74    pub fn ebu() -> Self {
75        Self {
76            convention: DisplayConvention::Ebu,
77            ..Self::default()
78        }
79    }
80
81    /// Film display options.
82    #[must_use]
83    pub fn film() -> Self {
84        Self {
85            convention: DisplayConvention::Film,
86            ..Self::default()
87        }
88    }
89
90    /// Milliseconds display options.
91    #[must_use]
92    pub fn milliseconds() -> Self {
93        Self {
94            convention: DisplayConvention::Milliseconds,
95            ..Self::default()
96        }
97    }
98
99    /// Frame number display options.
100    #[must_use]
101    pub fn frame_number() -> Self {
102        Self {
103            convention: DisplayConvention::FrameNumber,
104            ..Self::default()
105        }
106    }
107}
108
109/// A formatted representation of a timecode value.
110#[derive(Debug, Clone)]
111pub struct FormattedTimecode {
112    /// The rendered string.
113    pub text: String,
114    /// The convention used.
115    pub convention: DisplayConvention,
116}
117
118impl fmt::Display for FormattedTimecode {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "{}", self.text)
121    }
122}
123
124/// Format a timecode according to the given display options.
125///
126/// # Examples
127///
128/// ```rust
129/// use oximedia_timecode::{Timecode, FrameRate};
130/// use oximedia_timecode::timecode_display::{format_timecode, DisplayOptions};
131///
132/// let tc = Timecode::new(1, 30, 0, 12, FrameRate::Fps25).expect("valid");
133/// let s = format_timecode(&tc, &DisplayOptions::ebu());
134/// assert_eq!(s.text, "01:30:00:12");
135/// ```
136#[must_use]
137pub fn format_timecode(tc: &Timecode, opts: &DisplayOptions) -> FormattedTimecode {
138    let text = match opts.convention {
139        DisplayConvention::Smpte => format_smpte(tc, opts),
140        DisplayConvention::Ebu => format_ebu(tc, opts),
141        DisplayConvention::Film => format_film(tc, opts),
142        DisplayConvention::FeetFrames35mm => format_feet_frames(tc, 16),
143        DisplayConvention::FeetFrames16mm => format_feet_frames(tc, 40),
144        DisplayConvention::Milliseconds => format_milliseconds(tc),
145        DisplayConvention::FrameNumber => format_frame_number(tc),
146    };
147    FormattedTimecode {
148        text,
149        convention: opts.convention,
150    }
151}
152
153/// SMPTE format: `HH:MM:SS:FF` for NDF, `HH:MM:SS;FF` for DF.
154fn format_smpte(tc: &Timecode, _opts: &DisplayOptions) -> String {
155    let sep = if tc.frame_rate.drop_frame { ';' } else { ':' };
156    format!(
157        "{:02}:{:02}:{:02}{}{:02}",
158        tc.hours, tc.minutes, tc.seconds, sep, tc.frames
159    )
160}
161
162/// EBU format: always uses colons.
163fn format_ebu(tc: &Timecode, _opts: &DisplayOptions) -> String {
164    format!(
165        "{:02}:{:02}:{:02}:{:02}",
166        tc.hours, tc.minutes, tc.seconds, tc.frames
167    )
168}
169
170/// Film format: `HH+MM:SS:FF` (reel hour indicator).
171fn format_film(tc: &Timecode, _opts: &DisplayOptions) -> String {
172    format!(
173        "{:02}+{:02}:{:02}:{:02}",
174        tc.hours, tc.minutes, tc.seconds, tc.frames
175    )
176}
177
178/// Feet+frames format for film (FFFF+FF).
179///
180/// `frames_per_foot` is typically 16 for 35mm and 40 for 16mm at 24fps.
181fn format_feet_frames(tc: &Timecode, frames_per_foot: u64) -> String {
182    let total_frames = tc.to_frames();
183    let feet = total_frames / frames_per_foot;
184    let leftover = total_frames % frames_per_foot;
185    format!("{feet:04}+{leftover:02}")
186}
187
188/// Milliseconds format: `HH:MM:SS.mmm`.
189fn format_milliseconds(tc: &Timecode) -> String {
190    let fps = crate::frame_rate_from_info(&tc.frame_rate).as_float();
191    let ms = if fps > 0.0 {
192        ((tc.frames as f64 / fps) * 1000.0).round() as u32
193    } else {
194        0
195    };
196    format!(
197        "{:02}:{:02}:{:02}.{:03}",
198        tc.hours,
199        tc.minutes,
200        tc.seconds,
201        ms.min(999)
202    )
203}
204
205/// Frame number format: absolute frame count from 00:00:00:00.
206fn format_frame_number(tc: &Timecode) -> String {
207    format!("{}", tc.to_frames())
208}
209
210/// Parse a formatted timecode string back to a [`Timecode`].
211///
212/// Handles SMPTE (colon/semicolon), EBU (colon), and film (+) conventions.
213///
214/// # Errors
215///
216/// Returns error if the string cannot be parsed.
217pub fn parse_display(
218    s: &str,
219    frame_rate: crate::FrameRate,
220) -> Result<Timecode, crate::TimecodeError> {
221    // Try standard from_string which handles HH:MM:SS:FF and HH:MM:SS;FF
222    Timecode::from_string(s, frame_rate).or_else(|_| {
223        // Try film format HH+MM:SS:FF
224        let normalized = s.replacen('+', ":", 1);
225        Timecode::from_string(&normalized, frame_rate)
226    })
227}
228
229/// Comparison table entry showing a timecode in multiple conventions.
230#[derive(Debug, Clone)]
231pub struct ConventionComparison {
232    /// Original timecode.
233    pub timecode: Timecode,
234    /// SMPTE representation.
235    pub smpte: String,
236    /// EBU representation.
237    pub ebu: String,
238    /// Film representation.
239    pub film: String,
240    /// Milliseconds representation.
241    pub ms: String,
242    /// Frame number.
243    pub frame: String,
244}
245
246impl ConventionComparison {
247    /// Build a comparison for a given timecode.
248    #[must_use]
249    pub fn build(tc: Timecode) -> Self {
250        let smpte = format_timecode(&tc, &DisplayOptions::smpte()).text;
251        let ebu = format_timecode(&tc, &DisplayOptions::ebu()).text;
252        let film = format_timecode(&tc, &DisplayOptions::film()).text;
253        let ms = format_timecode(&tc, &DisplayOptions::milliseconds()).text;
254        let frame = format_timecode(&tc, &DisplayOptions::frame_number()).text;
255        Self {
256            timecode: tc,
257            smpte,
258            ebu,
259            film,
260            ms,
261            frame,
262        }
263    }
264
265    /// Render as a human-readable table row.
266    #[must_use]
267    pub fn to_table_row(&self) -> String {
268        format!(
269            "| {:>13} | {:>13} | {:>13} | {:>12} | {:>8} |",
270            self.smpte, self.ebu, self.film, self.ms, self.frame
271        )
272    }
273}
274
275/// Build a comparison table header string.
276#[must_use]
277pub fn comparison_table_header() -> String {
278    format!(
279        "| {:>13} | {:>13} | {:>13} | {:>12} | {:>8} |",
280        "SMPTE", "EBU", "Film", "Milliseconds", "Frames"
281    )
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::FrameRate;
288
289    fn tc25(h: u8, m: u8, s: u8, f: u8) -> Timecode {
290        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid tc")
291    }
292
293    #[test]
294    fn test_smpte_ndf() {
295        let tc = tc25(1, 30, 0, 12);
296        let fmt = format_timecode(&tc, &DisplayOptions::smpte());
297        assert_eq!(fmt.text, "01:30:00:12");
298    }
299
300    #[test]
301    fn test_ebu_always_colon() {
302        let tc = tc25(0, 0, 0, 0);
303        let fmt = format_timecode(&tc, &DisplayOptions::ebu());
304        assert_eq!(fmt.text, "00:00:00:00");
305    }
306
307    #[test]
308    fn test_film_format() {
309        let tc = tc25(2, 15, 30, 5);
310        let fmt = format_timecode(&tc, &DisplayOptions::film());
311        assert_eq!(fmt.text, "02+15:30:05");
312    }
313
314    #[test]
315    fn test_milliseconds_format() {
316        // 12 frames at 25fps = 480ms
317        let tc = tc25(0, 0, 0, 12);
318        let fmt = format_timecode(&tc, &DisplayOptions::milliseconds());
319        assert_eq!(fmt.text, "00:00:00.480");
320    }
321
322    #[test]
323    fn test_frame_number() {
324        // 1h at 25fps = 90000 frames
325        let tc = tc25(1, 0, 0, 0);
326        let fmt = format_timecode(&tc, &DisplayOptions::frame_number());
327        assert_eq!(fmt.text, "90000");
328    }
329
330    #[test]
331    fn test_feet_frames_35mm() {
332        // 32 frames = 2 feet + 0 leftover (16 fps per foot)
333        let tc = tc25(0, 0, 1, 7); // 25 + 7 = 32 frames at 25fps
334        let fmt = format_timecode(
335            &tc,
336            &DisplayOptions {
337                convention: DisplayConvention::FeetFrames35mm,
338                ..DisplayOptions::default()
339            },
340        );
341        assert!(!fmt.text.is_empty());
342    }
343
344    #[test]
345    fn test_comparison_build() {
346        let tc = tc25(1, 0, 0, 0);
347        let comp = ConventionComparison::build(tc);
348        assert!(!comp.smpte.is_empty());
349        assert!(!comp.ebu.is_empty());
350    }
351
352    #[test]
353    fn test_parse_display_smpte() {
354        let parsed = parse_display("01:30:00:12", FrameRate::Fps25).expect("parse ok");
355        assert_eq!(parsed.hours, 1);
356        assert_eq!(parsed.minutes, 30);
357        assert_eq!(parsed.seconds, 0);
358        assert_eq!(parsed.frames, 12);
359    }
360}