Skip to main content

oximedia_edit/
color_label.rs

1//! Clip color labels and metadata tags for organizational workflow.
2//!
3//! Provides a labeling system where clips can be assigned named colors
4//! and arbitrary tags. This helps editors organize large projects by
5//! visually distinguishing clips (e.g., interview = blue, b-roll = green)
6//! and filtering/searching by tags.
7
8#![allow(dead_code)]
9
10use std::collections::{HashMap, HashSet};
11
12use crate::clip::ClipId;
13
14/// A named color label.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct ColorLabel {
17    /// Label name (e.g. "Interview", "B-Roll", "Music").
18    pub name: String,
19    /// Color as RGB hex string (e.g. "#FF0000").
20    pub color: String,
21    /// Optional keyboard shortcut digit (1-9).
22    pub shortcut: Option<u8>,
23}
24
25impl ColorLabel {
26    /// Create a new color label.
27    #[must_use]
28    pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
29        Self {
30            name: name.into(),
31            color: color.into(),
32            shortcut: None,
33        }
34    }
35
36    /// Set a keyboard shortcut.
37    #[must_use]
38    pub fn with_shortcut(mut self, shortcut: u8) -> Self {
39        self.shortcut = Some(shortcut.min(9));
40        self
41    }
42
43    /// Parse the color as RGB bytes. Returns `None` if invalid.
44    #[must_use]
45    pub fn rgb(&self) -> Option<(u8, u8, u8)> {
46        let hex = self.color.trim_start_matches('#');
47        if hex.len() != 6 {
48            return None;
49        }
50        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
51        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
52        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
53        Some((r, g, b))
54    }
55}
56
57/// A set of standard color labels commonly used in NLE workflows.
58pub struct StandardLabels;
59
60impl StandardLabels {
61    /// Get a set of standard production labels.
62    #[must_use]
63    pub fn production() -> Vec<ColorLabel> {
64        vec![
65            ColorLabel::new("Interview", "#4A90D9").with_shortcut(1),
66            ColorLabel::new("B-Roll", "#7ED321").with_shortcut(2),
67            ColorLabel::new("Music", "#BD10E0").with_shortcut(3),
68            ColorLabel::new("SFX", "#F5A623").with_shortcut(4),
69            ColorLabel::new("Graphics", "#D0021B").with_shortcut(5),
70            ColorLabel::new("Voiceover", "#50E3C2").with_shortcut(6),
71            ColorLabel::new("Approved", "#417505").with_shortcut(7),
72            ColorLabel::new("Rejected", "#9B9B9B").with_shortcut(8),
73            ColorLabel::new("Review", "#F8E71C").with_shortcut(9),
74        ]
75    }
76}
77
78/// A metadata tag that can be attached to a clip.
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub struct Tag {
81    /// Tag key (e.g. "scene", "take", "rating").
82    pub key: String,
83    /// Tag value (e.g. "Scene 3", "Take 2", "5").
84    pub value: String,
85}
86
87impl Tag {
88    /// Create a new tag.
89    #[must_use]
90    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
91        Self {
92            key: key.into(),
93            value: value.into(),
94        }
95    }
96
97    /// Create a simple (key-only) tag with empty value.
98    #[must_use]
99    pub fn simple(key: impl Into<String>) -> Self {
100        Self {
101            key: key.into(),
102            value: String::new(),
103        }
104    }
105}
106
107/// Manages color labels and tags for clips.
108#[derive(Debug, Default)]
109pub struct LabelManager {
110    /// Available color labels.
111    labels: Vec<ColorLabel>,
112    /// Clip to label name mapping.
113    clip_labels: HashMap<ClipId, String>,
114    /// Clip to tags mapping.
115    clip_tags: HashMap<ClipId, Vec<Tag>>,
116    /// All known tag keys (for autocomplete).
117    known_tag_keys: HashSet<String>,
118}
119
120impl LabelManager {
121    /// Create a new label manager.
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            labels: Vec::new(),
126            clip_labels: HashMap::new(),
127            clip_tags: HashMap::new(),
128            known_tag_keys: HashSet::new(),
129        }
130    }
131
132    /// Create a label manager with standard production labels.
133    #[must_use]
134    pub fn with_standard_labels() -> Self {
135        let mut mgr = Self::new();
136        mgr.labels = StandardLabels::production();
137        mgr
138    }
139
140    /// Add a custom color label.
141    pub fn add_label(&mut self, label: ColorLabel) {
142        if !self.labels.iter().any(|l| l.name == label.name) {
143            self.labels.push(label);
144        }
145    }
146
147    /// Remove a color label by name.
148    pub fn remove_label(&mut self, name: &str) -> bool {
149        let len_before = self.labels.len();
150        self.labels.retain(|l| l.name != name);
151        // Also remove from clips
152        self.clip_labels.retain(|_, v| v != name);
153        self.labels.len() < len_before
154    }
155
156    /// Get all available labels.
157    #[must_use]
158    pub fn all_labels(&self) -> &[ColorLabel] {
159        &self.labels
160    }
161
162    /// Get a label by name.
163    #[must_use]
164    pub fn get_label(&self, name: &str) -> Option<&ColorLabel> {
165        self.labels.iter().find(|l| l.name == name)
166    }
167
168    /// Assign a color label to a clip.
169    pub fn set_clip_label(&mut self, clip_id: ClipId, label_name: &str) -> bool {
170        if self.labels.iter().any(|l| l.name == label_name) {
171            self.clip_labels.insert(clip_id, label_name.to_string());
172            true
173        } else {
174            false
175        }
176    }
177
178    /// Remove the color label from a clip.
179    pub fn remove_clip_label(&mut self, clip_id: ClipId) -> Option<String> {
180        self.clip_labels.remove(&clip_id)
181    }
182
183    /// Get the color label for a clip.
184    #[must_use]
185    pub fn get_clip_label(&self, clip_id: ClipId) -> Option<&ColorLabel> {
186        let label_name = self.clip_labels.get(&clip_id)?;
187        self.get_label(label_name)
188    }
189
190    /// Add a tag to a clip.
191    pub fn add_clip_tag(&mut self, clip_id: ClipId, tag: Tag) {
192        self.known_tag_keys.insert(tag.key.clone());
193        let tags = self.clip_tags.entry(clip_id).or_default();
194        // Don't add duplicate key-value pairs
195        if !tags.contains(&tag) {
196            tags.push(tag);
197        }
198    }
199
200    /// Remove a tag from a clip by key.
201    pub fn remove_clip_tag(&mut self, clip_id: ClipId, key: &str) -> bool {
202        if let Some(tags) = self.clip_tags.get_mut(&clip_id) {
203            let len_before = tags.len();
204            tags.retain(|t| t.key != key);
205            tags.len() < len_before
206        } else {
207            false
208        }
209    }
210
211    /// Get all tags for a clip.
212    #[must_use]
213    pub fn get_clip_tags(&self, clip_id: ClipId) -> &[Tag] {
214        self.clip_tags
215            .get(&clip_id)
216            .map(Vec::as_slice)
217            .unwrap_or(&[])
218    }
219
220    /// Find clips by label name.
221    #[must_use]
222    pub fn clips_with_label(&self, label_name: &str) -> Vec<ClipId> {
223        self.clip_labels
224            .iter()
225            .filter(|(_, v)| v.as_str() == label_name)
226            .map(|(&k, _)| k)
227            .collect()
228    }
229
230    /// Find clips by tag key.
231    #[must_use]
232    pub fn clips_with_tag_key(&self, key: &str) -> Vec<ClipId> {
233        self.clip_tags
234            .iter()
235            .filter(|(_, tags)| tags.iter().any(|t| t.key == key))
236            .map(|(&id, _)| id)
237            .collect()
238    }
239
240    /// Find clips by tag key-value pair.
241    #[must_use]
242    pub fn clips_with_tag(&self, key: &str, value: &str) -> Vec<ClipId> {
243        self.clip_tags
244            .iter()
245            .filter(|(_, tags)| tags.iter().any(|t| t.key == key && t.value == value))
246            .map(|(&id, _)| id)
247            .collect()
248    }
249
250    /// Get all known tag keys.
251    #[must_use]
252    pub fn known_tag_keys(&self) -> Vec<&str> {
253        self.known_tag_keys.iter().map(String::as_str).collect()
254    }
255
256    /// Remove all labels and tags for a clip.
257    pub fn remove_clip(&mut self, clip_id: ClipId) {
258        self.clip_labels.remove(&clip_id);
259        self.clip_tags.remove(&clip_id);
260    }
261
262    /// Clear everything.
263    pub fn clear(&mut self) {
264        self.clip_labels.clear();
265        self.clip_tags.clear();
266    }
267}
268
269// ─────────────────────────────────────────────────────────────────────────────
270// Tests
271// ─────────────────────────────────────────────────────────────────────────────
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_color_label_rgb() {
279        let label = ColorLabel::new("Test", "#FF8800");
280        let rgb = label.rgb();
281        assert_eq!(rgb, Some((255, 136, 0)));
282    }
283
284    #[test]
285    fn test_color_label_invalid_rgb() {
286        let label = ColorLabel::new("Bad", "not-a-color");
287        assert!(label.rgb().is_none());
288    }
289
290    #[test]
291    fn test_color_label_shortcut() {
292        let label = ColorLabel::new("Test", "#000000").with_shortcut(5);
293        assert_eq!(label.shortcut, Some(5));
294        // Clamp to 9
295        let label2 = ColorLabel::new("Test2", "#000000").with_shortcut(15);
296        assert_eq!(label2.shortcut, Some(9));
297    }
298
299    #[test]
300    fn test_standard_labels() {
301        let labels = StandardLabels::production();
302        assert_eq!(labels.len(), 9);
303        assert_eq!(labels[0].name, "Interview");
304        assert!(labels[0].rgb().is_some());
305    }
306
307    #[test]
308    fn test_tag_creation() {
309        let tag = Tag::new("scene", "Scene 1");
310        assert_eq!(tag.key, "scene");
311        assert_eq!(tag.value, "Scene 1");
312
313        let simple = Tag::simple("favorite");
314        assert_eq!(simple.key, "favorite");
315        assert!(simple.value.is_empty());
316    }
317
318    #[test]
319    fn test_label_manager_add_remove() {
320        let mut mgr = LabelManager::new();
321        mgr.add_label(ColorLabel::new("Test", "#FF0000"));
322        assert_eq!(mgr.all_labels().len(), 1);
323
324        // Duplicate name should not add
325        mgr.add_label(ColorLabel::new("Test", "#00FF00"));
326        assert_eq!(mgr.all_labels().len(), 1);
327
328        assert!(mgr.remove_label("Test"));
329        assert_eq!(mgr.all_labels().len(), 0);
330    }
331
332    #[test]
333    fn test_label_manager_clip_label() {
334        let mut mgr = LabelManager::with_standard_labels();
335
336        // Assign label
337        assert!(mgr.set_clip_label(1, "Interview"));
338        assert!(!mgr.set_clip_label(2, "NonExistent"));
339
340        // Get label
341        let label = mgr.get_clip_label(1);
342        assert!(label.is_some());
343        assert_eq!(label.expect("should exist").name, "Interview");
344
345        assert!(mgr.get_clip_label(2).is_none());
346
347        // Remove label
348        assert!(mgr.remove_clip_label(1).is_some());
349        assert!(mgr.get_clip_label(1).is_none());
350    }
351
352    #[test]
353    fn test_label_manager_clip_tags() {
354        let mut mgr = LabelManager::new();
355
356        mgr.add_clip_tag(1, Tag::new("scene", "1"));
357        mgr.add_clip_tag(1, Tag::new("take", "3"));
358        mgr.add_clip_tag(1, Tag::new("scene", "1")); // duplicate
359
360        assert_eq!(mgr.get_clip_tags(1).len(), 2);
361        assert_eq!(mgr.get_clip_tags(999).len(), 0);
362
363        assert!(mgr.remove_clip_tag(1, "scene"));
364        assert_eq!(mgr.get_clip_tags(1).len(), 1);
365        assert!(!mgr.remove_clip_tag(1, "nonexistent"));
366    }
367
368    #[test]
369    fn test_label_manager_find_clips() {
370        let mut mgr = LabelManager::with_standard_labels();
371        mgr.set_clip_label(1, "Interview");
372        mgr.set_clip_label(2, "Interview");
373        mgr.set_clip_label(3, "B-Roll");
374
375        let interviews = mgr.clips_with_label("Interview");
376        assert_eq!(interviews.len(), 2);
377
378        let broll = mgr.clips_with_label("B-Roll");
379        assert_eq!(broll.len(), 1);
380    }
381
382    #[test]
383    fn test_label_manager_find_by_tag() {
384        let mut mgr = LabelManager::new();
385        mgr.add_clip_tag(1, Tag::new("scene", "1"));
386        mgr.add_clip_tag(2, Tag::new("scene", "2"));
387        mgr.add_clip_tag(3, Tag::new("take", "1"));
388
389        assert_eq!(mgr.clips_with_tag_key("scene").len(), 2);
390        assert_eq!(mgr.clips_with_tag("scene", "1").len(), 1);
391    }
392
393    #[test]
394    fn test_label_manager_known_keys() {
395        let mut mgr = LabelManager::new();
396        mgr.add_clip_tag(1, Tag::new("scene", "1"));
397        mgr.add_clip_tag(2, Tag::new("take", "1"));
398        let keys = mgr.known_tag_keys();
399        assert_eq!(keys.len(), 2);
400    }
401
402    #[test]
403    fn test_label_manager_remove_clip() {
404        let mut mgr = LabelManager::with_standard_labels();
405        mgr.set_clip_label(1, "Interview");
406        mgr.add_clip_tag(1, Tag::new("scene", "1"));
407        mgr.remove_clip(1);
408        assert!(mgr.get_clip_label(1).is_none());
409        assert!(mgr.get_clip_tags(1).is_empty());
410    }
411
412    #[test]
413    fn test_label_manager_clear() {
414        let mut mgr = LabelManager::with_standard_labels();
415        mgr.set_clip_label(1, "Interview");
416        mgr.add_clip_tag(1, Tag::new("scene", "1"));
417        mgr.clear();
418        assert!(mgr.get_clip_label(1).is_none());
419        assert!(mgr.get_clip_tags(1).is_empty());
420    }
421
422    #[test]
423    fn test_removing_label_definition_removes_from_clips() {
424        let mut mgr = LabelManager::new();
425        mgr.add_label(ColorLabel::new("Custom", "#AABBCC"));
426        mgr.set_clip_label(1, "Custom");
427        assert!(mgr.get_clip_label(1).is_some());
428        mgr.remove_label("Custom");
429        assert!(mgr.get_clip_label(1).is_none());
430    }
431
432    // ── Additional comprehensive tests ────────────────────────────────────
433
434    #[test]
435    fn test_color_label_without_shortcut_is_none() {
436        let label = ColorLabel::new("NoShortcut", "#123456");
437        assert!(label.shortcut.is_none());
438    }
439
440    #[test]
441    fn test_color_label_shortcut_clamp_zero() {
442        // Shortcut 0 should clamp to 0 (min(0,9) == 0)
443        let label = ColorLabel::new("Zero", "#000000").with_shortcut(0);
444        assert_eq!(label.shortcut, Some(0));
445    }
446
447    #[test]
448    fn test_color_label_rgb_black() {
449        let label = ColorLabel::new("Black", "#000000");
450        assert_eq!(label.rgb(), Some((0, 0, 0)));
451    }
452
453    #[test]
454    fn test_color_label_rgb_white() {
455        let label = ColorLabel::new("White", "#FFFFFF");
456        assert_eq!(label.rgb(), Some((255, 255, 255)));
457    }
458
459    #[test]
460    fn test_color_label_rgb_lowercase_hex() {
461        // Lowercase hex should also parse correctly
462        let label = ColorLabel::new("Lower", "#aabbcc");
463        assert_eq!(label.rgb(), Some((0xAA, 0xBB, 0xCC)));
464    }
465
466    #[test]
467    fn test_color_label_rgb_short_hex_invalid() {
468        // Only 3 hex chars → invalid
469        let label = ColorLabel::new("Short", "#ABC");
470        assert!(label.rgb().is_none());
471    }
472
473    #[test]
474    fn test_standard_labels_all_have_shortcuts() {
475        let labels = StandardLabels::production();
476        for label in &labels {
477            assert!(
478                label.shortcut.is_some(),
479                "Label '{}' has no shortcut",
480                label.name
481            );
482        }
483    }
484
485    #[test]
486    fn test_standard_labels_all_have_valid_rgb() {
487        let labels = StandardLabels::production();
488        for label in &labels {
489            assert!(
490                label.rgb().is_some(),
491                "Label '{}' has invalid color '{}'",
492                label.name,
493                label.color
494            );
495        }
496    }
497
498    #[test]
499    fn test_tag_equality() {
500        let a = Tag::new("scene", "1");
501        let b = Tag::new("scene", "1");
502        let c = Tag::new("scene", "2");
503        assert_eq!(a, b);
504        assert_ne!(a, c);
505    }
506
507    #[test]
508    fn test_label_manager_multiple_tags_same_key_different_values() {
509        // A clip can have tags with the same key but different values.
510        let mut mgr = LabelManager::new();
511        mgr.add_clip_tag(1, Tag::new("actor", "Alice"));
512        mgr.add_clip_tag(1, Tag::new("actor", "Bob"));
513        assert_eq!(mgr.get_clip_tags(1).len(), 2);
514    }
515
516    #[test]
517    fn test_label_manager_clips_with_tag_value_exact_match() {
518        let mut mgr = LabelManager::new();
519        mgr.add_clip_tag(1, Tag::new("rating", "5"));
520        mgr.add_clip_tag(2, Tag::new("rating", "3"));
521        mgr.add_clip_tag(3, Tag::new("rating", "5"));
522
523        let five_star = mgr.clips_with_tag("rating", "5");
524        assert_eq!(five_star.len(), 2);
525        assert!(!five_star.contains(&2));
526    }
527
528    #[test]
529    fn test_label_manager_remove_clip_clears_both_label_and_tags() {
530        let mut mgr = LabelManager::with_standard_labels();
531        mgr.set_clip_label(42, "Music");
532        mgr.add_clip_tag(42, Tag::new("key", "value"));
533        mgr.remove_clip(42);
534        assert!(mgr.get_clip_label(42).is_none());
535        assert!(mgr.get_clip_tags(42).is_empty());
536    }
537
538    #[test]
539    fn test_label_manager_known_tag_keys_deduplicated() {
540        let mut mgr = LabelManager::new();
541        // Adding the same key via different clips
542        mgr.add_clip_tag(1, Tag::new("scene", "A"));
543        mgr.add_clip_tag(2, Tag::new("scene", "B"));
544        mgr.add_clip_tag(3, Tag::new("take", "1"));
545        let keys = mgr.known_tag_keys();
546        assert_eq!(keys.len(), 2, "Should deduplicate 'scene' key");
547    }
548
549    #[test]
550    fn test_label_manager_set_clip_label_updates_existing() {
551        let mut mgr = LabelManager::with_standard_labels();
552        mgr.set_clip_label(1, "Interview");
553        mgr.set_clip_label(1, "B-Roll"); // overwrite
554        let label = mgr.get_clip_label(1).expect("should exist");
555        assert_eq!(label.name, "B-Roll");
556    }
557
558    #[test]
559    fn test_label_manager_clips_with_label_empty_when_none_assigned() {
560        let mgr = LabelManager::with_standard_labels();
561        let clips = mgr.clips_with_label("Interview");
562        assert!(clips.is_empty());
563    }
564}