Skip to main content

oximedia_review/
timeline_note.rs

1//! Temporal notes and markers for review sessions.
2
3use std::time::SystemTime;
4
5/// A time range in a media clip.
6#[derive(Debug, Clone, Copy)]
7pub struct TimeRange {
8    /// First frame of the range (inclusive).
9    pub start_frame: u64,
10    /// Last frame of the range (inclusive).
11    pub end_frame: u64,
12    /// Frames per second.
13    pub frame_rate: f64,
14}
15
16impl TimeRange {
17    /// Create a new time range.
18    #[must_use]
19    pub fn new(start: u64, end: u64, fps: f64) -> Self {
20        Self {
21            start_frame: start,
22            end_frame: end,
23            frame_rate: fps,
24        }
25    }
26
27    /// Number of frames in the range (inclusive both ends).
28    #[must_use]
29    pub fn duration_frames(&self) -> u64 {
30        if self.end_frame >= self.start_frame {
31            self.end_frame - self.start_frame + 1
32        } else {
33            0
34        }
35    }
36
37    /// Duration of the range in seconds.
38    #[must_use]
39    pub fn duration_seconds(&self) -> f64 {
40        if self.frame_rate > 0.0 {
41            self.duration_frames() as f64 / self.frame_rate
42        } else {
43            0.0
44        }
45    }
46
47    /// Check whether `frame` falls within this range (inclusive).
48    #[must_use]
49    pub fn contains_frame(&self, frame: u64) -> bool {
50        frame >= self.start_frame && frame <= self.end_frame
51    }
52
53    /// Check whether this range overlaps with `other`.
54    #[must_use]
55    pub fn overlaps(&self, other: &TimeRange) -> bool {
56        self.start_frame <= other.end_frame && other.start_frame <= self.end_frame
57    }
58}
59
60/// Classification of a timeline note.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum NoteType {
63    /// General feedback.
64    General,
65    /// Visual issue (color, exposure, etc.).
66    Visual,
67    /// Audio issue.
68    Audio,
69    /// Edit pacing / timing issue.
70    Pacing,
71    /// Technical issue (codec, format, quality).
72    Technical,
73    /// Legal concern (clearances, rights).
74    Legal,
75}
76
77impl NoteType {
78    /// Short human-readable name.
79    #[must_use]
80    pub fn name(&self) -> &'static str {
81        match self {
82            NoteType::General => "General",
83            NoteType::Visual => "Visual",
84            NoteType::Audio => "Audio",
85            NoteType::Pacing => "Pacing",
86            NoteType::Technical => "Technical",
87            NoteType::Legal => "Legal",
88        }
89    }
90
91    /// Representative emoji for the note type.
92    #[must_use]
93    pub fn emoji(&self) -> &'static str {
94        match self {
95            NoteType::General => "\u{1F4DD}",       // 📝
96            NoteType::Visual => "\u{1F441}",        // 👁
97            NoteType::Audio => "\u{1F50A}",         // 🔊
98            NoteType::Pacing => "\u{2702}\u{FE0F}", // ✂️
99            NoteType::Technical => "\u{1F527}",     // 🔧
100            NoteType::Legal => "\u{2696}\u{FE0F}",  // ⚖️
101        }
102    }
103}
104
105/// A temporal note attached to a specific time range in the media.
106#[derive(Debug, Clone)]
107pub struct TimelineNote {
108    /// Unique identifier.
109    pub id: String,
110    /// Author name.
111    pub author: String,
112    /// Time range this note covers.
113    pub time_range: TimeRange,
114    /// Text of the note.
115    pub text: String,
116    /// Classification of the note.
117    pub note_type: NoteType,
118    /// Whether the note has been resolved.
119    pub resolved: bool,
120    /// Creation timestamp.
121    pub created_at: SystemTime,
122    /// User-defined tags.
123    pub tags: Vec<String>,
124}
125
126impl TimelineNote {
127    /// Create a new timeline note.
128    #[must_use]
129    pub fn new(author: &str, range: TimeRange, text: &str, note_type: NoteType) -> Self {
130        use std::collections::hash_map::DefaultHasher;
131        use std::hash::{Hash, Hasher};
132
133        // Generate a deterministic-looking ID from author + time + text
134        let mut hasher = DefaultHasher::new();
135        author.hash(&mut hasher);
136        text.hash(&mut hasher);
137        range.start_frame.hash(&mut hasher);
138        SystemTime::now()
139            .duration_since(SystemTime::UNIX_EPOCH)
140            .unwrap_or_default()
141            .subsec_nanos()
142            .hash(&mut hasher);
143        let id = format!("note-{:016x}", hasher.finish());
144
145        Self {
146            id,
147            author: author.to_string(),
148            time_range: range,
149            text: text.to_string(),
150            note_type,
151            resolved: false,
152            created_at: SystemTime::now(),
153            tags: Vec::new(),
154        }
155    }
156
157    /// Attach a tag to this note.
158    #[must_use]
159    pub fn with_tag(mut self, tag: &str) -> Self {
160        self.tags.push(tag.to_string());
161        self
162    }
163
164    /// Mark this note as resolved.
165    pub fn resolve(&mut self) {
166        self.resolved = true;
167    }
168
169    /// Check whether this note covers the given frame.
170    #[must_use]
171    pub fn overlaps_frame(&self, frame: u64) -> bool {
172        self.time_range.contains_frame(frame)
173    }
174}
175
176/// A collection of timeline notes for a review session.
177pub struct TimelineNoteCollection {
178    notes: Vec<TimelineNote>,
179    /// Total duration of the associated media in frames.
180    #[allow(dead_code)]
181    media_duration_frames: u64,
182}
183
184impl TimelineNoteCollection {
185    /// Create a new collection for media with the given duration.
186    #[must_use]
187    pub fn new(duration_frames: u64) -> Self {
188        Self {
189            notes: Vec::new(),
190            media_duration_frames: duration_frames,
191        }
192    }
193
194    /// Add a note to the collection.
195    pub fn add_note(&mut self, note: TimelineNote) {
196        self.notes.push(note);
197    }
198
199    /// Look up a note by its ID.
200    #[must_use]
201    pub fn get_note(&self, id: &str) -> Option<&TimelineNote> {
202        self.notes.iter().find(|n| n.id == id)
203    }
204
205    /// Mark the note with the given ID as resolved.  Returns `true` on success.
206    pub fn resolve_note(&mut self, id: &str) -> bool {
207        if let Some(note) = self.notes.iter_mut().find(|n| n.id == id) {
208            note.resolve();
209            true
210        } else {
211            false
212        }
213    }
214
215    /// All notes that cover the given frame.
216    #[must_use]
217    pub fn notes_at_frame(&self, frame: u64) -> Vec<&TimelineNote> {
218        self.notes
219            .iter()
220            .filter(|n| n.overlaps_frame(frame))
221            .collect()
222    }
223
224    /// All notes written by a specific author.
225    #[must_use]
226    pub fn notes_by_author(&self, author: &str) -> Vec<&TimelineNote> {
227        self.notes.iter().filter(|n| n.author == author).collect()
228    }
229
230    /// Number of unresolved notes.
231    #[must_use]
232    pub fn unresolved_count(&self) -> usize {
233        self.notes.iter().filter(|n| !n.resolved).count()
234    }
235
236    /// All notes in insertion order.
237    #[must_use]
238    pub fn all_notes(&self) -> &[TimelineNote] {
239        &self.notes
240    }
241
242    /// All notes of a given type.
243    #[must_use]
244    pub fn notes_by_type(&self, note_type: NoteType) -> Vec<&TimelineNote> {
245        self.notes
246            .iter()
247            .filter(|n| n.note_type == note_type)
248            .collect()
249    }
250
251    /// Generate a plain-text summary of all notes.
252    #[must_use]
253    pub fn export_summary(&self) -> String {
254        let mut out = String::from("Timeline Note Summary\n");
255        out.push_str("=====================\n\n");
256        if self.notes.is_empty() {
257            out.push_str("No notes.\n");
258            return out;
259        }
260        for note in &self.notes {
261            out.push_str(&format!(
262                "[{}] {} ({}) frames {}-{}\n",
263                if note.resolved { "RESOLVED" } else { "OPEN" },
264                note.note_type.name(),
265                note.author,
266                note.time_range.start_frame,
267                note.time_range.end_frame,
268            ));
269            out.push_str(&format!("  {}\n", note.text));
270            if !note.tags.is_empty() {
271                out.push_str(&format!("  Tags: {}\n", note.tags.join(", ")));
272            }
273            out.push('\n');
274        }
275        out
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Tests
281// ---------------------------------------------------------------------------
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn make_range(start: u64, end: u64) -> TimeRange {
288        TimeRange::new(start, end, 24.0)
289    }
290
291    fn make_note(author: &str, start: u64, end: u64) -> TimelineNote {
292        TimelineNote::new(
293            author,
294            make_range(start, end),
295            "test note",
296            NoteType::General,
297        )
298    }
299
300    #[test]
301    fn test_time_range_new() {
302        let r = make_range(10, 20);
303        assert_eq!(r.start_frame, 10);
304        assert_eq!(r.end_frame, 20);
305        assert_eq!(r.duration_frames(), 11); // inclusive
306    }
307
308    #[test]
309    fn test_time_range_contains() {
310        let r = make_range(10, 20);
311        assert!(r.contains_frame(10));
312        assert!(r.contains_frame(15));
313        assert!(r.contains_frame(20));
314        assert!(!r.contains_frame(9));
315        assert!(!r.contains_frame(21));
316    }
317
318    #[test]
319    fn test_time_range_overlaps() {
320        let r1 = make_range(0, 10);
321        let r2 = make_range(5, 15);
322        let r3 = make_range(11, 20);
323        assert!(r1.overlaps(&r2));
324        assert!(r2.overlaps(&r1));
325        assert!(!r1.overlaps(&r3));
326        assert!(!r3.overlaps(&r1));
327        // Edge: adjacent ranges share no frame, but boundary touches
328        let r4 = make_range(10, 10);
329        assert!(r1.overlaps(&r4));
330    }
331
332    #[test]
333    fn test_time_range_duration_seconds() {
334        let r = TimeRange::new(0, 23, 24.0); // 24 frames at 24 fps = 1.0 s
335        let secs = r.duration_seconds();
336        assert!((secs - 1.0).abs() < 1e-9);
337    }
338
339    #[test]
340    fn test_note_new() {
341        let note = make_note("Alice", 0, 10);
342        assert_eq!(note.author, "Alice");
343        assert!(!note.resolved);
344        assert!(note.tags.is_empty());
345        assert!(!note.id.is_empty());
346    }
347
348    #[test]
349    fn test_note_with_tag() {
350        let note = make_note("Bob", 0, 5).with_tag("color").with_tag("urgent");
351        assert_eq!(note.tags.len(), 2);
352        assert!(note.tags.contains(&"color".to_string()));
353        assert!(note.tags.contains(&"urgent".to_string()));
354    }
355
356    #[test]
357    fn test_note_resolve() {
358        let mut note = make_note("Alice", 0, 10);
359        assert!(!note.resolved);
360        note.resolve();
361        assert!(note.resolved);
362    }
363
364    #[test]
365    fn test_collection_add() {
366        let mut col = TimelineNoteCollection::new(1000);
367        assert_eq!(col.all_notes().len(), 0);
368        col.add_note(make_note("Alice", 0, 10));
369        col.add_note(make_note("Bob", 5, 15));
370        assert_eq!(col.all_notes().len(), 2);
371    }
372
373    #[test]
374    fn test_collection_notes_at_frame() {
375        let mut col = TimelineNoteCollection::new(1000);
376        col.add_note(make_note("Alice", 0, 10));
377        col.add_note(make_note("Bob", 20, 30));
378        let at_5 = col.notes_at_frame(5);
379        assert_eq!(at_5.len(), 1);
380        assert_eq!(at_5[0].author, "Alice");
381        let at_25 = col.notes_at_frame(25);
382        assert_eq!(at_25.len(), 1);
383        assert_eq!(at_25[0].author, "Bob");
384        let at_15 = col.notes_at_frame(15);
385        assert_eq!(at_15.len(), 0);
386    }
387
388    #[test]
389    fn test_collection_unresolved() {
390        let mut col = TimelineNoteCollection::new(1000);
391        col.add_note(make_note("Alice", 0, 10));
392        col.add_note(make_note("Bob", 20, 30));
393        assert_eq!(col.unresolved_count(), 2);
394        let id = col.all_notes()[0].id.clone();
395        assert!(col.resolve_note(&id));
396        assert_eq!(col.unresolved_count(), 1);
397    }
398
399    #[test]
400    fn test_note_type_name() {
401        for nt in [
402            NoteType::General,
403            NoteType::Visual,
404            NoteType::Audio,
405            NoteType::Pacing,
406            NoteType::Technical,
407            NoteType::Legal,
408        ] {
409            assert!(!nt.name().is_empty());
410        }
411    }
412
413    #[test]
414    fn test_export_summary_not_empty() {
415        let mut col = TimelineNoteCollection::new(1000);
416        col.add_note(make_note("Alice", 0, 10));
417        let summary = col.export_summary();
418        assert!(!summary.is_empty());
419        assert!(summary.contains("Alice"));
420    }
421}