Skip to main content

edit/
ops.rs

1//! Edit operations and the invariants they preserve.
2//!
3//! Every timeline edit is an [`EditOp`] applied to an
4//! [`EditProject`]. Operations are validated and
5//! invariant-preserving (see [`EditProject::check_invariants`]). Undo /
6//! redo is layered on top in [`crate::history`].
7//!
8//! Trim / split / speed operate on the segment list; the zoom operations
9//! operate on the zoom list, which is kept sorted by start frame.
10
11use crate::project::EditProject;
12use crate::segment::{Frame, TimelineSegment};
13use crate::style::{AspectRatio, BackgroundConfig, CropRect, CursorConfig};
14use crate::zoom::{EditEase, ZoomId, ZoomSegment};
15
16/// Which edge of a segment a [`EditOp::Trim`] moves.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TrimEdge {
19    /// The in-point (`source_start`).
20    Start,
21    /// The out-point (`source_end`).
22    End,
23}
24
25/// A single, undoable edit applied to an [`EditProject`].
26#[derive(Clone, Debug, PartialEq)]
27pub enum EditOp {
28    /// Split the segment containing project frame `at` into two adjacent
29    /// segments sharing the cut. A no-op if `at` lands on a boundary.
30    Split {
31        /// Project frame to cut at.
32        at: Frame,
33    },
34    /// Move segment `index`'s in/out point to source frame `to`,
35    /// clamped so the segment stays non-empty and within the source.
36    Trim {
37        /// Index into [`EditProject::segments`].
38        index: usize,
39        /// Which edge to move.
40        edge: TrimEdge,
41        /// Target source frame.
42        to: Frame,
43    },
44    /// Remove project range `[start, end)`, closing the gap (ripple).
45    RippleDelete {
46        /// Range start (project frame, inclusive).
47        start: Frame,
48        /// Range end (project frame, exclusive).
49        end: Frame,
50    },
51    /// Set segment `index`'s playback speed (`timescale`), sanitized to a
52    /// finite positive multiplier.
53    SetSpeed {
54        /// Index into [`EditProject::segments`].
55        index: usize,
56        /// New speed multiplier.
57        timescale: f64,
58    },
59    /// Add a zoom region. Its `id` is assigned fresh on insert (the `id`
60    /// field of the supplied value is ignored).
61    AddZoom {
62        /// The zoom to add (window / amount / mode / ease).
63        zoom: ZoomSegment,
64    },
65    /// Remove the zoom with the given id.
66    RemoveZoom {
67        /// Id of the zoom to remove.
68        id: ZoomId,
69    },
70    /// Move a zoom's window to `[start, end)`.
71    MoveZoom {
72        /// Id of the zoom to move.
73        id: ZoomId,
74        /// New window start (project frame).
75        start: Frame,
76        /// New window end (project frame).
77        end: Frame,
78    },
79    /// Set the crop rectangle. A full-frame rect clears the crop.
80    SetCrop {
81        /// Normalized crop rect (`[0, 1]` of the source frame).
82        rect: CropRect,
83    },
84    /// Set the output aspect ratio (reframes the export canvas).
85    SetAspect {
86        /// New aspect ratio.
87        ratio: AspectRatio,
88    },
89    /// Set the background framing (backdrop + padding / radius / shadow).
90    SetBackground {
91        /// New background config.
92        config: BackgroundConfig,
93    },
94    /// Set the cursor styling (size / smoothing / ripples / hide-static).
95    SetCursor {
96        /// New cursor config.
97        cursor: CursorConfig,
98    },
99    /// Retune the easing curve of the zoom with the given id (ED.13).
100    SetZoomEase {
101        /// Id of the zoom to retune.
102        id: ZoomId,
103        /// New easing curve.
104        ease: EditEase,
105    },
106}
107
108/// Why an [`EditOp`] could not be applied.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum EditError {
111    /// A segment index was out of range.
112    SegmentIndexOutOfRange {
113        /// The requested index.
114        index: usize,
115        /// The number of segments.
116        len: usize,
117    },
118    /// A delete / move range was empty (`end <= start`).
119    EmptyRange,
120    /// A ripple delete would remove every segment, leaving an empty
121    /// timeline. The timeline must always keep at least one segment.
122    WouldEmptyTimeline,
123    /// No zoom with the requested id exists.
124    ZoomNotFound(ZoomId),
125    /// A project frame was at or past the end of the timeline.
126    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    /// Apply an edit operation, mutating the project in place.
151    ///
152    /// Prefer driving edits through [`crate::History`], which makes them
153    /// undoable; this is the underlying primitive.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`EditError`] for an out-of-range segment index, an empty
158    /// range, an unknown zoom id, or a split past the end of the timeline.
159    pub fn apply(&mut self, op: &EditOp) -> Result<(), EditError> {
160        // Matched by reference so non-`Copy` payloads (e.g. `SetBackground`,
161        // which owns a wallpaper `String`) bind without moving out of `*op`.
162        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        // A split exactly on a segment boundary changes nothing.
197        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                // Clamp into `[lo, frame_count]`; the old `frame_count.max(lo)`
221                // upper bound let `source_end` run past the source.
222                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        // Walk via the canonical segment-offset iterator (single source of
234        // the project-frame running sum).
235        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        // The timeline must keep at least one segment — refuse a delete
264        // that would empty it (the project unchanged on error).
265        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        // A full-frame crop is stored as "no crop" so the export fast-path
329        // can skip the videocrop element entirely.
330        if rect.is_full() {
331            self.crop = None;
332            return;
333        }
334        // Sanitize to a valid in-frame sub-rect (non-zero extent, fully
335        // inside `[0, 1]`) — the UI can submit any field value.
336        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    /// Check the project's structural invariants. Used by tests (and a
349    /// useful debugging aid): segments are non-empty and within the
350    /// source; the zoom list is sorted, each zoom is non-empty, and every
351    /// zoom id is below `next_zoom_id`.
352    ///
353    /// # Errors
354    ///
355    /// Returns a human-readable description of the first violated
356    /// invariant.
357    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        // Real-time split is exactly duration-preserving.
433        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        // Trim end past the source clamps to frame_count.
464        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        // Delete the middle 300 project frames; duration drops by ~300 and
491        // the timeline stays a contiguous concatenation (no gap).
492        p.apply(&EditOp::RippleDelete {
493            start: 300,
494            end: 600,
495        })
496        .unwrap();
497        assert_eq!(p.project_duration(), 600);
498        // The two surviving pieces are [0,300) and [600,900).
499        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(); // one segment, project [0, 900)
508        // Deleting the whole timeline would leave zero segments — refused,
509        // and the project is left untouched.
510        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        // Invalid speed sanitizes to real time.
539        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        // Id is assigned fresh (the 999 placeholder is ignored).
556        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        // Move it.
562        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        // Remove it.
572        p.apply(&EditOp::RemoveZoom { id }).unwrap();
573        assert!(p.zooms.is_empty());
574        // Removing a missing id errors.
575        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        // A full-frame crop clears it.
621        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        // 0.5 + 0.8 > 1.0 — runs off the right edge.
632        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(); // 900-frame source, one segment [0, 900)
673        // Trimming the out-point past the source clamps to frame_count
674        // (regression: the old upper bound let it run past the source).
675        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        // Retuning an unknown zoom errors.
705        assert!(
706            p.apply(&EditOp::SetZoomEase {
707                id: ZoomId(999),
708                ease: EditEase::Linear,
709            })
710            .is_err()
711        );
712    }
713}