1use 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
18struct 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 pub overlay: Vec<(f64, f64)>,
33 pub active: bool,
35 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
56fn 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
75fn 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
95fn 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 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; }
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 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 let ready = state.cached.iter().any(|d| !d.cuts.is_empty() || !d.quiet.is_empty());
220 let mut act: Option<bool> = None; 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; 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 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 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 assert!(state.cached.is_empty());
381 assert!(state.overlay.is_empty());
382 }
383}