Skip to main content

ravenrs_movie/
composition.rs

1//! One moment of an edited film, drawn: the film, then everything over it.
2
3use std::rc::Rc;
4
5use ravenrs_core::{Border, Color, Effect, Insets, PathFill, Point, Primitive, Quad, Rect, Role, Scene};
6use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
7use ravenrs_style::{Semantic, TextStyle};
8use ravenrs_widgets::{
9    BuildCx, Icon, IconName, LottieAnimation, NodeId, Paint, QuadPaint, Semantics, Text, VStack, View,
10    ViewExt,
11};
12
13use crate::movie::Movie;
14use crate::project::{CalloutStyle, CardLook, ClipKind, Overlay, OverlayKind, Place, Region};
15
16/// How long a zoom takes to travel, in milliseconds.
17const ZOOM_TRAVEL_MS: f32 = 500.0;
18/// How long the ring of a click lasts, in milliseconds.
19const RIPPLE_MS: u64 = 400;
20const POINTER_SIZE: f32 = 20.0;
21const HALO_RADIUS: f32 = 30.0;
22/// How long a soft callout takes to appear, in milliseconds.
23const SOFT_MS: f32 = 300.0;
24/// How long each line of an opening or a farewell takes to come in, in milliseconds.
25const WORDS_IN_MS: f32 = 500.0;
26/// How far a line rises as it comes in, in logical pixels of a 960-wide film.
27const WORDS_RISE: f32 = 14.0;
28
29/// What part of the film fills the screen at a moment.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub(crate) struct Camera {
32    origin: Point,
33    scale: f32,
34}
35
36#[cfg_attr(feature = "live", ravenrs_live::expose)]
37impl Camera {
38    /// A point of the film, on the screen.
39    pub(crate) fn to_screen(self, p: Point) -> Point {
40        Point::new((p.x - self.origin.x) * self.scale, (p.y - self.origin.y) * self.scale)
41    }
42
43    pub(crate) fn scale(self) -> f32 {
44        self.scale
45    }
46
47    pub(crate) fn region(self, region: Region) -> Rect {
48        let at = self.to_screen(Point::new(region.x, region.y));
49        Rect::new(at.x, at.y, region.width * self.scale, region.height * self.scale)
50    }
51}
52
53fn smooth(t: f32) -> f32 {
54    let t = t.clamp(0.0, 1.0);
55    t * t * (3.0 - 2.0 * t)
56}
57
58/// Where the camera is at `at_ms`, for a screen `width` wide. With `whole`, zooms
59/// do not count, which is how the editor places them.
60pub(crate) fn camera_at(movie: &Movie, at_ms: u64, width: f32, whole: bool) -> Camera {
61    let logical = movie.logical_size();
62    let base = width / logical.width.max(1.0);
63    let zoom = (!whole)
64        .then(|| {
65            movie.project().overlays.iter().find_map(|overlay| match overlay.kind {
66                OverlayKind::Zoom { region } if overlay.covers(at_ms) => Some((overlay, region)),
67                _ => None,
68            })
69        })
70        .flatten();
71    let Some((overlay, region)) = zoom else {
72        return Camera { origin: Point::ZERO, scale: base };
73    };
74    let into = (at_ms - overlay.start_ms) as f32 / ZOOM_TRAVEL_MS;
75    let left = (overlay.end_ms() - at_ms) as f32 / ZOOM_TRAVEL_MS;
76    let amount = smooth(into.min(left));
77    // The whole screen stays filled: the region grows to the film's proportions.
78    let fit = (region.width / logical.width).max(region.height / logical.height).clamp(0.05, 1.0);
79    let (shown_width, shown_height) = (logical.width * fit, logical.height * fit);
80    let x = (region.x + region.width / 2.0 - shown_width / 2.0).clamp(0.0, logical.width - shown_width);
81    let y = (region.y + region.height / 2.0 - shown_height / 2.0).clamp(0.0, logical.height - shown_height);
82    let shown = 1.0 + (fit - 1.0) * amount;
83    Camera { origin: Point::new(x * amount, y * amount), scale: base / shown }
84}
85
86/// Places a view at a spot of the screen, out of the flow.
87struct At<V> {
88    frame: Rect,
89    view: V,
90}
91
92impl<V: View> View for At<V> {
93    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
94        let child = cx.child(&self.view);
95        cx.container(
96            LayoutStyle {
97                absolute: Some((self.frame.x(), self.frame.y())),
98                width: Some(self.frame.width()),
99                height: Some(self.frame.height()),
100                align: Align::Stretch,
101                ..LayoutStyle::stack(Axis::Vertical)
102            },
103            &[child],
104            Paint::None,
105        )
106    }
107}
108
109/// A rectangle out of the flow, with what it paints.
110fn patch(cx: &mut BuildCx<'_>, frame: Rect, paint: QuadPaint) -> NodeId {
111    let node = cx.container(
112        LayoutStyle {
113            absolute: Some((frame.x(), frame.y())),
114            width: Some(frame.width()),
115            height: Some(frame.height()),
116            ..LayoutStyle::stack(Axis::Vertical)
117        },
118        &[],
119        Paint::Quad(paint),
120    );
121    cx.semantics(node, Semantics::hidden());
122    node
123}
124
125/// Room between the lines of a card.
126fn gap(cx: &mut BuildCx<'_>, height: f32) -> NodeId {
127    let node = cx.container(LayoutStyle { height: Some(height), ..LayoutStyle::leaf() }, &[], Paint::None);
128    cx.semantics(node, Semantics::hidden());
129    node
130}
131
132/// One moment of an edited film, `width` wide and as tall as its proportions say.
133/// The player and the editor both draw with it, so what is edited is what is shown.
134#[derive(Debug)]
135#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
136pub struct Composition {
137    movie: Movie,
138    at_ms: u64,
139    width: f32,
140    whole: bool,
141    logo: Option<LottieAnimation>,
142}
143
144#[cfg_attr(feature = "live", ravenrs_live::expose)]
145impl Composition {
146    /// The moment `at_ms` of the timeline of `movie`.
147    pub fn new(movie: Movie, at_ms: u64) -> Self {
148        Self { movie, at_ms, width: 960.0, whole: false, logo: None }
149    }
150
151    /// Its width, in logical pixels (960 by default).
152    pub fn width(mut self, width: f32) -> Self {
153        self.width = width;
154        self
155    }
156
157    /// The app's animated logo, which plays on the opening and the farewell cards
158    /// ([`CardLook`]), in the time of the film: it follows a pause or a jump.
159    pub fn logo(mut self, logo: Option<LottieAnimation>) -> Self {
160        self.logo = logo;
161        self
162    }
163
164    /// Shows the whole screen even during a zoom, to place one.
165    pub(crate) fn ignoring_zoom(mut self) -> Self {
166        self.whole = true;
167        self
168    }
169
170    fn height(&self) -> f32 {
171        let logical = self.movie.logical_size();
172        self.width * logical.height / logical.width.max(1.0)
173    }
174
175    fn film(&self, cx: &mut BuildCx<'_>, camera: Camera, film_ms: u64) -> NodeId {
176        let (movie, background) = (self.movie.clone(), self.movie.background());
177        let list = self.movie.draw_list(film_ms);
178        let draw = Rc::new(move |frame: Rect, scene: &mut Scene| {
179            // A zoom draws the film larger than the screen, so it has to be cut at the edge.
180            scene.push_clip(frame);
181            scene.push_quad(Quad::new(frame, background));
182            let Some((_, list)) = &list else {
183                scene.pop_clip();
184                return;
185            };
186            let start = scene.len();
187            let drawn: Vec<Primitive> = list.iter().filter_map(|i| movie.primitive(*i).cloned()).collect();
188            scene.extend_from_slice(&drawn);
189            let offset = Point::new(
190                frame.x() - camera.origin.x * camera.scale,
191                frame.y() - camera.origin.y * camera.scale,
192            );
193            let end = scene.len();
194            scene.apply_effect(start..end, Effect { opacity: 1.0, offset, scale: camera.scale }, Point::ZERO);
195            scene.pop_clip();
196        });
197        let node = cx.container(
198            LayoutStyle {
199                absolute: Some((0.0, 0.0)),
200                width: Some(self.width),
201                height: Some(self.height()),
202                ..LayoutStyle::stack(Axis::Vertical)
203            },
204            &[],
205            Paint::Custom(draw),
206        );
207        cx.semantics(node, Semantics::new(Role::Image));
208        node
209    }
210
211    fn title_card(&self, cx: &mut BuildCx<'_>, clip: usize) -> NodeId {
212        let theme = *cx.theme();
213        let (title, subtitle, look) = match self.movie.project().clips.get(clip).map(|c| &c.kind) {
214            Some(ClipKind::Title { title, subtitle, look, .. }) => (title.clone(), subtitle.clone(), *look),
215            _ => (String::new(), String::new(), CardLook::Plain),
216        };
217        // The app's color, so that a film opens as something and not as an empty page.
218        let accent = theme.color(Semantic::Accent);
219        let ink = theme.color(Semantic::OnAccent);
220        let words = if look == CardLook::Plain {
221            let words = VStack::new((
222                Text::new(title).style(TextStyle::Display).foreground(ink),
223                Text::new(subtitle).foreground(ink),
224            ))
225            .spacing(8.0)
226            .align(Align::Center);
227            vec![cx.child(&words)]
228        } else {
229            self.moving_card(cx, clip, look, title, subtitle)
230        };
231        cx.container(
232            LayoutStyle {
233                absolute: Some((0.0, 0.0)),
234                width: Some(self.width),
235                height: Some(self.height()),
236                align: Align::Center,
237                justify: Justify::Center,
238                ..LayoutStyle::stack(Axis::Vertical)
239            },
240            &words,
241            Paint::Quad(QuadPaint::new(accent)),
242        )
243    }
244
245    /// An opening or a farewell: two soft lights drift on the app's color, the logo
246    /// plays, and the words come in one after the other, rising a little.
247    fn moving_card(
248        &self,
249        cx: &mut BuildCx<'_>,
250        clip: usize,
251        look: CardLook,
252        title: String,
253        subtitle: String,
254    ) -> Vec<NodeId> {
255        let theme = *cx.theme();
256        let ink = theme.color(Semantic::OnAccent);
257        let reduced = cx.reduced_motion();
258        let inside = self.at_ms.saturating_sub(self.movie.project().clip_start_ms(clip));
259        let inside = if reduced { u64::MAX / 4 } else { inside };
260        let size = self.width / 960.0;
261        // When each line comes in, counted back from when they are all in.
262        let all_in = look.words_in_ms() as f32;
263        let coming = |from: f32| smooth((inside as f32 - from) / WORDS_IN_MS);
264        let (title_in, line_in, subtitle_in) =
265            (coming(all_in - 900.0), coming(all_in - 700.0), coming(all_in - WORDS_IN_MS));
266        let mut children = Vec::new();
267
268        let drift = (inside.min(20_000) as f32 / 1000.0) * 6.0 * size;
269        for (x, y, radius, alpha) in [
270            (0.18 * self.width - drift, 0.22 * self.height(), 260.0 * size, 0.10),
271            (0.84 * self.width + drift, 0.86 * self.height(), 340.0 * size, 0.07),
272        ] {
273            let frame = Rect::new(x - radius, y - radius, radius * 2.0, radius * 2.0);
274            let paint = QuadPaint { corner_radius: radius, ..QuadPaint::new(ink.faded(alpha)) };
275            children.push(patch(cx, frame, paint));
276        }
277
278        let mut column = Vec::new();
279        let logo_size = if look == CardLook::Opening { 168.0 } else { 128.0 } * size;
280        if let Some(logo) = self.logo.clone() {
281            let seconds = (inside as f64 / 1000.0).min(logo.duration());
282            let (width, height) = (logo.width().max(1.0), logo.height().max(1.0));
283            let draw = Rc::new(move |frame: Rect, scene: &mut Scene| {
284                let scale = (frame.width() / width).min(frame.height() / height);
285                let x = frame.x() + (frame.width() - width * scale) / 2.0;
286                let y = frame.y() + (frame.height() - height * scale) / 2.0;
287                scene.push_clip(Rect::new(x, y, width * scale, height * scale));
288                for path in logo.paths_at(seconds, scale * 2.0) {
289                    scene.push_path(PathFill { transform: [scale, 0.0, 0.0, scale, x, y], ..path });
290                }
291                scene.pop_clip();
292            });
293            let node = cx.container(
294                LayoutStyle { width: Some(logo_size), height: Some(logo_size), ..LayoutStyle::leaf() },
295                &[],
296                Paint::Custom(draw),
297            );
298            cx.semantics(node, Semantics::hidden());
299            column.push(node);
300            column.push(gap(cx, 20.0 * size));
301        }
302        let rise = |amount: f32| Insets { top: (1.0 - amount) * WORDS_RISE * size, ..Insets::default() };
303        let heading = Text::new(title)
304            .style(TextStyle::Display)
305            .foreground(ink.faded(title_in))
306            .padding(rise(title_in));
307        column.push(cx.child(&heading));
308        let line_width = 120.0 * size * line_in;
309        column.push(gap(cx, 12.0 * size));
310        let line = cx.container(
311            LayoutStyle { width: Some(line_width.max(0.5)), height: Some(3.0 * size), ..LayoutStyle::leaf() },
312            &[],
313            Paint::Quad(QuadPaint { corner_radius: 2.0, ..QuadPaint::new(ink.faded(0.7 * line_in)) }),
314        );
315        cx.semantics(line, Semantics::hidden());
316        column.push(line);
317        column.push(gap(cx, 12.0 * size));
318        let under = Text::new(subtitle)
319            .style(TextStyle::Title)
320            .foreground(ink.faded(0.9 * subtitle_in))
321            .padding(rise(subtitle_in));
322        column.push(cx.child(&under));
323        let column = cx.container(
324            LayoutStyle { align: Align::Center, ..LayoutStyle::stack(Axis::Vertical) },
325            &column,
326            Paint::None,
327        );
328        children.push(column);
329        children
330    }
331
332    fn overlay(&self, cx: &mut BuildCx<'_>, overlay: &Overlay, camera: Camera, children: &mut Vec<NodeId>) {
333        let theme = *cx.theme();
334        let scrim = theme.color(Semantic::Scrim);
335        match &overlay.kind {
336            OverlayKind::Hide { region } => {
337                let paint = QuadPaint {
338                    corner_radius: theme.radii.sm,
339                    ..QuadPaint::new(theme.color(Semantic::Control))
340                };
341                children.push(patch(cx, camera.region(*region), paint));
342            }
343            OverlayKind::Spotlight { region } => {
344                let lit = camera.region(*region);
345                let (width, height) = (self.width, self.height());
346                let right = lit.x() + lit.width();
347                let bottom = lit.y() + lit.height();
348                for frame in [
349                    Rect::new(0.0, 0.0, width, lit.y().max(0.0)),
350                    Rect::new(0.0, bottom, width, (height - bottom).max(0.0)),
351                    Rect::new(0.0, lit.y(), lit.x().max(0.0), lit.height()),
352                    Rect::new(right, lit.y(), (width - right).max(0.0), lit.height()),
353                ] {
354                    children.push(patch(cx, frame, QuadPaint::new(scrim)));
355                }
356            }
357            OverlayKind::Callout { text, region, style, soft } => {
358                let frame = camera.region(*region);
359                // Gradually means by its color: the words and the balloon fade together.
360                let age = (self.at_ms - overlay.start_ms).min(overlay.end_ms() - self.at_ms) as f32;
361                let alpha = if *soft { smooth(age / SOFT_MS) } else { 1.0 };
362                let (fill, ink, edge) = match style {
363                    CalloutStyle::Balloon => (Semantic::Surface, Semantic::Ink, Semantic::Accent),
364                    CalloutStyle::Label => (Semantic::Accent, Semantic::OnAccent, Semantic::Accent),
365                };
366                let balloon = Text::new(text.clone())
367                    .style(TextStyle::Callout)
368                    .foreground(theme.color(ink).faded(alpha))
369                    .padding((8.0, 12.0))
370                    .background(theme.color(fill).faded(alpha))
371                    .corner_radius(if *style == CalloutStyle::Label {
372                        frame.height().min(999.0)
373                    } else {
374                        theme.radii.md
375                    })
376                    .border(2.0, theme.color(edge).faded(alpha));
377                children.push(cx.child(&At { frame, view: balloon }));
378            }
379            OverlayKind::Caption { text } => {
380                let height = 44.0;
381                let frame = Rect::new(0.0, self.height() - height - 12.0, self.width, height);
382                let line = Text::new(text.clone())
383                    .style(TextStyle::Callout)
384                    .foreground(Color::WHITE)
385                    .padding((6.0, 14.0))
386                    .background(scrim)
387                    .corner_radius(theme.radii.sm);
388                let centered = VStack::new((line,)).align(Align::Center);
389                children.push(cx.child(&At { frame, view: centered }));
390            }
391            OverlayKind::FadeIn | OverlayKind::FadeOut => {
392                let t = (self.at_ms - overlay.start_ms) as f32 / overlay.duration_ms.max(1) as f32;
393                let dark = if matches!(overlay.kind, OverlayKind::FadeIn) { 1.0 - t } else { t };
394                let frame = Rect::new(0.0, 0.0, self.width, self.height());
395                children.push(patch(
396                    cx,
397                    frame,
398                    QuadPaint::new(Color::rgba(0.0, 0.0, 0.0, dark.clamp(0.0, 1.0))),
399                ));
400            }
401            // These are drawn by others: the camera, the player, the pointer.
402            _ => {}
403        }
404    }
405
406    fn pointer(&self, cx: &mut BuildCx<'_>, camera: Camera, film_ms: u64, children: &mut Vec<NodeId>) {
407        let theme = *cx.theme();
408        let accent = theme.color(Semantic::Accent);
409        let highlighted = self
410            .movie
411            .project()
412            .overlays
413            .iter()
414            .any(|o| matches!(o.kind, OverlayKind::PointerHighlight) && o.covers(self.at_ms));
415        let Some(position) = self.movie.pointer_at(film_ms) else {
416            return;
417        };
418        let at = camera.to_screen(position);
419        if highlighted {
420            let frame =
421                Rect::new(at.x - HALO_RADIUS, at.y - HALO_RADIUS, HALO_RADIUS * 2.0, HALO_RADIUS * 2.0);
422            let paint = QuadPaint { corner_radius: HALO_RADIUS, ..QuadPaint::new(accent.faded(0.22)) };
423            children.push(patch(cx, frame, paint));
424        }
425        if let Some((click, age)) = self.movie.click_before(film_ms)
426            && age < RIPPLE_MS
427        {
428            let progress = age as f32 / RIPPLE_MS as f32;
429            let radius = (8.0 + 24.0 * progress) * if highlighted { 1.5 } else { 1.0 };
430            let center = camera.to_screen(click);
431            let frame = Rect::new(center.x - radius, center.y - radius, radius * 2.0, radius * 2.0);
432            let paint = QuadPaint {
433                fill: Color::TRANSPARENT.into(),
434                corner_radius: radius,
435                border: Some(Border { width: 3.0, color: accent.faded(1.0 - progress) }),
436                shadow: None,
437            };
438            children.push(patch(cx, frame, paint));
439        }
440        // The arrow's tip is at the corner of the icon, about where the pointer is.
441        for (grow, color) in [(4.0, Color::WHITE), (0.0, theme.color(Semantic::Ink))] {
442            let size = POINTER_SIZE + grow;
443            let frame = Rect::new(at.x - 3.0 - grow / 2.0, at.y - 3.0 - grow / 2.0, size, size);
444            let icon = Icon::new(IconName::MousePointer).size(size).color(color);
445            let node = cx.child(&At { frame, view: icon });
446            cx.semantics(node, Semantics::hidden());
447            children.push(node);
448        }
449    }
450}
451
452impl View for Composition {
453    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
454        let camera = camera_at(&self.movie, self.at_ms, self.width, self.whole);
455        let place = self.movie.project().place_at(self.at_ms);
456        let mut children = Vec::new();
457        let film_ms = match place {
458            Some(Place::Title(clip)) => {
459                children.push(self.title_card(cx, clip));
460                None
461            }
462            Some(Place::Film(ms)) => {
463                children.push(self.film(cx, camera, ms));
464                Some(ms)
465            }
466            None => None,
467        };
468        let overlays: Vec<Overlay> =
469            self.movie.project().overlays.iter().filter(|o| o.covers(self.at_ms)).cloned().collect();
470        // What covers the film goes first, the pointer over it, the words over all.
471        for overlay in overlays
472            .iter()
473            .filter(|o| matches!(o.kind, OverlayKind::Hide { .. } | OverlayKind::Spotlight { .. }))
474        {
475            self.overlay(cx, overlay, camera, &mut children);
476        }
477        if let Some(ms) = film_ms {
478            self.pointer(cx, camera, ms, &mut children);
479        }
480        for overlay in overlays
481            .iter()
482            .filter(|o| !matches!(o.kind, OverlayKind::Hide { .. } | OverlayKind::Spotlight { .. }))
483        {
484            self.overlay(cx, overlay, camera, &mut children);
485        }
486        let node = cx.container(
487            LayoutStyle {
488                width: Some(self.width),
489                height: Some(self.height()),
490                overflow_hidden: true,
491                ..LayoutStyle::stack(Axis::Vertical)
492            },
493            &children,
494            Paint::None,
495        );
496        cx.clip(node);
497        node
498    }
499}