Skip to main content

oximedia_timecode/
tc_metadata.rs

1#![allow(dead_code)]
2//! Timecode metadata for embedding and extracting timecode-related info
3//! alongside media streams.
4//!
5//! Provides structures for tagging media with timecode origins, recording dates,
6//! reel identifiers, and user-bits payloads conforming to SMPTE 12M.
7
8use crate::{FrameRate, Timecode, TimecodeError};
9use std::collections::HashMap;
10
11/// Source type that originally generated the timecode.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TimecodeSource {
14    /// Linear Timecode from audio track
15    Ltc,
16    /// Vertical Interval Timecode
17    Vitc,
18    /// MIDI Time Code
19    Mtc,
20    /// Network Time Protocol derived
21    Ntp,
22    /// Precision Time Protocol derived
23    Ptp,
24    /// Manually entered / free-run generator
25    FreeRun,
26    /// Timecode reconstructed from file metadata
27    FileMetadata,
28}
29
30/// Recording date associated with a timecode.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RecordDate {
33    /// Year (e.g. 2026)
34    pub year: u16,
35    /// Month (1-12)
36    pub month: u8,
37    /// Day (1-31)
38    pub day: u8,
39}
40
41impl RecordDate {
42    /// Creates a new recording date.
43    ///
44    /// # Errors
45    ///
46    /// Returns `TimecodeError::InvalidConfiguration` for out-of-range values.
47    pub fn new(year: u16, month: u8, day: u8) -> Result<Self, TimecodeError> {
48        if month == 0 || month > 12 {
49            return Err(TimecodeError::InvalidConfiguration);
50        }
51        if day == 0 || day > 31 {
52            return Err(TimecodeError::InvalidConfiguration);
53        }
54        Ok(Self { year, month, day })
55    }
56
57    /// Formats the date as ISO 8601 (YYYY-MM-DD).
58    pub fn to_iso_string(&self) -> String {
59        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
60    }
61}
62
63/// User bits payload from SMPTE 12M (32 bits split into 8 nibbles).
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct UserBitsPayload {
66    /// Raw 32-bit user bits value
67    pub raw: u32,
68    /// Whether the user bits encode a date (BG flag)
69    pub is_date: bool,
70}
71
72impl UserBitsPayload {
73    /// Creates a new user bits payload from a raw value.
74    pub fn new(raw: u32, is_date: bool) -> Self {
75        Self { raw, is_date }
76    }
77
78    /// Extracts a nibble (0-7) from the user bits.
79    pub fn nibble(&self, index: u8) -> u8 {
80        if index > 7 {
81            return 0;
82        }
83        ((self.raw >> (index * 4)) & 0x0F) as u8
84    }
85
86    /// Sets a nibble (0-7) in the user bits.
87    pub fn set_nibble(&mut self, index: u8, value: u8) {
88        if index > 7 {
89            return;
90        }
91        let shift = index * 4;
92        self.raw &= !(0x0F << shift);
93        self.raw |= ((value & 0x0F) as u32) << shift;
94    }
95
96    /// Decodes the user bits as a BCD date (if applicable).
97    ///
98    /// SMPTE 12M encodes dates as: nibbles 0-1 = day, 2-3 = month, 4-7 = year.
99    pub fn decode_date(&self) -> Option<RecordDate> {
100        if !self.is_date {
101            return None;
102        }
103        let day = self.nibble(0) * 10 + self.nibble(1);
104        let month = self.nibble(2) * 10 + self.nibble(3);
105        let year_hi = self.nibble(4) as u16 * 10 + self.nibble(5) as u16;
106        let year_lo = self.nibble(6) as u16 * 10 + self.nibble(7) as u16;
107        let year = year_hi * 100 + year_lo;
108        RecordDate::new(year, month, day).ok()
109    }
110
111    /// Encodes a date into user bits in BCD format.
112    pub fn encode_date(date: &RecordDate) -> Self {
113        let mut payload = Self::new(0, true);
114        let day_hi = date.day / 10;
115        let day_lo = date.day % 10;
116        let month_hi = date.month / 10;
117        let month_lo = date.month % 10;
118        let year_hi_hi = (date.year / 1000) as u8;
119        let year_hi_lo = ((date.year / 100) % 10) as u8;
120        let year_lo_hi = ((date.year / 10) % 10) as u8;
121        let year_lo_lo = (date.year % 10) as u8;
122        payload.set_nibble(0, day_hi);
123        payload.set_nibble(1, day_lo);
124        payload.set_nibble(2, month_hi);
125        payload.set_nibble(3, month_lo);
126        payload.set_nibble(4, year_hi_hi);
127        payload.set_nibble(5, year_hi_lo);
128        payload.set_nibble(6, year_lo_hi);
129        payload.set_nibble(7, year_lo_lo);
130        payload
131    }
132}
133
134/// Reel identifier associated with a timecode.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ReelId {
137    /// Reel name or number
138    pub name: String,
139    /// Optional sequence index within the reel
140    pub sequence: Option<u32>,
141}
142
143impl ReelId {
144    /// Creates a new reel identifier.
145    pub fn new(name: impl Into<String>) -> Self {
146        Self {
147            name: name.into(),
148            sequence: None,
149        }
150    }
151
152    /// Sets the sequence number.
153    pub fn with_sequence(mut self, seq: u32) -> Self {
154        self.sequence = Some(seq);
155        self
156    }
157}
158
159/// Comprehensive timecode metadata block.
160///
161/// Bundles a timecode with all associated metadata such as source, reel, date,
162/// user bits, and custom key-value tags.
163#[derive(Debug, Clone)]
164pub struct TcMetadata {
165    /// The timecode value
166    pub timecode: Timecode,
167    /// Frame rate used for the timecode
168    pub frame_rate: FrameRate,
169    /// Source of the timecode
170    pub source: TimecodeSource,
171    /// Optional reel identifier
172    pub reel: Option<ReelId>,
173    /// Optional recording date
174    pub record_date: Option<RecordDate>,
175    /// User bits payload
176    pub user_bits: Option<UserBitsPayload>,
177    /// Arbitrary string key-value tags
178    pub tags: HashMap<String, String>,
179    /// Scene label
180    pub scene: Option<String>,
181    /// Take number
182    pub take: Option<u32>,
183}
184
185impl TcMetadata {
186    /// Creates new metadata for a timecode.
187    pub fn new(timecode: Timecode, frame_rate: FrameRate, source: TimecodeSource) -> Self {
188        Self {
189            timecode,
190            frame_rate,
191            source,
192            reel: None,
193            record_date: None,
194            user_bits: None,
195            tags: HashMap::new(),
196            scene: None,
197            take: None,
198        }
199    }
200
201    /// Sets the reel identifier.
202    pub fn with_reel(mut self, reel: ReelId) -> Self {
203        self.reel = Some(reel);
204        self
205    }
206
207    /// Sets the recording date.
208    pub fn with_record_date(mut self, date: RecordDate) -> Self {
209        self.record_date = Some(date);
210        self
211    }
212
213    /// Sets the user bits.
214    pub fn with_user_bits(mut self, ub: UserBitsPayload) -> Self {
215        self.user_bits = Some(ub);
216        self
217    }
218
219    /// Adds a custom tag.
220    pub fn with_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
221        self.tags.insert(key.into(), value.into());
222        self
223    }
224
225    /// Sets the scene label.
226    pub fn with_scene(mut self, scene: impl Into<String>) -> Self {
227        self.scene = Some(scene.into());
228        self
229    }
230
231    /// Sets the take number.
232    pub fn with_take(mut self, take: u32) -> Self {
233        self.take = Some(take);
234        self
235    }
236
237    /// Formats metadata as a human-readable summary string.
238    pub fn summary(&self) -> String {
239        let mut parts = vec![format!("TC={}", self.timecode)];
240        parts.push(format!("src={:?}", self.source));
241        if let Some(ref reel) = self.reel {
242            parts.push(format!("reel={}", reel.name));
243        }
244        if let Some(ref date) = self.record_date {
245            parts.push(format!("date={}", date.to_iso_string()));
246        }
247        if let Some(ref scene) = self.scene {
248            parts.push(format!("scene={scene}"));
249        }
250        if let Some(take) = self.take {
251            parts.push(format!("take={take}"));
252        }
253        parts.join(" | ")
254    }
255
256    /// Validates that the metadata is internally consistent.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the timecode frame rate info does not match the declared frame rate.
261    pub fn validate(&self) -> Result<(), TimecodeError> {
262        let expected_fps = self.frame_rate.frames_per_second() as u8;
263        if self.timecode.frame_rate.fps != expected_fps {
264            return Err(TimecodeError::InvalidConfiguration);
265        }
266        if self.timecode.frame_rate.drop_frame != self.frame_rate.is_drop_frame() {
267            return Err(TimecodeError::InvalidConfiguration);
268        }
269        Ok(())
270    }
271}
272
273/// A timeline of metadata entries keyed by frame number.
274#[derive(Debug, Clone)]
275pub struct MetadataTimeline {
276    /// Entries sorted by frame number
277    entries: Vec<(u64, TcMetadata)>,
278}
279
280impl MetadataTimeline {
281    /// Creates an empty metadata timeline.
282    pub fn new() -> Self {
283        Self {
284            entries: Vec::new(),
285        }
286    }
287
288    /// Adds a metadata entry at the given frame.
289    pub fn insert(&mut self, frame: u64, meta: TcMetadata) {
290        let pos = self.entries.partition_point(|(f, _)| *f < frame);
291        self.entries.insert(pos, (frame, meta));
292    }
293
294    /// Finds the metadata entry at or before the given frame.
295    pub fn lookup(&self, frame: u64) -> Option<&TcMetadata> {
296        let pos = self.entries.partition_point(|(f, _)| *f <= frame);
297        if pos == 0 {
298            return None;
299        }
300        Some(&self.entries[pos - 1].1)
301    }
302
303    /// Returns the number of entries.
304    pub fn len(&self) -> usize {
305        self.entries.len()
306    }
307
308    /// Returns whether the timeline is empty.
309    pub fn is_empty(&self) -> bool {
310        self.entries.is_empty()
311    }
312
313    /// Returns all entries as a slice.
314    pub fn entries(&self) -> &[(u64, TcMetadata)] {
315        &self.entries
316    }
317}
318
319impl Default for MetadataTimeline {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn make_tc() -> Timecode {
330        Timecode::new(1, 2, 3, 4, FrameRate::Fps25).unwrap()
331    }
332
333    #[test]
334    fn test_record_date_valid() {
335        let d = RecordDate::new(2026, 3, 2).unwrap();
336        assert_eq!(d.to_iso_string(), "2026-03-02");
337    }
338
339    #[test]
340    fn test_record_date_invalid_month() {
341        assert!(RecordDate::new(2026, 13, 1).is_err());
342    }
343
344    #[test]
345    fn test_record_date_invalid_day() {
346        assert!(RecordDate::new(2026, 1, 0).is_err());
347    }
348
349    #[test]
350    fn test_user_bits_nibble() {
351        let mut ub = UserBitsPayload::new(0, false);
352        ub.set_nibble(0, 0x0A);
353        assert_eq!(ub.nibble(0), 0x0A);
354        assert_eq!(ub.nibble(1), 0);
355    }
356
357    #[test]
358    fn test_user_bits_date_encode_decode() {
359        let date = RecordDate::new(2026, 3, 15).unwrap();
360        let ub = UserBitsPayload::encode_date(&date);
361        let decoded = ub.decode_date().unwrap();
362        assert_eq!(decoded.year, 2026);
363        assert_eq!(decoded.month, 3);
364        assert_eq!(decoded.day, 15);
365    }
366
367    #[test]
368    fn test_user_bits_no_date() {
369        let ub = UserBitsPayload::new(0x12345678, false);
370        assert!(ub.decode_date().is_none());
371    }
372
373    #[test]
374    fn test_reel_id() {
375        let reel = ReelId::new("A001").with_sequence(1);
376        assert_eq!(reel.name, "A001");
377        assert_eq!(reel.sequence, Some(1));
378    }
379
380    #[test]
381    fn test_tc_metadata_new() {
382        let tc = make_tc();
383        let meta = TcMetadata::new(tc, FrameRate::Fps25, TimecodeSource::Ltc);
384        assert_eq!(meta.source, TimecodeSource::Ltc);
385        assert!(meta.reel.is_none());
386    }
387
388    #[test]
389    fn test_tc_metadata_with_builders() {
390        let tc = make_tc();
391        let meta = TcMetadata::new(tc, FrameRate::Fps25, TimecodeSource::Vitc)
392            .with_reel(ReelId::new("B002"))
393            .with_scene("42A")
394            .with_take(3)
395            .with_tag("camera", "A");
396        assert_eq!(meta.scene.as_deref(), Some("42A"));
397        assert_eq!(meta.take, Some(3));
398        assert_eq!(meta.tags.get("camera").unwrap(), "A");
399    }
400
401    #[test]
402    fn test_tc_metadata_summary() {
403        let tc = make_tc();
404        let meta = TcMetadata::new(tc, FrameRate::Fps25, TimecodeSource::Ltc).with_scene("1A");
405        let s = meta.summary();
406        assert!(s.contains("TC=01:02:03:04"));
407        assert!(s.contains("scene=1A"));
408    }
409
410    #[test]
411    fn test_tc_metadata_validate_ok() {
412        let tc = make_tc();
413        let meta = TcMetadata::new(tc, FrameRate::Fps25, TimecodeSource::Ltc);
414        assert!(meta.validate().is_ok());
415    }
416
417    #[test]
418    fn test_tc_metadata_validate_mismatch() {
419        let tc = make_tc();
420        let meta = TcMetadata::new(tc, FrameRate::Fps30, TimecodeSource::Ltc);
421        assert!(meta.validate().is_err());
422    }
423
424    #[test]
425    fn test_metadata_timeline_insert_and_lookup() {
426        let tc = make_tc();
427        let meta = TcMetadata::new(tc, FrameRate::Fps25, TimecodeSource::FreeRun);
428        let mut tl = MetadataTimeline::new();
429        tl.insert(100, meta.clone());
430        tl.insert(200, meta);
431        assert_eq!(tl.len(), 2);
432        let found = tl.lookup(150).unwrap();
433        assert_eq!(found.timecode.hours, 1);
434    }
435
436    #[test]
437    fn test_metadata_timeline_empty_lookup() {
438        let tl = MetadataTimeline::new();
439        assert!(tl.lookup(0).is_none());
440        assert!(tl.is_empty());
441    }
442
443    #[test]
444    fn test_timecode_source_variants() {
445        let sources = [
446            TimecodeSource::Ltc,
447            TimecodeSource::Vitc,
448            TimecodeSource::Mtc,
449            TimecodeSource::Ntp,
450            TimecodeSource::Ptp,
451            TimecodeSource::FreeRun,
452            TimecodeSource::FileMetadata,
453        ];
454        assert_eq!(sources.len(), 7);
455    }
456}