Skip to main content

motion_core/authoring/timeline/
track.rs

1//! Timeline clips, tracks and the fluent track builder that schedules them.
2
3use super::{BoundaryAdjustment, BoundaryEdge, MAX_CLIPS, Scene, add_time, align, on_track};
4use crate::authoring::{scene, transition};
5use crate::{Duration, Error, FrameRate, SceneContext, Time};
6use std::{fmt, sync::Arc};
7
8/// One immutable, positive-duration scene occurrence.
9#[derive(Clone)]
10pub struct Clip {
11    pub(super) id: usize,
12    pub(super) start: Time,
13    pub(super) duration: Duration,
14    pub(super) scene: Arc<dyn Scene>,
15    pub(super) transition_in: Option<TransitionIn>,
16}
17
18#[derive(Clone)]
19pub(super) struct TransitionIn {
20    pub(super) effect: Arc<dyn transition::Transition>,
21    pub(super) duration: Duration,
22}
23
24impl fmt::Debug for Clip {
25    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
26        formatter
27            .debug_struct("Clip")
28            .field("id", &self.id)
29            .field("start", &self.start)
30            .field("duration", &self.duration)
31            .finish_non_exhaustive()
32    }
33}
34
35impl Clip {
36    /// Stable insertion ID within its track, independent of sorted order.
37    pub fn id(&self) -> usize {
38        self.id
39    }
40
41    /// Snapped activation time relative to the timeline.
42    pub fn start(&self) -> Time {
43        self.start
44    }
45
46    /// Snapped active duration, also supplied as clip metadata to the callback.
47    pub fn duration(&self) -> Duration {
48        self.duration
49    }
50
51    /// Exclusive end; validated at construction.
52    pub fn end(&self) -> Time {
53        self.start + self.duration
54    }
55
56    /// Retained stateless scene sampled through SceneContext.
57    pub fn scene(&self) -> &dyn Scene {
58        self.scene.as_ref()
59    }
60
61    /// Incoming transition and its aligned overlap duration, when present.
62    pub fn transition_in(&self) -> Option<(&dyn transition::Transition, Duration)> {
63        self.transition_in
64            .as_ref()
65            .map(|value| (value.effect.as_ref(), value.duration))
66    }
67}
68
69/// Sorted clips with explicit adjacent-pair overlaps; later tracks paint on top.
70#[derive(Clone, Debug)]
71pub struct Track {
72    pub(super) clips: Arc<[Clip]>,
73    pub(super) duration: Duration,
74}
75
76impl Track {
77    /// Temporal order; clip insertion IDs remain stable after absolute placement.
78    pub fn clips(&self) -> &[Clip] {
79        &self.clips
80    }
81
82    /// End of the track, including its trailing gap.
83    pub fn duration(&self) -> Duration {
84        self.duration
85    }
86
87    /// Active incoming/latest clip, or none in gaps; during a transition the
88    /// scheduler also evaluates its outgoing predecessor for composition.
89    pub fn active_clip(&self, time: Time) -> Option<&Clip> {
90        let index = self
91            .clips
92            .partition_point(|clip| clip.start <= time)
93            .checked_sub(1)?;
94        self.clips.get(index).filter(|clip| time < clip.end())
95    }
96}
97
98/// Fluent scheduler with deferred diagnostics and immediate try_* alternatives.
99/// The requested cursor is not repeatedly rounded: each boundary is snapped once.
100pub struct TrackBuilder {
101    pub(super) index: usize,
102    pub(super) rate: FrameRate,
103    pub(super) clips: Vec<Clip>,
104    pub(super) cursor: Time,
105    pub(super) adjustments: Vec<BoundaryAdjustment>,
106    pub(super) pending: Option<Arc<dyn transition::Transition>>,
107    pub(super) errors: Vec<Arc<Error>>,
108}
109
110impl TrackBuilder {
111    fn defer(mut self, operation: impl FnOnce(&mut Self) -> crate::Result<()>) -> Self {
112        if let Err(error) = operation(&mut self)
113            && self.errors.len() < 1024
114        {
115            self.errors.push(Arc::new(on_track(
116                self.index,
117                format!("clip[{}]: {error}", self.clips.len()),
118            )));
119        }
120        self
121    }
122
123    /// Appends a finite scene, retaining invalid input for validate().
124    pub fn push(self, scene: impl Scene + 'static) -> Self {
125        let cursor = self.cursor;
126        self.defer(|builder| builder.place_checked(cursor, scene))
127    }
128
129    /// Appends a finite authoring callback accepting drawables or Result outputs.
130    pub fn scene<Output: scene::IntoSceneOutput + 'static>(
131        self,
132        duration: Duration,
133        build: impl Fn(&SceneContext<'_>) -> Output + Send + Sync + 'static,
134    ) -> Self {
135        self.push(scene::scene(build).with_duration(duration))
136    }
137
138    /// Adds an adjacent transition, retaining invalid input for validate().
139    pub fn transition(
140        self,
141        effect: impl transition::Transition + 'static,
142        duration: Duration,
143    ) -> Self {
144        self.defer(|builder| builder.transition_checked(effect, duration))
145    }
146
147    /// Places a scene at absolute ticks, retaining invalid input for validate().
148    pub fn place(self, start: Time, scene: impl Scene + 'static) -> Self {
149        self.defer(|builder| builder.place_checked(start, scene))
150    }
151
152    /// Advances the cursor, retaining invalid input for validate().
153    pub fn gap(self, duration: Duration) -> Self {
154        self.defer(|builder| builder.gap_checked(duration))
155    }
156
157    /// Immediately checks and applies one adjacent transition.
158    pub fn try_transition(
159        mut self,
160        effect: impl transition::Transition + 'static,
161        duration: Duration,
162    ) -> crate::Result<Self> {
163        self.transition_checked(effect, duration)?;
164        Ok(self)
165    }
166
167    /// Immediately checks and places one scene.
168    pub fn try_place(mut self, start: Time, scene: impl Scene + 'static) -> crate::Result<Self> {
169        self.place_checked(start, scene)?;
170        Ok(self)
171    }
172
173    /// Immediately checks and advances the cursor.
174    pub fn try_gap(mut self, duration: Duration) -> crate::Result<Self> {
175        self.gap_checked(duration)?;
176        Ok(self)
177    }
178
179    /// Appends at the requested cursor; finite positive Scene::duration is required.
180    pub fn try_push(self, scene: impl Scene + 'static) -> crate::Result<Self> {
181        let cursor = self.cursor;
182        self.try_place(cursor, scene)
183    }
184
185    /// Advances the next pushed clip into the previous clip by a positive duration.
186    /// Only one adjacent pair may overlap; a pending transition must be consumed.
187    fn transition_checked(
188        &mut self,
189        effect: impl transition::Transition + 'static,
190        duration: Duration,
191    ) -> crate::Result<()> {
192        if self.pending.is_some() || duration <= Time::ZERO {
193            return Err(on_track(
194                self.index,
195                "Transition needs positive duration and no pending transition",
196            ));
197        }
198        let previous = self
199            .clips
200            .last()
201            .ok_or_else(|| on_track(self.index, "Transition requires a preceding clip"))?;
202        if align(self.cursor, self.rate)?.0 != previous.end() {
203            return Err(on_track(self.index, "Transition cannot follow a gap"));
204        }
205        let start = self
206            .cursor
207            .ticks()
208            .checked_sub(duration.ticks())
209            .map(Time::from_ticks)
210            .filter(|time| *time >= Time::ZERO)
211            .ok_or_else(|| on_track(self.index, "Transition exceeds the preceding clip"))?;
212        let aligned = align(start, self.rate)?.0;
213        if aligned < previous.start
214            || aligned >= previous.end()
215            || previous
216                .transition_in
217                .as_ref()
218                .is_some_and(|incoming| aligned < previous.start + incoming.duration)
219        {
220            return Err(on_track(
221                self.index,
222                "Transition would be empty or overlap another transition",
223            ));
224        }
225        self.cursor = start;
226        self.pending = Some(Arc::new(effect));
227        Ok(())
228    }
229
230    /// Places at an absolute nonnegative tick time and sorts by snapped start.
231    /// The cursor advances to max(previous cursor, requested end), never backwards.
232    /// Invalid or rounding-collapsed clips and same-track overlaps fail immediately.
233    fn place_checked(&mut self, start: Time, scene: impl Scene + 'static) -> crate::Result<()> {
234        if self.clips.len() >= MAX_CLIPS {
235            return Err(on_track(self.index, "At most 16384 clips are allowed"));
236        }
237        if start < Time::ZERO {
238            return Err(on_track(self.index, "Clip start must be nonnegative"));
239        }
240        let duration = scene.duration().ok_or_else(|| {
241            on_track(
242                self.index,
243                "Scene has no finite duration; use with_duration",
244            )
245        })?;
246        if duration <= Time::ZERO {
247            return Err(on_track(self.index, "Scene duration must be positive"));
248        }
249        let requested_end =
250            add_time(start, duration).map_err(|error| on_track(self.index, error))?;
251        let (aligned_start, start_frame) =
252            align(start, self.rate).map_err(|error| on_track(self.index, error))?;
253        let (aligned_end, end_frame) =
254            align(requested_end, self.rate).map_err(|error| on_track(self.index, error))?;
255        if aligned_end <= aligned_start {
256            return Err(on_track(
257                self.index,
258                "Clip becomes empty after frame alignment; increase its duration",
259            ));
260        }
261        let index = self
262            .clips
263            .partition_point(|clip| clip.start <= aligned_start);
264        let id = self.clips.len();
265        let overlaps = index
266            .checked_sub(1)
267            .and_then(|previous| self.clips.get(previous))
268            .filter(|clip| clip.end() > aligned_start)
269            .or_else(|| {
270                self.clips
271                    .get(index)
272                    .filter(|clip| clip.start < aligned_end)
273            });
274        let transition_in = if let Some(effect) = &self.pending {
275            let previous = self
276                .clips
277                .last()
278                .ok_or_else(|| on_track(self.index, "Transition requires a preceding clip"))?;
279            if start != self.cursor
280                || index != self.clips.len()
281                || aligned_start < previous.start
282                || aligned_start >= previous.end()
283                || aligned_end < previous.end()
284                || previous
285                    .transition_in
286                    .as_ref()
287                    .is_some_and(|incoming| aligned_start < previous.start + incoming.duration)
288            {
289                return Err(on_track(
290                    self.index,
291                    "Incoming clip must cover the adjacent overlap without a triple overlap",
292                ));
293            }
294            Some(TransitionIn {
295                effect: effect.clone(),
296                duration: previous.end() - aligned_start,
297            })
298        } else {
299            None
300        };
301        if let Some(other) = overlaps.filter(|_| transition_in.is_none()) {
302            return Err(on_track(
303                self.index,
304                format!(
305                    "clip[{id}] [{}, {}) overlaps clip[{}] [{}, {}); use an explicit adjacent transition or separate track",
306                    aligned_start.ticks(),
307                    aligned_end.ticks(),
308                    other.id,
309                    other.start.ticks(),
310                    other.end().ticks()
311                ),
312            ));
313        }
314        for (edge, requested, aligned, frame) in [
315            (BoundaryEdge::Start, start, aligned_start, start_frame),
316            (BoundaryEdge::End, requested_end, aligned_end, end_frame),
317        ] {
318            if requested != aligned {
319                self.adjustments.push(BoundaryAdjustment {
320                    track: Some(self.index),
321                    clip: Some(id),
322                    edge,
323                    requested,
324                    aligned,
325                    frame,
326                });
327            }
328        }
329        self.clips.insert(
330            index,
331            Clip {
332                id,
333                start: aligned_start,
334                duration: aligned_end - aligned_start,
335                scene: Arc::new(scene),
336                transition_in,
337            },
338        );
339        self.cursor = self.cursor.max(requested_end);
340        self.pending = None;
341        Ok(())
342    }
343
344    /// Advances the raw cursor, contributing empty time even when it trails all clips.
345    fn gap_checked(&mut self, duration: Duration) -> crate::Result<()> {
346        if self.pending.is_some() {
347            return Err(on_track(
348                self.index,
349                "A pending transition must be followed by a clip, not a gap",
350            ));
351        }
352        if duration < Time::ZERO {
353            return Err(on_track(self.index, "Gap must be nonnegative"));
354        }
355        self.cursor =
356            add_time(self.cursor, duration).map_err(|error| on_track(self.index, error))?;
357        Ok(())
358    }
359
360    /// Requested append position, before frame alignment.
361    pub fn cursor(&self) -> Time {
362        self.cursor
363    }
364
365    pub(super) fn finish(mut self) -> crate::Result<(Track, Vec<BoundaryAdjustment>)> {
366        if !self.errors.is_empty() {
367            return Err(Error::BuildErrors(self.errors));
368        }
369        if self.pending.is_some() {
370            return Err(on_track(
371                self.index,
372                "Pending transition has no incoming clip",
373            ));
374        }
375        let (duration, frame) =
376            align(self.cursor, self.rate).map_err(|error| on_track(self.index, error))?;
377        if duration != self.cursor {
378            self.adjustments.push(BoundaryAdjustment {
379                track: Some(self.index),
380                clip: None,
381                edge: BoundaryEdge::Cursor,
382                requested: self.cursor,
383                aligned: duration,
384                frame,
385            });
386        }
387        Ok((
388            Track {
389                clips: self.clips.into(),
390                duration,
391            },
392            self.adjustments,
393        ))
394    }
395}