1use serde::{Deserialize, Serialize};
5
6const SHORTEST_MS: u64 = 100;
8
9#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
11#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
12pub struct Region {
13 pub x: f32,
15 pub y: f32,
17 pub width: f32,
19 pub height: f32,
21}
22
23#[cfg_attr(feature = "live", ravenrs_live::expose)]
24impl Region {
25 pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
27 Self { x, y, width, height }
28 }
29}
30
31#[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 #[default]
38 Plain,
39 Opening,
42 Closing,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl CardLook {
49 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
71pub enum ClipKind {
72 Film {
74 from_ms: u64,
76 to_ms: u64,
78 },
79 Title {
81 title: String,
83 subtitle: String,
85 duration_ms: u64,
87 #[serde(default)]
89 look: CardLook,
90 },
91}
92
93#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
96pub struct Clip {
97 pub kind: ClipKind,
99 pub speed: f32,
101}
102
103#[cfg_attr(feature = "live", ravenrs_live::expose)]
104impl Clip {
105 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
118#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
119pub enum CalloutStyle {
120 #[default]
122 Balloon,
123 Label,
125}
126
127#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
130pub struct Marker {
131 pub title: String,
133 pub at_ms: u64,
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139#[non_exhaustive]
140#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
141pub enum OverlayKind {
142 Callout {
144 text: String,
146 region: Region,
148 #[serde(default)]
150 style: CalloutStyle,
151 #[serde(default)]
153 soft: bool,
154 },
155 Caption {
157 text: String,
159 },
160 Spotlight {
162 region: Region,
164 },
165 Hide {
167 region: Region,
169 },
170 Zoom {
172 region: Region,
174 },
175 FadeIn,
177 FadeOut,
179 PointerHighlight,
181 Hotspot {
183 region: Region,
185 title: String,
187 text: String,
189 stop: bool,
191 },
192}
193
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
197pub struct Overlay {
198 pub id: u64,
200 pub track: u8,
202 pub start_ms: u64,
204 pub duration_ms: u64,
206 pub kind: OverlayKind,
208}
209
210#[cfg_attr(feature = "live", ravenrs_live::expose)]
211impl Overlay {
212 pub fn end_ms(&self) -> u64 {
214 self.start_ms + self.duration_ms
215 }
216
217 pub fn covers(&self, ms: u64) -> bool {
219 self.start_ms <= ms && ms < self.end_ms()
220 }
221}
222
223#[derive(Clone, Copy, Debug, PartialEq)]
225#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
226pub enum Place {
227 Film(u64),
229 Title(usize),
231}
232
233#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
235#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
236pub struct Project {
237 pub clips: Vec<Clip>,
239 pub overlays: Vec<Overlay>,
241 #[serde(default)]
243 pub markers: Vec<Marker>,
244 next_id: u64,
245}
246
247#[cfg_attr(feature = "live", ravenrs_live::expose)]
248impl Project {
249 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 pub fn duration_ms(&self) -> u64 {
261 self.clips.iter().map(Clip::duration_ms).sum()
262 }
263
264 pub fn clip_start_ms(&self, clip: usize) -> u64 {
266 self.clips.iter().take(clip).map(Clip::duration_ms).sum()
267 }
268
269 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 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 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 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 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 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 pub fn trim_start(&mut self, index: usize, ms: i64) {
378 self.trim(index, ms, true);
379 }
380
381 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 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 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 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 pub fn overlay_mut(&mut self, id: u64) -> Option<&mut Overlay> {
457 self.overlays.iter_mut().find(|overlay| overlay.id == id)
458 }
459
460 pub fn remove_overlay(&mut self, id: u64) {
462 self.overlays.retain(|overlay| overlay.id != id);
463 }
464
465 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}