1use 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
16const ZOOM_TRAVEL_MS: f32 = 500.0;
18const RIPPLE_MS: u64 = 400;
20const POINTER_SIZE: f32 = 20.0;
21const HALO_RADIUS: f32 = 30.0;
22const SOFT_MS: f32 = 300.0;
24const WORDS_IN_MS: f32 = 500.0;
26const WORDS_RISE: f32 = 14.0;
28
29#[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 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
58pub(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 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
86struct 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
109fn 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
125fn 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#[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 pub fn new(movie: Movie, at_ms: u64) -> Self {
148 Self { movie, at_ms, width: 960.0, whole: false, logo: None }
149 }
150
151 pub fn width(mut self, width: f32) -> Self {
153 self.width = width;
154 self
155 }
156
157 pub fn logo(mut self, logo: Option<LottieAnimation>) -> Self {
160 self.logo = logo;
161 self
162 }
163
164 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 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 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 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 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 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 _ => {}
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 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 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}