Skip to main content

oximedia_edit/
group_edit.rs

1//! Group editing operations for multi-clip batch modifications.
2//!
3//! Provides the ability to create, manipulate, and apply editing operations
4//! to groups of clips as a single atomic unit, enabling efficient batch
5//! edits, synchronised moves, and linked transformations.
6
7#![allow(dead_code)]
8
9use std::collections::{HashMap, HashSet};
10use std::fmt;
11
12// ---------------------------------------------------------------------------
13// Group identity and membership
14// ---------------------------------------------------------------------------
15
16/// Unique identifier for a clip group.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct GroupId(pub u64);
19
20impl fmt::Display for GroupId {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        write!(f, "group-{}", self.0)
23    }
24}
25
26/// Defines how clips within a group respond when one member is edited.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum GroupBehavior {
29    /// All clips move together rigidly (translate in lock-step).
30    Locked,
31    /// Clips maintain relative timing but may be individually trimmed.
32    Relative,
33    /// Clips are loosely associated; edits only propagate on explicit request.
34    Loose,
35}
36
37impl fmt::Display for GroupBehavior {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Locked => write!(f, "locked"),
41            Self::Relative => write!(f, "relative"),
42            Self::Loose => write!(f, "loose"),
43        }
44    }
45}
46
47/// A group of clips that can be edited as a unit.
48#[derive(Debug, Clone)]
49pub struct EditGroup {
50    /// Unique identifier for this group.
51    pub id: GroupId,
52    /// Human-readable name for the group.
53    pub name: String,
54    /// Clip IDs that belong to this group.
55    pub members: HashSet<u64>,
56    /// How edits propagate within the group.
57    pub behavior: GroupBehavior,
58    /// Whether the group is currently locked against edits.
59    pub locked: bool,
60    /// Optional color label for UI display (RGBA).
61    pub color: Option<u32>,
62}
63
64impl EditGroup {
65    /// Create a new group with the given id and name.
66    pub fn new(id: GroupId, name: impl Into<String>) -> Self {
67        Self {
68            id,
69            name: name.into(),
70            members: HashSet::new(),
71            behavior: GroupBehavior::Locked,
72            locked: false,
73            color: None,
74        }
75    }
76
77    /// Builder: set the group behavior.
78    #[must_use]
79    pub fn with_behavior(mut self, behavior: GroupBehavior) -> Self {
80        self.behavior = behavior;
81        self
82    }
83
84    /// Builder: set color label.
85    #[must_use]
86    pub fn with_color(mut self, rgba: u32) -> Self {
87        self.color = Some(rgba);
88        self
89    }
90
91    /// Add a clip ID to this group.
92    pub fn add_member(&mut self, clip_id: u64) -> bool {
93        self.members.insert(clip_id)
94    }
95
96    /// Remove a clip ID from this group.
97    pub fn remove_member(&mut self, clip_id: u64) -> bool {
98        self.members.remove(&clip_id)
99    }
100
101    /// Returns `true` if the clip is in this group.
102    #[must_use]
103    pub fn contains(&self, clip_id: u64) -> bool {
104        self.members.contains(&clip_id)
105    }
106
107    /// Returns the number of clips in the group.
108    #[must_use]
109    pub fn member_count(&self) -> usize {
110        self.members.len()
111    }
112
113    /// Returns `true` if the group has no members.
114    #[must_use]
115    pub fn is_empty(&self) -> bool {
116        self.members.is_empty()
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Batch operation
122// ---------------------------------------------------------------------------
123
124/// An operation to apply to every clip in a group.
125#[derive(Debug, Clone, PartialEq)]
126pub enum BatchOp {
127    /// Move every clip by a signed offset (in timebase units).
128    MoveBy(i64),
129    /// Scale the duration of every clip by a factor.
130    ScaleDuration(f64),
131    /// Set the opacity of every clip (0.0 = transparent, 1.0 = opaque).
132    SetOpacity(f64),
133    /// Trim the in-point of every clip by a signed offset.
134    TrimInBy(i64),
135    /// Trim the out-point of every clip by a signed offset.
136    TrimOutBy(i64),
137    /// Mute or unmute every clip.
138    SetMute(bool),
139    /// Delete every clip in the group.
140    Delete,
141}
142
143impl fmt::Display for BatchOp {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::MoveBy(d) => write!(f, "move by {d}"),
147            Self::ScaleDuration(s) => write!(f, "scale duration x{s}"),
148            Self::SetOpacity(o) => write!(f, "opacity {o}"),
149            Self::TrimInBy(d) => write!(f, "trim in by {d}"),
150            Self::TrimOutBy(d) => write!(f, "trim out by {d}"),
151            Self::SetMute(m) => write!(f, "mute={m}"),
152            Self::Delete => write!(f, "delete"),
153        }
154    }
155}
156
157/// Result of applying a batch operation.
158#[derive(Debug, Clone)]
159pub struct BatchResult {
160    /// Number of clips affected.
161    pub affected: usize,
162    /// Number of clips that were skipped (e.g. locked).
163    pub skipped: usize,
164    /// Errors keyed by clip ID.
165    pub errors: HashMap<u64, String>,
166}
167
168impl BatchResult {
169    /// Create a new, empty result.
170    #[must_use]
171    pub fn new() -> Self {
172        Self {
173            affected: 0,
174            skipped: 0,
175            errors: HashMap::new(),
176        }
177    }
178
179    /// Returns `true` if no errors occurred.
180    #[must_use]
181    pub fn is_ok(&self) -> bool {
182        self.errors.is_empty()
183    }
184
185    /// Returns `true` if there were any errors.
186    #[must_use]
187    pub fn has_errors(&self) -> bool {
188        !self.errors.is_empty()
189    }
190}
191
192impl Default for BatchResult {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Group registry
200// ---------------------------------------------------------------------------
201
202/// Counter for assigning unique group IDs.
203fn next_group_id() -> GroupId {
204    use std::sync::atomic::{AtomicU64, Ordering};
205    static CTR: AtomicU64 = AtomicU64::new(1);
206    GroupId(CTR.fetch_add(1, Ordering::Relaxed))
207}
208
209/// Manages all clip groups in a project.
210#[derive(Debug, Clone)]
211pub struct GroupEditRegistry {
212    /// All known groups, keyed by their group-ID.
213    groups: HashMap<GroupId, EditGroup>,
214    /// Reverse index: clip-ID -> group-IDs it belongs to.
215    clip_to_groups: HashMap<u64, HashSet<GroupId>>,
216}
217
218impl GroupEditRegistry {
219    /// Create a new, empty registry.
220    #[must_use]
221    pub fn new() -> Self {
222        Self {
223            groups: HashMap::new(),
224            clip_to_groups: HashMap::new(),
225        }
226    }
227
228    /// Create a new group and return its ID.
229    pub fn create_group(&mut self, name: impl Into<String>) -> GroupId {
230        let id = next_group_id();
231        let group = EditGroup::new(id, name);
232        self.groups.insert(id, group);
233        id
234    }
235
236    /// Delete a group by its ID.
237    pub fn delete_group(&mut self, id: GroupId) -> Option<EditGroup> {
238        if let Some(group) = self.groups.remove(&id) {
239            for &clip_id in &group.members {
240                if let Some(set) = self.clip_to_groups.get_mut(&clip_id) {
241                    set.remove(&id);
242                }
243            }
244            Some(group)
245        } else {
246            None
247        }
248    }
249
250    /// Get an immutable reference to a group.
251    #[must_use]
252    pub fn get(&self, id: GroupId) -> Option<&EditGroup> {
253        self.groups.get(&id)
254    }
255
256    /// Get a mutable reference to a group.
257    pub fn get_mut(&mut self, id: GroupId) -> Option<&mut EditGroup> {
258        self.groups.get_mut(&id)
259    }
260
261    /// Add a clip to a group. Returns `true` if the clip was newly added.
262    pub fn add_clip_to_group(&mut self, group_id: GroupId, clip_id: u64) -> bool {
263        let added = self
264            .groups
265            .get_mut(&group_id)
266            .is_some_and(|g| g.add_member(clip_id));
267        if added {
268            self.clip_to_groups
269                .entry(clip_id)
270                .or_default()
271                .insert(group_id);
272        }
273        added
274    }
275
276    /// Remove a clip from a group. Returns `true` if the clip was removed.
277    pub fn remove_clip_from_group(&mut self, group_id: GroupId, clip_id: u64) -> bool {
278        let removed = self
279            .groups
280            .get_mut(&group_id)
281            .is_some_and(|g| g.remove_member(clip_id));
282        if removed {
283            if let Some(set) = self.clip_to_groups.get_mut(&clip_id) {
284                set.remove(&group_id);
285            }
286        }
287        removed
288    }
289
290    /// Find all groups that a clip belongs to.
291    pub fn groups_for_clip(&self, clip_id: u64) -> Vec<GroupId> {
292        self.clip_to_groups
293            .get(&clip_id)
294            .map_or_else(Vec::new, |set| set.iter().copied().collect())
295    }
296
297    /// Returns the total number of groups.
298    #[must_use]
299    pub fn group_count(&self) -> usize {
300        self.groups.len()
301    }
302
303    /// Returns `true` if there are no groups.
304    #[must_use]
305    pub fn is_empty(&self) -> bool {
306        self.groups.is_empty()
307    }
308
309    /// Apply a batch operation to every member of a group.
310    ///
311    /// This is a dry-run: it returns a `BatchResult` describing what would
312    /// happen, without actually modifying clip data (the caller is
313    /// responsible for applying the changes to the timeline).
314    #[must_use]
315    pub fn plan_batch_op(&self, group_id: GroupId, _op: &BatchOp) -> BatchResult {
316        let mut result = BatchResult::new();
317        let group = match self.groups.get(&group_id) {
318            Some(g) => g,
319            None => return result,
320        };
321        if group.locked {
322            result.skipped = group.member_count();
323            return result;
324        }
325        result.affected = group.member_count();
326        result
327    }
328}
329
330impl Default for GroupEditRegistry {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336// ---------------------------------------------------------------------------
337// Tests
338// ---------------------------------------------------------------------------
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_group_id_display() {
346        assert_eq!(GroupId(42).to_string(), "group-42");
347    }
348
349    #[test]
350    fn test_group_behavior_display() {
351        assert_eq!(GroupBehavior::Locked.to_string(), "locked");
352        assert_eq!(GroupBehavior::Relative.to_string(), "relative");
353        assert_eq!(GroupBehavior::Loose.to_string(), "loose");
354    }
355
356    #[test]
357    fn test_edit_group_new() {
358        let g = EditGroup::new(GroupId(1), "My Group");
359        assert_eq!(g.name, "My Group");
360        assert!(g.is_empty());
361        assert_eq!(g.behavior, GroupBehavior::Locked);
362    }
363
364    #[test]
365    fn test_edit_group_add_remove_member() {
366        let mut g = EditGroup::new(GroupId(1), "g");
367        assert!(g.add_member(10));
368        assert!(!g.add_member(10)); // duplicate
369        assert_eq!(g.member_count(), 1);
370        assert!(g.contains(10));
371        assert!(g.remove_member(10));
372        assert!(!g.remove_member(10)); // already removed
373        assert!(g.is_empty());
374    }
375
376    #[test]
377    fn test_edit_group_builders() {
378        let g = EditGroup::new(GroupId(1), "g")
379            .with_behavior(GroupBehavior::Loose)
380            .with_color(0xFF0000FF);
381        assert_eq!(g.behavior, GroupBehavior::Loose);
382        assert_eq!(g.color, Some(0xFF0000FF));
383    }
384
385    #[test]
386    fn test_batch_op_display() {
387        assert_eq!(BatchOp::MoveBy(-100).to_string(), "move by -100");
388        assert_eq!(BatchOp::Delete.to_string(), "delete");
389        assert_eq!(BatchOp::SetMute(true).to_string(), "mute=true");
390    }
391
392    #[test]
393    fn test_batch_result_default() {
394        let r = BatchResult::default();
395        assert!(r.is_ok());
396        assert!(!r.has_errors());
397        assert_eq!(r.affected, 0);
398    }
399
400    #[test]
401    fn test_batch_result_with_errors() {
402        let mut r = BatchResult::new();
403        r.errors.insert(1, "locked".to_string());
404        assert!(r.has_errors());
405        assert!(!r.is_ok());
406    }
407
408    #[test]
409    fn test_registry_create_delete() {
410        let mut reg = GroupEditRegistry::new();
411        let gid = reg.create_group("Test");
412        assert_eq!(reg.group_count(), 1);
413        assert!(reg.delete_group(gid).is_some());
414        assert!(reg.is_empty());
415    }
416
417    #[test]
418    fn test_registry_add_clip_to_group() {
419        let mut reg = GroupEditRegistry::new();
420        let gid = reg.create_group("G1");
421        assert!(reg.add_clip_to_group(gid, 100));
422        assert!(reg.get(gid).expect("get should succeed").contains(100));
423    }
424
425    #[test]
426    fn test_registry_remove_clip_from_group() {
427        let mut reg = GroupEditRegistry::new();
428        let gid = reg.create_group("G1");
429        reg.add_clip_to_group(gid, 100);
430        assert!(reg.remove_clip_from_group(gid, 100));
431        assert!(!reg.get(gid).expect("get should succeed").contains(100));
432    }
433
434    #[test]
435    fn test_registry_groups_for_clip() {
436        let mut reg = GroupEditRegistry::new();
437        let g1 = reg.create_group("A");
438        let g2 = reg.create_group("B");
439        reg.add_clip_to_group(g1, 5);
440        reg.add_clip_to_group(g2, 5);
441        let groups = reg.groups_for_clip(5);
442        assert_eq!(groups.len(), 2);
443    }
444
445    #[test]
446    fn test_registry_plan_batch_op() {
447        let mut reg = GroupEditRegistry::new();
448        let gid = reg.create_group("G");
449        reg.add_clip_to_group(gid, 1);
450        reg.add_clip_to_group(gid, 2);
451        let result = reg.plan_batch_op(gid, &BatchOp::MoveBy(50));
452        assert_eq!(result.affected, 2);
453        assert!(result.is_ok());
454    }
455
456    #[test]
457    fn test_registry_plan_batch_op_locked() {
458        let mut reg = GroupEditRegistry::new();
459        let gid = reg.create_group("Locked");
460        reg.add_clip_to_group(gid, 1);
461        reg.get_mut(gid).expect("get_mut should succeed").locked = true;
462        let result = reg.plan_batch_op(gid, &BatchOp::Delete);
463        assert_eq!(result.affected, 0);
464        assert_eq!(result.skipped, 1);
465    }
466
467    #[test]
468    fn test_registry_default() {
469        let reg = GroupEditRegistry::default();
470        assert!(reg.is_empty());
471    }
472
473    #[test]
474    fn test_delete_group_cleans_reverse_index() {
475        let mut reg = GroupEditRegistry::new();
476        let gid = reg.create_group("X");
477        reg.add_clip_to_group(gid, 42);
478        reg.delete_group(gid);
479        assert!(reg.groups_for_clip(42).is_empty());
480    }
481}