Skip to main content

motion_core/authoring/
transition.rs

1//! Snapshot transitions; all captures use the shared linear fill pipeline.
2//!
3//! ```
4//! use motion_core::prelude::*;
5//! use motion_core::{
6//!     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
7//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
8//! };
9//! use motion_core::authoring::Scene as _;
10//! let first = (|context: &SceneContext<'_>| Node::from(context.bounds().fill(Color::WHITE)))
11//!     .with_duration(1.seconds());
12//! let next = (|context: &SceneContext<'_>| Node::from(context.bounds().fill(Color::BLACK)))
13//!     .with_duration(1.seconds());
14//! let timeline = Timeline::new(Size::new(8.0, 4.0), fps!(30)).track(|track| {
15//!     track
16//!         .push(first)
17//!         .transition(CrossFade, 500.milliseconds())
18//!         .push(next)
19//! }).build()?;
20//! assert_eq!(timeline.frame_count(), 45);
21//! let cache = SceneCache::default();
22//! let context = SceneContext::new(Time::ZERO, timeline.frame_rate(), timeline.size(), &cache);
23//! timeline.render(&context).compile(
24//!     timeline.size(),
25//!     PixelSize::new(8, 4),
26//!     ValidationLimits::default(),
27//! )?;
28//! # Ok::<(), motion_core::Error>(())
29//! ```
30
31use crate::{Color, Node, NodeModifiers, Point, Rect, SceneContext, Shape};
32
33/// A stateless composition of two already evaluated clip snapshots.
34pub trait Transition: Send + Sync {
35    /// Composes finite progress in 0..=1 inside the context design viewport.
36    fn compose(
37        &self,
38        progress: f64,
39        outgoing: Node,
40        incoming: Node,
41        context: &SceneContext<'_>,
42    ) -> Node;
43
44    /// continuous-time easing; finite overshoot is clamped by the scheduler.
45    fn easing(&self) -> crate::Easing {
46        crate::Easing::Linear
47    }
48}
49
50impl Transition for std::sync::Arc<dyn Transition> {
51    fn compose(
52        &self,
53        progress: f64,
54        outgoing: Node,
55        incoming: Node,
56        context: &SceneContext<'_>,
57    ) -> Node {
58        self.as_ref().compose(progress, outgoing, incoming, context)
59    }
60
61    fn easing(&self) -> crate::Easing {
62        self.as_ref().easing()
63    }
64}
65
66struct Failure(crate::ValidationError);
67
68impl crate::Drawable for Failure {
69    fn bounds(&self, _: &crate::DrawContext) -> Result<Rect, crate::ValidationError> {
70        Err(self.0.clone())
71    }
72
73    fn draw(
74        &self,
75        _: &crate::DrawContext,
76        _: &mut crate::Canvas<'_>,
77    ) -> Result<(), crate::ValidationError> {
78        Err(self.0.clone())
79    }
80}
81
82/// Retains a construction failure as a checked node instead of drawing fallback content.
83pub fn diagnostic(error: crate::ValidationError) -> Node {
84    Node::from(Failure(error))
85}
86
87pub(crate) fn failure(field: &str, message: &str) -> Node {
88    diagnostic(crate::ValidationError::invalid(
89        "transition",
90        field,
91        message,
92    ))
93}
94
95fn sum(samples: impl IntoIterator<Item = (Node, f64)>) -> Node {
96    Node::weighted_sum(samples).expect("bounded built-in transition weights")
97}
98
99fn endpoint(
100    progress: f64,
101    outgoing: &Node,
102    incoming: &Node,
103    context: &SceneContext<'_>,
104) -> Option<Node> {
105    if !progress.is_finite() || !(0.0..=1.0).contains(&progress) {
106        return Some(failure(
107            "progress",
108            "Transition progress must be finite in 0..=1",
109        ));
110    }
111    if progress == 0.0 {
112        return Some(
113            Node::group([
114                outgoing.clone(),
115                Node::group([incoming.clone()]).opacity(0.0),
116            ])
117            .clip_with(context.bounds(), crate::ClipMode::Geometric),
118        );
119    }
120    if progress == 1.0 {
121        return Some(
122            Node::group([
123                Node::group([outgoing.clone()]).opacity(0.0),
124                incoming.clone(),
125            ])
126            .clip_with(context.bounds(), crate::ClipMode::Geometric),
127        );
128    }
129    None
130}
131
132fn select(mask: Node, outgoing: Node, incoming: Node, context: &SceneContext<'_>) -> Node {
133    sum([
134        (
135            outgoing.mask(mask.clone(), crate::MaskMode::InvertedAlpha),
136            1.0,
137        ),
138        (incoming.mask(mask, crate::MaskMode::Alpha), 1.0),
139    ])
140    .clip_with(context.bounds(), crate::ClipMode::Geometric)
141}
142
143/// Exact linear-premultiplied interpolation, including transparent snapshots.
144#[derive(Clone, Copy, Debug, Default)]
145pub struct CrossFade;
146
147impl Transition for CrossFade {
148    fn compose(
149        &self,
150        progress: f64,
151        outgoing: Node,
152        incoming: Node,
153        context: &SceneContext<'_>,
154    ) -> Node {
155        if let Some(node) = endpoint(progress, &outgoing, &incoming, context) {
156            return node;
157        }
158        sum([(outgoing, 1.0 - progress), (incoming, progress)])
159            .clip_with(context.bounds(), crate::ClipMode::Geometric)
160    }
161}
162
163/// Incoming viewport moves in the selected direction over a stationary outgoing view.
164#[derive(Clone, Copy, Debug)]
165pub struct Slide {
166    /// Incoming travel direction.
167    pub direction: Direction,
168}
169
170/// Both viewports move in the selected direction with an adjoining boundary.
171#[derive(Clone, Copy, Debug)]
172pub struct Push {
173    /// Viewport travel direction.
174    pub direction: Direction,
175}
176
177fn displacement(direction: Direction, context: &SceneContext<'_>) -> (f64, f64) {
178    match direction {
179        Direction::Left => (-context.size().width, 0.0),
180        Direction::Right => (context.size().width, 0.0),
181        Direction::Up => (0.0, -context.size().height),
182        Direction::Down => (0.0, context.size().height),
183    }
184}
185
186impl Transition for Slide {
187    fn compose(
188        &self,
189        progress: f64,
190        outgoing: Node,
191        incoming: Node,
192        context: &SceneContext<'_>,
193    ) -> Node {
194        if let Some(node) = endpoint(progress, &outgoing, &incoming, context) {
195            return node;
196        }
197        let (horizontal, vertical) = displacement(self.direction, context);
198        let offset = (-horizontal * (1.0 - progress), -vertical * (1.0 - progress));
199        let mask = Node::from(context.bounds().fill(Color::WHITE)).translate(offset.0, offset.1);
200        let incoming = incoming
201            .clip_with(context.bounds(), crate::ClipMode::Geometric)
202            .translate(offset.0, offset.1);
203        sum([
204            (outgoing.mask(mask, crate::MaskMode::InvertedAlpha), 1.0),
205            (incoming, 1.0),
206        ])
207        .clip_with(context.bounds(), crate::ClipMode::Geometric)
208    }
209}
210
211impl Transition for Push {
212    fn compose(
213        &self,
214        progress: f64,
215        outgoing: Node,
216        incoming: Node,
217        context: &SceneContext<'_>,
218    ) -> Node {
219        if let Some(node) = endpoint(progress, &outgoing, &incoming, context) {
220            return node;
221        }
222        let (horizontal, vertical) = displacement(self.direction, context);
223        sum([
224            (
225                outgoing
226                    .clip_with(context.bounds(), crate::ClipMode::Geometric)
227                    .translate(horizontal * progress, vertical * progress),
228                1.0,
229            ),
230            (
231                incoming
232                    .clip_with(context.bounds(), crate::ClipMode::Geometric)
233                    .translate(-horizontal * (1.0 - progress), -vertical * (1.0 - progress)),
234                1.0,
235            ),
236        ])
237        .clip_with(context.bounds(), crate::ClipMode::Geometric)
238    }
239}
240
241/// Projection wipe: radians from +X, with a nonnegative logical-pixel feather.
242#[derive(Clone, Copy, Debug, Default)]
243pub struct Wipe {
244    /// Direction of the advancing edge's normal.
245    pub angle: f64,
246    /// Linear transition width in design pixels; zero is hard.
247    pub feather: f64,
248}
249
250impl Transition for Wipe {
251    fn compose(
252        &self,
253        progress: f64,
254        outgoing: Node,
255        incoming: Node,
256        context: &SceneContext<'_>,
257    ) -> Node {
258        if !self.angle.is_finite() || !self.feather.is_finite() || self.feather < 0.0 {
259            return failure("wipe", "Wipe requires finite angle and nonnegative feather");
260        }
261        if let Some(node) = endpoint(progress, &outgoing, &incoming, context) {
262            return node;
263        }
264        let normal = Point::new(libm::cos(self.angle), libm::sin(self.angle));
265        let projections = [
266            0.0,
267            context.size().width * normal.x,
268            context.size().height * normal.y,
269            context.size().width * normal.x + context.size().height * normal.y,
270        ];
271        let minimum = projections.iter().copied().fold(f64::INFINITY, f64::min);
272        let maximum = projections
273            .iter()
274            .copied()
275            .fold(f64::NEG_INFINITY, f64::max);
276        let position = minimum - self.feather * 0.5 + (maximum - minimum + self.feather) * progress;
277        let mask = if self.feather == 0.0 {
278            let radius = libm::hypot(context.size().width, context.size().height) * 2.0;
279            Node::from(Rect::new(-radius, -radius, position, radius).fill(Color::WHITE))
280                .pivot(crate::Pivot::Point(Point::ORIGIN))
281                .rotate(self.angle)
282        } else {
283            let first = Point::new(
284                normal.x * (position - self.feather * 0.5),
285                normal.y * (position - self.feather * 0.5),
286            );
287            let last = Point::new(
288                normal.x * (position + self.feather * 0.5),
289                normal.y * (position + self.feather * 0.5),
290            );
291            Node::from(
292                context.bounds().fill(
293                    crate::LinearGradient::new(first, last)
294                        .stop(0.0, Color::WHITE)
295                        .stop(1.0, Color::WHITE.with_alpha(0.0))
296                        .units(crate::GradientUnits::UserSpace)
297                        .space(crate::GradientSpace::LinearRgb),
298                ),
299            )
300        };
301        select(mask, outgoing, incoming, context)
302    }
303}
304
305/// Built-in iris reveal geometry centered in the design viewport.
306#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
307#[cfg_attr(
308    feature = "serde",
309    derive(serde::Serialize, serde::Deserialize),
310    serde(rename_all = "snake_case")
311)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub enum IrisShape {
314    /// Circle reaches every viewport corner at progress one.
315    #[default]
316    Circle,
317    /// Rectangle grows proportionally in both dimensions.
318    Rectangle,
319}
320
321/// Centered growing-shape selection of the incoming snapshot.
322#[derive(Clone, Copy, Debug, Default)]
323pub struct Iris {
324    /// Reveal geometry.
325    pub shape: IrisShape,
326}
327
328impl Transition for Iris {
329    fn compose(
330        &self,
331        progress: f64,
332        outgoing: Node,
333        incoming: Node,
334        context: &SceneContext<'_>,
335    ) -> Node {
336        if let Some(node) = endpoint(progress, &outgoing, &incoming, context) {
337            return node;
338        }
339        let center = context.center();
340        let mask = match self.shape {
341            IrisShape::Circle => {
342                match crate::Circle::new(center, libm::hypot(center.x, center.y) * progress) {
343                    Ok(circle) => Node::from(circle.fill(Color::WHITE)),
344                    Err(error) => return diagnostic(error),
345                }
346            }
347            IrisShape::Rectangle => Node::from(
348                Rect::new(
349                    center.x * (1.0 - progress),
350                    center.y * (1.0 - progress),
351                    center.x * (1.0 + progress),
352                    center.y * (1.0 + progress),
353                )
354                .fill(Color::WHITE),
355            ),
356        };
357        select(mask, outgoing, incoming, context)
358    }
359}
360
361/// Twelve spatial zoom samples per snapshot; no Gaussian substitute or time resampling.
362#[derive(Clone, Copy, Debug, Default)]
363pub struct ZoomBlur;
364
365impl Transition for ZoomBlur {
366    fn compose(
367        &self,
368        progress: f64,
369        outgoing: Node,
370        incoming: Node,
371        context: &SceneContext<'_>,
372    ) -> Node {
373        if let Some(node) = endpoint(progress, &outgoing, &incoming, context) {
374            return node;
375        }
376        let blur = |node: Node, base: f64| {
377            let samples = (0..12).map(|index| {
378                node.clone()
379                    .clip_with(context.bounds(), crate::ClipMode::Geometric)
380                    .pivot(crate::Pivot::Point(context.center()))
381                    .scale(
382                        base + libm::sin(std::f64::consts::PI * progress)
383                            * 0.2
384                            * (f64::from(index) + 0.5)
385                            / 12.0,
386                    )
387            });
388            Node::accumulate(samples).expect("fixed zoom samples")
389        };
390        CrossFade.compose(
391            progress,
392            blur(outgoing, 1.0 + progress * 0.25),
393            blur(incoming, 0.75 + progress * 0.25),
394            context,
395        )
396    }
397}
398
399/// A movement vector in layer-local coordinates, not the edge of entry.
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401#[cfg_attr(
402    feature = "serde",
403    derive(serde::Serialize, serde::Deserialize),
404    serde(rename_all = "snake_case")
405)]
406#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
407pub enum Direction {
408    /// Move/reveal toward decreasing X; incoming push content enters from the right.
409    Left,
410    /// Move/reveal toward increasing X; incoming push content enters from the left.
411    Right,
412    /// Move/reveal toward decreasing Y; incoming push content enters from below.
413    Up,
414    /// Move/reveal toward increasing Y; incoming push content enters from above.
415    Down,
416}