Skip to main content

oximedia_timecode/
timecode_log.rs

1//! Timecode log module for recording timecode-stamped production notes
2//! and metadata events.
3//!
4//! This module provides a production log that associates textual notes,
5//! event markers, and metadata with specific timecode positions. It is
6//! useful for:
7//!
8//! - Logging editorial decisions with precise timecode reference
9//! - Recording QC (Quality Control) findings at specific points
10//! - Storing production cue sheets and event lists
11//! - Exporting EDL-compatible event descriptions
12//!
13//! # Example
14//!
15//! ```rust
16//! use oximedia_timecode::{Timecode, FrameRate};
17//! use oximedia_timecode::timecode_log::{TimecodeLog, LogEntry, LogLevel};
18//!
19//! let mut log = TimecodeLog::new("Production A");
20//! let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid tc");
21//! log.record(tc, LogLevel::Info, "Scene 1 start");
22//! ```
23
24#![allow(dead_code)]
25
26use crate::{FrameRate, Timecode, TimecodeError};
27use std::fmt;
28
29/// Severity level for log entries.
30#[derive(
31    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
32)]
33pub enum LogLevel {
34    /// Debug-level event (verbose).
35    Debug = 0,
36    /// Informational event (normal production notes).
37    Info = 1,
38    /// Warning event (potential issue that was resolved or noted).
39    Warning = 2,
40    /// Error event (problem that affected the production).
41    Error = 3,
42    /// Critical event (scene/take retake required).
43    Critical = 4,
44}
45
46impl fmt::Display for LogLevel {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            LogLevel::Debug => write!(f, "DEBUG"),
50            LogLevel::Info => write!(f, "INFO"),
51            LogLevel::Warning => write!(f, "WARN"),
52            LogLevel::Error => write!(f, "ERROR"),
53            LogLevel::Critical => write!(f, "CRITICAL"),
54        }
55    }
56}
57
58/// A single entry in the timecode log.
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct LogEntry {
61    /// The timecode position of this event.
62    pub timecode: Timecode,
63    /// Severity level.
64    pub level: LogLevel,
65    /// Human-readable message.
66    pub message: String,
67    /// Optional category tag (e.g., "QC", "EDITORIAL", "AUDIO").
68    pub category: Option<String>,
69    /// Optional metadata key-value pairs.
70    pub metadata: std::collections::HashMap<String, String>,
71    /// Wall-clock timestamp when this entry was recorded (Unix seconds).
72    pub wall_clock_secs: Option<i64>,
73}
74
75impl LogEntry {
76    /// Create a new log entry.
77    #[must_use]
78    pub fn new(timecode: Timecode, level: LogLevel, message: impl Into<String>) -> Self {
79        Self {
80            timecode,
81            level,
82            message: message.into(),
83            category: None,
84            metadata: std::collections::HashMap::new(),
85            wall_clock_secs: None,
86        }
87    }
88
89    /// Set the category tag.
90    #[must_use]
91    pub fn with_category(mut self, category: impl Into<String>) -> Self {
92        self.category = Some(category.into());
93        self
94    }
95
96    /// Add a metadata key-value pair.
97    #[must_use]
98    pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
99        self.metadata.insert(key.into(), value.into());
100        self
101    }
102
103    /// Set the wall-clock timestamp.
104    #[must_use]
105    pub fn with_wall_clock(mut self, secs: i64) -> Self {
106        self.wall_clock_secs = Some(secs);
107        self
108    }
109
110    /// Format the entry as a single log line.
111    #[must_use]
112    pub fn format_line(&self) -> String {
113        let cat = self
114            .category
115            .as_deref()
116            .map(|c| format!("[{c}] "))
117            .unwrap_or_default();
118        format!("{} {}{} {}", self.timecode, cat, self.level, self.message)
119    }
120}
121
122impl fmt::Display for LogEntry {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}", self.format_line())
125    }
126}
127
128/// Filter criteria for querying log entries.
129#[derive(Debug, Default)]
130pub struct LogFilter {
131    /// Minimum log level (inclusive).
132    pub min_level: Option<LogLevel>,
133    /// Required category (exact match).
134    pub category: Option<String>,
135    /// Substring match on message.
136    pub message_contains: Option<String>,
137    /// Start timecode of the range (inclusive).
138    pub range_start: Option<Timecode>,
139    /// End timecode of the range (inclusive).
140    pub range_end: Option<Timecode>,
141}
142
143impl LogFilter {
144    /// Create a filter that matches all entries.
145    #[must_use]
146    pub fn all() -> Self {
147        Self::default()
148    }
149
150    /// Filter by minimum level.
151    #[must_use]
152    pub fn with_min_level(mut self, level: LogLevel) -> Self {
153        self.min_level = Some(level);
154        self
155    }
156
157    /// Filter by category.
158    #[must_use]
159    pub fn with_category(mut self, cat: impl Into<String>) -> Self {
160        self.category = Some(cat.into());
161        self
162    }
163
164    /// Filter by message substring.
165    #[must_use]
166    pub fn with_message(mut self, msg: impl Into<String>) -> Self {
167        self.message_contains = Some(msg.into());
168        self
169    }
170
171    /// Filter by timecode range.
172    #[must_use]
173    pub fn with_range(mut self, start: Timecode, end: Timecode) -> Self {
174        self.range_start = Some(start);
175        self.range_end = Some(end);
176        self
177    }
178
179    /// Test whether an entry matches this filter.
180    #[must_use]
181    pub fn matches(&self, entry: &LogEntry) -> bool {
182        if let Some(min) = self.min_level {
183            if entry.level < min {
184                return false;
185            }
186        }
187        if let Some(ref cat) = self.category {
188            if entry.category.as_deref() != Some(cat.as_str()) {
189                return false;
190            }
191        }
192        if let Some(ref needle) = self.message_contains {
193            if !entry.message.contains(needle.as_str()) {
194                return false;
195            }
196        }
197        if let Some(ref start) = self.range_start {
198            if entry.timecode < *start {
199                return false;
200            }
201        }
202        if let Some(ref end) = self.range_end {
203            if entry.timecode > *end {
204                return false;
205            }
206        }
207        true
208    }
209}
210
211/// Production timecode log.
212///
213/// A sorted list of [`LogEntry`] values that can be recorded, queried,
214/// and exported in various formats.
215#[derive(Debug)]
216pub struct TimecodeLog {
217    /// Log name / production title.
218    pub name: String,
219    /// All log entries, kept sorted by timecode.
220    entries: Vec<LogEntry>,
221}
222
223impl TimecodeLog {
224    /// Create a new empty log.
225    #[must_use]
226    pub fn new(name: impl Into<String>) -> Self {
227        Self {
228            name: name.into(),
229            entries: Vec::new(),
230        }
231    }
232
233    /// Record a new entry.
234    pub fn record(&mut self, timecode: Timecode, level: LogLevel, message: impl Into<String>) {
235        let entry = LogEntry::new(timecode, level, message);
236        self.insert_sorted(entry);
237    }
238
239    /// Insert a pre-constructed entry in sorted order.
240    pub fn insert(&mut self, entry: LogEntry) {
241        self.insert_sorted(entry);
242    }
243
244    fn insert_sorted(&mut self, entry: LogEntry) {
245        let pos = self
246            .entries
247            .partition_point(|e| e.timecode <= entry.timecode);
248        self.entries.insert(pos, entry);
249    }
250
251    /// Return all entries matching the given filter.
252    #[must_use]
253    pub fn query(&self, filter: &LogFilter) -> Vec<&LogEntry> {
254        self.entries.iter().filter(|e| filter.matches(e)).collect()
255    }
256
257    /// Return all entries.
258    #[must_use]
259    pub fn all_entries(&self) -> &[LogEntry] {
260        &self.entries
261    }
262
263    /// Number of entries.
264    #[must_use]
265    pub fn len(&self) -> usize {
266        self.entries.len()
267    }
268
269    /// Whether the log is empty.
270    #[must_use]
271    pub fn is_empty(&self) -> bool {
272        self.entries.is_empty()
273    }
274
275    /// Remove all entries.
276    pub fn clear(&mut self) {
277        self.entries.clear();
278    }
279
280    /// Export as plain-text log.
281    #[must_use]
282    pub fn to_text(&self) -> String {
283        let mut out = format!("# Timecode Log: {}\n", self.name);
284        out.push_str(&format!("# Entries: {}\n\n", self.entries.len()));
285        for entry in &self.entries {
286            out.push_str(&format!("{}\n", entry.format_line()));
287        }
288        out
289    }
290
291    /// Export as CSV (timecode, level, category, message).
292    #[must_use]
293    pub fn to_csv(&self) -> String {
294        let mut out = String::from("timecode,level,category,message\n");
295        for entry in &self.entries {
296            let cat = entry.category.as_deref().unwrap_or("");
297            // Escape double quotes in message
298            let msg = entry.message.replace('"', "\"\"");
299            out.push_str(&format!(
300                "{},{},{},\"{}\"\n",
301                entry.timecode, entry.level, cat, msg
302            ));
303        }
304        out
305    }
306
307    /// Find the first entry at or after the given timecode.
308    #[must_use]
309    pub fn first_at_or_after(&self, tc: &Timecode) -> Option<&LogEntry> {
310        let pos = self.entries.partition_point(|e| &e.timecode < tc);
311        self.entries.get(pos)
312    }
313
314    /// Find all entries within a timecode range (inclusive).
315    #[must_use]
316    pub fn entries_in_range(&self, start: &Timecode, end: &Timecode) -> Vec<&LogEntry> {
317        self.entries
318            .iter()
319            .filter(|e| &e.timecode >= start && &e.timecode <= end)
320            .collect()
321    }
322
323    /// Parse from CSV text (inverse of `to_csv`).
324    ///
325    /// # Errors
326    ///
327    /// Returns error if a line has invalid timecode format.
328    pub fn from_csv(
329        name: impl Into<String>,
330        csv: &str,
331        frame_rate: FrameRate,
332    ) -> Result<Self, TimecodeError> {
333        let mut log = Self::new(name);
334        for (line_num, line) in csv.lines().enumerate() {
335            if line_num == 0 || line.trim().is_empty() {
336                continue; // skip header
337            }
338            let parts: Vec<&str> = line.splitn(4, ',').collect();
339            if parts.len() < 4 {
340                continue;
341            }
342            let tc_str = parts[0].trim();
343            let level_str = parts[1].trim();
344            let cat_str = parts[2].trim();
345            let msg = parts[3].trim().trim_matches('"').replace("\"\"", "\"");
346
347            let tc = Timecode::from_string(tc_str, frame_rate)?;
348            let level = match level_str {
349                "DEBUG" => LogLevel::Debug,
350                "INFO" => LogLevel::Info,
351                "WARN" => LogLevel::Warning,
352                "ERROR" => LogLevel::Error,
353                "CRITICAL" => LogLevel::Critical,
354                _ => LogLevel::Info,
355            };
356            let mut entry = LogEntry::new(tc, level, msg);
357            if !cat_str.is_empty() {
358                entry.category = Some(cat_str.to_string());
359            }
360            log.insert_sorted(entry);
361        }
362        Ok(log)
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::FrameRate;
370
371    fn tc(h: u8, m: u8, s: u8, f: u8) -> Timecode {
372        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid tc")
373    }
374
375    #[test]
376    fn test_record_and_query() {
377        let mut log = TimecodeLog::new("Test");
378        log.record(tc(1, 0, 0, 0), LogLevel::Info, "A");
379        log.record(tc(0, 0, 0, 0), LogLevel::Warning, "B");
380        assert_eq!(log.len(), 2);
381        // Entries should be sorted
382        assert_eq!(log.all_entries()[0].timecode, tc(0, 0, 0, 0));
383    }
384
385    #[test]
386    fn test_filter_by_level() {
387        let mut log = TimecodeLog::new("Test");
388        log.record(tc(0, 0, 1, 0), LogLevel::Debug, "debug");
389        log.record(tc(0, 0, 2, 0), LogLevel::Warning, "warn");
390        let filter = LogFilter::all().with_min_level(LogLevel::Warning);
391        let results = log.query(&filter);
392        assert_eq!(results.len(), 1);
393        assert_eq!(results[0].level, LogLevel::Warning);
394    }
395
396    #[test]
397    fn test_csv_round_trip() {
398        let mut log = TimecodeLog::new("Trip");
399        log.record(tc(1, 2, 3, 4), LogLevel::Info, "hello");
400        let csv = log.to_csv();
401        let log2 = TimecodeLog::from_csv("Trip", &csv, FrameRate::Fps25).expect("csv parse ok");
402        assert_eq!(log2.len(), 1);
403        assert_eq!(log2.all_entries()[0].message, "hello");
404    }
405
406    #[test]
407    fn test_to_text_contains_header() {
408        let log = TimecodeLog::new("MyProd");
409        let text = log.to_text();
410        assert!(text.contains("MyProd"));
411    }
412
413    #[test]
414    fn test_entries_in_range() {
415        let mut log = TimecodeLog::new("Range");
416        log.record(tc(0, 0, 1, 0), LogLevel::Info, "in");
417        log.record(tc(0, 0, 2, 0), LogLevel::Info, "also-in");
418        log.record(tc(0, 0, 5, 0), LogLevel::Info, "out");
419        let results = log.entries_in_range(&tc(0, 0, 0, 0), &tc(0, 0, 3, 0));
420        assert_eq!(results.len(), 2);
421    }
422
423    #[test]
424    fn test_filter_by_category() {
425        let mut log = TimecodeLog::new("Cat");
426        let e1 = LogEntry::new(tc(0, 0, 0, 0), LogLevel::Info, "a").with_category("QC");
427        let e2 = LogEntry::new(tc(0, 0, 1, 0), LogLevel::Info, "b").with_category("EDITORIAL");
428        log.insert(e1);
429        log.insert(e2);
430        let filter = LogFilter::all().with_category("QC");
431        let results = log.query(&filter);
432        assert_eq!(results.len(), 1);
433        assert_eq!(results[0].message, "a");
434    }
435
436    #[test]
437    fn test_filter_by_message() {
438        let mut log = TimecodeLog::new("Msg");
439        log.record(tc(0, 0, 0, 0), LogLevel::Info, "scene start");
440        log.record(tc(0, 0, 1, 0), LogLevel::Info, "cut here");
441        let filter = LogFilter::all().with_message("scene");
442        let results = log.query(&filter);
443        assert_eq!(results.len(), 1);
444    }
445
446    #[test]
447    fn test_first_at_or_after() {
448        let mut log = TimecodeLog::new("First");
449        log.record(tc(0, 0, 1, 0), LogLevel::Info, "first");
450        log.record(tc(0, 0, 5, 0), LogLevel::Info, "second");
451        let found = log.first_at_or_after(&tc(0, 0, 3, 0));
452        assert!(found.is_some());
453        assert_eq!(found.map(|e| e.message.as_str()), Some("second"));
454    }
455
456    #[test]
457    fn test_log_entry_format_line() {
458        let e = LogEntry::new(tc(1, 0, 0, 0), LogLevel::Error, "bad frame").with_category("QC");
459        let line = e.format_line();
460        assert!(line.contains("01:00:00:00"));
461        assert!(line.contains("[QC]"));
462        assert!(line.contains("ERROR"));
463        assert!(line.contains("bad frame"));
464    }
465
466    #[test]
467    fn test_log_clear() {
468        let mut log = TimecodeLog::new("Clear");
469        log.record(tc(0, 0, 0, 0), LogLevel::Info, "hello");
470        assert!(!log.is_empty());
471        log.clear();
472        assert!(log.is_empty());
473        assert_eq!(log.len(), 0);
474    }
475
476    #[test]
477    fn test_log_entry_with_metadata() {
478        let e = LogEntry::new(tc(0, 0, 0, 0), LogLevel::Info, "take 1")
479            .with_meta("camera", "A")
480            .with_meta("lens", "50mm");
481        assert_eq!(e.metadata.len(), 2);
482        assert_eq!(e.metadata.get("camera").map(String::as_str), Some("A"));
483    }
484
485    #[test]
486    fn test_log_entry_wall_clock() {
487        let e = LogEntry::new(tc(0, 0, 0, 0), LogLevel::Info, "x").with_wall_clock(1_700_000_000);
488        assert_eq!(e.wall_clock_secs, Some(1_700_000_000));
489    }
490}