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::ErrorReport;
5use crate::authoring::{scene, transition};
6use crate::{AudioClip, Duration, FrameRate, Node, SceneContext, Time, Timeline};
7use std::{fmt, sync::Arc};
8
9/// One immutable, positive-duration scene occurrence.
10#[derive(Clone)]
11pub struct Clip {
12    pub(super) id: usize,
13    pub(super) start: Time,
14    pub(super) duration: Duration,
15    pub(super) offset: Duration,
16    pub(super) scene: Arc<dyn Scene>,
17    pub(super) transition_in: Option<TransitionIn>,
18}
19
20#[derive(Clone)]
21pub(super) struct TransitionIn {
22    pub(super) effect: Arc<dyn transition::Transition>,
23    pub(super) duration: Duration,
24}
25
26impl fmt::Debug for Clip {
27    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28        formatter
29            .debug_struct("Clip")
30            .field("id", &self.id)
31            .field("start", &self.start)
32            .field("duration", &self.duration)
33            .finish_non_exhaustive()
34    }
35}
36
37impl Clip {
38    /// Stable insertion ID within its track, independent of sorted order.
39    pub fn id(&self) -> usize {
40        self.id
41    }
42
43    /// Snapped activation time relative to the timeline.
44    pub fn start(&self) -> Time {
45        self.start
46    }
47
48    /// Snapped active duration, also supplied as clip metadata to the callback.
49    pub fn duration(&self) -> Duration {
50        self.duration
51    }
52
53    /// Scene-local time at the clip's first frame; zero unless the clip was placed trimmed.
54    pub fn offset(&self) -> Duration {
55        self.offset
56    }
57
58    /// Exclusive end; validated at construction.
59    pub fn end(&self) -> Time {
60        self.start + self.duration
61    }
62
63    /// Retained stateless scene sampled through SceneContext.
64    pub fn scene(&self) -> &dyn Scene {
65        self.scene.as_ref()
66    }
67
68    /// Incoming transition and its aligned overlap duration, when present.
69    pub fn transition_in(&self) -> Option<(&dyn transition::Transition, Duration)> {
70        self.transition_in
71            .as_ref()
72            .map(|value| (value.effect.as_ref(), value.duration))
73    }
74}
75
76/// Sorted clips with explicit adjacent-pair overlaps; later tracks paint on top.
77#[derive(Clone, Debug)]
78pub struct Track {
79    pub(super) clips: Arc<[Clip]>,
80    pub(super) duration: Duration,
81}
82
83impl Track {
84    /// Temporal order; clip insertion IDs remain stable after absolute placement.
85    pub fn clips(&self) -> &[Clip] {
86        &self.clips
87    }
88
89    /// End of the track, including its trailing gap.
90    pub fn duration(&self) -> Duration {
91        self.duration
92    }
93
94    /// Active incoming/latest clip, or none in gaps; during a transition the
95    /// scheduler also evaluates its outgoing predecessor for composition.
96    pub fn active_clip(&self, time: Time) -> Option<&Clip> {
97        let index = self
98            .clips
99            .partition_point(|clip| clip.start <= time)
100            .checked_sub(1)?;
101        self.clips.get(index).filter(|clip| time < clip.end())
102    }
103
104    /// Renders the active clip at the context time, composing an incoming
105    /// transition with its predecessor; `None` in gaps.
106    pub fn render(&self, context: &SceneContext<'_>) -> Option<Node> {
107        let index = self
108            .clips
109            .partition_point(|clip| clip.start <= context.time())
110            .checked_sub(1)?;
111        let clip = &self.clips[index];
112        if context.time() >= clip.end() {
113            return None;
114        }
115        let render = |clip: &Clip| {
116            context.during(clip.start, clip.duration, |local| {
117                if clip.offset == Duration::ZERO {
118                    clip.scene.render(local)
119                } else {
120                    clip.scene
121                        .render(&local.shifted((Time::ZERO - clip.offset).since_origin()))
122                }
123            })
124        };
125        let node = if let Some(transition) = clip
126            .transition_in
127            .as_ref()
128            .filter(|transition| context.time() < clip.start + transition.duration)
129        {
130            let outgoing = render(&self.clips[index - 1]);
131            let incoming = render(clip);
132            let animation = crate::Tween::new(0.0f64, 1.0, transition.duration)
133                .easing(transition.effect.easing());
134            let progress =
135                crate::Signal::sample(&animation, context.time() - clip.start.since_origin());
136            if progress.is_finite() {
137                let local = context
138                    .shifted(clip.start.since_origin())
139                    .with_clip_duration(transition.duration);
140                transition
141                    .effect
142                    .compose(progress.clamp(0.0, 1.0), outgoing, incoming, &local)
143            } else {
144                transition::failure("easing", "Transition easing must produce finite progress")
145            }
146        } else {
147            render(clip)
148        };
149        Some(Node::group([node]))
150    }
151}
152
153/// Fluent scheduler with deferred diagnostics and immediate try_* alternatives.
154/// The requested cursor is not repeatedly rounded: each boundary is snapped once.
155pub struct TrackBuilder {
156    pub(super) index: usize,
157    pub(super) rate: FrameRate,
158    pub(super) clips: Vec<Clip>,
159    pub(super) cursor: Time,
160    pub(super) adjustments: Vec<BoundaryAdjustment>,
161    pub(super) pending: Option<Arc<dyn transition::Transition>>,
162    pub(super) errors: ErrorReport,
163    pub(super) audio: Vec<AudioClip>,
164}
165
166impl TrackBuilder {
167    fn defer(mut self, operation: impl FnOnce(&mut Self) -> crate::Result<()>) -> Self {
168        if let Err(error) = operation(&mut self) {
169            self.errors.record(on_track(
170                self.index,
171                format!("clip[{}]: {error}", self.clips.len()),
172            ));
173        }
174        self
175    }
176
177    /// Appends a finite scene, retaining invalid input for validate().
178    pub fn push(self, scene: impl Scene + 'static) -> Self {
179        let cursor = self.cursor;
180        self.defer(|builder| builder.place_checked(cursor, None, Duration::ZERO, scene))
181    }
182
183    /// Appends a finite authoring callback accepting drawables or Result outputs.
184    pub fn scene<Output: scene::IntoSceneOutput + 'static>(
185        self,
186        duration: Duration,
187        build: impl Fn(&SceneContext<'_>) -> Output + Send + Sync + 'static,
188    ) -> Self {
189        self.push(scene::scene(build).with_duration(duration))
190    }
191
192    /// Appends a scene at the cursor for `duration`, whatever its natural duration.
193    pub fn push_for(self, duration: Duration, scene: impl Scene + 'static) -> Self {
194        let cursor = self.cursor;
195        self.defer(|builder| builder.place_checked(cursor, Some(duration), Duration::ZERO, scene))
196    }
197
198    /// Places a scene at absolute ticks for `duration`, whatever its natural duration.
199    pub fn place_for(self, start: Time, duration: Duration, scene: impl Scene + 'static) -> Self {
200        self.defer(|builder| builder.place_checked(start, Some(duration), Duration::ZERO, scene))
201    }
202
203    /// Appends a scene at the cursor that starts `offset` into its own timeline.
204    pub fn push_trimmed(
205        self,
206        offset: Duration,
207        duration: Duration,
208        scene: impl Scene + 'static,
209    ) -> Self {
210        let cursor = self.cursor;
211        self.defer(|builder| builder.place_checked(cursor, Some(duration), offset, scene))
212    }
213
214    /// Immediately checks and appends a scene for `duration`.
215    pub fn try_push_for(
216        mut self,
217        duration: Duration,
218        scene: impl Scene + 'static,
219    ) -> crate::Result<Self> {
220        let cursor = self.cursor;
221        self.place_checked(cursor, Some(duration), Duration::ZERO, scene)?;
222        Ok(self)
223    }
224
225    /// Immediately checks and places a scene for `duration`.
226    pub fn try_place_for(
227        mut self,
228        start: Time,
229        duration: Duration,
230        scene: impl Scene + 'static,
231    ) -> crate::Result<Self> {
232        self.place_checked(start, Some(duration), Duration::ZERO, scene)?;
233        Ok(self)
234    }
235
236    /// Immediately checks and appends a scene that starts `offset` into its own timeline.
237    pub fn try_push_trimmed(
238        mut self,
239        offset: Duration,
240        duration: Duration,
241        scene: impl Scene + 'static,
242    ) -> crate::Result<Self> {
243        let cursor = self.cursor;
244        self.place_checked(cursor, Some(duration), offset, scene)?;
245        Ok(self)
246    }
247
248    /// Appends a nested timeline at the cursor, using its own duration. Its picture
249    /// is clipped and fitted into this timeline's design space; its audio joins this
250    /// timeline's soundtrack, shifted and cropped to the clip.
251    pub fn nest(self, child: Timeline) -> Self {
252        let cursor = self.cursor;
253        self.defer(|builder| builder.nest_checked(cursor, None, child))
254    }
255
256    /// Like [`nest`](Self::nest) with an explicit clip duration: the child's picture
257    /// and audio are cropped to it, or padded with silence when it is longer.
258    pub fn nest_for(self, duration: Duration, child: Timeline) -> Self {
259        let cursor = self.cursor;
260        self.defer(|builder| builder.nest_checked(cursor, Some(duration), child))
261    }
262
263    /// Places a nested timeline at an absolute time, like [`nest`](Self::nest).
264    pub fn place_timeline(self, start: Time, child: Timeline) -> Self {
265        self.defer(|builder| builder.nest_checked(start, None, child))
266    }
267
268    /// Immediately checks and appends a nested timeline at the cursor.
269    pub fn try_nest(mut self, child: Timeline) -> crate::Result<Self> {
270        let cursor = self.cursor;
271        self.nest_checked(cursor, None, child)?;
272        Ok(self)
273    }
274
275    /// Immediately checks and appends a nested timeline with an explicit duration.
276    pub fn try_nest_for(mut self, duration: Duration, child: Timeline) -> crate::Result<Self> {
277        let cursor = self.cursor;
278        self.nest_checked(cursor, Some(duration), child)?;
279        Ok(self)
280    }
281
282    /// Immediately checks and places a nested timeline.
283    pub fn try_place_timeline(mut self, start: Time, child: Timeline) -> crate::Result<Self> {
284        self.nest_checked(start, None, child)?;
285        Ok(self)
286    }
287
288    fn nest_checked(
289        &mut self,
290        start: Time,
291        duration: Option<Duration>,
292        child: Timeline,
293    ) -> crate::Result<()> {
294        let id = self.clips.len();
295        match duration {
296            Some(duration) => {
297                self.place_checked(start, Some(duration), Duration::ZERO, child.visual())?
298            }
299            None => self.place_checked(start, None, Duration::ZERO, child.visual())?,
300        }
301        let clip = self
302            .clips
303            .iter()
304            .find(|clip| clip.id == id)
305            .ok_or_else(|| on_track(self.index, "Nested clip was not placed"))?;
306        let (start, duration) = (clip.start, clip.duration);
307        let soundtrack = child.soundtrack();
308        let sources =
309            if soundtrack.master() != crate::Master::default() || !soundtrack.buses().is_empty() {
310                vec![AudioClip::composition(child.clone()).duration(duration)]
311            } else {
312                soundtrack.clips().to_vec()
313            };
314        for source in sources {
315            if let Some(source) = source.relocated(start.since_origin().magnitude(), duration)? {
316                self.audio.push(source);
317            }
318        }
319        Ok(())
320    }
321
322    /// Adds an adjacent transition, retaining invalid input for validate().
323    pub fn transition(
324        self,
325        effect: impl transition::Transition + 'static,
326        duration: Duration,
327    ) -> Self {
328        self.defer(|builder| builder.transition_checked(effect, duration))
329    }
330
331    /// Places a scene at absolute ticks, retaining invalid input for validate().
332    pub fn place(self, start: Time, scene: impl Scene + 'static) -> Self {
333        self.defer(|builder| builder.place_checked(start, None, Duration::ZERO, scene))
334    }
335
336    /// Advances the cursor, retaining invalid input for validate().
337    pub fn gap(self, duration: Duration) -> Self {
338        self.defer(|builder| builder.gap_checked(duration))
339    }
340
341    /// Immediately checks and applies one adjacent transition.
342    pub fn try_transition(
343        mut self,
344        effect: impl transition::Transition + 'static,
345        duration: Duration,
346    ) -> crate::Result<Self> {
347        self.transition_checked(effect, duration)?;
348        Ok(self)
349    }
350
351    /// Immediately checks and places one scene.
352    pub fn try_place(mut self, start: Time, scene: impl Scene + 'static) -> crate::Result<Self> {
353        self.place_checked(start, None, Duration::ZERO, scene)?;
354        Ok(self)
355    }
356
357    /// Immediately checks and advances the cursor.
358    pub fn try_gap(mut self, duration: Duration) -> crate::Result<Self> {
359        self.gap_checked(duration)?;
360        Ok(self)
361    }
362
363    /// Appends at the requested cursor; finite positive Scene::duration is required.
364    pub fn try_push(self, scene: impl Scene + 'static) -> crate::Result<Self> {
365        let cursor = self.cursor;
366        self.try_place(cursor, scene)
367    }
368
369    /// Advances the next pushed clip into the previous clip by a positive duration.
370    /// Only one adjacent pair may overlap; a pending transition must be consumed.
371    fn transition_checked(
372        &mut self,
373        effect: impl transition::Transition + 'static,
374        duration: Duration,
375    ) -> crate::Result<()> {
376        if self.pending.is_some() || duration <= Duration::ZERO {
377            return Err(on_track(
378                self.index,
379                "Transition needs positive duration and no pending transition",
380            ));
381        }
382        let previous = self
383            .clips
384            .last()
385            .ok_or_else(|| on_track(self.index, "Transition requires a preceding clip"))?;
386        if align(self.cursor, self.rate)?.0 != previous.end() {
387            return Err(on_track(self.index, "Transition cannot follow a gap"));
388        }
389        let start = self
390            .cursor
391            .checked_sub(duration)
392            .filter(|time| *time >= Time::ZERO)
393            .ok_or_else(|| on_track(self.index, "Transition exceeds the preceding clip"))?;
394        let aligned = align(start, self.rate)?.0;
395        if aligned < previous.start
396            || aligned >= previous.end()
397            || previous
398                .transition_in
399                .as_ref()
400                .is_some_and(|incoming| aligned < previous.start + incoming.duration)
401        {
402            return Err(on_track(
403                self.index,
404                "Transition would be empty or overlap another transition",
405            ));
406        }
407        self.cursor = start;
408        self.pending = Some(Arc::new(effect));
409        Ok(())
410    }
411
412    /// Places at an absolute nonnegative tick time and sorts by snapped start.
413    /// The cursor advances to max(previous cursor, requested end), never backwards.
414    /// Invalid or rounding-collapsed clips and same-track overlaps fail immediately.
415    fn place_checked(
416        &mut self,
417        start: Time,
418        duration: Option<Duration>,
419        offset: Duration,
420        scene: impl Scene + 'static,
421    ) -> crate::Result<()> {
422        if self.clips.len() >= MAX_CLIPS {
423            return Err(on_track(self.index, "At most 16384 clips are allowed"));
424        }
425        if start < Time::ZERO {
426            return Err(on_track(self.index, "Clip start must be nonnegative"));
427        }
428        let duration = duration.or_else(|| scene.duration()).ok_or_else(|| {
429            on_track(
430                self.index,
431                "Scene has no finite duration; use push_for or with_duration",
432            )
433        })?;
434        if duration <= Duration::ZERO {
435            return Err(on_track(self.index, "Clip duration must be positive"));
436        }
437        let requested_end =
438            add_time(start, duration).map_err(|error| on_track(self.index, error))?;
439        let (aligned_start, start_frame) =
440            align(start, self.rate).map_err(|error| on_track(self.index, error))?;
441        let (aligned_end, end_frame) =
442            align(requested_end, self.rate).map_err(|error| on_track(self.index, error))?;
443        if aligned_end <= aligned_start {
444            return Err(on_track(
445                self.index,
446                "Clip becomes empty after frame alignment; increase its duration",
447            ));
448        }
449        let index = self
450            .clips
451            .partition_point(|clip| clip.start <= aligned_start);
452        let id = self.clips.len();
453        let overlaps = index
454            .checked_sub(1)
455            .and_then(|previous| self.clips.get(previous))
456            .filter(|clip| clip.end() > aligned_start)
457            .or_else(|| {
458                self.clips
459                    .get(index)
460                    .filter(|clip| clip.start < aligned_end)
461            });
462        let transition_in = if let Some(effect) = &self.pending {
463            let previous = self
464                .clips
465                .last()
466                .ok_or_else(|| on_track(self.index, "Transition requires a preceding clip"))?;
467            if start != self.cursor
468                || index != self.clips.len()
469                || aligned_start < previous.start
470                || aligned_start >= previous.end()
471                || aligned_end < previous.end()
472                || previous
473                    .transition_in
474                    .as_ref()
475                    .is_some_and(|incoming| aligned_start < previous.start + incoming.duration)
476            {
477                return Err(on_track(
478                    self.index,
479                    "Incoming clip must cover the adjacent overlap without a triple overlap",
480                ));
481            }
482            Some(TransitionIn {
483                effect: effect.clone(),
484                duration: (previous.end() - aligned_start).magnitude(),
485            })
486        } else {
487            None
488        };
489        if let Some(other) = overlaps.filter(|_| transition_in.is_none()) {
490            return Err(on_track(
491                self.index,
492                format!(
493                    "clip[{id}] [{}, {}) overlaps clip[{}] [{}, {}); use an explicit adjacent transition or separate track",
494                    aligned_start.ticks(),
495                    aligned_end.ticks(),
496                    other.id,
497                    other.start.ticks(),
498                    other.end().ticks()
499                ),
500            ));
501        }
502        for (edge, requested, aligned, frame) in [
503            (BoundaryEdge::Start, start, aligned_start, start_frame),
504            (BoundaryEdge::End, requested_end, aligned_end, end_frame),
505        ] {
506            if requested != aligned {
507                self.adjustments.push(BoundaryAdjustment {
508                    track: Some(self.index),
509                    clip: Some(id),
510                    edge,
511                    requested,
512                    aligned,
513                    frame,
514                });
515            }
516        }
517        self.clips.insert(
518            index,
519            Clip {
520                id,
521                start: aligned_start,
522                duration: (aligned_end - aligned_start).magnitude(),
523                offset,
524                scene: Arc::new(scene),
525                transition_in,
526            },
527        );
528        self.cursor = self.cursor.max(requested_end);
529        self.pending = None;
530        Ok(())
531    }
532
533    /// Advances the raw cursor, contributing empty time even when it trails all clips.
534    fn gap_checked(&mut self, duration: Duration) -> crate::Result<()> {
535        if self.pending.is_some() {
536            return Err(on_track(
537                self.index,
538                "A pending transition must be followed by a clip, not a gap",
539            ));
540        }
541        self.cursor =
542            add_time(self.cursor, duration).map_err(|error| on_track(self.index, error))?;
543        Ok(())
544    }
545
546    /// Requested append position, before frame alignment.
547    pub fn cursor(&self) -> Time {
548        self.cursor
549    }
550
551    pub(super) fn finish(
552        mut self,
553    ) -> Result<(Track, Vec<BoundaryAdjustment>, Vec<AudioClip>), ErrorReport> {
554        if !self.errors.is_empty() {
555            return Err(self.errors);
556        }
557        if self.pending.is_some() {
558            return Err(on_track(self.index, "Pending transition has no incoming clip").into());
559        }
560        let (end, frame) =
561            align(self.cursor, self.rate).map_err(|error| on_track(self.index, error))?;
562        if end != self.cursor {
563            self.adjustments.push(BoundaryAdjustment {
564                track: Some(self.index),
565                clip: None,
566                edge: BoundaryEdge::Cursor,
567                requested: self.cursor,
568                aligned: end,
569                frame,
570            });
571        }
572        Ok((
573            Track {
574                clips: self.clips.into(),
575                duration: end.since_origin().magnitude(),
576            },
577            self.adjustments,
578            self.audio,
579        ))
580    }
581}