1use 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
21const IMAGE_DETAIL: f32 = 2.0;
23
24#[derive(Debug)]
26#[non_exhaustive]
27#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
28pub enum MovieError {
29 Damaged(String),
31 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
49pub struct Chapter {
50 pub title: String,
52 pub text: String,
54 pub at_ms: u64,
56}
57
58#[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 Stop,
65 Note,
67}
68
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
72pub struct Hotspot {
73 pub kind: HotspotKind,
75 pub x: f32,
77 pub y: f32,
79 pub width: f32,
81 pub height: f32,
83 pub title: String,
85 pub text: String,
87 pub from_ms: u64,
89 pub until_ms: u64,
91}
92
93#[cfg_attr(feature = "live", ravenrs_live::expose)]
94impl Hotspot {
95 pub fn bounds(&self) -> Rect {
97 Rect::new(self.x, self.y, self.width, self.height)
98 }
99
100 pub fn is_general(&self) -> bool {
103 self.width <= 0.0 || self.height <= 0.0
104 }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub(crate) enum FilmSound {
110 Click,
111 Key,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
116#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
117pub struct PointerKey {
118 pub at_ms: u64,
120 pub x: f32,
122 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#[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#[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 #[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#[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 pub fn with_project(&self, project: Project) -> Self {
253 Self::with(self.film.clone(), project)
254 }
255
256 pub fn project(&self) -> &Project {
258 &self.project
259 }
260
261 pub fn logical_size(&self) -> Size {
263 Size::new(self.film.header.logical[0], self.film.header.logical[1])
264 }
265
266 pub fn background(&self) -> Color {
268 color_of(self.film.header.background)
269 }
270
271 pub fn duration_ms(&self) -> u64 {
273 self.project.duration_ms()
274 }
275
276 pub fn film_duration_ms(&self) -> u64 {
278 self.film.header.duration_ms
279 }
280
281 pub fn chapters(&self) -> &[Chapter] {
284 &self.chapters
285 }
286
287 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 pub fn chapter_at(&self, ms: u64) -> Option<usize> {
307 self.chapters.iter().rposition(|chapter| chapter.at_ms <= ms)
308 }
309
310 pub fn hotspots(&self) -> &[Hotspot] {
312 &self.hotspots
313 }
314
315 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 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 pub fn primitive(&self, index: u32) -> Option<&Primitive> {
331 self.film.primitives.get(index as usize)
332 }
333
334 pub(crate) fn frames(&self) -> &[(u64, Rc<[u32]>)] {
336 &self.film.frames
337 }
338
339 pub(crate) fn pointer_keys(&self) -> &[PointerKey] {
341 &self.film.header.pointer
342 }
343
344 pub(crate) fn click_keys(&self) -> &[PointerKey] {
346 &self.film.header.clicks
347 }
348
349 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 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 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 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
423fn 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 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
481fn 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 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 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#[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 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 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 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 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 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 pub fn key(&mut self, at_ms: u64) {
802 self.header.keys.push(at_ms);
803 }
804
805 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 pub fn hotspot(&mut self, hotspot: Hotspot) {
812 self.header.hotspots.push(hotspot);
813 }
814
815 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}