Skip to main content

oximedia_timecode/
timecode_event.rs

1#![allow(dead_code)]
2//! Event-triggered timecode capture for marking in/out points and cue triggers.
3//!
4//! The [`TimecodeEventCapture`] struct accumulates [`TimecodeEvent`] records
5//! as they are triggered during a session.  Events carry a captured timecode,
6//! a label, and an optional payload so they can be used for:
7//!
8//! - Marking edit in/out points during a live or offline session.
9//! - Recording cue triggers (e.g. lighting, effects, playback).
10//! - Logging any user-defined production note alongside a precise timecode.
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use oximedia_timecode::{FrameRate, Timecode, timecode_event::TimecodeEventCapture};
16//!
17//! let mut capture = TimecodeEventCapture::new();
18//! let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid");
19//! capture.mark_in(tc);
20//! let out_tc = Timecode::new(1, 0, 30, 0, FrameRate::Fps25).expect("valid");
21//! capture.mark_out(out_tc);
22//! ```
23
24use crate::Timecode;
25
26// ---------------------------------------------------------------------------
27// Event kind
28// ---------------------------------------------------------------------------
29
30/// The kind of a captured timecode event.
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub enum EventKind {
33    /// Edit mark-in point.
34    MarkIn,
35    /// Edit mark-out point.
36    MarkOut,
37    /// Generic cue trigger (carry-along label in [`TimecodeEvent::label`]).
38    Cue,
39    /// User-defined / arbitrary event.
40    Custom(String),
41}
42
43impl std::fmt::Display for EventKind {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            EventKind::MarkIn => write!(f, "MARK_IN"),
47            EventKind::MarkOut => write!(f, "MARK_OUT"),
48            EventKind::Cue => write!(f, "CUE"),
49            EventKind::Custom(s) => write!(f, "CUSTOM:{}", s),
50        }
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Event record
56// ---------------------------------------------------------------------------
57
58/// A single timecode event record.
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct TimecodeEvent {
61    /// The timecode at which this event was captured.
62    pub timecode: Timecode,
63    /// The kind of event.
64    pub kind: EventKind,
65    /// Optional human-readable label (e.g., "Scene 3 Slate").
66    pub label: String,
67    /// Optional arbitrary payload (e.g., JSON-encoded metadata).
68    pub payload: Option<String>,
69}
70
71impl TimecodeEvent {
72    /// Create a new event with the given kind and an empty label.
73    pub fn new(timecode: Timecode, kind: EventKind) -> Self {
74        Self {
75            timecode,
76            kind,
77            label: String::new(),
78            payload: None,
79        }
80    }
81
82    /// Attach a human-readable label to this event.
83    pub fn with_label(mut self, label: impl Into<String>) -> Self {
84        self.label = label.into();
85        self
86    }
87
88    /// Attach an arbitrary payload string to this event.
89    pub fn with_payload(mut self, payload: impl Into<String>) -> Self {
90        self.payload = Some(payload.into());
91        self
92    }
93}
94
95impl std::fmt::Display for TimecodeEvent {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "[{}] {} {}", self.kind, self.timecode, self.label)
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Edit range (in/out pair)
103// ---------------------------------------------------------------------------
104
105/// An in/out range captured from mark-in and mark-out events.
106#[derive(Debug, Clone)]
107pub struct EditRange {
108    /// Mark-in timecode.
109    pub mark_in: Timecode,
110    /// Mark-out timecode.
111    pub mark_out: Timecode,
112}
113
114impl EditRange {
115    /// Duration of the edit range in frames.
116    ///
117    /// Returns 0 if mark_out is at or before mark_in.
118    pub fn duration_frames(&self) -> u64 {
119        let fi = self.mark_in.to_frames();
120        let fo = self.mark_out.to_frames();
121        fo.saturating_sub(fi)
122    }
123
124    /// Duration of the edit range in seconds (approximate for pull-down rates).
125    pub fn duration_seconds(&self) -> f64 {
126        self.mark_out.to_seconds_f64() - self.mark_in.to_seconds_f64()
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Capture controller
132// ---------------------------------------------------------------------------
133
134/// Accumulates timecode events recorded during a production session.
135///
136/// Thread safety: [`TimecodeEventCapture`] is not `Sync`; wrap in a `Mutex`
137/// or `RwLock` if sharing across threads.
138#[derive(Debug, Default)]
139pub struct TimecodeEventCapture {
140    /// All captured events, in the order they were recorded.
141    events: Vec<TimecodeEvent>,
142    /// The most recent mark-in timecode, if any (used by [`close_range`]).
143    pending_mark_in: Option<Timecode>,
144}
145
146impl TimecodeEventCapture {
147    /// Create an empty capture session.
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Record a mark-in event at `tc`.
153    ///
154    /// Also stores `tc` as the pending mark-in so that a subsequent
155    /// [`mark_out`](Self::mark_out) can construct an [`EditRange`].
156    pub fn mark_in(&mut self, tc: Timecode) {
157        self.pending_mark_in = Some(tc);
158        self.events.push(TimecodeEvent::new(tc, EventKind::MarkIn));
159    }
160
161    /// Record a mark-out event at `tc`.
162    ///
163    /// Returns the completed [`EditRange`] if a pending mark-in exists.
164    pub fn mark_out(&mut self, tc: Timecode) -> Option<EditRange> {
165        self.events.push(TimecodeEvent::new(tc, EventKind::MarkOut));
166        self.pending_mark_in.take().map(|mark_in| EditRange {
167            mark_in,
168            mark_out: tc,
169        })
170    }
171
172    /// Record a cue trigger at `tc` with an optional label.
173    pub fn cue(&mut self, tc: Timecode, label: impl Into<String>) {
174        self.events
175            .push(TimecodeEvent::new(tc, EventKind::Cue).with_label(label));
176    }
177
178    /// Record a custom event at `tc`.
179    pub fn custom(
180        &mut self,
181        tc: Timecode,
182        name: impl Into<String>,
183        label: impl Into<String>,
184        payload: Option<String>,
185    ) {
186        let mut ev = TimecodeEvent::new(tc, EventKind::Custom(name.into())).with_label(label);
187        if let Some(p) = payload {
188            ev = ev.with_payload(p);
189        }
190        self.events.push(ev);
191    }
192
193    /// Return a slice of all recorded events.
194    pub fn events(&self) -> &[TimecodeEvent] {
195        &self.events
196    }
197
198    /// Return only the mark-in events.
199    pub fn mark_ins(&self) -> Vec<&TimecodeEvent> {
200        self.events
201            .iter()
202            .filter(|e| e.kind == EventKind::MarkIn)
203            .collect()
204    }
205
206    /// Return only the mark-out events.
207    pub fn mark_outs(&self) -> Vec<&TimecodeEvent> {
208        self.events
209            .iter()
210            .filter(|e| e.kind == EventKind::MarkOut)
211            .collect()
212    }
213
214    /// Reconstruct all completed in/out ranges from the event log.
215    ///
216    /// Pairs mark-in events with the next mark-out event that follows them.
217    pub fn edit_ranges(&self) -> Vec<EditRange> {
218        let mut ranges = Vec::new();
219        let mut pending: Option<Timecode> = None;
220
221        for ev in &self.events {
222            match &ev.kind {
223                EventKind::MarkIn => {
224                    pending = Some(ev.timecode);
225                }
226                EventKind::MarkOut => {
227                    if let Some(mark_in) = pending.take() {
228                        ranges.push(EditRange {
229                            mark_in,
230                            mark_out: ev.timecode,
231                        });
232                    }
233                }
234                _ => {}
235            }
236        }
237
238        ranges
239    }
240
241    /// Clear all events and reset pending mark-in state.
242    pub fn clear(&mut self) {
243        self.events.clear();
244        self.pending_mark_in = None;
245    }
246
247    /// Number of recorded events.
248    pub fn len(&self) -> usize {
249        self.events.len()
250    }
251
252    /// Whether no events have been recorded.
253    pub fn is_empty(&self) -> bool {
254        self.events.is_empty()
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Tests
260// ---------------------------------------------------------------------------
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::FrameRate;
266
267    fn tc(h: u8, m: u8, s: u8, f: u8) -> Timecode {
268        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid timecode")
269    }
270
271    #[test]
272    fn test_mark_in_records_event() {
273        let mut cap = TimecodeEventCapture::new();
274        cap.mark_in(tc(0, 0, 1, 0));
275        assert_eq!(cap.len(), 1);
276        assert_eq!(cap.events()[0].kind, EventKind::MarkIn);
277    }
278
279    #[test]
280    fn test_mark_out_returns_range() {
281        let mut cap = TimecodeEventCapture::new();
282        cap.mark_in(tc(0, 0, 1, 0));
283        let range = cap.mark_out(tc(0, 0, 5, 0));
284        assert!(range.is_some());
285        let r = range.expect("should have range");
286        assert_eq!(r.duration_frames(), 4 * 25);
287    }
288
289    #[test]
290    fn test_mark_out_without_mark_in_returns_none() {
291        let mut cap = TimecodeEventCapture::new();
292        let range = cap.mark_out(tc(0, 0, 5, 0));
293        assert!(range.is_none());
294    }
295
296    #[test]
297    fn test_cue_event() {
298        let mut cap = TimecodeEventCapture::new();
299        cap.cue(tc(0, 1, 0, 0), "Scene 1");
300        assert_eq!(cap.len(), 1);
301        assert_eq!(cap.events()[0].kind, EventKind::Cue);
302        assert_eq!(cap.events()[0].label, "Scene 1");
303    }
304
305    #[test]
306    fn test_custom_event() {
307        let mut cap = TimecodeEventCapture::new();
308        cap.custom(
309            tc(0, 2, 0, 0),
310            "FLASH",
311            "Harding flash detected",
312            Some("{\"severity\":\"high\"}".into()),
313        );
314        assert_eq!(cap.len(), 1);
315        assert!(matches!(&cap.events()[0].kind, EventKind::Custom(n) if n == "FLASH"));
316        assert!(cap.events()[0].payload.is_some());
317    }
318
319    #[test]
320    fn test_edit_ranges_reconstruction() {
321        let mut cap = TimecodeEventCapture::new();
322        cap.mark_in(tc(0, 0, 1, 0));
323        cap.mark_out(tc(0, 0, 5, 0));
324        cap.mark_in(tc(0, 1, 0, 0));
325        cap.mark_out(tc(0, 1, 30, 0));
326
327        let ranges = cap.edit_ranges();
328        assert_eq!(ranges.len(), 2);
329        assert_eq!(ranges[0].duration_frames(), 4 * 25);
330        assert_eq!(ranges[1].duration_frames(), 30 * 25);
331    }
332
333    #[test]
334    fn test_mark_ins_filter() {
335        let mut cap = TimecodeEventCapture::new();
336        cap.mark_in(tc(0, 0, 1, 0));
337        cap.cue(tc(0, 0, 2, 0), "cue");
338        cap.mark_out(tc(0, 0, 5, 0));
339        assert_eq!(cap.mark_ins().len(), 1);
340        assert_eq!(cap.mark_outs().len(), 1);
341    }
342
343    #[test]
344    fn test_clear_resets_state() {
345        let mut cap = TimecodeEventCapture::new();
346        cap.mark_in(tc(0, 0, 1, 0));
347        cap.clear();
348        assert!(cap.is_empty());
349        // After clear, mark_out should return None (no pending mark_in)
350        let range = cap.mark_out(tc(0, 0, 5, 0));
351        assert!(range.is_none());
352    }
353
354    #[test]
355    fn test_duration_seconds() {
356        let r = EditRange {
357            mark_in: tc(0, 0, 0, 0),
358            mark_out: tc(0, 0, 4, 0),
359        };
360        assert!((r.duration_seconds() - 4.0).abs() < 1e-6);
361    }
362
363    #[test]
364    fn test_event_display() {
365        let ev = TimecodeEvent::new(tc(1, 2, 3, 4), EventKind::Cue).with_label("test");
366        let s = ev.to_string();
367        assert!(s.contains("CUE"));
368        assert!(s.contains("01:02:03:04"));
369    }
370
371    #[test]
372    fn test_event_kind_display() {
373        assert_eq!(EventKind::MarkIn.to_string(), "MARK_IN");
374        assert_eq!(EventKind::MarkOut.to_string(), "MARK_OUT");
375        assert_eq!(EventKind::Cue.to_string(), "CUE");
376        assert_eq!(EventKind::Custom("FOO".into()).to_string(), "CUSTOM:FOO");
377    }
378}