Skip to main content

app_ui/
editor_edits.rs

1//! Timeline editing — split + undo/redo (ED.11 / M-EDIT).
2//!
3//! The razor on the bench and the trim bin. A **split** divides the clip
4//! under the playhead into two segments; **undo/redo** walk the trim bin so
5//! nothing is ever lost. The heavy lifting is the (proptest-verified)
6//! [`edit::History`] from ED.2 — this module is the thin layer that runs an
7//! edit against that history and syncs the result into the reactive project
8//! signal the timeline reads.
9//!
10//! Split is duration-preserving, so the playhead clock needs no update.
11//! Duration-changing edits (ripple delete, trim) land next and use
12//! `EditorPlayer::set_duration`.
13
14use edit::{EditOp, EditProject, History};
15use leptos::prelude::*;
16
17use crate::editor_ipc;
18
19/// Reuse the existing edit history if it belongs to `current` (same source
20/// clip), otherwise start fresh. A different source path means a different
21/// clip was opened, so the old undo stack no longer applies. Pure.
22#[must_use]
23pub fn resolve_history(existing: Option<History>, current: &EditProject) -> History {
24    // Exact path comparison (not canonicalized — `fs::canonicalize` isn't
25    // available on wasm, and the backend passes a stable path per clip). A
26    // differently-spelled path to the same file just starts a fresh undo
27    // stack, which is safe.
28    match existing {
29        Some(history) if history.project().source.path == current.source.path => history,
30        _ => History::new(current.clone()),
31    }
32}
33
34/// Run `edit` against the project's persistent history, then sync the
35/// project signal to the result. `history` carries the undo/redo stacks
36/// across calls.
37fn run(
38    project: RwSignal<Option<EditProject>>,
39    history: StoredValue<Option<History>>,
40    edit: impl FnOnce(&mut History),
41) {
42    let Some(current) = project.get_untracked() else {
43        return;
44    };
45    let mut hist = resolve_history(history.get_value(), &current);
46    edit(&mut hist);
47    let edited = hist.project().clone();
48    let duration = edited.project_duration();
49    project.set(Some(edited));
50    history.set_value(Some(hist));
51    // Keep the backend clock's range in step with the (possibly changed)
52    // timeline length — a no-op for split, the point of it for ripple/undo.
53    editor_ipc::editor_transport(&editor_ipc::TransportAction::SetDuration { frames: duration });
54}
55
56/// Apply an op to the history, logging a rejection rather than silently
57/// dropping it. A stale-id / out-of-range op is a legitimate no-op (e.g. a
58/// `RemoveZoom` whose target was already removed across an undo), but a
59/// swallowed `Err` is invisible — surfacing it to the console keeps wiring
60/// regressions debuggable.
61fn apply_logged(history: &mut History, op: &EditOp) {
62    if let Err(err) = history.apply(op) {
63        leptos::logging::warn!("edit op rejected: {err}");
64    }
65}
66
67/// Split the clip under the playhead into two (the razor).
68pub fn split_at(
69    project: RwSignal<Option<EditProject>>,
70    history: StoredValue<Option<History>>,
71    at: u64,
72) {
73    run(project, history, |hist| {
74        apply_logged(hist, &EditOp::Split { at });
75    });
76}
77
78/// Ripple-delete the selected clip — remove it and close the gap (the
79/// downstream clips slide left; the timeline shortens).
80pub fn ripple_delete_selected(
81    project: RwSignal<Option<EditProject>>,
82    history: StoredValue<Option<History>>,
83    selected: Option<usize>,
84) {
85    let Some(index) = selected else {
86        return;
87    };
88    let Some(current) = project.get_untracked() else {
89        return;
90    };
91    let Some((start, end)) = current.segment_project_range(index) else {
92        return;
93    };
94    run(project, history, |hist| {
95        apply_logged(hist, &EditOp::RippleDelete { start, end });
96    });
97}
98
99/// Add a default ~1.5 s zoom (1.6× centre) starting at project frame `at`,
100/// clamped to the timeline. No-op if the window would be empty. `AddZoom`
101/// assigns the region a fresh id.
102pub fn add_zoom_default(
103    project: RwSignal<Option<EditProject>>,
104    history: StoredValue<Option<History>>,
105    at: u64,
106) {
107    let Some(current) = project.get_untracked() else {
108        return;
109    };
110    let duration = current.project_duration();
111    if duration == 0 {
112        return;
113    }
114    let start = at.min(duration.saturating_sub(1));
115    let window = (u64::from(current.project_fps) * 3 / 2).max(1); // ~1.5 s
116    let end = (start + window).min(duration);
117    if end <= start {
118        return;
119    }
120    let zoom = edit::zoom::ZoomSegment::manual(edit::zoom::ZoomId(0), start, end, 1.6);
121    run(project, history, |hist| {
122        apply_logged(hist, &EditOp::AddZoom { zoom });
123    });
124}
125
126/// Like [`add_zoom_default`] but punches in on the **cursor** at the playhead
127/// — the manual "zoom into the cursor" action. The target is the cursor's
128/// position at `at` from the project's captured track
129/// ([`EditProject::zoom_cursor_target`]), falling back to centre when no track
130/// was captured (so it degrades to the same result as `add_zoom_default`).
131pub fn add_zoom_at_cursor(
132    project: RwSignal<Option<EditProject>>,
133    history: StoredValue<Option<History>>,
134    at: u64,
135) {
136    let Some(current) = project.get_untracked() else {
137        return;
138    };
139    let duration = current.project_duration();
140    if duration == 0 {
141        return;
142    }
143    let start = at.min(duration.saturating_sub(1));
144    let window = (u64::from(current.project_fps) * 3 / 2).max(1); // ~1.5 s
145    let end = (start + window).min(duration);
146    if end <= start {
147        return;
148    }
149    let (x, y) = current.zoom_cursor_target(start);
150    let mut zoom = edit::zoom::ZoomSegment::manual(edit::zoom::ZoomId(0), start, end, 1.6);
151    zoom.mode = edit::zoom::ZoomMode::Manual { x, y };
152    run(project, history, |hist| {
153        apply_logged(hist, &EditOp::AddZoom { zoom });
154    });
155}
156
157/// Remove the zoom region with the given id.
158pub fn remove_zoom(
159    project: RwSignal<Option<EditProject>>,
160    history: StoredValue<Option<History>>,
161    id: edit::zoom::ZoomId,
162) {
163    run(project, history, |hist| {
164        apply_logged(hist, &EditOp::RemoveZoom { id });
165    });
166}
167
168/// Set segment `index`'s playback speed (`timescale`). This changes the
169/// project length, so `run` re-syncs the backend clock via `SetDuration`.
170pub fn set_speed(
171    project: RwSignal<Option<EditProject>>,
172    history: StoredValue<Option<History>>,
173    index: usize,
174    timescale: f64,
175) {
176    run(project, history, |hist| {
177        apply_logged(hist, &EditOp::SetSpeed { index, timescale });
178    });
179}
180
181/// Set the crop rectangle (a full-frame rect clears the crop). The op
182/// sanitizes the rect to a valid in-frame sub-rect.
183pub fn set_crop(
184    project: RwSignal<Option<EditProject>>,
185    history: StoredValue<Option<History>>,
186    rect: edit::style::CropRect,
187) {
188    run(project, history, |hist| {
189        apply_logged(hist, &EditOp::SetCrop { rect });
190    });
191}
192
193/// Set the output aspect ratio (reframes the export canvas).
194pub fn set_aspect(
195    project: RwSignal<Option<EditProject>>,
196    history: StoredValue<Option<History>>,
197    ratio: edit::style::AspectRatio,
198) {
199    run(project, history, |hist| {
200        apply_logged(hist, &EditOp::SetAspect { ratio });
201    });
202}
203
204/// Set the background framing config (backdrop + padding / radius / shadow).
205pub fn set_background(
206    project: RwSignal<Option<EditProject>>,
207    history: StoredValue<Option<History>>,
208    config: edit::style::BackgroundConfig,
209) {
210    run(project, history, move |hist| {
211        apply_logged(hist, &EditOp::SetBackground { config });
212    });
213}
214
215/// Set the cursor styling config (size / smoothing / ripples / etc.).
216pub fn set_cursor(
217    project: RwSignal<Option<EditProject>>,
218    history: StoredValue<Option<History>>,
219    cursor: edit::style::CursorConfig,
220) {
221    run(project, history, move |hist| {
222        apply_logged(hist, &EditOp::SetCursor { cursor });
223    });
224}
225
226/// Retune the easing curve of the zoom with the given id (ED.13 dopesheet).
227pub fn set_zoom_ease(
228    project: RwSignal<Option<EditProject>>,
229    history: StoredValue<Option<History>>,
230    id: edit::zoom::ZoomId,
231    ease: edit::zoom::EditEase,
232) {
233    run(project, history, |hist| {
234        apply_logged(hist, &EditOp::SetZoomEase { id, ease });
235    });
236}
237
238/// Undo the last edit (the trim bin — nothing is lost).
239pub fn undo(project: RwSignal<Option<EditProject>>, history: StoredValue<Option<History>>) {
240    run(project, history, |hist| {
241        hist.undo();
242    });
243}
244
245/// Redo the last undone edit.
246pub fn redo(project: RwSignal<Option<EditProject>>, history: StoredValue<Option<History>>) {
247    run(project, history, |hist| {
248        hist.redo();
249    });
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use edit::ClipRef;
256    use std::path::PathBuf;
257
258    fn project(path: &str) -> EditProject {
259        EditProject::from_recording(ClipRef::new(PathBuf::from(path), 1920, 1080, 30, 900))
260    }
261
262    #[test]
263    fn resolve_reuses_same_clip_history_preserving_undo() {
264        let p = project("/tmp/a.mp4");
265        let mut h = History::new(p.clone());
266        h.apply(&EditOp::Split { at: 300 }).unwrap();
267        assert!(h.can_undo());
268        // Same clip → reuse the history, undo stack intact.
269        let resolved = resolve_history(Some(h), &p);
270        assert!(resolved.can_undo());
271    }
272
273    #[test]
274    fn resolve_starts_fresh_for_a_different_clip() {
275        let mut h = History::new(project("/tmp/a.mp4"));
276        h.apply(&EditOp::Split { at: 300 }).unwrap();
277        let b = project("/tmp/b.mp4");
278        let resolved = resolve_history(Some(h), &b);
279        assert!(!resolved.can_undo(), "different clip → fresh history");
280        assert_eq!(resolved.project().source.path, b.source.path);
281    }
282
283    #[test]
284    fn resolve_none_starts_fresh() {
285        let p = project("/tmp/a.mp4");
286        let resolved = resolve_history(None, &p);
287        assert!(!resolved.can_undo());
288        assert_eq!(resolved.project().segments.len(), 1);
289    }
290}