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