simple_editor\ui/
autocut_ui.rs

1//! Auto-cut pane (non-blocking; the timeline stays usable while it is open). Works on the selected audio
2//! clips (a selected video clip uses its linked audio). Controls: threshold (dBFS slider -80..0),
3//! min silence, min speech, padding (DragValues), "Keep: loud parts / quiet parts" toggle, "Ripple (close
4//! gaps)" checkbox. It shows the detected segments live (count + total kept seconds) and publishes them to
5//! `overlay` (timeline time ranges to KEEP, per selected clip) so the timeline can shade them; buttons:
6//! "Split only" (cuts at the boundaries, removes nothing), "Apply" (Project::auto_cut with the cuts and the
7//! quiet ranges; linked video follows), "Clear". Peaks come from WaveformCache (None while computing →
8//! "analysing…"); detection uses engine::autocut. Undo once per Apply/Split. Returns what changed.
9
10use crate::engine::autocut::{self, AutoCutParams};
11use crate::media::waveform::{Peaks, WaveformCache};
12use crate::model::{ClipKind, Id, Project};
13use crate::theme::Palette;
14use eframe::egui::{self, Button, DragValue, Slider};
15use std::hash::{Hash, Hasher};
16use std::sync::Arc;
17
18/// Detection result for one audio clip (timeline seconds).
19struct Detection {
20    clip: Id,
21    cuts: Vec<f64>,
22    quiet: Vec<(f64, f64)>,
23    kept: usize,
24    kept_secs: f64,
25}
26
27pub struct AutoCutState {
28    pub params: AutoCutParams,
29    pub keep_quiet: bool,
30    pub ripple: bool,
31    /// Timeline ranges to keep (shaded by the timeline), recomputed each frame from the selection.
32    pub overlay: Vec<(f64, f64)>,
33    /// False after "Clear": detection (and the overlay) pauses until a control changes or "Detect".
34    pub active: bool,
35    /// Result of the last Apply / Split, shown as a status line.
36    pub status: String,
37    cache_key: u64,
38    cached: Vec<Detection>,
39}
40
41impl Default for AutoCutState {
42    fn default() -> Self {
43        Self {
44            params: AutoCutParams::default(),
45            keep_quiet: false,
46            ripple: true,
47            overlay: Vec::new(),
48            active: true,
49            status: String::new(),
50            cache_key: 0,
51            cached: Vec::new(),
52        }
53    }
54}
55
56/// Selected clips mapped to the audio clips to analyse (video → its linked audio), deduplicated.
57fn audio_targets(project: &Project, selection: &[Id]) -> Vec<Id> {
58    let mut out = Vec::new();
59    for &id in selection {
60        let Some(c) = project.clip(id) else { continue };
61        let target = if c.kind == ClipKind::Audio {
62            Some(id)
63        } else {
64            project.linked(id).into_iter().find(|&l| project.clip(l).is_some_and(|c| c.kind == ClipKind::Audio))
65        };
66        if let Some(t) = target {
67            if !out.contains(&t) {
68                out.push(t);
69            }
70        }
71    }
72    out
73}
74
75/// Complement of `remove` inside [start, end) — the ranges that survive the cut.
76fn keep_ranges(start: f64, end: f64, remove: &[(f64, f64)], out: &mut Vec<(f64, f64)>) {
77    let mut rs: Vec<(f64, f64)> = remove.to_vec();
78    rs.sort_by(|a, b| a.0.total_cmp(&b.0));
79    let mut cur = start;
80    for (a, b) in rs {
81        let (a, b) = (a.max(start), b.min(end));
82        if b <= a {
83            continue;
84        }
85        if a > cur + 1e-9 {
86            out.push((cur, a));
87        }
88        cur = cur.max(b);
89    }
90    if end > cur + 1e-9 {
91        out.push((cur, end));
92    }
93}
94
95/// Apply's ripple shifts every later clip left, which invalidates the absolute times cached for them —
96/// so applying right-to-left keeps each detection valid until it has been used.
97fn sort_right_to_left(project: &Project, cached: &mut [Detection]) {
98    cached.sort_by(|a, b| {
99        let s = |d: &Detection| project.clip(d.clip).map_or(0.0, |c| c.start);
100        s(b).total_cmp(&s(a))
101    });
102}
103
104pub fn show(
105    ui: &mut egui::Ui,
106    state: &mut AutoCutState,
107    project: &mut Project,
108    selection: &[Id],
109    waveforms: &mut WaveformCache,
110    _palette: &Palette,
111    undo: &mut dyn FnMut(&Project),
112) -> bool {
113    let mut changed = false;
114    ui.strong("Auto-cut");
115    let mut tweaked = false;
116    egui::Grid::new("autocut_params").num_columns(2).show(ui, |ui| {
117        ui.label("Threshold");
118        tweaked |= ui.add(Slider::new(&mut state.params.threshold_db, -80.0..=0.0).suffix(" dBFS")).changed();
119        ui.end_row();
120        ui.label("Min silence");
121        tweaked |=
122            ui.add(DragValue::new(&mut state.params.min_silence).range(0.0..=10.0).speed(0.01).suffix(" s")).changed();
123        ui.end_row();
124        ui.label("Min speech");
125        tweaked |=
126            ui.add(DragValue::new(&mut state.params.min_speech).range(0.0..=10.0).speed(0.01).suffix(" s")).changed();
127        ui.end_row();
128        ui.label("Padding");
129        tweaked |=
130            ui.add(DragValue::new(&mut state.params.padding).range(0.0..=5.0).speed(0.01).suffix(" s")).changed();
131        ui.end_row();
132        ui.label("Keep");
133        ui.horizontal(|ui| {
134            tweaked |= ui.selectable_value(&mut state.keep_quiet, false, "Loud parts").changed();
135            tweaked |= ui.selectable_value(&mut state.keep_quiet, true, "Quiet parts").changed();
136        });
137        ui.end_row();
138    });
139    ui.checkbox(&mut state.ripple, "Ripple (close gaps)");
140    if tweaked {
141        state.active = true;
142    }
143
144    let targets = audio_targets(project, selection);
145    if targets.is_empty() {
146        ui.label("Select an audio clip (or a video clip with linked audio)");
147        state.overlay.clear();
148        state.cached.clear();
149        state.cache_key = 0;
150        return false;
151    }
152    if !state.active {
153        if ui.button("Detect").clicked() {
154            state.active = true;
155        }
156        if !state.status.is_empty() {
157            ui.label(state.status.clone());
158        }
159        return false;
160    }
161
162    // ---- detection (cached: recomputed only when params / peaks / clip geometry change) ----
163    let mut peaks: Vec<(Id, Option<Arc<Peaks>>)> = Vec::with_capacity(targets.len());
164    let mut analysing = false;
165    let mut h = std::collections::hash_map::DefaultHasher::new();
166    state.params.threshold_db.to_bits().hash(&mut h);
167    state.params.min_silence.to_bits().hash(&mut h);
168    state.params.min_speech.to_bits().hash(&mut h);
169    state.params.padding.to_bits().hash(&mut h);
170    state.keep_quiet.hash(&mut h);
171    for &id in &targets {
172        let Some(c) = project.clip(id) else { continue };
173        let p = project.asset(c.asset).map(|a| a.path.clone()).and_then(|path| waveforms.get(&path, c.audio_stream));
174        if p.is_none() {
175            analysing = true;
176        }
177        (id, c.start.to_bits(), c.duration.to_bits(), c.src_in.to_bits(), c.speed.to_bits(), c.audio_stream)
178            .hash(&mut h);
179        p.as_ref().map_or(0usize, |a| Arc::as_ptr(a) as *const () as usize).hash(&mut h);
180        peaks.push((id, p));
181    }
182    let key = h.finish();
183    if key != state.cache_key {
184        state.cache_key = key;
185        state.cached.clear();
186        state.overlay.clear();
187        let mut keep = Vec::new();
188        for (id, p) in &peaks {
189            let Some(p) = p else { continue };
190            if p.is_empty() {
191                continue;
192            }
193            let Some(c) = project.clip(*id) else { continue };
194            if c.reverse || c.freeze.is_some() {
195                continue; // ponytail: reversed/frozen clips are not auto-cut (engine maps forward only)
196            }
197            let segs = autocut::loud_segments(p, c.src_in, c.src_len(), &state.params);
198            let (cuts, quiet) = autocut::to_timeline(&segs, c.start, c.src_in, c.duration, c.speed, state.keep_quiet);
199            keep.clear();
200            keep_ranges(c.start, c.end(), &quiet, &mut keep);
201            let kept_secs: f64 = keep.iter().map(|(a, b)| b - a).sum();
202            state.overlay.extend(keep.iter().copied());
203            state.cached.push(Detection { clip: *id, cuts, quiet, kept: keep.len(), kept_secs });
204        }
205        sort_right_to_left(project, &mut state.cached);
206    }
207
208    // ---- live status ----
209    if analysing {
210        ui.label("analysing…");
211    } else {
212        let kept: usize = state.cached.iter().map(|d| d.kept).sum();
213        let secs: f64 = state.cached.iter().map(|d| d.kept_secs).sum();
214        let removed: usize = state.cached.iter().map(|d| d.quiet.len()).sum();
215        ui.label(format!("{kept} segments to keep ({secs:.1} s), {removed} to remove"));
216    }
217
218    // ---- actions ----
219    let ready = state.cached.iter().any(|d| !d.cuts.is_empty() || !d.quiet.is_empty());
220    let mut act: Option<bool> = None; // Some(apply)
221    let mut clear = false;
222    ui.horizontal(|ui| {
223        if ui
224            .add_enabled(ready, Button::new("Split only"))
225            .on_hover_text("Cut at the boundaries, keep everything")
226            .clicked()
227        {
228            act = Some(false);
229        }
230        if ui.add_enabled(ready, Button::new("Apply")).on_hover_text("Cut and remove the unwanted parts").clicked() {
231            act = Some(true);
232        }
233        if ui.button("Clear").clicked() {
234            clear = true;
235        }
236    });
237    if let Some(apply) = act {
238        undo(project);
239        let mut cuts = 0usize;
240        let mut removed = 0usize;
241        for d in &state.cached {
242            cuts += d.cuts.len();
243            if apply {
244                removed += project.auto_cut(&[d.clip], &d.cuts, &d.quiet, state.ripple);
245            } else {
246                project.auto_cut(&[d.clip], &d.cuts, &[], false);
247            }
248        }
249        state.status =
250            if apply { format!("Removed {removed} segments ({cuts} cuts)") } else { format!("Split at {cuts} cuts") };
251        state.cache_key = 0; // clip ids changed → recompute next frame
252        state.cached.clear();
253        state.overlay.clear();
254        changed = true;
255    }
256    if clear {
257        state.active = false;
258        state.overlay.clear();
259        state.cached.clear();
260        state.cache_key = 0;
261    }
262    if !state.status.is_empty() {
263        ui.label(state.status.clone());
264    }
265    changed
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::media::Backend;
272    use crate::model::{Asset, AudioStreamInfo, Clip};
273    use eframe::egui::{vec2, Color32, Event, Pos2, RawInput, Rect};
274
275    #[test]
276    fn keep_ranges_is_complement() {
277        let mut out = Vec::new();
278        keep_ranges(0.0, 10.0, &[(2.0, 4.0), (6.0, 7.0)], &mut out);
279        assert_eq!(out, vec![(0.0, 2.0), (4.0, 6.0), (7.0, 10.0)]);
280        out.clear();
281        keep_ranges(0.0, 10.0, &[], &mut out);
282        assert_eq!(out, vec![(0.0, 10.0)]);
283        out.clear();
284        keep_ranges(0.0, 10.0, &[(0.0, 10.0)], &mut out);
285        assert!(out.is_empty());
286        out.clear();
287        // unsorted + out-of-clip ranges are clamped
288        keep_ranges(1.0, 9.0, &[(8.0, 12.0), (-1.0, 2.0)], &mut out);
289        assert_eq!(out, vec![(2.0, 8.0)]);
290    }
291
292    #[test]
293    fn detections_are_applied_right_to_left() {
294        let mut p = Project::new();
295        p.tracks[1].clips.push(Clip::new(1001, ClipKind::Audio, "a", 0.0, 10.0));
296        p.tracks[1].clips.push(Clip::new(1002, ClipKind::Audio, "b", 10.0, 10.0));
297        let det = |clip| Detection { clip, cuts: Vec::new(), quiet: Vec::new(), kept: 0, kept_secs: 0.0 };
298        let mut cached = vec![det(1001), det(1002)];
299        sort_right_to_left(&p, &mut cached);
300        assert_eq!(
301            cached.iter().map(|d| d.clip).collect::<Vec<_>>(),
302            vec![1002, 1001],
303            "the later clip must be cut before ripple moves it"
304        );
305    }
306
307    #[test]
308    fn audio_targets_map_video_to_linked_audio() {
309        let mut p = Project::new();
310        let aid = p.add_asset(Asset {
311            id: 0,
312            path: "C:/t.mp4".into(),
313            kind: ClipKind::Video,
314            duration: 5.0,
315            width: 320,
316            height: 240,
317            fps: 30.0,
318            audio_streams: vec![AudioStreamInfo { channels: 2, sample_rate: 48000, ..Default::default() }],
319            codec: String::new(),
320            folder: String::new(),
321            tags: Vec::new(),
322            label: 0,
323            description: String::new(),
324        });
325        p.insert_asset_clips(aid, 0.0, Some(0));
326        let vid = p.tracks[0].clips[0].id;
327        let aud = p.tracks[1].clips[0].id;
328        assert_eq!(audio_targets(&p, &[vid]), vec![aud], "video selection maps to its linked audio");
329        assert_eq!(audio_targets(&p, &[vid, aud]), vec![aud], "deduplicated");
330        // unlinked video-only clip maps to nothing
331        p.tracks[0].clips.push(Clip::new(99, ClipKind::Video, "solo", 6.0, 1.0));
332        assert!(audio_targets(&p, &[99]).is_empty());
333    }
334
335    #[test]
336    fn show_smoke_no_selection_and_pending_peaks() {
337        let ctx = egui::Context::default();
338        let mut project = Project::new();
339        let aid = project.add_asset(Asset {
340            id: 0,
341            path: format!("C:/does-not-exist-{}.mp4", std::process::id()),
342            kind: ClipKind::Video,
343            duration: 5.0,
344            width: 320,
345            height: 240,
346            fps: 30.0,
347            audio_streams: vec![AudioStreamInfo { channels: 2, sample_rate: 48000, ..Default::default() }],
348            codec: String::new(),
349            folder: String::new(),
350            tags: Vec::new(),
351            label: 0,
352            description: String::new(),
353        });
354        project.insert_asset_clips(aid, 0.0, Some(0));
355        let aud = project.tracks[1].clips[0].id;
356        let mut waves = WaveformCache::new(ctx.clone(), Backend::Ffmpeg);
357        let mut state = AutoCutState::default();
358        let pal = Palette::new(true, Color32::WHITE);
359        let mut undos = 0;
360        for selection in [vec![], vec![aud]] {
361            for _ in 0..3 {
362                let _ = ctx.run(
363                    RawInput {
364                        screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))),
365                        events: Vec::<Event>::new(),
366                        ..Default::default()
367                    },
368                    |ctx| {
369                        egui::CentralPanel::default().show(ctx, |ui| {
370                            let mut undo = |_: &Project| undos += 1;
371                            let changed = show(ui, &mut state, &mut project, &selection, &mut waves, &pal, &mut undo);
372                            assert!(!changed, "nothing should change without user action");
373                        });
374                    },
375                );
376            }
377        }
378        assert_eq!(undos, 0);
379        // missing file → the cache resolves to empty peaks → no detections, no overlay
380        assert!(state.cached.is_empty());
381        assert!(state.overlay.is_empty());
382    }
383}