1use std::time::SystemTime;
4
5#[derive(Debug, Clone, Copy)]
7pub struct TimeRange {
8 pub start_frame: u64,
10 pub end_frame: u64,
12 pub frame_rate: f64,
14}
15
16impl TimeRange {
17 #[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 #[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 #[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 #[must_use]
49 pub fn contains_frame(&self, frame: u64) -> bool {
50 frame >= self.start_frame && frame <= self.end_frame
51 }
52
53 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum NoteType {
63 General,
65 Visual,
67 Audio,
69 Pacing,
71 Technical,
73 Legal,
75}
76
77impl NoteType {
78 #[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 #[must_use]
93 pub fn emoji(&self) -> &'static str {
94 match self {
95 NoteType::General => "\u{1F4DD}", NoteType::Visual => "\u{1F441}", NoteType::Audio => "\u{1F50A}", NoteType::Pacing => "\u{2702}\u{FE0F}", NoteType::Technical => "\u{1F527}", NoteType::Legal => "\u{2696}\u{FE0F}", }
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct TimelineNote {
108 pub id: String,
110 pub author: String,
112 pub time_range: TimeRange,
114 pub text: String,
116 pub note_type: NoteType,
118 pub resolved: bool,
120 pub created_at: SystemTime,
122 pub tags: Vec<String>,
124}
125
126impl TimelineNote {
127 #[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 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 #[must_use]
159 pub fn with_tag(mut self, tag: &str) -> Self {
160 self.tags.push(tag.to_string());
161 self
162 }
163
164 pub fn resolve(&mut self) {
166 self.resolved = true;
167 }
168
169 #[must_use]
171 pub fn overlaps_frame(&self, frame: u64) -> bool {
172 self.time_range.contains_frame(frame)
173 }
174}
175
176pub struct TimelineNoteCollection {
178 notes: Vec<TimelineNote>,
179 #[allow(dead_code)]
181 media_duration_frames: u64,
182}
183
184impl TimelineNoteCollection {
185 #[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 pub fn add_note(&mut self, note: TimelineNote) {
196 self.notes.push(note);
197 }
198
199 #[must_use]
201 pub fn get_note(&self, id: &str) -> Option<&TimelineNote> {
202 self.notes.iter().find(|n| n.id == id)
203 }
204
205 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 #[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 #[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 #[must_use]
232 pub fn unresolved_count(&self) -> usize {
233 self.notes.iter().filter(|n| !n.resolved).count()
234 }
235
236 #[must_use]
238 pub fn all_notes(&self) -> &[TimelineNote] {
239 &self.notes
240 }
241
242 #[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 #[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#[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); }
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 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); 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}