Skip to main content

motion_core/authoring/
timeline.rs

1//! Tick-based multi-track scheduling, with exact rational frame boundaries.
2//!
3//! Current scheduling uses only scene contexts and rational frame boundaries.
4//!
5//! ```
6//! use motion_core::prelude::*;
7//! use motion_core::{
8//!     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
9//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
10//! };
11//! use motion_core::authoring::{Scene as _, timeline::Timeline};
12//! let tile =
13//!     (|_: &SceneContext<'_>| Node::from(Rect::new(0.0, 0.0, 8.0, 4.0).fill(Color::WHITE)))
14//!         .with_duration(1.seconds());
15//! let timeline = Timeline::new(Size::new(8.0, 4.0), 30)
16//!     .track(|track| track.push(tile).gap(500.milliseconds()));
17//! timeline.validate()?;
18//! assert_eq!(timeline.frame_count(), 45);
19//! let cache = SceneCache::default();
20//! let context = SceneContext::new(Time::ZERO, timeline.frame_rate(), timeline.size(), &cache);
21//! timeline.render(&context).compile(
22//!     timeline.size(),
23//!     PixelSize::new(8, 4),
24//!     ValidationLimits::default(),
25//! )?;
26//! # Ok::<(), motion_core::Error>(())
27//! ```
28
29use super::Scene;
30use crate::{
31    Color, Duration, Error, FrameRate, Node, NodeModifiers, SceneContext, Size, TICKS_PER_SECOND,
32    Time,
33};
34use std::{fmt, sync::Arc};
35
36#[cfg(test)]
37mod tests;
38mod track;
39
40pub use track::{Clip, Track, TrackBuilder};
41
42const MAX_TRACKS: usize = 256;
43
44const MAX_CLIPS: usize = 16_384;
45
46const MAX_ADJUSTMENTS: usize = 65_536;
47
48fn invalid(message: impl Into<String>) -> Error {
49    Error::Timeline {
50        timeline: "<native>".into(),
51        message: message.into(),
52    }
53}
54
55fn on_track(track: usize, message: impl fmt::Display) -> Error {
56    invalid(format!("track[{track}]: {message}"))
57}
58
59fn add_time(first: Time, second: Duration) -> crate::Result<Time> {
60    first
61        .ticks()
62        .checked_add(second.ticks())
63        .map(Time::from_ticks)
64        .ok_or_else(|| invalid("Scheduled time exceeds signed ticks"))
65}
66
67fn align(time: Time, rate: FrameRate) -> crate::Result<(Time, i64)> {
68    let quantum = i128::from(TICKS_PER_SECOND) * i128::from(rate.denominator());
69    let numerator = i128::from(rate.numerator());
70    let lower = (i128::from(time.ticks()) * numerator).div_euclid(quantum);
71    let boundary = |frame: i128| {
72        let ticks = frame * quantum;
73        ticks.div_euclid(numerator) + i128::from(ticks.rem_euclid(numerator) != 0)
74    };
75    let left = boundary(lower);
76    let right = boundary(lower + 1);
77    let frame = if i128::from(time.ticks()) - left < right - i128::from(time.ticks()) {
78        lower
79    } else {
80        lower + 1
81    };
82    let ticks = i64::try_from(boundary(frame))
83        .map_err(|_| invalid("Nearest frame boundary exceeds signed ticks"))?;
84    let frame =
85        i64::try_from(frame).map_err(|_| invalid("Nearest frame index exceeds signed range"))?;
86    Ok((Time::from_ticks(ticks), frame))
87}
88
89/// Boundary that moved when scheduling onto the timeline's frame grid.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum BoundaryEdge {
92    /// Clip activation start.
93    Start,
94    /// Clip exclusive end.
95    End,
96    /// Trailing track cursor, including an explicit gap.
97    Cursor,
98    /// Explicit timeline duration override.
99    Duration,
100}
101
102/// Structured warning; construction never writes warnings to stderr.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct BoundaryAdjustment {
105    /// Track insertion index, absent for a timeline duration override.
106    pub track: Option<usize>,
107    /// Stable clip insertion ID, absent for cursors or duration overrides.
108    pub clip: Option<usize>,
109    /// Adjusted boundary category.
110    pub edge: BoundaryEdge,
111    /// Original tick position.
112    pub requested: Time,
113    /// Nearest representable frame boundary; ties choose the later frame.
114    pub aligned: Time,
115    /// Exact aligned frame index.
116    pub frame: i64,
117}
118
119/// multi-track timeline with integer frame-aligned intervals.
120///
121/// Authoring builders retain diagnostics; try_* variants report immediately. Empty
122/// timelines last zero ticks; trailing gaps contribute duration. An explicit
123/// duration clips or extends all tracks without retiming their scenes.
124/// This is the canonical root Timeline.
125/// Nested output clips the child viewport before centered contain mapping into
126/// its parent's design space; child frame rate does not retime parent ticks.
127#[derive(Clone, Debug)]
128pub struct Timeline {
129    size: Size,
130    rate: FrameRate,
131    background: Option<Color>,
132    tracks: Arc<Vec<Track>>,
133    inferred: Duration,
134    explicit: Option<Duration>,
135    adjustments: Arc<Vec<BoundaryAdjustment>>,
136    audio: Arc<Vec<crate::AudioClip>>,
137    resolved_audio: Option<Arc<Vec<crate::AudioClip>>>,
138    master: crate::audio::Master,
139    audio_buses: Arc<Vec<crate::audio::AudioBus>>,
140    errors: Arc<Vec<Arc<Error>>>,
141}
142
143impl Timeline {
144    fn defer(self, operation: impl FnOnce(Self) -> crate::Result<Self>) -> Self {
145        let mut previous = self.clone();
146        match operation(self) {
147            Ok(value) => value,
148            Err(error) => {
149                let errors = Arc::make_mut(&mut previous.errors);
150                if errors.len() < 1024 {
151                    match error {
152                        Error::BuildErrors(values) => {
153                            errors.extend(values.into_iter().take(1024 - errors.len()))
154                        }
155                        error => errors.push(Arc::new(error)),
156                    }
157                }
158                previous
159            }
160        }
161    }
162
163    /// Creates an authoring timeline; invalid dimensions/rates are reported by validate().
164    pub fn new(size: Size, rate: impl Into<FrameRate>) -> Self {
165        let rate = rate.into();
166        Self::empty(size, rate).defer(|_| Self::try_new(size, rate))
167    }
168
169    fn empty(size: Size, rate: FrameRate) -> Self {
170        Self {
171            size,
172            rate,
173            background: None,
174            tracks: Arc::new(Vec::new()),
175            inferred: Time::ZERO,
176            explicit: None,
177            adjustments: Arc::new(Vec::new()),
178            audio: Arc::new(Vec::new()),
179            resolved_audio: None,
180            master: crate::audio::Master::default(),
181            audio_buses: Arc::new(Vec::new()),
182            errors: Arc::new(Vec::new()),
183        }
184    }
185
186    /// Validates every retained authoring diagnostic and nested scene before execution.
187    pub fn validate(&self) -> crate::Result<()> {
188        self.master.validate()?;
189        if self.audio_buses.len() > 16 {
190            return Err(invalid("At most sixteen audio buses"));
191        }
192        for (index, bus) in self.audio_buses.iter().enumerate() {
193            crate::audio::Master {
194                gain_db: bus.gain_db,
195                ..Default::default()
196            }
197            .validate()?;
198            if bus.name.is_empty()
199                || bus.name.len() > 128
200                || bus.effects.len() > 16
201                || self.audio_buses[..index]
202                    .iter()
203                    .any(|other| other.name == bus.name)
204            {
205                return Err(invalid("Invalid or duplicate audio bus"));
206            }
207        }
208        let mut errors = self.errors.as_ref().clone();
209        for (track_index, track) in self.tracks.iter().enumerate() {
210            for clip in track.clips.iter() {
211                if let Err(error) = clip.scene.validate() {
212                    errors.push(Arc::new(on_track(
213                        track_index,
214                        format!("clip[{}]: {error}", clip.id),
215                    )));
216                }
217            }
218        }
219        if errors.is_empty() {
220            Ok(())
221        } else {
222            Err(Error::BuildErrors(errors))
223        }
224    }
225
226    /// Constructs one track while accumulating all invalid operations.
227    pub fn track(self, build: impl FnOnce(TrackBuilder) -> TrackBuilder) -> Self {
228        self.defer(|timeline| timeline.try_track(|builder| Ok(build(builder))))
229    }
230
231    /// Adds timeline audio, deferring validation failures to execution.
232    pub fn audio(self, clip: crate::AudioClip) -> Self {
233        self.defer(|timeline| timeline.try_audio(clip))
234    }
235
236    /// Sets optional master processing, shared by preview and export.
237    pub fn master(mut self, master: crate::audio::Master) -> Self {
238        self.master = master;
239        self
240    }
241
242    /// Declares a named offline submix before the master.
243    pub fn audio_bus(self, bus: crate::audio::AudioBus) -> Self {
244        self.defer(|mut timeline| {
245            Arc::make_mut(&mut timeline.audio_buses).push(bus);
246            for clip in timeline.audio.as_ref().clone() {
247                if clip.unresolved_file() && clip.parameters().duration.is_none() {
248                    continue;
249                }
250                timeline.inferred = timeline.inferred.max(timeline.audio_end(&clip)?);
251            }
252            Ok(timeline)
253        })
254    }
255
256    /// Immutable master settings at the audio execution boundary.
257    pub fn audio_master(&self) -> crate::audio::Master {
258        self.master
259    }
260
261    /// Declared submixes in deterministic insertion order.
262    pub fn audio_buses(&self) -> &[crate::audio::AudioBus] {
263        &self.audio_buses
264    }
265
266    /// Overrides output duration, deferring validation failures to execution.
267    pub fn duration(self, duration: Duration) -> Self {
268        self.defer(|timeline| timeline.try_duration(duration))
269    }
270
271    /// Creates a timeline without discovering fonts, devices or encoders.
272    /// Rates above one frame per tick cannot define a distinct frame grid.
273    pub fn try_new(size: Size, rate: impl Into<FrameRate>) -> crate::Result<Self> {
274        let rate = rate.into();
275        if !size.width.is_finite()
276            || !size.height.is_finite()
277            || size.width <= 0.0
278            || size.height <= 0.0
279        {
280            return Err(invalid(
281                "Timeline design dimensions must be finite and positive",
282            ));
283        }
284        if i128::from(rate.numerator())
285            > i128::from(TICKS_PER_SECOND) * i128::from(rate.denominator())
286        {
287            return Err(invalid("Frame rate exceeds one frame per tick"));
288        }
289        Ok(Self::empty(size, rate))
290    }
291
292    /// Adds a full-design background below every track; alpha is retained.
293    pub fn background(mut self, color: Color) -> Self {
294        self.background = Some(color);
295        self
296    }
297
298    /// Adds audio without snapping its placement to the video grid. Audio
299    /// extends inferred output to the next frame boundary, never trims its tail.
300    pub fn try_audio(mut self, clip: crate::AudioClip) -> crate::Result<Self> {
301        if self.audio.len() >= 256 {
302            return Err(invalid("Timeline exceeds 256 audio clips"));
303        }
304        if clip.unresolved_file() && clip.parameters().duration.is_none() {
305            Arc::make_mut(&mut self.audio).push(clip);
306            return Ok(self);
307        }
308        let end = self.audio_end(&clip)?;
309        let frame = end.to_frame(self.rate);
310        let boundary = Time::checked_from_frame(frame, self.rate)
311            .ok_or_else(|| invalid("Audio end exceeds frame clock"))?;
312        let end = if boundary < end {
313            Time::checked_from_frame(frame + 1, self.rate)
314                .ok_or_else(|| invalid("Audio end exceeds frame clock"))?
315        } else {
316            boundary
317        };
318        self.inferred = self.inferred.max(end);
319        Arc::make_mut(&mut self.audio).push(clip);
320        Ok(self)
321    }
322
323    fn audio_end(&self, clip: &crate::AudioClip) -> crate::Result<Time> {
324        let mut end = clip.end()?;
325        if let Some(bus) = self
326            .audio_buses
327            .iter()
328            .find(|bus| Some(bus.name.as_str()) == clip.parameters().bus)
329        {
330            for effect in &bus.effects {
331                let tail = effect.tail();
332                if tail < Time::ZERO || tail.as_seconds() > 30.0 {
333                    return Err(invalid("Bus effect tail requires 0..=30 seconds"));
334                }
335                end = Time::from_ticks(
336                    end.ticks()
337                        .checked_add(tail.ticks())
338                        .ok_or_else(|| invalid("Bus tail exceeds frame clock"))?,
339                );
340            }
341        }
342        if end.as_seconds() > 86400.0 {
343            return Err(invalid("Audio end exceeds 24 hours"));
344        }
345        let frame = end.to_frame(self.rate);
346        let boundary = Time::checked_from_frame(frame, self.rate)
347            .ok_or_else(|| invalid("Audio end exceeds frame clock"))?;
348        Ok(if boundary < end {
349            Time::checked_from_frame(frame + 1, self.rate)
350                .ok_or_else(|| invalid("Audio end exceeds frame clock"))?
351        } else {
352            boundary
353        })
354    }
355
356    /// Atomically builds one track; all tracks together admit at most 16384
357    /// clips, 256 tracks and 65536 frame-adjustment warnings per snapshot.
358    pub fn try_track(
359        mut self,
360        build: impl FnOnce(TrackBuilder) -> crate::Result<TrackBuilder>,
361    ) -> crate::Result<Self> {
362        if self.tracks.len() >= MAX_TRACKS {
363            return Err(invalid("Timeline exceeds 256 tracks"));
364        }
365        let builder = TrackBuilder {
366            index: self.tracks.len(),
367            rate: self.rate,
368            clips: Vec::new(),
369            cursor: Time::ZERO,
370            adjustments: Vec::new(),
371            pending: None,
372            errors: Vec::new(),
373        };
374        let builder = build(builder)?;
375        if builder.index != self.tracks.len() || builder.rate != self.rate {
376            return Err(invalid(
377                "Track builder belongs to a different index or frame grid",
378            ));
379        }
380        let (track, adjustments) = builder.finish()?;
381        let total = self
382            .tracks
383            .iter()
384            .map(|track| track.clips.len())
385            .sum::<usize>()
386            + track.clips.len();
387        if total > MAX_CLIPS || self.adjustments.len() + adjustments.len() > MAX_ADJUSTMENTS {
388            return Err(invalid("Timeline exceeds clip or adjustment allowance"));
389        }
390        self.inferred = self.inferred.max(track.duration);
391        Arc::make_mut(&mut self.tracks).push(track);
392        Arc::make_mut(&mut self.adjustments).extend(adjustments);
393        Ok(self)
394    }
395
396    /// Overrides total duration on the nearest frame grid; zero is intentionally empty.
397    pub fn try_duration(mut self, duration: Duration) -> crate::Result<Self> {
398        if duration < Time::ZERO {
399            return Err(invalid("Timeline duration must be nonnegative"));
400        }
401        let (aligned, frame) = align(duration, self.rate)?;
402        if duration > Time::ZERO && aligned == Time::ZERO {
403            return Err(invalid(
404                "Timeline duration becomes empty after frame alignment",
405            ));
406        }
407        let adjustments = Arc::make_mut(&mut self.adjustments);
408        adjustments.retain(|adjustment| adjustment.edge != BoundaryEdge::Duration);
409        if duration != aligned {
410            if adjustments.len() >= MAX_ADJUSTMENTS {
411                return Err(invalid("Timeline exceeds adjustment allowance"));
412            }
413            adjustments.push(BoundaryAdjustment {
414                track: None,
415                clip: None,
416                edge: BoundaryEdge::Duration,
417                requested: duration,
418                aligned,
419                frame,
420            });
421        }
422        self.explicit = Some(aligned);
423        Ok(self)
424    }
425
426    /// Logical timeline viewport; supply this to composition rendering.
427    pub fn size(&self) -> Size {
428        self.size
429    }
430
431    /// Frame grid used for all schedule boundary adjustments.
432    pub fn frame_rate(&self) -> FrameRate {
433        self.rate
434    }
435
436    /// Finite inferred/overridden duration, independent of any parent clip duration.
437    pub fn total_duration(&self) -> Duration {
438        self.explicit.unwrap_or(self.inferred)
439    }
440
441    /// Exact output frame count. Existing Composition needs checked u32 conversion.
442    pub fn frame_count(&self) -> u64 {
443        self.total_duration().to_frame(self.rate) as u64
444    }
445
446    /// Immutable tracks in bottom-to-top paint order.
447    pub fn tracks(&self) -> &[Track] {
448        &self.tracks
449    }
450
451    /// Accumulated structured boundary warnings, without stderr side effects.
452    pub fn adjustments(&self) -> &[BoundaryAdjustment] {
453        &self.adjustments
454    }
455
456    /// Resolves flattened audio at an I/O boundary while preserving visual scene clocks.
457    pub fn resolve_audio(
458        &self,
459        mut resolve: impl FnMut(&crate::AudioClip) -> crate::Result<crate::AudioClip>,
460    ) -> crate::Result<Self> {
461        let clips = self
462            .audio_clips()?
463            .iter()
464            .map(&mut resolve)
465            .collect::<crate::Result<Vec<_>>>()?;
466        let mut timeline = self.clone();
467        for clip in &clips {
468            let end = timeline.audio_end(clip)?;
469            let frame = end.to_frame(timeline.rate);
470            let mut boundary = Time::checked_from_frame(frame, timeline.rate)
471                .ok_or_else(|| invalid("Audio end exceeds frame clock"))?;
472            if boundary < end {
473                boundary = Time::checked_from_frame(frame + 1, timeline.rate)
474                    .ok_or_else(|| invalid("Audio end exceeds frame clock"))?;
475            }
476            timeline.inferred = timeline.inferred.max(boundary);
477        }
478        timeline.resolved_audio = Some(Arc::new(clips));
479        Ok(timeline)
480    }
481}
482
483impl Scene for Timeline {
484    fn audio_composition(&self) -> Option<&Timeline> {
485        Some(self)
486    }
487
488    fn validate(&self) -> crate::Result<()> {
489        Timeline::validate(self)
490    }
491
492    fn duration(&self) -> Option<Duration> {
493        Some(self.total_duration())
494    }
495
496    fn audio_clips(&self) -> crate::Result<Vec<crate::AudioClip>> {
497        self.validate()?;
498        if let Some(clips) = &self.resolved_audio {
499            return Ok(clips.as_ref().clone());
500        }
501        let mut audio = self.audio.as_ref().clone();
502        for track in self.tracks.iter() {
503            for clip in track.clips.iter() {
504                let sources = if let Some(child) = clip.scene.audio_composition().filter(|child| {
505                    child.audio_master() != crate::Master::default()
506                        || !child.audio_buses().is_empty()
507                }) {
508                    vec![crate::AudioClip::composition(child.clone()).duration(clip.duration)]
509                } else {
510                    clip.scene.audio_clips()?
511                };
512                for source in sources {
513                    if let Some(source) = source.relocated(clip.start, clip.duration)? {
514                        audio.push(source);
515                    }
516                    if audio.len() > 256 {
517                        return Err(invalid("Nested timeline exceeds 256 audio clips"));
518                    }
519                }
520            }
521        }
522        Ok(audio)
523    }
524
525    fn render(&self, context: &SceneContext<'_>) -> Node {
526        if let Err(error) = self.validate() {
527            return super::transition::diagnostic(crate::ValidationError::invalid(
528                "timeline",
529                "build",
530                error.to_string(),
531            ));
532        }
533        if context.time() < Time::ZERO || context.time() >= self.total_duration() {
534            return Node::empty();
535        }
536        let parent_size = context.size();
537        let nested = context.is_nested_scene();
538        let context = context.for_timeline(self.size, self.rate);
539        let background = self
540            .background
541            .map(|color| Node::from(self.size.to_rect().fill(color)));
542        let clips = self.tracks.iter().filter_map(|track| {
543            let index = track
544                .clips
545                .partition_point(|clip| clip.start <= context.time())
546                .checked_sub(1)?;
547            let clip = &track.clips[index];
548            if context.time() >= clip.end() {
549                return None;
550            }
551            let render = |clip: &Clip| {
552                context.during(clip.start, clip.duration, |local| clip.scene.render(local))
553            };
554            let node = if let Some(transition) = clip
555                .transition_in
556                .as_ref()
557                .filter(|transition| context.time() < clip.start + transition.duration)
558            {
559                let outgoing = render(&track.clips[index - 1]);
560                let incoming = render(clip);
561                let animation = crate::Tween::new(0.0f64, 1.0, transition.duration)
562                    .easing(transition.effect.easing());
563                let progress = crate::Animation::sample(&animation, context.time() - clip.start);
564                if progress.is_finite() {
565                    let local = context
566                        .shifted(clip.start)
567                        .with_clip_duration(transition.duration);
568                    transition
569                        .effect
570                        .compose(progress.clamp(0.0, 1.0), outgoing, incoming, &local)
571                } else {
572                    super::transition::failure(
573                        "easing",
574                        "Transition easing must produce finite progress",
575                    )
576                }
577            } else {
578                render(clip)
579            };
580            Some(Node::group([node]))
581        });
582        let node = Node::group(background.into_iter().chain(clips));
583        if parent_size == self.size {
584            return if nested {
585                node.clip_with(self.size.to_rect(), crate::ClipMode::Geometric)
586            } else {
587                node
588            };
589        }
590        let scale =
591            (parent_size.width / self.size.width).min(parent_size.height / self.size.height);
592        let scale = if scale.is_finite() && scale > 0.0 {
593            scale
594        } else {
595            f64::NAN
596        };
597        node.clip_with(self.size.to_rect(), crate::ClipMode::Geometric)
598            .pivot(crate::Pivot::Point(crate::Point::ORIGIN))
599            .scale(scale)
600            .translate(
601                (parent_size.width - self.size.width * scale) * 0.5,
602                (parent_size.height - self.size.height * scale) * 0.5,
603            )
604    }
605}