Skip to main content

ravenrs_movie/
project.rs

1//! The edits on a film: clips in order and overlays on tracks. Nothing here
2//! touches the film, so any edit can be taken back.
3
4use serde::{Deserialize, Serialize};
5
6/// A clip is never cut shorter than this, in milliseconds.
7const SHORTEST_MS: u64 = 100;
8
9/// A rectangle on the film, in the logical pixels of the app.
10#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
11#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
12pub struct Region {
13    /// Left.
14    pub x: f32,
15    /// Top.
16    pub y: f32,
17    /// Width.
18    pub width: f32,
19    /// Height.
20    pub height: f32,
21}
22
23#[cfg_attr(feature = "live", ravenrs_live::expose)]
24impl Region {
25    /// A region at this place and of this size.
26    pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
27        Self { x, y, width, height }
28    }
29}
30
31/// How a title card looks and moves.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
33#[non_exhaustive]
34#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
35pub enum CardLook {
36    /// The title and the line under it, still.
37    #[default]
38    Plain,
39    /// The opening of a film: the logo plays, then the title and the line under it
40    /// come in. The player says them once they are in.
41    Opening,
42    /// The farewell at the end: the logo plays again, then the parting words come in,
43    /// and the player says them.
44    Closing,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl CardLook {
49    /// When the words of a card of this look are all in, in milliseconds from its start.
50    pub const fn words_in_ms(self) -> u64 {
51        match self {
52            Self::Plain => 0,
53            Self::Opening => 1_700,
54            Self::Closing => 1_300,
55        }
56    }
57
58    /// When the player starts saying the words, in milliseconds from the card's start.
59    pub const fn speaks_from_ms(self) -> u64 {
60        match self {
61            Self::Plain => 0,
62            Self::Opening => 1_100,
63            Self::Closing => 700,
64        }
65    }
66}
67
68/// What a clip shows.
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
71pub enum ClipKind {
72    /// A stretch of the film.
73    Film {
74        /// Where it starts in the film, in milliseconds.
75        from_ms: u64,
76        /// Where it ends in the film, in milliseconds.
77        to_ms: u64,
78    },
79    /// A card with a title, between stretches of the film.
80    Title {
81        /// The title.
82        title: String,
83        /// The line under it.
84        subtitle: String,
85        /// How long it stays, in milliseconds.
86        duration_ms: u64,
87        /// How it looks and moves.
88        #[serde(default)]
89        look: CardLook,
90    },
91}
92
93/// One piece of the main track.
94#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
96pub struct Clip {
97    /// What it shows.
98    pub kind: ClipKind,
99    /// 1 is real time, 2 is twice as fast. Cards ignore it.
100    pub speed: f32,
101}
102
103#[cfg_attr(feature = "live", ravenrs_live::expose)]
104impl Clip {
105    /// How long it lasts on the timeline.
106    pub fn duration_ms(&self) -> u64 {
107        match &self.kind {
108            ClipKind::Film { from_ms, to_ms } => {
109                (to_ms.saturating_sub(*from_ms) as f64 / f64::from(self.speed.max(0.01))) as u64
110            }
111            ClipKind::Title { duration_ms, .. } => *duration_ms,
112        }
113    }
114}
115
116/// How a callout looks.
117#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
118#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
119pub enum CalloutStyle {
120    /// A light balloon with a colored edge.
121    #[default]
122    Balloon,
123    /// A filled label in the accent color.
124    Label,
125}
126
127/// A chapter added in the editor, at a moment of the timeline.
128#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
130pub struct Marker {
131    /// Its name.
132    pub title: String,
133    /// When it starts on the timeline, in milliseconds.
134    pub at_ms: u64,
135}
136
137/// What an overlay does.
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139#[non_exhaustive]
140#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
141pub enum OverlayKind {
142    /// A balloon with text, placed on the film.
143    Callout {
144        /// What it says.
145        text: String,
146        /// Where it is.
147        region: Region,
148        /// How it looks.
149        #[serde(default)]
150        style: CalloutStyle,
151        /// It comes and goes gradually.
152        #[serde(default)]
153        soft: bool,
154    },
155    /// A line of text at the bottom, as subtitles.
156    Caption {
157        /// What it says.
158        text: String,
159    },
160    /// Darkens everything but this region.
161    Spotlight {
162        /// What stays lit.
163        region: Region,
164    },
165    /// Covers this region, for what the film should not show.
166    Hide {
167        /// What gets covered.
168        region: Region,
169    },
170    /// Zooms into this region and back out, sliding between them.
171    Zoom {
172        /// What fills the screen.
173        region: Region,
174    },
175    /// Comes in from black.
176    FadeIn,
177    /// Goes out to black.
178    FadeOut,
179    /// A soft circle follows the pointer, and clicks show bigger.
180    PointerHighlight,
181    /// A spot that talks to whoever is watching.
182    Hotspot {
183        /// Where it is.
184        region: Region,
185        /// In a few words.
186        title: String,
187        /// What to say.
188        text: String,
189        /// The film waits here for a click on the spot.
190        stop: bool,
191    },
192}
193
194/// Something over the film for a while, on a track of the timeline.
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
197pub struct Overlay {
198    /// Its identity, which edits do not change.
199    pub id: u64,
200    /// Its track, from 0.
201    pub track: u8,
202    /// When it starts on the timeline, in milliseconds.
203    pub start_ms: u64,
204    /// How long it lasts.
205    pub duration_ms: u64,
206    /// What it does.
207    pub kind: OverlayKind,
208}
209
210#[cfg_attr(feature = "live", ravenrs_live::expose)]
211impl Overlay {
212    /// When it ends on the timeline.
213    pub fn end_ms(&self) -> u64 {
214        self.start_ms + self.duration_ms
215    }
216
217    /// It is on screen at `ms`.
218    pub fn covers(&self, ms: u64) -> bool {
219        self.start_ms <= ms && ms < self.end_ms()
220    }
221}
222
223/// What the timeline shows at a moment.
224#[derive(Clone, Copy, Debug, PartialEq)]
225#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
226pub enum Place {
227    /// This moment of the film.
228    Film(u64),
229    /// The card that is this clip.
230    Title(usize),
231}
232
233/// A film's edits.
234#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
235#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
236pub struct Project {
237    /// The main track, in order.
238    pub clips: Vec<Clip>,
239    /// What goes over it.
240    pub overlays: Vec<Overlay>,
241    /// The chapters added in the editor.
242    #[serde(default)]
243    pub markers: Vec<Marker>,
244    next_id: u64,
245}
246
247#[cfg_attr(feature = "live", ravenrs_live::expose)]
248impl Project {
249    /// No edits: the whole film of this duration, as it is.
250    pub fn whole(duration_ms: u64) -> Self {
251        Self {
252            clips: vec![Clip { kind: ClipKind::Film { from_ms: 0, to_ms: duration_ms }, speed: 1.0 }],
253            overlays: Vec::new(),
254            markers: Vec::new(),
255            next_id: 1,
256        }
257    }
258
259    /// How long it all lasts.
260    pub fn duration_ms(&self) -> u64 {
261        self.clips.iter().map(Clip::duration_ms).sum()
262    }
263
264    /// When a clip starts on the timeline.
265    pub fn clip_start_ms(&self, clip: usize) -> u64 {
266        self.clips.iter().take(clip).map(Clip::duration_ms).sum()
267    }
268
269    /// The clip on the timeline at `ms`. The very end belongs to the last clip.
270    pub fn clip_at(&self, ms: u64) -> Option<usize> {
271        let mut start = 0;
272        for (i, clip) in self.clips.iter().enumerate() {
273            start += clip.duration_ms();
274            if ms < start {
275                return Some(i);
276            }
277        }
278        self.clips.len().checked_sub(1)
279    }
280
281    /// What the timeline shows at `ms`.
282    pub fn place_at(&self, ms: u64) -> Option<Place> {
283        let index = self.clip_at(ms)?;
284        let clip = &self.clips[index];
285        let inside = ms.saturating_sub(self.clip_start_ms(index)).min(clip.duration_ms());
286        Some(match &clip.kind {
287            ClipKind::Film { from_ms, to_ms } => {
288                Place::Film((*from_ms + (inside as f64 * f64::from(clip.speed)) as u64).min(*to_ms))
289            }
290            ClipKind::Title { .. } => Place::Title(index),
291        })
292    }
293
294    /// Where a moment of the film ended up on the timeline; `None` if it was cut.
295    pub fn film_to_timeline(&self, film_ms: u64) -> Option<u64> {
296        self.clips.iter().enumerate().find_map(|(i, clip)| match clip.kind {
297            ClipKind::Film { from_ms, to_ms } if from_ms <= film_ms && film_ms < to_ms.max(from_ms + 1) => {
298                let inside = (film_ms - from_ms) as f64 / f64::from(clip.speed.max(0.01));
299                Some(self.clip_start_ms(i) + inside as u64)
300            }
301            _ => None,
302        })
303    }
304
305    /// Cuts the clip under `ms` in two there. Overlays stay where they are.
306    /// Returns false when `ms` is too near a clip's edge to leave two pieces.
307    pub fn split(&mut self, ms: u64) -> bool {
308        let Some(index) = self.clip_at(ms) else {
309            return false;
310        };
311        let start = self.clip_start_ms(index);
312        let inside = ms.saturating_sub(start);
313        let clip = self.clips[index].clone();
314        if inside < SHORTEST_MS || clip.duration_ms().saturating_sub(inside) < SHORTEST_MS {
315            return false;
316        }
317        let ClipKind::Film { from_ms, to_ms } = clip.kind else {
318            return false;
319        };
320        let middle = from_ms + (inside as f64 * f64::from(clip.speed)) as u64;
321        self.clips[index].kind = ClipKind::Film { from_ms, to_ms: middle };
322        self.clips
323            .insert(index + 1, Clip { kind: ClipKind::Film { from_ms: middle, to_ms }, speed: clip.speed });
324        true
325    }
326
327    /// Takes a clip out and closes the gap: overlays after it come along, those
328    /// over it get shorter, and those only over it go.
329    pub fn delete_clip(&mut self, index: usize) {
330        if index >= self.clips.len() || self.clips.len() == 1 {
331            return;
332        }
333        let start = self.clip_start_ms(index);
334        let length = self.clips[index].duration_ms();
335        self.clips.remove(index);
336        self.close_gap(start, length);
337    }
338
339    fn close_gap(&mut self, start: u64, length: u64) {
340        let end = start + length;
341        self.overlays.retain_mut(|overlay| {
342            let (from, to) = (overlay.start_ms, overlay.end_ms());
343            if from >= start && to <= end {
344                return false;
345            }
346            let moved = |at: u64| if at <= start { at } else { at.saturating_sub(length).max(start) };
347            overlay.start_ms = moved(from);
348            overlay.duration_ms = moved(to) - overlay.start_ms;
349            overlay.duration_ms >= SHORTEST_MS
350        });
351    }
352
353    fn open_gap(&mut self, at: u64, length: u64) {
354        for overlay in &mut self.overlays {
355            if overlay.start_ms >= at {
356                overlay.start_ms += length;
357            }
358        }
359    }
360
361    /// Changes how fast a clip plays, which moves what comes after it.
362    pub fn set_speed(&mut self, index: usize, speed: f32) {
363        let Some(old) = self.clips.get(index).map(Clip::duration_ms) else {
364            return;
365        };
366        self.clips[index].speed = speed.clamp(0.25, 8.0);
367        let new = self.clips[index].duration_ms();
368        let end = self.clip_start_ms(index) + old;
369        for overlay in &mut self.overlays {
370            if overlay.start_ms >= end {
371                overlay.start_ms = (overlay.start_ms + new).saturating_sub(old);
372            }
373        }
374    }
375
376    /// Moves the start of a film clip by `ms` of the timeline. What comes after follows.
377    pub fn trim_start(&mut self, index: usize, ms: i64) {
378        self.trim(index, ms, true);
379    }
380
381    /// Moves the end of a film clip by `ms` of the timeline. What comes after follows.
382    pub fn trim_end(&mut self, index: usize, ms: i64) {
383        self.trim(index, ms, false);
384    }
385
386    fn trim(&mut self, index: usize, ms: i64, at_start: bool) {
387        let Some(clip) = self.clips.get(index).cloned() else {
388            return;
389        };
390        let ClipKind::Film { from_ms, to_ms } = clip.kind else {
391            return;
392        };
393        let old = clip.duration_ms();
394        let film = (ms as f64 * f64::from(clip.speed)) as i64;
395        let shortest = SHORTEST_MS as i64;
396        let (new_from, new_to) = if at_start {
397            ((from_ms as i64 + film).clamp(0, to_ms as i64 - shortest) as u64, to_ms)
398        } else {
399            (from_ms, (to_ms as i64 + film).max(from_ms as i64 + shortest) as u64)
400        };
401        self.clips[index].kind = ClipKind::Film { from_ms: new_from, to_ms: new_to };
402        let new = self.clips[index].duration_ms();
403        let end = self.clip_start_ms(index) + old;
404        for overlay in &mut self.overlays {
405            if overlay.start_ms >= end {
406                overlay.start_ms = (overlay.start_ms + new).saturating_sub(old);
407            }
408        }
409    }
410
411    /// Puts a card in at `ms`, cutting the clip there if needed. What is after it moves on.
412    pub fn insert_title(&mut self, ms: u64, title: &str, subtitle: &str, duration_ms: u64) -> usize {
413        self.insert_card(ms, title, subtitle, duration_ms, CardLook::Plain)
414    }
415
416    /// A [`Self::insert_title`] of a `look`: an opening at 0, a farewell at the end.
417    pub fn insert_card(
418        &mut self,
419        ms: u64,
420        title: &str,
421        subtitle: &str,
422        duration_ms: u64,
423        look: CardLook,
424    ) -> usize {
425        self.split(ms);
426        let mut index = self.clips.len();
427        let mut start = 0;
428        for (i, clip) in self.clips.iter().enumerate() {
429            if start >= ms {
430                index = i;
431                break;
432            }
433            start += clip.duration_ms();
434        }
435        let at = self.clip_start_ms(index);
436        self.open_gap(at, duration_ms);
437        let kind =
438            ClipKind::Title { title: title.to_owned(), subtitle: subtitle.to_owned(), duration_ms, look };
439        self.clips.insert(index, Clip { kind, speed: 1.0 });
440        index
441    }
442
443    /// Adds an overlay on the first track free for its time, and says which it is.
444    pub fn add_overlay(&mut self, kind: OverlayKind, start_ms: u64, duration_ms: u64) -> u64 {
445        let id = self.next_id.max(1);
446        self.next_id = id + 1;
447        let overlaps = |o: &Overlay| start_ms < o.end_ms() && o.start_ms < start_ms + duration_ms;
448        let track = (0..u8::MAX)
449            .find(|track| !self.overlays.iter().any(|o| o.track == *track && overlaps(o)))
450            .unwrap_or(0);
451        self.overlays.push(Overlay { id, track, start_ms, duration_ms, kind });
452        id
453    }
454
455    /// An overlay by its identity.
456    pub fn overlay_mut(&mut self, id: u64) -> Option<&mut Overlay> {
457        self.overlays.iter_mut().find(|overlay| overlay.id == id)
458    }
459
460    /// Takes an overlay out.
461    pub fn remove_overlay(&mut self, id: u64) {
462        self.overlays.retain(|overlay| overlay.id != id);
463    }
464
465    /// How many tracks of overlays there are.
466    pub fn track_count(&self) -> usize {
467        self.overlays.iter().map(|o| usize::from(o.track) + 1).max().unwrap_or(0)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn caption() -> OverlayKind {
476        OverlayKind::Caption { text: "x".to_owned() }
477    }
478
479    #[test]
480    fn untouched_the_timeline_is_the_film() {
481        let project = Project::whole(10_000);
482        assert_eq!(project.duration_ms(), 10_000);
483        assert_eq!(project.place_at(4_000), Some(Place::Film(4_000)));
484        assert_eq!(project.place_at(10_000), Some(Place::Film(10_000)));
485        assert_eq!(project.film_to_timeline(4_000), Some(4_000));
486    }
487
488    #[test]
489    fn cutting_out_the_middle_joins_the_rest() {
490        let mut project = Project::whole(10_000);
491        assert!(project.split(2_000));
492        assert!(project.split(5_000));
493        assert_eq!(project.clips.len(), 3);
494        project.delete_clip(1);
495        assert_eq!(project.duration_ms(), 7_000);
496        assert_eq!(project.place_at(1_999), Some(Place::Film(1_999)));
497        assert_eq!(project.place_at(2_000), Some(Place::Film(5_000)));
498        assert_eq!(project.film_to_timeline(3_000), None, "it was cut");
499        assert_eq!(project.film_to_timeline(6_000), Some(3_000));
500    }
501
502    #[test]
503    fn a_cut_too_near_an_edge_does_nothing_and_the_last_clip_stays() {
504        let mut project = Project::whole(1_000);
505        assert!(!project.split(10));
506        assert!(!project.split(995));
507        project.delete_clip(0);
508        assert_eq!(project.clips.len(), 1);
509        assert_eq!(project.duration_ms(), 1_000);
510    }
511
512    #[test]
513    fn deleting_carries_the_overlays_along() {
514        let mut project = Project::whole(10_000);
515        let before = project.add_overlay(caption(), 500, 1_000);
516        let across = project.add_overlay(caption(), 1_500, 2_000);
517        let over = project.add_overlay(caption(), 2_500, 500);
518        let after = project.add_overlay(caption(), 6_000, 1_000);
519        project.split(2_000);
520        project.split(5_000);
521        project.delete_clip(1);
522        let start = |project: &mut Project, id| project.overlay_mut(id).map(|o| (o.start_ms, o.duration_ms));
523        assert_eq!(start(&mut project, before), Some((500, 1_000)));
524        assert_eq!(start(&mut project, across), Some((1_500, 500)), "only what was left of it");
525        assert_eq!(start(&mut project, over), None, "it was only over what went");
526        assert_eq!(start(&mut project, after), Some((3_000, 1_000)));
527    }
528
529    #[test]
530    fn speed_changes_the_length_and_what_is_shown() {
531        let mut project = Project::whole(10_000);
532        project.split(4_000);
533        let later = project.add_overlay(caption(), 5_000, 500);
534        project.set_speed(0, 2.0);
535        assert_eq!(project.duration_ms(), 8_000);
536        assert_eq!(project.place_at(1_000), Some(Place::Film(2_000)));
537        assert_eq!(project.place_at(2_500), Some(Place::Film(4_500)));
538        assert_eq!(project.film_to_timeline(2_000), Some(1_000));
539        assert_eq!(project.overlay_mut(later).map(|o| o.start_ms), Some(3_000));
540    }
541
542    #[test]
543    fn trimming_moves_what_follows_and_stops_at_the_film() {
544        let mut project = Project::whole(10_000);
545        project.split(5_000);
546        project.trim_end(0, -1_000);
547        assert_eq!(project.duration_ms(), 9_000);
548        assert_eq!(project.place_at(4_000), Some(Place::Film(5_000)));
549        project.trim_start(0, 1_000);
550        assert_eq!(project.place_at(0), Some(Place::Film(1_000)));
551        project.trim_start(0, -99_000);
552        assert_eq!(project.place_at(0), Some(Place::Film(0)), "not before the film starts");
553        project.trim_end(0, -99_000);
554        assert_eq!(project.clips[0].duration_ms(), SHORTEST_MS, "nor shorter than a sliver");
555    }
556
557    #[test]
558    fn a_card_opens_a_gap_in_the_timeline() {
559        let mut project = Project::whole(10_000);
560        let later = project.add_overlay(caption(), 6_000, 500);
561        let index = project.insert_title(4_000, "Part two", "", 3_000);
562        assert_eq!(index, 1);
563        assert_eq!(project.duration_ms(), 13_000);
564        assert_eq!(project.place_at(5_000), Some(Place::Title(1)));
565        assert_eq!(project.place_at(7_000), Some(Place::Film(4_000)));
566        assert_eq!(project.overlay_mut(later).map(|o| o.start_ms), Some(9_000));
567
568        let first = project.insert_title(0, "Welcome", "", 1_000);
569        assert_eq!(first, 0, "at the very start");
570    }
571
572    #[test]
573    fn overlays_find_a_free_track_and_a_project_survives_a_file() {
574        let mut project = Project::whole(10_000);
575        project.add_overlay(caption(), 0, 2_000);
576        project.add_overlay(OverlayKind::FadeIn, 1_000, 2_000);
577        project.add_overlay(OverlayKind::PointerHighlight, 2_000, 1_000);
578        assert_eq!(project.overlays.iter().map(|o| o.track).collect::<Vec<_>>(), [0, 1, 0]);
579        assert_eq!(project.track_count(), 2);
580        let text = serde_json::to_string(&project).expect("written");
581        assert_eq!(serde_json::from_str::<Project>(&text).expect("read"), project);
582    }
583}