1use crate::project::EditProject;
12use crate::segment::{Frame, TimelineSegment};
13use crate::style::{AspectRatio, BackgroundConfig, CropRect, CursorConfig};
14use crate::zoom::{EditEase, ZoomId, ZoomSegment};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TrimEdge {
19 Start,
21 End,
23}
24
25#[derive(Clone, Debug, PartialEq)]
27pub enum EditOp {
28 Split {
31 at: Frame,
33 },
34 Trim {
37 index: usize,
39 edge: TrimEdge,
41 to: Frame,
43 },
44 RippleDelete {
46 start: Frame,
48 end: Frame,
50 },
51 SetSpeed {
54 index: usize,
56 timescale: f64,
58 },
59 AddZoom {
62 zoom: ZoomSegment,
64 },
65 RemoveZoom {
67 id: ZoomId,
69 },
70 MoveZoom {
72 id: ZoomId,
74 start: Frame,
76 end: Frame,
78 },
79 SetCrop {
81 rect: CropRect,
83 },
84 SetAspect {
86 ratio: AspectRatio,
88 },
89 SetBackground {
91 config: BackgroundConfig,
93 },
94 SetCursor {
96 cursor: CursorConfig,
98 },
99 SetZoomEase {
101 id: ZoomId,
103 ease: EditEase,
105 },
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum EditError {
111 SegmentIndexOutOfRange {
113 index: usize,
115 len: usize,
117 },
118 EmptyRange,
120 WouldEmptyTimeline,
123 ZoomNotFound(ZoomId),
125 PastEndOfTimeline(Frame),
127}
128
129impl std::fmt::Display for EditError {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 Self::SegmentIndexOutOfRange { index, len } => {
133 write!(f, "segment index {index} out of range (len {len})")
134 }
135 Self::EmptyRange => write!(f, "range is empty (end <= start)"),
136 Self::WouldEmptyTimeline => {
137 write!(f, "ripple delete would remove the entire timeline")
138 }
139 Self::ZoomNotFound(id) => write!(f, "no zoom with id {id:?}"),
140 Self::PastEndOfTimeline(frame) => {
141 write!(f, "project frame {frame} is past the end of the timeline")
142 }
143 }
144 }
145}
146
147impl std::error::Error for EditError {}
148
149impl EditProject {
150 pub fn apply(&mut self, op: &EditOp) -> Result<(), EditError> {
160 match op {
163 EditOp::Split { at } => self.apply_split(*at),
164 EditOp::Trim { index, edge, to } => self.apply_trim(*index, *edge, *to),
165 EditOp::RippleDelete { start, end } => self.apply_ripple_delete(*start, *end),
166 EditOp::SetSpeed { index, timescale } => self.apply_set_speed(*index, *timescale),
167 EditOp::AddZoom { zoom } => {
168 self.apply_add_zoom(*zoom);
169 Ok(())
170 }
171 EditOp::RemoveZoom { id } => self.apply_remove_zoom(*id),
172 EditOp::MoveZoom { id, start, end } => self.apply_move_zoom(*id, *start, *end),
173 EditOp::SetCrop { rect } => {
174 self.apply_set_crop(*rect);
175 Ok(())
176 }
177 EditOp::SetAspect { ratio } => {
178 self.aspect = *ratio;
179 Ok(())
180 }
181 EditOp::SetBackground { config } => {
182 self.background = config.clone();
183 Ok(())
184 }
185 EditOp::SetCursor { cursor } => {
186 self.cursor = *cursor;
187 Ok(())
188 }
189 EditOp::SetZoomEase { id, ease } => self.apply_set_zoom_ease(*id, *ease),
190 }
191 }
192
193 fn apply_split(&mut self, at: Frame) -> Result<(), EditError> {
194 let (index, cut) = self.locate(at).ok_or(EditError::PastEndOfTimeline(at))?;
195 let seg = self.segments[index];
196 if cut <= seg.source_start || cut >= seg.source_end {
198 return Ok(());
199 }
200 let left = TimelineSegment::with_speed(seg.source_start, cut, seg.timescale);
201 let right = TimelineSegment::with_speed(cut, seg.source_end, seg.timescale);
202 self.segments.splice(index..=index, [left, right]);
203 Ok(())
204 }
205
206 fn apply_trim(&mut self, index: usize, edge: TrimEdge, to: Frame) -> Result<(), EditError> {
207 let len = self.segments.len();
208 let frame_count = self.source.frame_count;
209 let seg = self
210 .segments
211 .get_mut(index)
212 .ok_or(EditError::SegmentIndexOutOfRange { index, len })?;
213 match edge {
214 TrimEdge::Start => {
215 let hi = seg.source_end.saturating_sub(1);
216 seg.source_start = to.min(hi);
217 }
218 TrimEdge::End => {
219 let lo = seg.source_start + 1;
220 seg.source_end = to.max(lo).min(frame_count);
223 }
224 }
225 Ok(())
226 }
227
228 fn apply_ripple_delete(&mut self, d0: Frame, d1: Frame) -> Result<(), EditError> {
229 if d1 <= d0 {
230 return Err(EditError::EmptyRange);
231 }
232 let mut out: Vec<TimelineSegment> = Vec::with_capacity(self.segments.len() + 1);
233 for (_, seg_start, seg) in self.segment_offsets() {
236 let seg_end = seg_start + seg.project_len();
237 let overlaps = seg_start < d1 && seg_end > d0;
238 if overlaps {
239 if seg_start < d0 {
240 let cut = seg.source_frame_at(d0 - seg_start);
241 if cut > seg.source_start {
242 out.push(TimelineSegment::with_speed(
243 seg.source_start,
244 cut,
245 seg.timescale,
246 ));
247 }
248 }
249 if seg_end > d1 {
250 let cut = seg.source_frame_at(d1 - seg_start);
251 if seg.source_end > cut {
252 out.push(TimelineSegment::with_speed(
253 cut,
254 seg.source_end,
255 seg.timescale,
256 ));
257 }
258 }
259 } else {
260 out.push(*seg);
261 }
262 }
263 if out.is_empty() {
266 return Err(EditError::WouldEmptyTimeline);
267 }
268 self.segments = out;
269 Ok(())
270 }
271
272 fn apply_set_speed(&mut self, index: usize, timescale: f64) -> Result<(), EditError> {
273 let len = self.segments.len();
274 let seg = self
275 .segments
276 .get_mut(index)
277 .ok_or(EditError::SegmentIndexOutOfRange { index, len })?;
278 seg.timescale = if timescale.is_finite() && timescale > 0.0 {
279 timescale
280 } else {
281 1.0
282 };
283 Ok(())
284 }
285
286 fn apply_add_zoom(&mut self, zoom: ZoomSegment) {
287 let id = ZoomId(self.next_zoom_id);
288 self.next_zoom_id = self.next_zoom_id.wrapping_add(1);
289 self.zooms.push(ZoomSegment { id, ..zoom });
290 self.zooms.sort_by_key(|z| z.start);
291 }
292
293 fn apply_remove_zoom(&mut self, id: ZoomId) -> Result<(), EditError> {
294 let before = self.zooms.len();
295 self.zooms.retain(|z| z.id != id);
296 if self.zooms.len() == before {
297 return Err(EditError::ZoomNotFound(id));
298 }
299 Ok(())
300 }
301
302 fn apply_move_zoom(&mut self, id: ZoomId, start: Frame, end: Frame) -> Result<(), EditError> {
303 if end <= start {
304 return Err(EditError::EmptyRange);
305 }
306 let zoom = self
307 .zooms
308 .iter_mut()
309 .find(|z| z.id == id)
310 .ok_or(EditError::ZoomNotFound(id))?;
311 zoom.start = start;
312 zoom.end = end;
313 self.zooms.sort_by_key(|z| z.start);
314 Ok(())
315 }
316
317 fn apply_set_zoom_ease(&mut self, id: ZoomId, ease: EditEase) -> Result<(), EditError> {
318 let zoom = self
319 .zooms
320 .iter_mut()
321 .find(|z| z.id == id)
322 .ok_or(EditError::ZoomNotFound(id))?;
323 zoom.ease = ease;
324 Ok(())
325 }
326
327 fn apply_set_crop(&mut self, rect: CropRect) {
328 if rect.is_full() {
331 self.crop = None;
332 return;
333 }
334 let x = rect.x.clamp(0.0, 1.0 - 1e-3);
337 let y = rect.y.clamp(0.0, 1.0 - 1e-3);
338 let width = rect.width.clamp(1e-3, 1.0 - x);
339 let height = rect.height.clamp(1e-3, 1.0 - y);
340 self.crop = Some(CropRect {
341 x,
342 y,
343 width,
344 height,
345 });
346 }
347
348 pub fn check_invariants(&self) -> Result<(), String> {
358 if self.segments.is_empty() {
359 return Err("project has no segments (empty timeline)".to_string());
360 }
361 for (i, s) in self.segments.iter().enumerate() {
362 if s.source_start >= s.source_end {
363 return Err(format!("segment {i} is empty or inverted: {s:?}"));
364 }
365 if s.source_end > self.source.frame_count {
366 return Err(format!(
367 "segment {i} extends past source ({} > {})",
368 s.source_end, self.source.frame_count
369 ));
370 }
371 if !(s.timescale.is_finite() && s.timescale > 0.0) {
372 return Err(format!("segment {i} has invalid timescale {}", s.timescale));
373 }
374 }
375 for pair in self.zooms.windows(2) {
376 if pair[0].start > pair[1].start {
377 return Err("zoom list is not sorted by start".to_string());
378 }
379 }
380 for (i, z) in self.zooms.iter().enumerate() {
381 if z.end <= z.start {
382 return Err(format!("zoom {i} is empty: {z:?}"));
383 }
384 if z.id.0 >= self.next_zoom_id {
385 return Err(format!(
386 "zoom {i} id {} >= next_zoom_id {}",
387 z.id.0, self.next_zoom_id
388 ));
389 }
390 }
391 if let Some(c) = self.crop {
392 let in_unit = |v: f32| (-1e-4..=1.0 + 1e-4).contains(&v);
393 if !(in_unit(c.x)
394 && in_unit(c.y)
395 && c.width > 0.0
396 && c.height > 0.0
397 && c.x + c.width <= 1.0 + 1e-4
398 && c.y + c.height <= 1.0 + 1e-4)
399 {
400 return Err(format!("crop rect out of bounds: {c:?}"));
401 }
402 }
403 Ok(())
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::clip::ClipRef;
411 use crate::zoom::ZoomSegment;
412 use std::path::PathBuf;
413
414 fn project() -> EditProject {
415 EditProject::from_recording(ClipRef::new(
416 PathBuf::from("/tmp/rec.mp4"),
417 1920,
418 1080,
419 30,
420 900,
421 ))
422 }
423
424 #[test]
425 fn split_makes_two_segments_and_preserves_duration() {
426 let mut p = project();
427 let before = p.project_duration();
428 p.apply(&EditOp::Split { at: 300 }).unwrap();
429 assert_eq!(p.segments.len(), 2);
430 assert_eq!(p.segments[0], TimelineSegment::new(0, 300));
431 assert_eq!(p.segments[1], TimelineSegment::new(300, 900));
432 assert_eq!(p.project_duration(), before);
434 p.check_invariants().unwrap();
435 }
436
437 #[test]
438 fn split_at_boundary_is_noop() {
439 let mut p = project();
440 p.apply(&EditOp::Split { at: 0 }).unwrap();
441 assert_eq!(p.segments.len(), 1);
442 }
443
444 #[test]
445 fn split_past_end_errors() {
446 let mut p = project();
447 assert_eq!(
448 p.apply(&EditOp::Split { at: 5000 }),
449 Err(EditError::PastEndOfTimeline(5000))
450 );
451 }
452
453 #[test]
454 fn trim_clamps_to_non_empty_and_bounds() {
455 let mut p = project();
456 p.apply(&EditOp::Trim {
457 index: 0,
458 edge: TrimEdge::Start,
459 to: 100,
460 })
461 .unwrap();
462 assert_eq!(p.segments[0].source_start, 100);
463 p.apply(&EditOp::Trim {
465 index: 0,
466 edge: TrimEdge::End,
467 to: 99_999,
468 })
469 .unwrap();
470 assert_eq!(p.segments[0].source_end, 900);
471 p.check_invariants().unwrap();
472 }
473
474 #[test]
475 fn trim_bad_index_errors() {
476 let mut p = project();
477 assert!(matches!(
478 p.apply(&EditOp::Trim {
479 index: 9,
480 edge: TrimEdge::Start,
481 to: 0
482 }),
483 Err(EditError::SegmentIndexOutOfRange { index: 9, len: 1 })
484 ));
485 }
486
487 #[test]
488 fn ripple_delete_closes_the_gap() {
489 let mut p = project();
490 p.apply(&EditOp::RippleDelete {
493 start: 300,
494 end: 600,
495 })
496 .unwrap();
497 assert_eq!(p.project_duration(), 600);
498 assert_eq!(p.segments.len(), 2);
500 assert_eq!(p.segments[0], TimelineSegment::new(0, 300));
501 assert_eq!(p.segments[1], TimelineSegment::new(600, 900));
502 p.check_invariants().unwrap();
503 }
504
505 #[test]
506 fn ripple_delete_entire_timeline_is_rejected() {
507 let mut p = project(); assert_eq!(
511 p.apply(&EditOp::RippleDelete { start: 0, end: 900 }),
512 Err(EditError::WouldEmptyTimeline)
513 );
514 assert_eq!(p.segments.len(), 1);
515 assert_eq!(p.project_duration(), 900);
516 p.check_invariants().unwrap();
517 }
518
519 #[test]
520 fn ripple_delete_empty_range_errors() {
521 let mut p = project();
522 assert_eq!(
523 p.apply(&EditOp::RippleDelete { start: 50, end: 50 }),
524 Err(EditError::EmptyRange)
525 );
526 }
527
528 #[test]
529 fn set_speed_changes_duration() {
530 let mut p = project();
531 p.apply(&EditOp::SetSpeed {
532 index: 0,
533 timescale: 2.0,
534 })
535 .unwrap();
536 assert!((p.segments[0].timescale - 2.0).abs() < 1e-9);
537 assert_eq!(p.project_duration(), 450);
538 p.apply(&EditOp::SetSpeed {
540 index: 0,
541 timescale: -1.0,
542 })
543 .unwrap();
544 assert!((p.segments[0].timescale - 1.0).abs() < 1e-9);
545 }
546
547 #[test]
548 fn zoom_add_remove_move() {
549 let mut p = project();
550 p.apply(&EditOp::AddZoom {
551 zoom: ZoomSegment::manual(ZoomId(999), 100, 200, 1.6),
552 })
553 .unwrap();
554 assert_eq!(p.zooms.len(), 1);
555 let id = p.zooms[0].id;
557 assert_eq!(id, ZoomId(0));
558 assert_eq!(p.next_zoom_id, 1);
559 p.check_invariants().unwrap();
560
561 p.apply(&EditOp::MoveZoom {
563 id,
564 start: 400,
565 end: 500,
566 })
567 .unwrap();
568 assert_eq!(p.zooms[0].start, 400);
569 assert_eq!(p.zooms[0].end, 500);
570
571 p.apply(&EditOp::RemoveZoom { id }).unwrap();
573 assert!(p.zooms.is_empty());
574 assert_eq!(
576 p.apply(&EditOp::RemoveZoom { id }),
577 Err(EditError::ZoomNotFound(id))
578 );
579 }
580
581 #[test]
582 fn added_zooms_stay_sorted_by_start() {
583 let mut p = project();
584 for start in [300, 100, 200] {
585 p.apply(&EditOp::AddZoom {
586 zoom: ZoomSegment::manual(ZoomId(0), start, start + 50, 1.5),
587 })
588 .unwrap();
589 }
590 let starts: Vec<_> = p.zooms.iter().map(|z| z.start).collect();
591 assert_eq!(starts, vec![100, 200, 300]);
592 p.check_invariants().unwrap();
593 }
594
595 #[test]
596 fn set_aspect_changes_ratio() {
597 let mut p = project();
598 assert_eq!(p.aspect, AspectRatio::Wide);
599 p.apply(&EditOp::SetAspect {
600 ratio: AspectRatio::Vertical,
601 })
602 .unwrap();
603 assert_eq!(p.aspect, AspectRatio::Vertical);
604 p.check_invariants().unwrap();
605 }
606
607 #[test]
608 fn set_crop_stores_subrect_and_clears_on_full() {
609 let mut p = project();
610 assert!(p.crop.is_none());
611 let rect = CropRect {
612 x: 0.1,
613 y: 0.1,
614 width: 0.8,
615 height: 0.8,
616 };
617 p.apply(&EditOp::SetCrop { rect }).unwrap();
618 assert_eq!(p.crop, Some(rect));
619 p.check_invariants().unwrap();
620 p.apply(&EditOp::SetCrop {
622 rect: CropRect::full(),
623 })
624 .unwrap();
625 assert!(p.crop.is_none());
626 }
627
628 #[test]
629 fn out_of_bounds_crop_fails_invariants() {
630 let mut p = project();
631 p.crop = Some(CropRect {
633 x: 0.5,
634 y: 0.0,
635 width: 0.8,
636 height: 1.0,
637 });
638 assert!(p.check_invariants().is_err());
639 }
640
641 #[test]
642 fn set_background_replaces_config() {
643 let mut p = project();
644 let bg = BackgroundConfig {
645 padding: 96,
646 corner_radius: 20,
647 shadow: 40,
648 ..BackgroundConfig::default()
649 };
650 p.apply(&EditOp::SetBackground { config: bg.clone() })
651 .unwrap();
652 assert_eq!(p.background, bg);
653 p.check_invariants().unwrap();
654 }
655
656 #[test]
657 fn set_cursor_replaces_config() {
658 let mut p = project();
659 let cur = CursorConfig {
660 size_pct: 220,
661 smoothing: 40,
662 click_ripples: false,
663 ..CursorConfig::default()
664 };
665 p.apply(&EditOp::SetCursor { cursor: cur }).unwrap();
666 assert_eq!(p.cursor, cur);
667 p.check_invariants().unwrap();
668 }
669
670 #[test]
671 fn trim_end_clamps_within_source() {
672 let mut p = project(); p.apply(&EditOp::Trim {
676 index: 0,
677 edge: TrimEdge::End,
678 to: 99_999,
679 })
680 .unwrap();
681 assert_eq!(p.segments[0].source_end, 900);
682 p.check_invariants().unwrap();
683 }
684
685 #[test]
686 fn set_zoom_ease_retunes_the_zoom() {
687 let mut p = project();
688 p.apply(&EditOp::AddZoom {
689 zoom: ZoomSegment::manual(ZoomId(0), 100, 200, 2.0),
690 })
691 .unwrap();
692 let id = p.zooms[0].id;
693 assert_eq!(
694 p.zooms[0].ease,
695 EditEase::InOutCubic,
696 "default is Easy Ease"
697 );
698 p.apply(&EditOp::SetZoomEase {
699 id,
700 ease: EditEase::Linear,
701 })
702 .unwrap();
703 assert_eq!(p.zooms[0].ease, EditEase::Linear);
704 assert!(
706 p.apply(&EditOp::SetZoomEase {
707 id: ZoomId(999),
708 ease: EditEase::Linear,
709 })
710 .is_err()
711 );
712 }
713}