Skip to main content

oximedia_conform/
types.rs

1//! Common types for oximedia-conform.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Frame rate representation.
7#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
8pub enum FrameRate {
9    /// 23.976 fps (24000/1001).
10    Fps23976,
11    /// 24 fps.
12    Fps24,
13    /// 25 fps (PAL).
14    Fps25,
15    /// 29.97 fps (30000/1001) drop-frame.
16    Fps2997DF,
17    /// 29.97 fps (30000/1001) non-drop-frame.
18    Fps2997NDF,
19    /// 30 fps.
20    Fps30,
21    /// 50 fps.
22    Fps50,
23    /// 59.94 fps (60000/1001).
24    Fps5994,
25    /// 60 fps.
26    Fps60,
27    /// Custom frame rate.
28    Custom(f64),
29}
30
31impl FrameRate {
32    /// Get the frame rate as a floating-point number.
33    #[must_use]
34    pub fn as_f64(self) -> f64 {
35        match self {
36            Self::Fps23976 => 24000.0 / 1001.0,
37            Self::Fps24 => 24.0,
38            Self::Fps25 => 25.0,
39            Self::Fps2997DF | Self::Fps2997NDF => 30000.0 / 1001.0,
40            Self::Fps30 => 30.0,
41            Self::Fps50 => 50.0,
42            Self::Fps5994 => 60000.0 / 1001.0,
43            Self::Fps60 => 60.0,
44            Self::Custom(fps) => fps,
45        }
46    }
47
48    /// Get the frame rate as a rational (numerator, denominator).
49    #[must_use]
50    pub const fn as_rational(self) -> (u32, u32) {
51        match self {
52            Self::Fps23976 => (24000, 1001),
53            Self::Fps24 => (24, 1),
54            Self::Fps25 => (25, 1),
55            Self::Fps2997DF | Self::Fps2997NDF => (30000, 1001),
56            Self::Fps30 => (30, 1),
57            Self::Fps50 => (50, 1),
58            Self::Fps5994 => (60000, 1001),
59            Self::Fps60 => (60, 1),
60            Self::Custom(_) => (0, 0), // Not representable
61        }
62    }
63
64    /// Check if this is a drop-frame rate.
65    #[must_use]
66    pub const fn is_drop_frame(self) -> bool {
67        matches!(self, Self::Fps2997DF)
68    }
69}
70
71/// Timecode representation.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
73pub struct Timecode {
74    /// Hours (0-23).
75    pub hours: u8,
76    /// Minutes (0-59).
77    pub minutes: u8,
78    /// Seconds (0-59).
79    pub seconds: u8,
80    /// Frames (0-fps-1).
81    pub frames: u8,
82}
83
84impl Timecode {
85    /// Create a new timecode.
86    #[must_use]
87    pub const fn new(hours: u8, minutes: u8, seconds: u8, frames: u8) -> Self {
88        Self {
89            hours,
90            minutes,
91            seconds,
92            frames,
93        }
94    }
95
96    /// Convert to total frames.
97    #[must_use]
98    pub fn to_frames(self, fps: FrameRate) -> u64 {
99        let fps_int = fps.as_f64() as u64;
100        let hours_frames = u64::from(self.hours) * 3600 * fps_int;
101        let minutes_frames = u64::from(self.minutes) * 60 * fps_int;
102        let seconds_frames = u64::from(self.seconds) * fps_int;
103        hours_frames + minutes_frames + seconds_frames + u64::from(self.frames)
104    }
105
106    /// Create from total frames.
107    #[must_use]
108    pub fn from_frames(frames: u64, fps: FrameRate) -> Self {
109        let fps_int = fps.as_f64() as u64;
110        let hours = (frames / (3600 * fps_int)) as u8;
111        let remaining = frames % (3600 * fps_int);
112        let minutes = (remaining / (60 * fps_int)) as u8;
113        let remaining = remaining % (60 * fps_int);
114        let seconds = (remaining / fps_int) as u8;
115        let frames = (remaining % fps_int) as u8;
116        Self::new(hours, minutes, seconds, frames)
117    }
118
119    /// Parse from string in format "HH:MM:SS:FF".
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the format is invalid.
124    pub fn parse(s: &str) -> Result<Self, String> {
125        let parts: Vec<&str> = s.split(':').collect();
126        if parts.len() != 4 {
127            return Err(format!("Invalid timecode format: {s}"));
128        }
129
130        let hours = parts[0]
131            .parse::<u8>()
132            .map_err(|_| format!("Invalid hours: {}", parts[0]))?;
133        let minutes = parts[1]
134            .parse::<u8>()
135            .map_err(|_| format!("Invalid minutes: {}", parts[1]))?;
136        let seconds = parts[2]
137            .parse::<u8>()
138            .map_err(|_| format!("Invalid seconds: {}", parts[2]))?;
139        let frames = parts[3]
140            .parse::<u8>()
141            .map_err(|_| format!("Invalid frames: {}", parts[3]))?;
142
143        Ok(Self::new(hours, minutes, seconds, frames))
144    }
145}
146
147impl std::fmt::Display for Timecode {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(
150            f,
151            "{:02}:{:02}:{:02}:{:02}",
152            self.hours, self.minutes, self.seconds, self.frames
153        )
154    }
155}
156
157/// Media file reference.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MediaFile {
160    /// Unique identifier.
161    pub id: uuid::Uuid,
162    /// File path.
163    pub path: PathBuf,
164    /// File name.
165    pub filename: String,
166    /// Duration in seconds.
167    pub duration: Option<f64>,
168    /// Start timecode.
169    pub timecode_start: Option<Timecode>,
170    /// Width in pixels.
171    pub width: Option<u32>,
172    /// Height in pixels.
173    pub height: Option<u32>,
174    /// Frame rate.
175    pub fps: Option<FrameRate>,
176    /// File size in bytes.
177    pub size: Option<u64>,
178    /// MD5 checksum.
179    pub md5: Option<String>,
180    /// `XXHash` checksum.
181    pub xxhash: Option<String>,
182    /// Additional metadata (JSON).
183    pub metadata: Option<String>,
184    /// When the file was cataloged.
185    pub cataloged_at: chrono::DateTime<chrono::Utc>,
186}
187
188impl MediaFile {
189    /// Create a new media file reference.
190    #[must_use]
191    pub fn new(path: PathBuf) -> Self {
192        let filename = path
193            .file_name()
194            .and_then(|n| n.to_str())
195            .unwrap_or("")
196            .to_string();
197
198        Self {
199            id: uuid::Uuid::new_v4(),
200            path,
201            filename,
202            duration: None,
203            timecode_start: None,
204            width: None,
205            height: None,
206            fps: None,
207            size: None,
208            md5: None,
209            xxhash: None,
210            metadata: None,
211            cataloged_at: chrono::Utc::now(),
212        }
213    }
214}
215
216/// Clip reference from EDL/XML.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct ClipReference {
219    /// Clip identifier (reel name, clip name, etc.).
220    pub id: String,
221    /// Source file name or pattern.
222    pub source_file: Option<String>,
223    /// Source in timecode.
224    pub source_in: Timecode,
225    /// Source out timecode.
226    pub source_out: Timecode,
227    /// Record in timecode.
228    pub record_in: Timecode,
229    /// Record out timecode.
230    pub record_out: Timecode,
231    /// Track type (video, audio).
232    pub track: TrackType,
233    /// Frame rate.
234    pub fps: FrameRate,
235    /// Additional metadata.
236    pub metadata: std::collections::HashMap<String, String>,
237}
238
239/// Track type.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241pub enum TrackType {
242    /// Video track.
243    Video,
244    /// Audio track.
245    Audio,
246    /// Both audio and video.
247    AudioVideo,
248}
249
250/// Clip match result.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ClipMatch {
253    /// The clip reference being matched.
254    pub clip: ClipReference,
255    /// The matched media file.
256    pub media: MediaFile,
257    /// Match score (0.0 - 1.0).
258    pub score: f64,
259    /// Match method used.
260    pub method: MatchMethod,
261    /// Additional match details.
262    pub details: String,
263}
264
265/// Match method.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
267pub enum MatchMethod {
268    /// Exact filename match.
269    ExactFilename,
270    /// Fuzzy filename match.
271    FuzzyFilename,
272    /// Timecode match.
273    Timecode,
274    /// Content hash match.
275    ContentHash,
276    /// Duration match.
277    Duration,
278    /// Combined match (multiple methods).
279    Combined,
280}
281
282impl std::fmt::Display for MatchMethod {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        let s = match self {
285            Self::ExactFilename => "Exact Filename",
286            Self::FuzzyFilename => "Fuzzy Filename",
287            Self::Timecode => "Timecode",
288            Self::ContentHash => "Content Hash",
289            Self::Duration => "Duration",
290            Self::Combined => "Combined",
291        };
292        write!(f, "{s}")
293    }
294}
295
296/// A clip parsed from an NLE project file (FCPXML, AAF, etc.).
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct ConformClip {
299    /// Clip name.
300    pub name: String,
301    /// Offset in the timeline (seconds from timeline start).
302    pub offset_s: f64,
303    /// Duration of the clip in the timeline (seconds).
304    pub duration_s: f64,
305    /// Source in-point (seconds from source start).
306    pub src_in_s: f64,
307    /// Source out-point (seconds from source start).
308    pub src_out_s: f64,
309}
310
311/// A sequence (timeline) within an NLE project.
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313pub struct ConformSequence {
314    /// Sequence name.
315    pub name: String,
316    /// Frame rate of the sequence.
317    pub frame_rate: f32,
318    /// Ordered list of clips in the sequence.
319    pub clips: Vec<ConformClip>,
320}
321
322/// A project parsed from an NLE project file.
323#[derive(Debug, Clone, Default, Serialize, Deserialize)]
324pub struct ConformProject {
325    /// Project name.
326    pub name: String,
327    /// Sequences / timelines in this project.
328    pub sequences: Vec<ConformSequence>,
329}
330
331/// Output format for conformed sequence.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333pub enum OutputFormat {
334    /// Concatenated MP4 file.
335    Mp4,
336    /// Matroska container.
337    Matroska,
338    /// Frame sequence (DPX).
339    FrameSequenceDpx,
340    /// Frame sequence (TIFF).
341    FrameSequenceTiff,
342    /// Frame sequence (PNG).
343    FrameSequencePng,
344    /// EDL with updated paths.
345    Edl,
346    /// Final Cut Pro XML.
347    FcpXml,
348    /// Adobe Premiere XML.
349    PremiereXml,
350    /// AAF file.
351    Aaf,
352}
353
354impl std::fmt::Display for OutputFormat {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        let s = match self {
357            Self::Mp4 => "MP4",
358            Self::Matroska => "Matroska",
359            Self::FrameSequenceDpx => "DPX Sequence",
360            Self::FrameSequenceTiff => "TIFF Sequence",
361            Self::FrameSequencePng => "PNG Sequence",
362            Self::Edl => "EDL",
363            Self::FcpXml => "FCP XML",
364            Self::PremiereXml => "Premiere XML",
365            Self::Aaf => "AAF",
366        };
367        write!(f, "{s}")
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_frame_rate_conversion() {
377        assert!((FrameRate::Fps25.as_f64() - 25.0).abs() < f64::EPSILON);
378        assert!((FrameRate::Fps2997NDF.as_f64() - 29.97).abs() < 0.01);
379    }
380
381    #[test]
382    fn test_timecode_frames_conversion() {
383        let tc = Timecode::new(1, 0, 0, 0);
384        let frames = tc.to_frames(FrameRate::Fps25);
385        assert_eq!(frames, 90000); // 1 hour * 3600 seconds * 25 fps
386
387        let tc2 = Timecode::from_frames(frames, FrameRate::Fps25);
388        assert_eq!(tc, tc2);
389    }
390
391    #[test]
392    fn test_timecode_parsing() {
393        let tc = Timecode::parse("01:23:45:12").expect("tc should be valid");
394        assert_eq!(tc.hours, 1);
395        assert_eq!(tc.minutes, 23);
396        assert_eq!(tc.seconds, 45);
397        assert_eq!(tc.frames, 12);
398    }
399
400    #[test]
401    fn test_timecode_display() {
402        let tc = Timecode::new(1, 23, 45, 12);
403        assert_eq!(tc.to_string(), "01:23:45:12");
404    }
405
406    #[test]
407    fn test_timecode_comparison() {
408        let tc1 = Timecode::new(1, 0, 0, 0);
409        let tc2 = Timecode::new(1, 0, 0, 1);
410        assert!(tc1 < tc2);
411    }
412}