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, ResourceStore, 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), fps!(30))
16//!     .track(|track| track.push(tile).gap(500.milliseconds()))
17//!     .build()?;
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, Shape, Size,
32    TICKS_PER_SECOND, Time,
33};
34use std::{fmt, sync::Arc};
35
36mod builder;
37mod diagnostics;
38mod resolved;
39mod soundtrack;
40#[cfg(test)]
41mod tests;
42mod track;
43
44pub use builder::TimelineBuilder;
45pub use diagnostics::{BoundaryAdjustment, BoundaryEdge, Diagnostics, TimelineWarning};
46pub use resolved::ResolvedAudio;
47pub use soundtrack::Soundtrack;
48pub use track::{Clip, Track, TrackBuilder};
49
50const MAX_TRACKS: usize = 256;
51
52const MAX_CLIPS: usize = 16_384;
53
54const MAX_ADJUSTMENTS: usize = 65_536;
55
56fn invalid(message: impl Into<String>) -> Error {
57    Error::Timeline {
58        timeline: "<native>".into(),
59        message: message.into(),
60    }
61}
62
63fn on_track(track: usize, message: impl fmt::Display) -> Error {
64    invalid(format!("track[{track}]: {message}"))
65}
66
67fn add_time(first: Time, second: Duration) -> crate::Result<Time> {
68    first
69        .checked_add(second)
70        .ok_or_else(|| invalid("Scheduled time exceeds signed ticks"))
71}
72
73fn align(time: Time, rate: FrameRate) -> crate::Result<(Time, i64)> {
74    let quantum = i128::from(TICKS_PER_SECOND) * i128::from(rate.denominator());
75    let numerator = i128::from(rate.numerator());
76    let lower = (i128::from(time.ticks()) * numerator).div_euclid(quantum);
77    let boundary = |frame: i128| {
78        let ticks = frame * quantum;
79        ticks.div_euclid(numerator) + i128::from(ticks.rem_euclid(numerator) != 0)
80    };
81    let left = boundary(lower);
82    let right = boundary(lower + 1);
83    let frame = if i128::from(time.ticks()) - left < right - i128::from(time.ticks()) {
84        lower
85    } else {
86        lower + 1
87    };
88    let ticks = i64::try_from(boundary(frame))
89        .map_err(|_| invalid("Nearest frame boundary exceeds signed ticks"))?;
90    let frame =
91        i64::try_from(frame).map_err(|_| invalid("Nearest frame index exceeds signed range"))?;
92    Ok((Time::from_ticks(ticks), frame))
93}
94
95/// Immutable, validated multi-track timeline with integer frame-aligned intervals.
96///
97/// Created by [`TimelineBuilder::build`]; a built timeline never changes. Empty
98/// timelines last zero ticks; trailing gaps contribute duration. An explicit
99/// duration clips or extends all tracks without retiming their scenes.
100/// This is the canonical root Timeline.
101/// Nested output clips the child viewport before centered contain mapping into
102/// its parent's design space; child frame rate does not retime parent ticks.
103#[derive(Clone, Debug)]
104pub struct Timeline {
105    size: Size,
106    rate: FrameRate,
107    background: Option<Color>,
108    tracks: Arc<Vec<Track>>,
109    duration: Duration,
110    inferred_duration: bool,
111    diagnostics: Diagnostics,
112    soundtrack: Soundtrack,
113}
114
115impl Timeline {
116    /// Starts a timeline; invalid dimensions or rates are reported by `build`.
117    #[allow(clippy::new_ret_no_self)]
118    pub fn new(size: Size, rate: FrameRate) -> TimelineBuilder {
119        match TimelineBuilder::checked(size, rate) {
120            Ok(builder) => builder,
121            Err(error) => TimelineBuilder::empty(Size::new(1.0, 1.0), rate).failed(error),
122        }
123    }
124
125    /// Starts a timeline without discovering fonts, devices or encoders.
126    /// Rates above one frame per tick cannot define a distinct frame grid.
127    pub fn try_new(size: Size, rate: FrameRate) -> crate::Result<TimelineBuilder> {
128        TimelineBuilder::checked(size, rate)
129    }
130
131    /// Reopens this timeline for further edits. Audio resolved by
132    /// [`resolve_audio`](Self::resolve_audio) is dropped and must be resolved again.
133    pub fn to_builder(&self) -> TimelineBuilder {
134        TimelineBuilder::from_timeline(self)
135    }
136
137    /// Logical timeline viewport; supply this to composition rendering.
138    pub fn size(&self) -> Size {
139        self.size
140    }
141
142    /// Frame grid used for all schedule boundary adjustments.
143    pub fn frame_rate(&self) -> FrameRate {
144        self.rate
145    }
146
147    /// Background below every track, if any.
148    pub fn background(&self) -> Option<Color> {
149        self.background
150    }
151
152    /// Final duration: the explicit value, or the end of the tracks and audio,
153    /// independent of any parent clip duration.
154    pub fn duration(&self) -> Duration {
155        self.duration
156    }
157
158    /// Exact output frame count. Existing Composition needs checked u32 conversion.
159    pub fn frame_count(&self) -> u64 {
160        self.duration.to_frame(self.rate)
161    }
162
163    /// Immutable tracks in bottom-to-top paint order.
164    pub fn tracks(&self) -> &[Track] {
165        &self.tracks
166    }
167
168    /// Audio clips, buses and master settings declared on this timeline.
169    pub fn soundtrack(&self) -> &Soundtrack {
170        &self.soundtrack
171    }
172
173    /// Structured warnings, without stderr side effects.
174    pub fn diagnostics(&self) -> &Diagnostics {
175        &self.diagnostics
176    }
177
178    /// Resolves the soundtrack's clips at an I/O boundary, for example by reading
179    /// files. The timeline itself is unchanged; the result carries the clips and the
180    /// output duration they require.
181    pub fn resolve_audio(
182        &self,
183        mut resolve: impl FnMut(&crate::AudioClip) -> crate::Result<crate::AudioClip>,
184    ) -> crate::Result<ResolvedAudio> {
185        let clips = self
186            .soundtrack
187            .clips
188            .iter()
189            .map(&mut resolve)
190            .collect::<crate::Result<Vec<_>>>()?;
191        let mut duration = self.duration;
192        if self.inferred_duration {
193            for clip in &clips {
194                duration = duration.max(self.soundtrack.clip_end(clip, self.rate)?);
195            }
196        }
197        Ok(ResolvedAudio { clips, duration })
198    }
199
200    /// Wraps this timeline as a picture-only [`Scene`], for embedding where audio
201    /// must not follow, for example `SceneContext::embed`.
202    pub fn visual(&self) -> TimelineVisual {
203        TimelineVisual(self.clone())
204    }
205
206    /// Builds one snapshot without retaining previous-frame state. Times at or after
207    /// [`duration`](Self::duration) produce nothing.
208    pub fn render(&self, context: &SceneContext<'_>) -> Node {
209        self.render_until(context, self.duration)
210    }
211
212    /// Like [`render`](Self::render) but keeps drawing until `end`, for exports whose
213    /// resolved audio outlasts the picture.
214    pub fn render_until(&self, context: &SceneContext<'_>, end: Duration) -> Node {
215        if context.time() < Time::ZERO || context.time() >= end.after_origin() {
216            return Node::empty();
217        }
218        let parent_size = context.size();
219        let nested = context.is_nested_scene();
220        let context = context.for_timeline(self.size, self.rate);
221        let background = self
222            .background
223            .map(|color| Node::from(self.size.to_rect().fill(color)));
224        let clips = self
225            .tracks
226            .iter()
227            .filter_map(|track| track.render(&context));
228        let node = Node::group(background.into_iter().chain(clips));
229        if parent_size == self.size {
230            return if nested {
231                node.clip_with(self.size.to_rect(), crate::ClipMode::Geometric)
232            } else {
233                node
234            };
235        }
236        let scale =
237            (parent_size.width / self.size.width).min(parent_size.height / self.size.height);
238        let scale = if scale.is_finite() && scale > 0.0 {
239            scale
240        } else {
241            f64::NAN
242        };
243        node.clip_with(self.size.to_rect(), crate::ClipMode::Geometric)
244            .pivot(crate::Pivot::Point(crate::Point::ORIGIN))
245            .scale(scale)
246            .translate(
247                (parent_size.width - self.size.width * scale) * 0.5,
248                (parent_size.height - self.size.height * scale) * 0.5,
249            )
250    }
251}
252
253/// Picture-only view of a [`Timeline`], created by [`Timeline::visual`].
254///
255/// Clips and nested scenes see the timeline's own size and frame rate; its audio is
256/// not carried along.
257#[derive(Clone, Debug)]
258pub struct TimelineVisual(Timeline);
259
260impl Scene for TimelineVisual {
261    fn render(&self, context: &SceneContext<'_>) -> Node {
262        self.0.render(context)
263    }
264
265    fn duration(&self) -> Option<Duration> {
266        Some(self.0.duration)
267    }
268}