Skip to main content

ravenrs_movie/
movie.rs

1//! A film in memory and in a file: what the app drew, each thing stored once.
2
3use std::collections::HashMap;
4use std::io::{Read, Write};
5use std::rc::Rc;
6use std::sync::Arc;
7
8use flate2::Compression;
9use flate2::read::ZlibDecoder;
10use flate2::write::ZlibEncoder;
11use ravenrs_core::{
12    Border, Brush, Color, FontRef, GlyphRun, ImageData, ImageQuad, LinearGradient, Point, PositionedGlyph,
13    Primitive, Quad, Rect, Shadow, Size,
14};
15use serde::{Deserialize, Serialize};
16
17use crate::project::{OverlayKind, Project};
18
19const MAGIC: &[u8] = b"RAVENMOVIE2\n";
20
21/// A picture in a film needs no more pixels than twice what it is drawn at.
22const IMAGE_DETAIL: f32 = 2.0;
23
24/// What can go wrong with a film.
25#[derive(Debug)]
26#[non_exhaustive]
27#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
28pub enum MovieError {
29    /// The file is not a film, or it was cut short.
30    Damaged(String),
31    /// The film has no frames.
32    Empty,
33}
34
35impl std::fmt::Display for MovieError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Damaged(why) => write!(f, "the film is damaged: {why}"),
39            Self::Empty => f.write_str("the film has no frames"),
40        }
41    }
42}
43
44impl std::error::Error for MovieError {}
45
46/// A part of the film, in the list of chapters.
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
49pub struct Chapter {
50    /// Its name.
51    pub title: String,
52    /// What it teaches.
53    pub text: String,
54    /// When it starts, in milliseconds.
55    pub at_ms: u64,
56}
57
58/// What a spot on the screen does.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[non_exhaustive]
61#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
62pub enum HotspotKind {
63    /// The film stops here until someone clicks the spot.
64    Stop,
65    /// The spot's text shows while it lasts; clicking it brings the text back.
66    Note,
67}
68
69/// A spot on the screen that talks to whoever is watching.
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
72pub struct Hotspot {
73    /// What it does.
74    pub kind: HotspotKind,
75    /// Where, in the logical pixels of the app.
76    pub x: f32,
77    /// Where, in the logical pixels of the app.
78    pub y: f32,
79    /// Its width.
80    pub width: f32,
81    /// Its height.
82    pub height: f32,
83    /// In a few words.
84    pub title: String,
85    /// What to say.
86    pub text: String,
87    /// When it appears, in milliseconds.
88    pub from_ms: u64,
89    /// When it goes away, in milliseconds.
90    pub until_ms: u64,
91}
92
93#[cfg_attr(feature = "live", ravenrs_live::expose)]
94impl Hotspot {
95    /// Where it is on the screen of the app.
96    pub fn bounds(&self) -> Rect {
97        Rect::new(self.x, self.y, self.width, self.height)
98    }
99
100    /// Whether it talks about the screen as a whole, with no spot to mark. It
101    /// has no size, and its box shows in the middle of the film.
102    pub fn is_general(&self) -> bool {
103        self.width <= 0.0 || self.height <= 0.0
104    }
105}
106
107/// A sound a film makes by itself.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub(crate) enum FilmSound {
110    Click,
111    Key,
112}
113
114/// Where the pointer was at a moment.
115#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
116#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
117pub struct PointerKey {
118    /// When, in milliseconds.
119    pub at_ms: u64,
120    /// Where across, in logical pixels.
121    pub x: f32,
122    /// Where down, in logical pixels.
123    pub y: f32,
124}
125
126fn tidy(value: f32) -> f32 {
127    (value * 64.0).round() / 64.0
128}
129
130fn tidy_color(color: Color) -> [f32; 4] {
131    [color.r, color.g, color.b, color.a].map(|c| (c * 1024.0).round() / 1024.0)
132}
133
134fn tidy_rect(rect: Rect) -> [f32; 4] {
135    [rect.x(), rect.y(), rect.width(), rect.height()].map(tidy)
136}
137
138fn color_of([r, g, b, a]: [f32; 4]) -> Color {
139    Color::rgba(r, g, b, a)
140}
141
142fn rect_of([x, y, width, height]: [f32; 4]) -> Rect {
143    Rect::new(x, y, width, height)
144}
145
146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
147enum StoredBrush {
148    S([f32; 4]),
149    G([f32; 2], [f32; 2], [f32; 4], [f32; 4]),
150}
151
152/// One thing the app drew, in as few characters as a file can have it.
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
154enum Stored {
155    Q {
156        r: [f32; 4],
157        c: [f32; 4],
158        f: StoredBrush,
159        #[serde(default, skip_serializing_if = "Option::is_none")]
160        b: Option<(f32, [f32; 4])>,
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        s: Option<([f32; 2], f32, [f32; 4])>,
163    },
164    T {
165        f: u16,
166        s: f32,
167        c: [f32; 4],
168        y: f32,
169        g: Vec<(u32, f32)>,
170    },
171    I {
172        i: u16,
173        r: [f32; 4],
174        u: [f32; 4],
175        c: [f32; 4],
176        o: f32,
177    },
178    C([f32; 4]),
179    P,
180}
181
182#[derive(Clone, Debug, Serialize, Deserialize)]
183enum StoredFont {
184    Builtin(u8),
185    Embedded { blob: u32, index: u32 },
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize)]
189struct StoredImage {
190    width: u32,
191    height: u32,
192    blob: u32,
193}
194
195/// A frame as the difference from the one before: this many things stay at the
196/// start, this many at the end, and these come between.
197#[derive(Clone, Debug, Serialize, Deserialize)]
198struct StoredFrame {
199    at: u64,
200    head: u32,
201    tail: u32,
202    mid: Vec<u32>,
203}
204
205#[derive(Clone, Debug, Serialize, Deserialize)]
206struct Header {
207    logical: [f32; 2],
208    background: [f32; 4],
209    duration_ms: u64,
210    fonts: Vec<StoredFont>,
211    images: Vec<StoredImage>,
212    shapes: Vec<Stored>,
213    frames: Vec<StoredFrame>,
214    pointer: Vec<PointerKey>,
215    clicks: Vec<PointerKey>,
216    /// When a key was pressed. Films recorded before there was sound have none.
217    #[serde(default)]
218    keys: Vec<u64>,
219    chapters: Vec<Chapter>,
220    hotspots: Vec<Hotspot>,
221    blob_lengths: Vec<u64>,
222}
223
224#[derive(Debug)]
225struct Film {
226    header: Header,
227    blobs: Vec<Vec<u8>>,
228    primitives: Vec<Primitive>,
229    frames: Vec<(u64, Rc<[u32]>)>,
230}
231
232/// A film and its edits. Cloning is cheap: the film itself is shared.
233#[derive(Clone, Debug)]
234#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
235pub struct Movie {
236    film: Rc<Film>,
237    project: Project,
238    chapters: Rc<[Chapter]>,
239    hotspots: Rc<[Hotspot]>,
240    sounds: Rc<[(u64, FilmSound)]>,
241}
242
243#[cfg_attr(feature = "live", ravenrs_live::expose)]
244impl Movie {
245    fn with(film: Rc<Film>, project: Project) -> Self {
246        let (chapters, hotspots) = edited_marks(&film.header, &project);
247        let sounds = edited_sounds(&film.header, &project);
248        Self { film, project, chapters: chapters.into(), hotspots: hotspots.into(), sounds: sounds.into() }
249    }
250
251    /// The same film with these edits.
252    pub fn with_project(&self, project: Project) -> Self {
253        Self::with(self.film.clone(), project)
254    }
255
256    /// Its edits.
257    pub fn project(&self) -> &Project {
258        &self.project
259    }
260
261    /// The size of the app it shows, in logical pixels.
262    pub fn logical_size(&self) -> Size {
263        Size::new(self.film.header.logical[0], self.film.header.logical[1])
264    }
265
266    /// The color behind everything.
267    pub fn background(&self) -> Color {
268        color_of(self.film.header.background)
269    }
270
271    /// How long it runs with its edits, in milliseconds.
272    pub fn duration_ms(&self) -> u64 {
273        self.project.duration_ms()
274    }
275
276    /// How long the film runs with no edits.
277    pub fn film_duration_ms(&self) -> u64 {
278        self.film.header.duration_ms
279    }
280
281    /// Its chapters, on the edited timeline. A chapter that was cut starts where
282    /// the cut left it, unless nothing of it remains.
283    pub fn chapters(&self) -> &[Chapter] {
284        &self.chapters
285    }
286
287    /// A moment to show before it plays: past any fade from black at the start, and
288    /// past the coming in of an opening card, so that it shows its words.
289    pub fn poster_ms(&self) -> u64 {
290        let faded = self
291            .project
292            .overlays
293            .iter()
294            .filter(|o| matches!(o.kind, OverlayKind::FadeIn) && o.start_ms == 0)
295            .map(crate::project::Overlay::end_ms)
296            .max()
297            .unwrap_or(0);
298        let opened = match self.project.clips.first().map(|clip| &clip.kind) {
299            Some(crate::project::ClipKind::Title { look, .. }) => look.words_in_ms(),
300            _ => 0,
301        };
302        faded.max(opened).min(self.duration_ms())
303    }
304
305    /// The chapter playing at `ms` of the timeline.
306    pub fn chapter_at(&self, ms: u64) -> Option<usize> {
307        self.chapters.iter().rposition(|chapter| chapter.at_ms <= ms)
308    }
309
310    /// Its hotspots, on the edited timeline.
311    pub fn hotspots(&self) -> &[Hotspot] {
312        &self.hotspots
313    }
314
315    /// The clicks and keys after `from_ms` and up to `to_ms` of the timeline.
316    pub(crate) fn sounds_between(&self, from_ms: u64, to_ms: u64) -> &[(u64, FilmSound)] {
317        let start = self.sounds.partition_point(|(at, _)| *at <= from_ms);
318        let end = self.sounds.partition_point(|(at, _)| *at <= to_ms);
319        self.sounds.get(start..end).unwrap_or(&[])
320    }
321
322    /// What the app drew at this moment of the film.
323    pub fn draw_list(&self, film_ms: u64) -> Option<(usize, Rc<[u32]>)> {
324        let frames = &self.film.frames;
325        let index = frames.partition_point(|(at, _)| *at <= film_ms).saturating_sub(1);
326        frames.get(index).map(|(_, list)| (index, list.clone()))
327    }
328
329    /// A thing the app drew, from a [`Self::draw_list`].
330    pub fn primitive(&self, index: u32) -> Option<&Primitive> {
331        self.film.primitives.get(index as usize)
332    }
333
334    /// Every frame of the film: when it starts and what it draws.
335    pub(crate) fn frames(&self) -> &[(u64, Rc<[u32]>)] {
336        &self.film.frames
337    }
338
339    /// Everywhere the pointer was noted.
340    pub(crate) fn pointer_keys(&self) -> &[PointerKey] {
341        &self.film.header.pointer
342    }
343
344    /// Every click.
345    pub(crate) fn click_keys(&self) -> &[PointerKey] {
346        &self.film.header.clicks
347    }
348
349    /// Where the pointer was at this moment of the film.
350    pub fn pointer_at(&self, film_ms: u64) -> Option<Point> {
351        let keys = &self.film.header.pointer;
352        let after = keys.partition_point(|key| key.at_ms <= film_ms);
353        match (after.checked_sub(1).and_then(|i| keys.get(i)), keys.get(after)) {
354            (Some(a), Some(b)) if b.at_ms > a.at_ms => {
355                let t = (film_ms - a.at_ms) as f32 / (b.at_ms - a.at_ms) as f32;
356                Some(Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t))
357            }
358            (Some(a), _) => Some(Point::new(a.x, a.y)),
359            (None, Some(b)) => Some(Point::new(b.x, b.y)),
360            (None, None) => None,
361        }
362    }
363
364    /// The last click at or before this moment of the film, and how long ago it was.
365    pub fn click_before(&self, film_ms: u64) -> Option<(Point, u64)> {
366        let clicks = &self.film.header.clicks;
367        let key = clicks.get(clicks.partition_point(|key| key.at_ms <= film_ms).checked_sub(1)?)?;
368        Some((Point::new(key.x, key.y), film_ms - key.at_ms))
369    }
370
371    /// The whole film as the bytes of a file, with its edits.
372    pub fn to_bytes(&self) -> Vec<u8> {
373        let header = serde_json::to_vec(&self.film.header).unwrap_or_default();
374        let project = serde_json::to_vec(&self.project).unwrap_or_default();
375        let body = deflate(&header);
376        let edits = deflate(&project);
377        let mut bytes = Vec::new();
378        bytes.extend_from_slice(MAGIC);
379        for part in [&body, &edits] {
380            bytes.extend_from_slice(&(part.len() as u64).to_le_bytes());
381            bytes.extend_from_slice(part);
382        }
383        for blob in &self.film.blobs {
384            bytes.extend_from_slice(blob);
385        }
386        bytes
387    }
388
389    /// Reads a film back from the bytes of a file.
390    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MovieError> {
391        let damaged = |why: &str| MovieError::Damaged(why.to_owned());
392        let mut rest = bytes.strip_prefix(MAGIC).ok_or_else(|| damaged("it does not start like a film"))?;
393        let mut part = || -> Result<Vec<u8>, MovieError> {
394            let (length, after) =
395                rest.split_first_chunk::<8>().ok_or_else(|| damaged("a part is missing"))?;
396            let length =
397                usize::try_from(u64::from_le_bytes(*length)).map_err(|_| damaged("a part is too long"))?;
398            let data = after.get(..length).ok_or_else(|| damaged("a part was cut"))?;
399            rest = &after[length..];
400            inflate(data)
401        };
402        let header = part()?;
403        let project = part()?;
404        let header: Header =
405            serde_json::from_slice(&header).map_err(|e| MovieError::Damaged(e.to_string()))?;
406        let project: Project =
407            serde_json::from_slice(&project).map_err(|e| MovieError::Damaged(e.to_string()))?;
408        let mut blobs = Vec::with_capacity(header.blob_lengths.len());
409        for length in &header.blob_lengths {
410            let length = usize::try_from(*length).map_err(|_| damaged("a blob is too long"))?;
411            let blob = rest.get(..length).ok_or_else(|| damaged("a blob was cut"))?;
412            blobs.push(blob.to_vec());
413            rest = &rest[length..];
414        }
415        if header.frames.is_empty() {
416            return Err(MovieError::Empty);
417        }
418        let film = Film::open(header, blobs)?;
419        Ok(Self::with(Rc::new(film), project))
420    }
421}
422
423/// Chapters and hotspots, moved to where the edits put them, with the hotspots
424/// added on the timeline.
425fn edited_marks(header: &Header, project: &Project) -> (Vec<Chapter>, Vec<Hotspot>) {
426    let mut chapters: Vec<Chapter> = header
427        .chapters
428        .iter()
429        .filter_map(|chapter| {
430            // If its first moments were cut, it starts where the film picks up again.
431            let next = project.clips.iter().filter_map(|clip| match clip.kind {
432                crate::project::ClipKind::Film { from_ms, .. } if from_ms >= chapter.at_ms => Some(from_ms),
433                _ => None,
434            });
435            let at = project.film_to_timeline(chapter.at_ms).or_else(|| {
436                let end = header
437                    .chapters
438                    .iter()
439                    .map(|c| c.at_ms)
440                    .find(|at| *at > chapter.at_ms)
441                    .unwrap_or(u64::MAX);
442                next.filter(|from| *from < end).min().and_then(|from| project.film_to_timeline(from))
443            })?;
444            Some(Chapter { at_ms: at, ..chapter.clone() })
445        })
446        .collect();
447    chapters.extend(project.markers.iter().map(|marker| Chapter {
448        title: marker.title.clone(),
449        text: String::new(),
450        at_ms: marker.at_ms,
451    }));
452    chapters.sort_by_key(|chapter| chapter.at_ms);
453    let mut hotspots: Vec<Hotspot> = header
454        .hotspots
455        .iter()
456        .filter_map(|spot| {
457            let from = project.film_to_timeline(spot.from_ms)?;
458            let length = spot.until_ms.saturating_sub(spot.from_ms);
459            Some(Hotspot { from_ms: from, until_ms: from + length, ..spot.clone() })
460        })
461        .collect();
462    for overlay in &project.overlays {
463        if let OverlayKind::Hotspot { region, title, text, stop } = &overlay.kind {
464            hotspots.push(Hotspot {
465                kind: if *stop { HotspotKind::Stop } else { HotspotKind::Note },
466                x: region.x,
467                y: region.y,
468                width: region.width,
469                height: region.height,
470                title: title.clone(),
471                text: text.clone(),
472                from_ms: overlay.start_ms,
473                until_ms: overlay.end_ms(),
474            });
475        }
476    }
477    hotspots.sort_by_key(|spot| spot.from_ms);
478    (chapters, hotspots)
479}
480
481/// Clicks and keys on the edited timeline: a part that was cut makes no sound.
482fn edited_sounds(header: &Header, project: &Project) -> Vec<(u64, FilmSound)> {
483    let clicks = header.clicks.iter().map(|click| (click.at_ms, FilmSound::Click));
484    let keys = header.keys.iter().map(|at| (*at, FilmSound::Key));
485    let mut sounds: Vec<(u64, FilmSound)> =
486        clicks.chain(keys).filter_map(|(at, sound)| Some((project.film_to_timeline(at)?, sound))).collect();
487    sounds.sort_by_key(|(at, _)| *at);
488    sounds
489}
490
491#[cfg_attr(feature = "live", ravenrs_live::expose)]
492impl Film {
493    fn open(header: Header, blobs: Vec<Vec<u8>>) -> Result<Self, MovieError> {
494        let damaged = |why: &str| MovieError::Damaged(why.to_owned());
495        let mut fonts = Vec::with_capacity(header.fonts.len());
496        for font in &header.fonts {
497            fonts.push(match font {
498                StoredFont::Builtin(number) => ravenrs_text::builtin_font(*number)
499                    .ok_or_else(|| damaged("it asks for a font that is not here"))?,
500                StoredFont::Embedded { blob, index } => {
501                    let bytes =
502                        inflate(blobs.get(*blob as usize).ok_or_else(|| damaged("a font is missing"))?)?;
503                    FontRef { id: font_id(&bytes), data: Arc::new(bytes), index: *index }
504                }
505            });
506        }
507        let mut images = Vec::with_capacity(header.images.len());
508        for image in &header.images {
509            let pixels =
510                inflate(blobs.get(image.blob as usize).ok_or_else(|| damaged("a picture is missing"))?)?;
511            images.push(
512                ImageData::from_rgba8(image.width, image.height, pixels)
513                    .ok_or_else(|| damaged("a picture is the wrong size"))?,
514            );
515        }
516        let primitives = header
517            .shapes
518            .iter()
519            .map(|shape| shape.primitive(&fonts, &images))
520            .collect::<Result<Vec<_>, _>>()?;
521        let mut frames: Vec<(u64, Rc<[u32]>)> = Vec::with_capacity(header.frames.len());
522        let mut list: Vec<u32> = Vec::new();
523        for frame in &header.frames {
524            let (head, tail) = (frame.head as usize, frame.tail as usize);
525            if head + tail > list.len() {
526                return Err(damaged("a frame does not fit the one before"));
527            }
528            let mut next = Vec::with_capacity(head + frame.mid.len() + tail);
529            next.extend_from_slice(&list[..head]);
530            next.extend_from_slice(&frame.mid);
531            next.extend_from_slice(&list[list.len() - tail..]);
532            if next.iter().any(|shape| *shape as usize >= primitives.len()) {
533                return Err(damaged("a frame draws something that is not there"));
534            }
535            frames.push((frame.at, next.as_slice().into()));
536            list = next;
537        }
538        Ok(Self { header, blobs, primitives, frames })
539    }
540}
541
542fn font_id(bytes: &[u8]) -> u64 {
543    // FNV-1a, with a high bit so that it does not meet the ids fonts get at run time.
544    let hash = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
545        (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
546    });
547    hash | (1 << 63)
548}
549
550#[cfg_attr(feature = "live", ravenrs_live::expose)]
551impl Stored {
552    fn primitive(&self, fonts: &[FontRef], images: &[ImageData]) -> Result<Primitive, MovieError> {
553        let damaged = |why: &str| MovieError::Damaged(why.to_owned());
554        Ok(match self {
555            Self::Q { r, c, f, b, s } => Primitive::Quad(Quad {
556                rect: rect_of(*r),
557                corner_radii: *c,
558                fill: match f {
559                    StoredBrush::S(color) => Brush::Solid(color_of(*color)),
560                    StoredBrush::G(start, end, from, to) => Brush::LinearGradient(LinearGradient {
561                        start: Point::new(start[0], start[1]),
562                        end: Point::new(end[0], end[1]),
563                        from: color_of(*from),
564                        to: color_of(*to),
565                    }),
566                },
567                border: b.map(|(width, color)| Border { width, color: color_of(color) }),
568                shadow: s.map(|(offset, blur, color)| Shadow {
569                    offset: Point::new(offset[0], offset[1]),
570                    blur,
571                    color: color_of(color),
572                }),
573            }),
574            Self::T { f, s, c, y, g } => Primitive::Glyphs(GlyphRun {
575                font: fonts.get(usize::from(*f)).ok_or_else(|| damaged("a font is missing"))?.clone(),
576                size: *s,
577                color: color_of(*c),
578                glyphs: g.iter().map(|(id, x)| PositionedGlyph { id: *id, x: *x, y: *y }).collect(),
579            }),
580            Self::I { i, r, u, c, o } => {
581                let image =
582                    images.get(usize::from(*i)).ok_or_else(|| damaged("a picture is missing"))?.clone();
583                Primitive::Image(ImageQuad {
584                    rect: rect_of(*r),
585                    image,
586                    uv: *u,
587                    corner_radii: *c,
588                    opacity: *o,
589                })
590            }
591            Self::C(r) => Primitive::PushClip(rect_of(*r)),
592            Self::P => Primitive::PopClip,
593        })
594    }
595}
596
597fn deflate(bytes: &[u8]) -> Vec<u8> {
598    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
599    // Writing to a `Vec` does not fail.
600    let _ = encoder.write_all(bytes);
601    encoder.finish().unwrap_or_default()
602}
603
604fn inflate(data: &[u8]) -> Result<Vec<u8>, MovieError> {
605    let mut bytes = Vec::new();
606    ZlibDecoder::new(data).read_to_end(&mut bytes).map_err(|e| MovieError::Damaged(e.to_string()))?;
607    Ok(bytes)
608}
609
610fn shrink(image: &ImageData, rect: Rect) -> (u32, u32, Vec<u8>) {
611    let (width, height) = (image.width(), image.height());
612    let wanted = |size: f32, have: u32| ((size * IMAGE_DETAIL).ceil() as u32).clamp(1, have);
613    let (new_width, new_height) = (wanted(rect.width(), width), wanted(rect.height(), height));
614    if (new_width, new_height) == (width, height) {
615        return (width, height, image.pixels().to_vec());
616    }
617    let mut pixels = Vec::with_capacity(new_width as usize * new_height as usize * 4);
618    for y in 0..new_height {
619        let (top, bottom) =
620            (y * height / new_height, ((y + 1) * height / new_height).max(y * height / new_height + 1));
621        for x in 0..new_width {
622            let (left, right) =
623                (x * width / new_width, ((x + 1) * width / new_width).max(x * width / new_width + 1));
624            let mut sum = [0_u32; 4];
625            for sy in top..bottom {
626                for sx in left..right {
627                    let at = (sy as usize * width as usize + sx as usize) * 4;
628                    for (total, value) in sum.iter_mut().zip(&image.pixels()[at..at + 4]) {
629                        *total += u32::from(*value);
630                    }
631                }
632            }
633            let count = (bottom - top) * (right - left);
634            pixels.extend(sum.map(|total| (total / count) as u8));
635        }
636    }
637    (new_width, new_height, pixels)
638}
639
640/// Puts a film together from what the app draws, keeping each different thing once.
641#[derive(Clone, Debug)]
642#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
643pub struct FilmBuilder {
644    header: Header,
645    blobs: Vec<Vec<u8>>,
646    known: HashMap<String, u32>,
647    known_fonts: HashMap<(u64, u32), u16>,
648    known_images: HashMap<u64, u16>,
649    list: Vec<u32>,
650}
651
652#[cfg_attr(feature = "live", ravenrs_live::expose)]
653impl FilmBuilder {
654    /// A film of an app of this logical size, over this color.
655    pub fn new(logical: Size, background: Color) -> Self {
656        Self {
657            header: Header {
658                logical: [logical.width, logical.height],
659                background: tidy_color(background),
660                duration_ms: 0,
661                fonts: Vec::new(),
662                images: Vec::new(),
663                shapes: Vec::new(),
664                frames: Vec::new(),
665                pointer: Vec::new(),
666                clicks: Vec::new(),
667                keys: Vec::new(),
668                chapters: Vec::new(),
669                hotspots: Vec::new(),
670                blob_lengths: Vec::new(),
671            },
672            blobs: Vec::new(),
673            known: HashMap::new(),
674            known_fonts: HashMap::new(),
675            known_images: HashMap::new(),
676            list: Vec::new(),
677        }
678    }
679
680    fn add_blob(&mut self, bytes: &[u8]) -> u32 {
681        let blob = deflate(bytes);
682        self.header.blob_lengths.push(blob.len() as u64);
683        self.blobs.push(blob);
684        (self.blobs.len() - 1) as u32
685    }
686
687    fn font(&mut self, font: &FontRef) -> u16 {
688        let key = (font.id, font.index);
689        if let Some(known) = self.known_fonts.get(&key) {
690            return *known;
691        }
692        let stored = match ravenrs_text::builtin_font_number(font.bytes()) {
693            Some(number) => StoredFont::Builtin(number),
694            None => StoredFont::Embedded { blob: self.add_blob(font.bytes()), index: font.index },
695        };
696        self.header.fonts.push(stored);
697        let number = (self.header.fonts.len() - 1) as u16;
698        self.known_fonts.insert(key, number);
699        number
700    }
701
702    fn image(&mut self, quad: &ImageQuad) -> u16 {
703        if let Some(known) = self.known_images.get(&quad.image.id()) {
704            return *known;
705        }
706        let (width, height, pixels) = shrink(&quad.image, quad.rect);
707        let blob = self.add_blob(&pixels);
708        self.header.images.push(StoredImage { width, height, blob });
709        let number = (self.header.images.len() - 1) as u16;
710        self.known_images.insert(quad.image.id(), number);
711        number
712    }
713
714    fn stored(&mut self, primitive: &Primitive) -> Option<Stored> {
715        Some(match primitive {
716            Primitive::Quad(quad) => Stored::Q {
717                r: tidy_rect(quad.rect),
718                c: quad.corner_radii.map(tidy),
719                f: match quad.fill {
720                    Brush::Solid(color) => StoredBrush::S(tidy_color(color)),
721                    Brush::LinearGradient(g) => StoredBrush::G(
722                        [g.start.x, g.start.y],
723                        [g.end.x, g.end.y],
724                        tidy_color(g.from),
725                        tidy_color(g.to),
726                    ),
727                    _ => return None,
728                },
729                b: quad.border.map(|border| (tidy(border.width), tidy_color(border.color))),
730                s: quad.shadow.map(|shadow| {
731                    (
732                        [tidy(shadow.offset.x), tidy(shadow.offset.y)],
733                        tidy(shadow.blur),
734                        tidy_color(shadow.color),
735                    )
736                }),
737            },
738            Primitive::Glyphs(run) => {
739                let y = run.glyphs.first().map_or(0.0, |glyph| tidy(glyph.y));
740                Stored::T {
741                    f: self.font(&run.font),
742                    s: tidy(run.size),
743                    c: tidy_color(run.color),
744                    y,
745                    g: run.glyphs.iter().map(|glyph| (glyph.id, tidy(glyph.x))).collect(),
746                }
747            }
748            Primitive::Image(quad) => Stored::I {
749                i: self.image(quad),
750                r: tidy_rect(quad.rect),
751                u: quad.uv,
752                c: quad.corner_radii.map(tidy),
753                o: quad.opacity,
754            },
755            Primitive::PushClip(rect) => Stored::C(tidy_rect(*rect)),
756            Primitive::PopClip => Stored::P,
757            _ => return None,
758        })
759    }
760
761    /// Adds what the app draws at `at_ms`. Nothing is added when it is the same
762    /// as the last time: that picture just stays longer on screen.
763    pub fn frame(&mut self, at_ms: u64, primitives: &[Primitive]) {
764        self.header.duration_ms = self.header.duration_ms.max(at_ms);
765        let mut list = Vec::with_capacity(primitives.len());
766        for primitive in primitives {
767            let Some(stored) = self.stored(primitive) else {
768                continue;
769            };
770            // The text of a thing is its identity, floats included.
771            let key = serde_json::to_string(&stored).unwrap_or_default();
772            let next = self.header.shapes.len() as u32;
773            let index = *self.known.entry(key).or_insert(next);
774            if index == next {
775                self.header.shapes.push(stored);
776            }
777            list.push(index);
778        }
779        if !self.header.frames.is_empty() && list == self.list {
780            return;
781        }
782        let head = list.iter().zip(&self.list).take_while(|(a, b)| a == b).count();
783        let tail =
784            list[head..].iter().rev().zip(self.list[head..].iter().rev()).take_while(|(a, b)| a == b).count();
785        let mid = list[head..list.len() - tail].to_vec();
786        self.header.frames.push(StoredFrame { at: at_ms, head: head as u32, tail: tail as u32, mid });
787        self.list = list;
788    }
789
790    /// Notes where the pointer is.
791    pub fn pointer(&mut self, at_ms: u64, position: Point) {
792        self.header.pointer.push(PointerKey { at_ms, x: tidy(position.x), y: tidy(position.y) });
793    }
794
795    /// Notes a click.
796    pub fn click(&mut self, at_ms: u64, position: Point) {
797        self.header.clicks.push(PointerKey { at_ms, x: tidy(position.x), y: tidy(position.y) });
798    }
799
800    /// Notes a key being pressed, for its sound.
801    pub fn key(&mut self, at_ms: u64) {
802        self.header.keys.push(at_ms);
803    }
804
805    /// Starts a chapter at `at_ms`.
806    pub fn chapter(&mut self, title: impl Into<String>, text: impl Into<String>, at_ms: u64) {
807        self.header.chapters.push(Chapter { title: title.into(), text: text.into(), at_ms });
808    }
809
810    /// Adds a spot on the screen.
811    pub fn hotspot(&mut self, hotspot: Hotspot) {
812        self.header.hotspots.push(hotspot);
813    }
814
815    /// The film, lasting until `duration_ms` even if the picture stopped changing before.
816    pub fn finish(mut self, duration_ms: u64) -> Result<Movie, MovieError> {
817        self.header.duration_ms = self.header.duration_ms.max(duration_ms);
818        if self.header.frames.is_empty() {
819            return Err(MovieError::Empty);
820        }
821        let project = Project::whole(self.header.duration_ms);
822        let film = Film::open(self.header, self.blobs)?;
823        Ok(Movie::with(Rc::new(film), project))
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::project::{OverlayKind, Region};
831
832    fn square(x: f32) -> Primitive {
833        Primitive::Quad(Quad::new(Rect::new(x, 0.0, 10.0, 10.0), Color::BLACK).radius(2.0))
834    }
835
836    fn builder() -> FilmBuilder {
837        FilmBuilder::new(Size::new(100.0, 50.0), Color::hex(0xffffff))
838    }
839
840    fn drawn(movie: &Movie, ms: u64) -> Vec<Primitive> {
841        let (_, list) = movie.draw_list(ms).expect("a frame");
842        list.iter().map(|i| movie.primitive(*i).expect("a shape").clone()).collect()
843    }
844
845    #[test]
846    fn the_same_picture_is_not_stored_and_each_thing_is_stored_once() {
847        let mut film = builder();
848        film.frame(0, &[square(0.0), square(20.0)]);
849        film.frame(100, &[square(0.0), square(20.0)]);
850        film.frame(200, &[square(0.0), square(30.0)]);
851        film.frame(300, &[square(0.0), square(20.0)]);
852        let movie = film.finish(1000).expect("film");
853        assert_eq!(movie.film.frames.len(), 3);
854        assert_eq!(movie.film.header.shapes.len(), 3, "three different squares in all");
855        assert_eq!(movie.duration_ms(), 1000);
856        assert_eq!(drawn(&movie, 150), [square(0.0), square(20.0)]);
857        assert_eq!(drawn(&movie, 250), [square(0.0), square(30.0)]);
858    }
859
860    #[test]
861    fn a_film_survives_a_file_with_its_edits() {
862        let mut film = builder();
863        film.frame(0, &[square(0.0), square(5.0), square(20.0)]);
864        film.frame(100, &[square(0.0), square(7.0), square(9.0), square(20.0)]);
865        film.frame(200, &[square(20.0)]);
866        film.pointer(0, Point::new(1.0, 2.0));
867        film.click(100, Point::new(1.0, 2.0));
868        film.chapter("One", "first", 0);
869        let mut project = Project::whole(300);
870        project.add_overlay(OverlayKind::Caption { text: "hello".to_owned() }, 0, 100);
871        let movie = film.finish(300).expect("film").with_project(project.clone());
872        let back = Movie::from_bytes(&movie.to_bytes()).expect("read back");
873        assert_eq!(back.project(), &project);
874        assert_eq!(back.chapters()[0].title, "One");
875        for ms in [0, 100, 200] {
876            assert_eq!(drawn(&back, ms), drawn(&movie, ms), "at {ms}");
877        }
878        assert_eq!(back.click_before(150), Some((Point::new(1.0, 2.0), 50)));
879    }
880
881    #[test]
882    fn text_keeps_its_glyphs_and_builtin_fonts_are_not_copied() {
883        let font = ravenrs_text::builtin_font(0).expect("a font");
884        let run = |x: f32| {
885            Primitive::Glyphs(GlyphRun {
886                font: font.clone(),
887                size: 14.0,
888                color: Color::BLACK,
889                glyphs: vec![
890                    PositionedGlyph { id: 42, x, y: 20.0 },
891                    PositionedGlyph { id: 43, x: x + 8.0, y: 20.0 },
892                ],
893            })
894        };
895        let mut film = builder();
896        film.frame(0, &[run(3.0)]);
897        let bytes = film.finish(0).expect("film").to_bytes();
898        assert!(bytes.len() < 1024, "{} bytes", bytes.len());
899        assert_eq!(drawn(&Movie::from_bytes(&bytes).expect("back"), 0), [run(3.0)]);
900    }
901
902    #[test]
903    fn a_big_picture_shrinks_to_what_it_is_drawn_at_and_stored_once() {
904        let image =
905            ImageData::from_fn(256, 256, |x, _| if x < 128 { Color::hex(0) } else { Color::hex(0xffffff) })
906                .expect("image");
907        let quad = |x: f32| Primitive::Image(ImageQuad::new(Rect::new(x, 0.0, 16.0, 16.0), image.clone()));
908        let mut film = builder();
909        film.frame(0, &[quad(0.0)]);
910        film.frame(10, &[quad(5.0)]);
911        let movie = film.finish(20).expect("film");
912        assert_eq!(movie.film.header.images.len(), 1);
913        assert_eq!((movie.film.header.images[0].width, movie.film.header.images[0].height), (32, 32));
914    }
915
916    #[test]
917    fn the_pointer_slides_between_where_it_was_noted() {
918        let mut film = builder();
919        film.frame(0, &[square(0.0)]);
920        film.pointer(100, Point::new(0.0, 0.0));
921        film.pointer(200, Point::new(10.0, 20.0));
922        let movie = film.finish(300).expect("film");
923        assert_eq!(movie.pointer_at(150), Some(Point::new(5.0, 10.0)));
924        assert_eq!(movie.pointer_at(0), Some(Point::new(0.0, 0.0)));
925        assert_eq!(movie.pointer_at(999), Some(Point::new(10.0, 20.0)));
926    }
927
928    #[test]
929    fn marks_follow_the_edits() {
930        let mut film = builder();
931        film.frame(0, &[square(0.0)]);
932        film.chapter("One", "", 0);
933        film.chapter("Two", "", 5_000);
934        film.chapter("Three", "", 8_000);
935        let spot = |at: u64| Hotspot {
936            kind: HotspotKind::Stop,
937            x: 0.0,
938            y: 0.0,
939            width: 1.0,
940            height: 1.0,
941            title: format!("at {at}"),
942            text: String::new(),
943            from_ms: at,
944            until_ms: at,
945        };
946        film.hotspot(spot(3_000));
947        film.hotspot(spot(6_000));
948        let movie = film.finish(10_000).expect("film");
949        let mut project = movie.project().clone();
950        project.split(2_000);
951        project.split(5_500);
952        project.delete_clip(1);
953        project.add_overlay(
954            OverlayKind::Hotspot {
955                region: Region::new(1.0, 1.0, 2.0, 2.0),
956                title: "added".to_owned(),
957                text: String::new(),
958                stop: false,
959            },
960            1_000,
961            500,
962        );
963        let edited = movie.with_project(project);
964        let chapters: Vec<(&str, u64)> =
965            edited.chapters().iter().map(|c| (c.title.as_str(), c.at_ms)).collect();
966        assert_eq!(chapters, [("One", 0), ("Two", 2_000), ("Three", 4_500)], "two begins where it picks up");
967        let spots: Vec<(&str, u64)> =
968            edited.hotspots().iter().map(|s| (s.title.as_str(), s.from_ms)).collect();
969        assert_eq!(spots, [("added", 1_000), ("at 6000", 2_500)], "the one in the cut went");
970    }
971
972    #[test]
973    fn something_that_is_not_a_film_says_so() {
974        assert!(matches!(Movie::from_bytes(b"nope"), Err(MovieError::Damaged(_))));
975        let mut film = builder();
976        film.frame(0, &[square(0.0)]);
977        let bytes = film.finish(0).expect("film").to_bytes();
978        assert!(matches!(Movie::from_bytes(&bytes[..bytes.len() - 2]), Err(MovieError::Damaged(_))));
979        assert!(matches!(builder().finish(0), Err(MovieError::Empty)));
980    }
981}