Skip to main content

oximedia_edit/
nested_sequence.rs

1//! Nested sequence (sub-sequence) support for timeline editing.
2//!
3//! Allows timelines to contain references to other timelines, enabling
4//! hierarchical composition, reusable sequence templates, and
5//! non-destructive editing of complex projects.
6
7use std::collections::HashMap;
8use std::fmt;
9
10use oximedia_core::Rational;
11
12// ---------------------------------------------------------------------------
13// Sequence identity
14// ---------------------------------------------------------------------------
15
16/// Unique identifier for a sequence.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct SequenceId(pub u64);
19
20impl fmt::Display for SequenceId {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        write!(f, "seq-{}", self.0)
23    }
24}
25
26// ---------------------------------------------------------------------------
27// Nested sequence
28// ---------------------------------------------------------------------------
29
30/// How a nested sequence is rendered when its frame rate differs from the
31/// parent timeline.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ConformMethod {
34    /// Drop or duplicate frames to match the parent rate.
35    NearestFrame,
36    /// Blend adjacent frames for smoother conversion.
37    FrameBlend,
38    /// Use optical flow for highest quality (computationally expensive).
39    OpticalFlow,
40}
41
42impl fmt::Display for ConformMethod {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::NearestFrame => write!(f, "nearest"),
46            Self::FrameBlend => write!(f, "blend"),
47            Self::OpticalFlow => write!(f, "optical-flow"),
48        }
49    }
50}
51
52/// A reference to a sub-sequence placed on a parent timeline.
53#[derive(Debug, Clone)]
54pub struct NestedSequenceRef {
55    /// ID of the child sequence being referenced.
56    pub sequence_id: SequenceId,
57    /// Position on the parent timeline (timebase units). May be negative for pre-roll.
58    pub position: i64,
59    /// In-point within the child sequence.
60    pub in_point: i64,
61    /// Out-point within the child sequence.
62    pub out_point: i64,
63    /// Speed multiplier (1.0 = normal).
64    pub speed: f64,
65    /// How to conform mismatched frame rates.
66    pub conform: ConformMethod,
67    /// Whether the nested ref is flattened (baked) or live.
68    pub flattened: bool,
69}
70
71impl NestedSequenceRef {
72    /// Create a new nested sequence reference.
73    #[must_use]
74    pub fn new(sequence_id: SequenceId, position: i64, in_point: i64, out_point: i64) -> Self {
75        Self {
76            sequence_id,
77            position,
78            in_point,
79            out_point,
80            speed: 1.0,
81            conform: ConformMethod::NearestFrame,
82            flattened: false,
83        }
84    }
85
86    /// Builder: set speed.
87    #[must_use]
88    pub fn with_speed(mut self, speed: f64) -> Self {
89        self.speed = speed;
90        self
91    }
92
93    /// Builder: set conform method.
94    #[must_use]
95    pub fn with_conform(mut self, method: ConformMethod) -> Self {
96        self.conform = method;
97        self
98    }
99
100    /// Duration of this reference on the parent timeline.
101    ///
102    /// Returns the number of parent timebase units occupied by this reference,
103    /// computed from the source range scaled by speed. Always non-negative.
104    #[allow(clippy::cast_precision_loss)]
105    #[allow(clippy::cast_possible_truncation)]
106    #[allow(clippy::cast_sign_loss)]
107    #[must_use]
108    pub fn duration(&self) -> i64 {
109        let source_len = (self.out_point - self.in_point).max(0);
110        if self.speed.abs() < f64::EPSILON {
111            return 0;
112        }
113        (source_len as f64 / self.speed).round().max(0.0) as i64
114    }
115
116    /// End position on the parent timeline.
117    #[must_use]
118    pub fn end_position(&self) -> i64 {
119        self.position.saturating_add(self.duration())
120    }
121}
122
123// ---------------------------------------------------------------------------
124// Sequence definition
125// ---------------------------------------------------------------------------
126
127/// A sequence (timeline) that can be nested inside other sequences.
128#[derive(Debug, Clone)]
129pub struct NestedSequence {
130    /// Unique identifier.
131    pub id: SequenceId,
132    /// Human-readable name.
133    pub name: String,
134    /// Frame rate of this sequence, expressed as a `Rational` (e.g. `Rational::new(24, 1)`).
135    pub frame_rate: Rational,
136    /// Total duration in timebase units.
137    pub duration: i64,
138    /// Resolution in pixels: `(width, height)`. Defaults to `(1920, 1080)`.
139    pub resolution: (u32, u32),
140    /// Number of tracks in this sequence.
141    pub track_count: u32,
142    /// Nested references *within* this sequence (sub-sub-sequences).
143    pub nested_refs: Vec<NestedSequenceRef>,
144}
145
146impl NestedSequence {
147    /// Create a new sequence with default 1920×1080 resolution.
148    #[must_use]
149    pub fn new(id: SequenceId, name: impl Into<String>, frame_rate: Rational) -> Self {
150        Self {
151            id,
152            name: name.into(),
153            frame_rate,
154            duration: 0,
155            resolution: (1920, 1080),
156            track_count: 0,
157            nested_refs: Vec::new(),
158        }
159    }
160
161    /// Builder: set duration.
162    #[must_use]
163    pub fn with_duration(mut self, duration: i64) -> Self {
164        self.duration = duration;
165        self
166    }
167
168    /// Builder: set resolution.
169    #[must_use]
170    pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
171        self.resolution = (width, height);
172        self
173    }
174
175    /// Builder: set track count.
176    #[must_use]
177    pub fn with_track_count(mut self, count: u32) -> Self {
178        self.track_count = count;
179        self
180    }
181
182    /// Add a nested reference.
183    pub fn add_ref(&mut self, r: NestedSequenceRef) {
184        self.nested_refs.push(r);
185    }
186
187    /// Remove a nested reference by child sequence ID.
188    pub fn remove_ref(&mut self, child_id: SequenceId) -> usize {
189        let before = self.nested_refs.len();
190        self.nested_refs.retain(|r| r.sequence_id != child_id);
191        before - self.nested_refs.len()
192    }
193
194    /// Returns `true` if this sequence contains a reference to `child_id`.
195    #[must_use]
196    pub fn references(&self, child_id: SequenceId) -> bool {
197        self.nested_refs.iter().any(|r| r.sequence_id == child_id)
198    }
199
200    /// Compute the duration of this sequence in an outer (parent) timebase.
201    ///
202    /// Uses integer rounding (nearest-frame) to convert from this sequence's
203    /// own `frame_rate` into `outer_rate`. All three [`ConformMethod`] variants
204    /// produce the same duration accounting; `FrameBlend` and `OpticalFlow`
205    /// differ only in render quality, not timeline length.
206    ///
207    /// # Returns
208    ///
209    /// The number of `outer_rate` units required to represent this sequence's
210    /// full duration. Returns `0` if either rate has a zero component.
211    ///
212    /// # Example
213    ///
214    /// ```
215    /// use oximedia_core::Rational;
216    /// use oximedia_edit::nested_sequence::{NestedSequence, SequenceId};
217    ///
218    /// // 24 inner frames at 24 fps = 1 second = 60 frames at 60 fps
219    /// let seq = NestedSequence::new(SequenceId(1), "Clip", Rational::new(24, 1))
220    ///     .with_duration(24);
221    /// let outer = seq.duration_in_outer_timebase(Rational::new(60, 1));
222    /// assert_eq!(outer, 60);
223    /// ```
224    #[must_use]
225    pub fn duration_in_outer_timebase(&self, outer_rate: Rational) -> i64 {
226        // Guard against zero denominators / numerators.
227        if self.frame_rate.num == 0
228            || self.frame_rate.den == 0
229            || outer_rate.num == 0
230            || outer_rate.den == 0
231        {
232            return 0;
233        }
234        // Convert:
235        //   inner_duration (ticks at inner_rate) → outer_duration (ticks at outer_rate)
236        //
237        //   outer_duration = inner_duration * (outer_rate / inner_rate)
238        //                  = inner_duration * outer_rate.num * inner_rate.den
239        //                    ─────────────────────────────────────────────────
240        //                           outer_rate.den * inner_rate.num
241        //
242        // Rounding: add half of the denominator before integer division to
243        // implement "round to nearest frame" (NearestFrame semantics).
244        // FrameBlend and OpticalFlow share the same timeline duration.
245        let numerator: i128 =
246            self.duration as i128 * outer_rate.num as i128 * self.frame_rate.den as i128;
247        let denominator: i128 = outer_rate.den as i128 * self.frame_rate.num as i128;
248        if denominator == 0 {
249            return 0;
250        }
251        // Round: (numerator + denominator/2) / denominator
252        // Using the sign-aware rounding: for positive values add half-denom.
253        let half = denominator.abs() / 2;
254        let rounded = if numerator >= 0 {
255            (numerator + half) / denominator
256        } else {
257            (numerator - half) / denominator
258        };
259        rounded as i64
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Sequence registry
265// ---------------------------------------------------------------------------
266
267/// Manages all sequences in a project and detects circular references.
268#[derive(Debug, Clone)]
269pub struct SequenceRegistry {
270    /// All known sequences.
271    sequences: HashMap<SequenceId, NestedSequence>,
272    /// Auto-increment counter.
273    next_id: u64,
274}
275
276impl SequenceRegistry {
277    /// Create a new, empty registry.
278    #[must_use]
279    pub fn new() -> Self {
280        Self {
281            sequences: HashMap::new(),
282            next_id: 1,
283        }
284    }
285
286    /// Create a new sequence and return its ID.
287    pub fn create(&mut self, name: impl Into<String>, frame_rate: Rational) -> SequenceId {
288        let id = SequenceId(self.next_id);
289        self.next_id += 1;
290        let seq = NestedSequence::new(id, name, frame_rate);
291        self.sequences.insert(id, seq);
292        id
293    }
294
295    /// Get a sequence by ID.
296    #[must_use]
297    pub fn get(&self, id: SequenceId) -> Option<&NestedSequence> {
298        self.sequences.get(&id)
299    }
300
301    /// Get a mutable reference.
302    pub fn get_mut(&mut self, id: SequenceId) -> Option<&mut NestedSequence> {
303        self.sequences.get_mut(&id)
304    }
305
306    /// Remove a sequence and all references to it from other sequences.
307    pub fn remove(&mut self, id: SequenceId) -> Option<NestedSequence> {
308        let removed = self.sequences.remove(&id);
309        // Clean up references in remaining sequences.
310        for seq in self.sequences.values_mut() {
311            seq.remove_ref(id);
312        }
313        removed
314    }
315
316    /// Returns the number of sequences.
317    #[must_use]
318    pub fn count(&self) -> usize {
319        self.sequences.len()
320    }
321
322    /// Returns `true` if there are no sequences.
323    #[must_use]
324    pub fn is_empty(&self) -> bool {
325        self.sequences.is_empty()
326    }
327
328    /// Check if adding a reference from `parent` to `child` would create a
329    /// cycle. Returns `true` if a cycle would be created.
330    #[must_use]
331    pub fn would_create_cycle(&self, parent: SequenceId, child: SequenceId) -> bool {
332        if parent == child {
333            return true;
334        }
335        // DFS from child's nested refs to see if we can reach parent
336        let mut stack = vec![child];
337        let mut visited = std::collections::HashSet::new();
338        while let Some(current) = stack.pop() {
339            if !visited.insert(current) {
340                continue;
341            }
342            if let Some(seq) = self.sequences.get(&current) {
343                for r in &seq.nested_refs {
344                    if r.sequence_id == parent {
345                        return true;
346                    }
347                    stack.push(r.sequence_id);
348                }
349            }
350        }
351        false
352    }
353
354    /// Compute the nesting depth of a sequence (1 = no nesting).
355    #[must_use]
356    pub fn depth(&self, id: SequenceId) -> usize {
357        let seq = match self.sequences.get(&id) {
358            Some(s) => s,
359            None => return 0,
360        };
361        if seq.nested_refs.is_empty() {
362            return 1;
363        }
364        let max_child = seq
365            .nested_refs
366            .iter()
367            .map(|r| self.depth(r.sequence_id))
368            .max()
369            .unwrap_or(0);
370        1 + max_child
371    }
372}
373
374impl Default for SequenceRegistry {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380// ---------------------------------------------------------------------------
381// Tests
382// ---------------------------------------------------------------------------
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_sequence_id_display() {
390        assert_eq!(SequenceId(7).to_string(), "seq-7");
391    }
392
393    #[test]
394    fn test_rational_frame_rate_display() {
395        // Rational::Display shows "num/den" — verify 24/1 and NTSC.
396        let fr = Rational::new(24, 1);
397        assert_eq!(fr.to_string(), "24/1");
398        let ntsc = Rational::new(30000, 1001);
399        assert_eq!(ntsc.to_string(), "30000/1001");
400    }
401
402    #[test]
403    fn test_conform_method_display() {
404        assert_eq!(ConformMethod::NearestFrame.to_string(), "nearest");
405        assert_eq!(ConformMethod::OpticalFlow.to_string(), "optical-flow");
406    }
407
408    #[test]
409    fn test_nested_ref_duration_normal_speed() {
410        let r = NestedSequenceRef::new(SequenceId(1), 0, 0, 1000);
411        assert_eq!(r.duration(), 1000);
412    }
413
414    #[test]
415    fn test_nested_ref_duration_double_speed() {
416        let r = NestedSequenceRef::new(SequenceId(1), 0, 0, 1000).with_speed(2.0);
417        assert_eq!(r.duration(), 500);
418    }
419
420    #[test]
421    fn test_nested_ref_duration_zero_speed() {
422        let r = NestedSequenceRef::new(SequenceId(1), 0, 0, 1000).with_speed(0.0);
423        assert_eq!(r.duration(), 0);
424    }
425
426    #[test]
427    fn test_nested_ref_end_position() {
428        let r = NestedSequenceRef::new(SequenceId(1), 500, 0, 1000);
429        assert_eq!(r.end_position(), 1500);
430    }
431
432    #[test]
433    fn test_nested_ref_negative_position() {
434        // i64 positions allow pre-roll before timeline start.
435        let r = NestedSequenceRef::new(SequenceId(1), -24, 0, 48);
436        assert_eq!(r.position, -24_i64);
437        assert_eq!(r.end_position(), 24); // -24 + 48 = 24
438    }
439
440    #[test]
441    fn test_nested_sequence_add_and_remove_ref() {
442        let mut seq = NestedSequence::new(SequenceId(1), "Main", Rational::new(24, 1));
443        seq.add_ref(NestedSequenceRef::new(SequenceId(2), 0, 0, 100));
444        assert!(seq.references(SequenceId(2)));
445        let removed = seq.remove_ref(SequenceId(2));
446        assert_eq!(removed, 1);
447        assert!(!seq.references(SequenceId(2)));
448    }
449
450    #[test]
451    fn test_nested_sequence_default_resolution() {
452        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(24, 1));
453        assert_eq!(seq.resolution, (1920, 1080));
454    }
455
456    #[test]
457    fn test_nested_sequence_with_resolution() {
458        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(24, 1))
459            .with_resolution(1280, 720);
460        assert_eq!(seq.resolution, (1280, 720));
461    }
462
463    #[test]
464    fn test_registry_create_and_get() {
465        let mut reg = SequenceRegistry::new();
466        let id = reg.create("Timeline 1", Rational::new(30, 1));
467        assert!(reg.get(id).is_some());
468        assert_eq!(reg.count(), 1);
469    }
470
471    #[test]
472    fn test_registry_remove_cleans_refs() {
473        let mut reg = SequenceRegistry::new();
474        let parent = reg.create("Parent", Rational::new(24, 1));
475        let child = reg.create("Child", Rational::new(24, 1));
476        reg.get_mut(parent)
477            .expect("test expectation failed")
478            .add_ref(NestedSequenceRef::new(child, 0, 0, 100));
479        reg.remove(child);
480        assert!(!reg
481            .get(parent)
482            .expect("get should succeed")
483            .references(child));
484    }
485
486    #[test]
487    fn test_registry_cycle_detection_self() {
488        let mut reg = SequenceRegistry::new();
489        let id = reg.create("S", Rational::new(24, 1));
490        assert!(reg.would_create_cycle(id, id));
491    }
492
493    #[test]
494    fn test_registry_cycle_detection_indirect() {
495        let mut reg = SequenceRegistry::new();
496        let a = reg.create("A", Rational::new(24, 1));
497        let b = reg.create("B", Rational::new(24, 1));
498        let c = reg.create("C", Rational::new(24, 1));
499        reg.get_mut(a)
500            .expect("test expectation failed")
501            .add_ref(NestedSequenceRef::new(b, 0, 0, 100));
502        reg.get_mut(b)
503            .expect("test expectation failed")
504            .add_ref(NestedSequenceRef::new(c, 0, 0, 100));
505        // c -> a would create a cycle
506        assert!(reg.would_create_cycle(c, a));
507        // a -> c already exists, not a cycle from that direction
508        assert!(!reg.would_create_cycle(a, c));
509    }
510
511    #[test]
512    fn test_registry_depth() {
513        let mut reg = SequenceRegistry::new();
514        let a = reg.create("A", Rational::new(24, 1));
515        let b = reg.create("B", Rational::new(24, 1));
516        reg.get_mut(a)
517            .expect("test expectation failed")
518            .add_ref(NestedSequenceRef::new(b, 0, 0, 100));
519        assert_eq!(reg.depth(a), 2);
520        assert_eq!(reg.depth(b), 1);
521    }
522
523    #[test]
524    fn test_registry_default() {
525        let reg = SequenceRegistry::default();
526        assert!(reg.is_empty());
527    }
528
529    #[test]
530    fn test_nested_sequence_builders() {
531        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(25, 1))
532            .with_duration(5000)
533            .with_track_count(4);
534        assert_eq!(seq.duration, 5000);
535        assert_eq!(seq.track_count, 4);
536    }
537
538    #[test]
539    fn test_nested_ref_with_conform() {
540        let r = NestedSequenceRef::new(SequenceId(1), 0, 0, 100)
541            .with_conform(ConformMethod::FrameBlend);
542        assert_eq!(r.conform, ConformMethod::FrameBlend);
543    }
544
545    // --- duration_in_outer_timebase tests ---
546
547    #[test]
548    fn test_duration_in_outer_same_rate() {
549        // 100 frames at 25 fps → 100 frames at 25 fps
550        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(25, 1)).with_duration(100);
551        assert_eq!(seq.duration_in_outer_timebase(Rational::new(25, 1)), 100);
552    }
553
554    #[test]
555    fn test_duration_24fps_to_60fps() {
556        // 24 inner frames at 24 fps = 1 second = 60 frames at 60 fps
557        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(24, 1)).with_duration(24);
558        assert_eq!(seq.duration_in_outer_timebase(Rational::new(60, 1)), 60);
559    }
560
561    #[test]
562    fn test_duration_60fps_to_24fps() {
563        // 60 inner frames at 60 fps = 1 second = 24 frames at 24 fps
564        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(60, 1)).with_duration(60);
565        assert_eq!(seq.duration_in_outer_timebase(Rational::new(24, 1)), 24);
566    }
567
568    #[test]
569    fn test_duration_ntsc_to_integer_fps() {
570        // 30000/1001 fps (NTSC) — 30030 inner ticks ≈ 1001 * 30 = 1 second
571        // outer: 30 fps integer — expect 30 outer frames (1 s)
572        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(30000, 1001))
573            .with_duration(30000); // 30000 / (30000/1001) = 1001/1 seconds... hmm
574                                   // Actually 30000 inner ticks at 30000/1001 fps:
575                                   //   duration_secs = 30000 * 1001 / 30000 = 1001 s   (not useful for a unit test)
576                                   // Use a smaller value: 30000/1001 fps, duration = 1001 ticks:
577                                   //   1001 * (1001/30000) s * (30/1) fps = 1001 * 1001 / 30000 ≈ 33.4 outer frames
578                                   // Let's use an exact case: 30000 ticks at 30000/1001 fps →
579                                   //   30000 / (30000/1001) = 1001 s  → at 30fps = 30030 outer
580        let seq2 = NestedSequence::new(SequenceId(1), "S", Rational::new(30000, 1001))
581            .with_duration(30000);
582        // outer 30/1:  30000 * 30 * 1001 / (1 * 30000) = 30030
583        assert_eq!(seq2.duration_in_outer_timebase(Rational::new(30, 1)), 30030);
584        drop(seq); // silence unused warning
585    }
586
587    #[test]
588    fn test_duration_zero_inner_duration() {
589        let seq = NestedSequence::new(SequenceId(1), "S", Rational::new(24, 1)).with_duration(0);
590        assert_eq!(seq.duration_in_outer_timebase(Rational::new(60, 1)), 0);
591    }
592}