simple_editor\ui/
subtitles_ui.rs

1//! Subtitles panel. Toolbar: "Add at playhead" (cue [playhead, playhead+2 s) with the text "Subtitle",
2//! selected + text field focused), "Import…" (rfd: srt/vtt → engine::subtitles::parse → replace or append
3//! after a yes/no), "Export SRT…" / "Export VTT…" (engine::subtitles::to_srt/to_vtt), "Burn in" checkbox
4//! (project.show_subtitles), and a "Style" collapsing section (font combo from `fonts`, size, colour,
5//! outline width/colour, background box colour, margin from bottom = project.subtitle_margin).
6//! Below: the cue list (egui::Grid / ScrollArea): start and end as editable timecode-ish DragValues in
7//! seconds (3 decimals, end ≥ start + 0.1, keep the list sorted via Project::sort_cues), a multiline text
8//! field, a play button (seek to the cue and play → `seeked` + `play`), a select checkbox and a delete
9//! one; the cue containing the playhead is highlighted; a "Split at playhead" button on the highlighted
10//! cue. A second toolbar row: "To text clips" (Project::cues_to_text_clips — editable Text clips on a
11//! "Subtitles" track), "Delete selected", "Delete in range" (the In/Out range) and "Clear all".
12//! "Open folder" → `open_folder`: the app writes the .srt sidecar and opens the folder. Undo once per
13//! gesture (same edit_start rule as the inspector); returns what changed.
14//!
15//! The "Transcribe" section drives `engine::transcribe`: pick a whisper.cpp model (its download size is
16//! named before the click and the download shows a progress bar), transcribe the selected clip's audio on
17//! a worker thread, and turn the transcript into cues ("Transcribe & generate subtitles"). The raw
18//! word timings are kept, so "Regenerate cues" rebuilds the cues with new grouping knobs (pause split,
19//! punctuation, max words/chars) without re-transcribing; a `--prompt` field feeds whisper vocabulary
20//! hints. Underneath it,
21//! the double-take detector lists the lines that were said more than once — "Mark on timeline" drops a
22//! marker per flubbed take (described by what was said) and "Cut the duplicates" ripples every take but
23//! the last one out, dragging the cues, the markers and the transcript along with the cut. With no model
24//! and no whisper.exe the section only ever explains what to install.
25
26use crate::engine::export::Progress;
27use crate::engine::transcribe::{self, Segment};
28use crate::model::{Id, Project};
29use crate::theme::Palette;
30use crate::ui::tools::{glyph_text_button, Glyph};
31use crate::ui::{edit_start, once};
32use eframe::egui::{self, Button, DragValue, Response, Slider};
33use std::path::PathBuf;
34use std::sync::atomic::Ordering;
35use std::sync::Arc;
36use std::time::Duration;
37
38/// Minimum cue length (end ≥ start + this).
39const MIN_CUE: f64 = 0.1;
40
41/// How long a pause may sit between one take and its retake before they are unrelated lines.
42const TAKE_WINDOW: f64 = 20.0;
43
44/// Speech-to-text and the double-take detector (the "Transcribe" section).
45pub struct TranscribeState {
46    pub open: bool,
47    /// Index into `transcribe::MODELS`.
48    pub model: usize,
49    pub language: String,
50    pub max_chars: usize,
51    /// Wrapped lines per cue, joined with newlines (1 = one-liners, 2 = the usual two-line subs).
52    pub lines: usize,
53    pub min_dur: f64,
54    pub words: bool,
55    /// Vocabulary/style hints passed to whisper (`--prompt`).
56    pub prompt: String,
57    /// Sentence grouping for word-timed runs (gap, punctuation, max words/chars) — regenerate-time knobs.
58    pub group: transcribe::GroupOpts,
59    /// Raw one-word timings of the last word-timed run (timeline seconds) — what "Regenerate" regroups.
60    pub raw_words: Vec<(f64, f64, String)>,
61    /// Cues added by the last generate, so a regenerate replaces them instead of stacking duplicates.
62    pub generated: Vec<Id>,
63    /// Double-take similarity, 0.5..=1.0.
64    pub threshold: f32,
65    /// Transcript of the last run, in timeline seconds.
66    pub segments: Vec<Segment>,
67    /// Repeated takes over `segments` (each group keeps its last member).
68    pub groups: Vec<Vec<usize>>,
69    pub status: String,
70    /// Clip the transcript came from, and its source -> timeline (offset, scale).
71    clip: Option<Id>,
72    map: (f64, f64),
73    /// Markers dropped by "Mark on timeline", so a re-mark or a cut can take them away again.
74    marks: Vec<Id>,
75    job: Option<transcribe::Job>,
76    download: Option<Arc<Progress>>,
77    /// whisper binary, looked up once (the lookup stats PATH) — "Re-check" after installing it.
78    exe: Option<Option<PathBuf>>,
79}
80
81impl Default for TranscribeState {
82    fn default() -> Self {
83        Self {
84            open: false,
85            model: transcribe::default_model(),
86            language: "auto".into(),
87            max_chars: 42,
88            lines: 1,
89            min_dur: 1.0,
90            words: false,
91            prompt: String::new(),
92            group: transcribe::GroupOpts::default(),
93            raw_words: Vec::new(),
94            generated: Vec::new(),
95            threshold: 0.85,
96            segments: Vec::new(),
97            groups: Vec::new(),
98            status: String::new(),
99            clip: None,
100            map: (0.0, 1.0),
101            marks: Vec::new(),
102            job: None,
103            download: None,
104            exe: None,
105        }
106    }
107}
108
109#[derive(Default)]
110pub struct SubtitlesState {
111    pub selected: Option<crate::model::Id>,
112    /// Multi-selected cues (the row checkboxes) for "Delete selected".
113    pub checked: std::collections::HashSet<Id>,
114    pub show_style: bool,
115    /// Cue whose text field should grab focus (set by "Add at playhead").
116    pub focus: Option<Id>,
117    pub transcribe: TranscribeState,
118}
119
120#[derive(Default)]
121pub struct SubtitlesResponse {
122    pub edited: bool,
123    pub seeked: bool,
124    /// The cue's play button: seek there and start playback.
125    pub play: bool,
126    /// "Open folder" — the app writes the .srt sidecar next to the project and opens it in Explorer.
127    pub open_folder: bool,
128}
129
130/// "Add at playhead": a 2 s cue starting at the playhead.
131fn add_at(project: &mut Project, playhead: f64) -> Id {
132    project.add_cue(playhead, playhead + 2.0, "Subtitle")
133}
134
135/// Import parsed cues, replacing or appending.
136fn apply_import(project: &mut Project, cues: &[(f64, f64, String)], replace: bool) {
137    if replace {
138        project.subtitles.clear();
139    }
140    for (s, e, t) in cues {
141        project.add_cue(*s, *e, t.clone());
142    }
143}
144
145pub fn show(
146    ui: &mut egui::Ui,
147    state: &mut SubtitlesState,
148    project: &mut Project,
149    playhead: &mut f64,
150    selection: &[Id],
151    fonts: &[String],
152    palette: &Palette,
153    undo: &mut dyn FnMut(&Project),
154) -> SubtitlesResponse {
155    let mut resp = SubtitlesResponse::default();
156    let mut undone = false;
157    let ph = *playhead;
158
159    ui.horizontal_wrapped(|ui| {
160        if ui.button("Add at playhead").clicked() {
161            once(&mut undone, undo, project);
162            let id = add_at(project, ph);
163            state.selected = Some(id);
164            state.focus = Some(id);
165            resp.edited = true;
166        }
167        if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::ImportArrow, "Import…").clicked() {
168            import_dialog(project, &mut undone, undo, &mut resp);
169        }
170        if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::ExportArrow, "Export SRT…").clicked() {
171            export_dialog(project, false);
172        }
173        if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::ExportArrow, "Export VTT…").clicked() {
174            export_dialog(project, true);
175        }
176        if ui.button("Open folder").on_hover_text("Open the project's subtitle folder in Explorer").clicked() {
177            resp.open_folder = true;
178        }
179        let mut burn = project.show_subtitles;
180        let r = ui.checkbox(&mut burn, "Burn in");
181        if r.changed() {
182            once(&mut undone, undo, project);
183            project.show_subtitles = burn;
184            resp.edited = true;
185        }
186        ui.toggle_value(&mut state.show_style, "Style");
187        ui.toggle_value(&mut state.transcribe.open, "Transcribe");
188    });
189    ui.horizontal_wrapped(|ui| {
190        let any = !project.subtitles.is_empty();
191        let sel = state.checked.len();
192        let label = if sel > 0 { format!("To text clips ({sel})") } else { "To text clips".into() };
193        if ui
194            .add_enabled(any, Button::new(label))
195            .on_hover_text("Turn the selected cues (or all of them) into editable Text clips on a \"Subtitles\" track")
196            .clicked()
197        {
198            once(&mut undone, undo, project);
199            let only: Vec<Id> = state.checked.iter().copied().collect();
200            let n = project.cues_to_text_clips(if only.is_empty() { None } else { Some(&only) });
201            state.checked.clear();
202            resp.edited = n > 0;
203        }
204        let n = state.checked.len();
205        if ui.add_enabled(n > 0, Button::new(format!("Delete selected ({n})"))).clicked() {
206            once(&mut undone, undo, project);
207            project.subtitles.retain(|c| !state.checked.contains(&c.id));
208            state.checked.clear();
209            resp.edited = true;
210        }
211        let range = match (project.in_point, project.out_point) {
212            (Some(a), Some(b)) if b > a => Some((a, b)),
213            _ => None,
214        };
215        if ui
216            .add_enabled(any && range.is_some(), Button::new("Delete in range"))
217            .on_hover_text("Delete every cue that overlaps the In/Out range (set with I and O)")
218            .clicked()
219        {
220            let (a, b) = range.expect("button enabled only with a range");
221            once(&mut undone, undo, project);
222            project.subtitles.retain(|c| c.end <= a || c.start >= b);
223            state.checked.retain(|id| project.subtitles.iter().any(|c| c.id == *id));
224            resp.edited = true;
225        }
226        if ui.add_enabled(any, Button::new("Clear all")).clicked()
227            && rfd::MessageDialog::new()
228                .set_title("Clear subtitles")
229                .set_description(format!("Delete all {} subtitles?", project.subtitles.len()))
230                .set_buttons(rfd::MessageButtons::YesNo)
231                .show()
232                == rfd::MessageDialogResult::Yes
233        {
234            once(&mut undone, undo, project);
235            project.subtitles.clear();
236            state.checked.clear();
237            resp.edited = true;
238        }
239    });
240
241    if state.show_style {
242        style_section(ui, project, fonts, &mut undone, undo, &mut resp);
243    }
244    if state.transcribe.open {
245        ui.separator();
246        transcribe_section(ui, &mut state.transcribe, project, selection, &mut undone, undo, &mut resp);
247    }
248    ui.separator();
249
250    let mut resort = false;
251    let mut del: Option<Id> = None;
252    let mut split: Option<Id> = None;
253    let mut convert: Option<Id> = None;
254    let (shift, primary_down) = ui.input(|i| (i.modifiers.shift, i.pointer.primary_down()));
255    egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
256        for i in 0..project.subtitles.len() {
257            let (id, start, end) = {
258                let c = &project.subtitles[i];
259                (c.id, c.start, c.end)
260            };
261            let active = ph >= start && ph < end;
262            let fill = if active {
263                palette.selection.gamma_multiply(0.25)
264            } else if state.selected == Some(id) {
265                palette.selection.gamma_multiply(0.12)
266            } else {
267                egui::Color32::TRANSPARENT
268            };
269            let row_rect = egui::Frame::new()
270                .fill(fill)
271                .inner_margin(2.0)
272                .show(ui, |ui| {
273                    ui.horizontal(|ui| {
274                        let mut on = state.checked.contains(&id);
275                        if ui.checkbox(&mut on, "").on_hover_text("Select for \"Delete selected\"").changed() {
276                            if on {
277                                state.checked.insert(id);
278                            } else {
279                                state.checked.remove(&id);
280                            }
281                        }
282                        let mut v = start;
283                        let r =
284                            ui.add(DragValue::new(&mut v).range(0.0..=(end - MIN_CUE)).speed(0.05).fixed_decimals(3));
285                        if edit_start(&r) {
286                            once(&mut undone, undo, project);
287                        }
288                        if r.changed() {
289                            project.subtitles[i].start = v.clamp(0.0, end - MIN_CUE);
290                            resp.edited = true;
291                        }
292                        if r.drag_stopped() || (r.changed() && !r.dragged()) {
293                            resort = true;
294                        }
295                        let mut v = end;
296                        let r = ui.add(
297                            DragValue::new(&mut v).range((start + MIN_CUE)..=86400.0).speed(0.05).fixed_decimals(3),
298                        );
299                        if edit_start(&r) {
300                            once(&mut undone, undo, project);
301                        }
302                        if r.changed() {
303                            project.subtitles[i].end = v.max(start + MIN_CUE);
304                            resp.edited = true;
305                        }
306                        if glyph_text_button(ui, Glyph::Play, "").on_hover_text("Play from this cue").clicked() {
307                            *playhead = start;
308                            state.selected = Some(id);
309                            resp.seeked = true;
310                            resp.play = true;
311                        }
312                        if active && ph > start + 0.05 && ph < end - 0.05 && ui.small_button("Split").clicked() {
313                            split = Some(id);
314                        }
315                        if ui.small_button("T").on_hover_text("Convert to an editable Text clip").clicked() {
316                            convert = Some(id);
317                        }
318                        if crate::ui::markers_ui::x_button(ui).on_hover_text("Delete this cue").clicked() {
319                            del = Some(id);
320                        }
321                    });
322                    let mut text = project.subtitles[i].text.clone();
323                    let r = ui.add(egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY));
324                    // One undo entry per visit to the field, not per keystroke. "Add at playhead" focuses the
325                    // new cue itself and has already pushed one, so that focus does not push another.
326                    if state.focus == Some(id) {
327                        r.request_focus();
328                        state.focus = None;
329                    } else if r.gained_focus() {
330                        once(&mut undone, undo, project);
331                    }
332                    if r.has_focus() {
333                        state.selected = Some(id);
334                    }
335                    if r.changed() {
336                        project.subtitles[i].text = text;
337                        resp.edited = true;
338                    }
339                })
340                .response
341                .rect;
342            // Shift+drag over rows sweeps them into the selection (plain drags still edit the widgets)
343            if shift && primary_down && ui.rect_contains_pointer(row_rect) {
344                state.checked.insert(id);
345            }
346        }
347        if project.subtitles.is_empty() {
348            ui.weak("No subtitles. \"Add at playhead\" or import an .srt / .vtt file.");
349        }
350    });
351
352    if let Some(id) = convert {
353        once(&mut undone, undo, project);
354        project.cues_to_text_clips(Some(&[id]));
355        state.checked.remove(&id);
356        resp.edited = true;
357    }
358    if let Some(id) = del {
359        once(&mut undone, undo, project);
360        project.remove_cue(id);
361        state.checked.remove(&id);
362        if state.selected == Some(id) {
363            state.selected = None;
364        }
365        resp.edited = true;
366    }
367    if let Some(id) = split {
368        if let Some(c) = project.subtitles.iter_mut().find(|c| c.id == id) {
369            let (end, text) = (c.end, c.text.clone());
370            once(&mut undone, undo, project);
371            if let Some(c) = project.subtitles.iter_mut().find(|c| c.id == id) {
372                c.end = ph;
373            }
374            let nid = project.add_cue(ph, end, text);
375            state.selected = Some(nid);
376            resp.edited = true;
377        }
378    }
379    if resort {
380        project.sort_cues();
381    }
382    resp
383}
384
385fn style_section(
386    ui: &mut egui::Ui,
387    project: &mut Project,
388    fonts: &[String],
389    undone: &mut bool,
390    undo: &mut dyn FnMut(&Project),
391    resp: &mut SubtitlesResponse,
392) {
393    let mut style = project.subtitle_style.clone();
394    let mut margin = project.subtitle_margin;
395    let mut cont = (project.subtitle_cont_prefix.clone(), project.subtitle_cont_suffix.clone());
396    let mut start = false;
397    let mut changed = false;
398    let note = |r: &Response, start: &mut bool, changed: &mut bool| {
399        *start |= edit_start(r);
400        *changed |= r.changed();
401    };
402    egui::Grid::new("subtitle_style").num_columns(2).show(ui, |ui| {
403        ui.label("Font");
404        egui::ComboBox::from_id_salt("sub_font").selected_text(style.font.clone()).show_ui(ui, |ui| {
405            for f in fonts {
406                note(&ui.selectable_value(&mut style.font, f.clone(), f), &mut start, &mut changed);
407            }
408        });
409        ui.end_row();
410        ui.label("Size");
411        note(&ui.add(DragValue::new(&mut style.size).range(8.0..=300.0)), &mut start, &mut changed);
412        ui.end_row();
413        ui.label("Colour");
414        note(&ui.color_edit_button_srgba_unmultiplied(&mut style.color), &mut start, &mut changed);
415        ui.end_row();
416        ui.label("Outline");
417        ui.horizontal(|ui| {
418            note(&ui.color_edit_button_srgba_unmultiplied(&mut style.outline_color), &mut start, &mut changed);
419            note(
420                &ui.add(DragValue::new(&mut style.outline_width).range(0.0..=20.0).speed(0.1)),
421                &mut start,
422                &mut changed,
423            );
424        });
425        ui.end_row();
426        ui.label("Box");
427        note(&ui.color_edit_button_srgba_unmultiplied(&mut style.box_color), &mut start, &mut changed);
428        ui.end_row();
429        ui.label("Margin");
430        note(&ui.add(DragValue::new(&mut margin).range(0.0..=1000.0)), &mut start, &mut changed);
431        ui.end_row();
432        ui.label("Format");
433        ui.horizontal(|ui| {
434            note(&ui.toggle_value(&mut style.bold, "B"), &mut start, &mut changed);
435            note(&ui.toggle_value(&mut style.italic, "I"), &mut start, &mut changed);
436            for (v, lab) in [(0u8, "Left"), (1, "Center"), (2, "Right")] {
437                note(&ui.selectable_value(&mut style.align, v, lab), &mut start, &mut changed);
438            }
439        });
440        ui.end_row();
441        ui.label("Line spacing");
442        note(&ui.add(DragValue::new(&mut style.line_spacing).range(0.5..=3.0).speed(0.02)), &mut start, &mut changed);
443        ui.end_row();
444        ui.label("Letter spacing");
445        note(
446            &ui.add(DragValue::new(&mut style.letter_spacing).range(-5.0..=30.0).speed(0.1)),
447            &mut start,
448            &mut changed,
449        );
450        ui.end_row();
451        ui.label("Shadow");
452        ui.horizontal(|ui| {
453            note(&ui.checkbox(&mut style.shadow, ""), &mut start, &mut changed);
454            note(&ui.color_edit_button_srgba_unmultiplied(&mut style.shadow_color), &mut start, &mut changed);
455        });
456        ui.end_row();
457        ui.label("Continuation").on_hover_text(
458            "Added where a sentence is split across cues: the suffix ends the cut-off cue, the prefix \
459             starts the next (e.g. suffix \" —\" for em-dashes). Applied by Transcribe / Regenerate cues.",
460        );
461        ui.horizontal(|ui| {
462            note(
463                &ui.add(egui::TextEdit::singleline(&mut cont.0).desired_width(50.0).hint_text("prefix")),
464                &mut start,
465                &mut changed,
466            );
467            note(
468                &ui.add(egui::TextEdit::singleline(&mut cont.1).desired_width(50.0).hint_text("suffix")),
469                &mut start,
470                &mut changed,
471            );
472        });
473        ui.end_row();
474    });
475    if start {
476        once(undone, undo, project);
477    }
478    if changed {
479        project.subtitle_style = style;
480        project.subtitle_margin = margin;
481        (project.subtitle_cont_prefix, project.subtitle_cont_suffix) = cont;
482        resp.edited = true;
483    }
484}
485
486/// What a run needs: the clip's audio and how its source time maps back onto the timeline.
487struct Target {
488    clip: Id,
489    path: String,
490    src_start: f64,
491    src_dur: f64,
492    /// Timeline seconds of the transcript's zero, and source seconds -> timeline seconds.
493    offset: f64,
494    scale: f64,
495}
496
497/// The first selected clip that has footage behind it.
498///
499/// ponytail: one linear map over the whole clip, so a reversed clip transcribes forwards and a keyframed
500/// speed ramp drifts between its keys. Transcribe per ramp segment if that ever shows.
501fn target(project: &Project, selection: &[Id]) -> Option<Target> {
502    let c = selection.iter().find_map(|&id| project.clip(id))?;
503    let path = project.asset(c.asset).map(|a| a.path.clone()).filter(|p| !p.is_empty())?;
504    let (a, b) = (c.src_time(c.start), c.src_time(c.start + c.duration));
505    let (a, b) = if b > a + 0.05 { (a, b) } else { (c.src_in, c.src_in + c.duration.max(0.1)) };
506    Some(Target { clip: c.id, path, src_start: a, src_dur: b - a, offset: c.start, scale: c.duration / (b - a) })
507}
508
509/// A marker on every take "Cut the duplicates" would remove, named and described by what was said.
510fn mark_dups(project: &mut Project, segs: &[Segment], groups: &[Vec<usize>]) -> Vec<Id> {
511    let mut ids = Vec::new();
512    for g in groups {
513        for &i in &g[..g.len() - 1] {
514            let Some(s) = segs.get(i) else { continue };
515            let id = project.add_marker(s.start, transcribe::short_label(&s.text, 28));
516            if let Some(m) = project.marker_mut(id) {
517                m.duration = (s.end - s.start).max(0.0);
518                m.note = s.text.clone();
519            }
520            ids.push(id);
521        }
522    }
523    ids
524}
525
526/// Move one transcribed span through a ripple cut; false when the cut swallowed it.
527fn ripple_segment(s: &mut Segment, ranges: &[(f64, f64)]) -> bool {
528    let Some(a) = transcribe::ripple_time(s.start, ranges) else { return false };
529    let b = transcribe::ripple_time(s.end, ranges).unwrap_or(a + (s.end - s.start));
530    s.words.retain_mut(|w| match (transcribe::ripple_time(w.0, ranges), transcribe::ripple_time(w.1, ranges)) {
531        (Some(x), Some(y)) => {
532            (w.0, w.1) = (x, y);
533            true
534        }
535        _ => false,
536    });
537    (s.start, s.end) = (a, b.max(a));
538    true
539}
540
541/// Ripple every take but the last of each group out of the timeline, then drag the cues, our markers and
542/// the transcript along with the cut. Returns how many clips went.
543fn cut_dups(project: &mut Project, st: &mut TranscribeState) -> usize {
544    let ranges = transcribe::dup_ranges(&st.segments, &st.groups);
545    let (Some(clip), false) = (st.clip, ranges.is_empty()) else { return 0 };
546    let cuts: Vec<f64> = ranges.iter().flat_map(|&(a, b)| [a, b]).collect();
547    let n = project.auto_cut(&[clip], &cuts, &ranges, true);
548    for id in st.marks.drain(..) {
549        project.remove_marker(id);
550    }
551    project.subtitles.retain_mut(|c| {
552        let Some(a) = transcribe::ripple_time(c.start, &ranges) else { return false };
553        let b = transcribe::ripple_time(c.end, &ranges).unwrap_or(a + (c.end - c.start));
554        (c.start, c.end) = (a, b.max(a + MIN_CUE));
555        true
556    });
557    project.sort_cues();
558    st.segments.retain_mut(|s| ripple_segment(s, &ranges));
559    st.groups = transcribe::duplicate_takes(&st.segments, st.threshold, TAKE_WINDOW);
560    n
561}
562
563/// Regroup the raw words (if the last run had word timings) with the current knobs and regenerate the
564/// cues, replacing the previously generated ones. No re-transcription — pure post-processing.
565fn generate(st: &mut TranscribeState, project: &mut Project) -> String {
566    if !st.raw_words.is_empty() {
567        st.segments = transcribe::group_words(&st.raw_words, &st.group);
568        st.groups = transcribe::duplicate_takes(&st.segments, st.threshold, TAKE_WINDOW);
569    }
570    let cont = (project.subtitle_cont_prefix.clone(), project.subtitle_cont_suffix.clone());
571    let cues = transcribe::to_cues(&st.segments, st.max_chars, st.lines, st.min_dur, (&cont.0, &cont.1));
572    let old: std::collections::HashSet<Id> = st.generated.iter().copied().collect();
573    project.subtitles.retain(|c| !old.contains(&c.id));
574    st.generated = cues.iter().map(|(s, e, t)| project.add_cue(*s, *e, t.clone())).collect();
575    format!("{} cues from {} segments", st.generated.len(), st.segments.len())
576}
577
578/// The "Transcribe" section: model + download, the run, and the double-take list.
579fn transcribe_section(
580    ui: &mut egui::Ui,
581    st: &mut TranscribeState,
582    project: &mut Project,
583    selection: &[Id],
584    undone: &mut bool,
585    undo: &mut dyn FnMut(&Project),
586    resp: &mut SubtitlesResponse,
587) {
588    let (name, file, mb) = transcribe::MODELS[st.model.min(transcribe::MODELS.len() - 1)];
589    let have = transcribe::have_model(file);
590    let exe = st.exe.get_or_insert_with(transcribe::exe).clone();
591    let warn = ui.visuals().warn_fg_color;
592
593    let downloading = st.download.as_ref().is_some_and(|p| !p.is_done());
594    ui.horizontal_wrapped(|ui| {
595        ui.label("Model");
596        ui.add_enabled_ui(!downloading, |ui| {
597            egui::ComboBox::from_id_salt("whisper_model").selected_text(name).show_ui(ui, |ui| {
598                for (i, (n, _, size)) in transcribe::MODELS.iter().enumerate() {
599                    ui.selectable_value(&mut st.model, i, format!("{n}  ({size} MB)"));
600                }
601            });
602        });
603        if have {
604            ui.weak("downloaded");
605        }
606    });
607    if !have {
608        // the opt-in: nothing is fetched until this button is pressed, and the size is on it
609        ui.weak(format!("Downloads {mb} MB once, from huggingface.co into {}.", transcribe::models_dir().display()));
610        match st.download.clone() {
611            Some(p) if !p.is_done() => {
612                ui.add(egui::ProgressBar::new(p.fraction()).show_percentage().text(p.status()));
613                if ui.button("Cancel").clicked() {
614                    p.cancel.store(true, Ordering::SeqCst);
615                }
616                ui.ctx().request_repaint_after(Duration::from_millis(200));
617            }
618            done => {
619                if let Some(e) = done.and_then(|p| p.error()) {
620                    ui.colored_label(warn, e);
621                }
622                if ui.button(format!("Download model ({mb} MB)")).clicked() {
623                    st.download = Some(transcribe::download_model(file));
624                }
625            }
626        }
627    }
628    if exe.is_none() {
629        ui.horizontal_wrapped(|ui| {
630            ui.colored_label(warn, transcribe::install_hint());
631            if ui.small_button("Re-check").clicked() {
632                st.exe = None;
633            }
634        });
635    }
636
637    egui::Grid::new("transcribe_params").num_columns(2).show(ui, |ui| {
638        ui.label("Language");
639        ui.add(egui::TextEdit::singleline(&mut st.language).desired_width(60.0).hint_text("auto"));
640        ui.end_row();
641        ui.label("Line length");
642        ui.add(DragValue::new(&mut st.max_chars).range(20..=90).suffix(" chars"));
643        ui.end_row();
644        ui.label("Lines per cue");
645        ui.add(DragValue::new(&mut st.lines).range(1..=4))
646            .on_hover_text("Wrapped lines shown together in one cue (2 = classic two-line subtitles)");
647        ui.end_row();
648        ui.label("Min duration");
649        ui.add(DragValue::new(&mut st.min_dur).range(0.3..=5.0).speed(0.05).suffix(" s"));
650        ui.end_row();
651        ui.label("Word timings");
652        ui.checkbox(&mut st.words, "")
653            .on_hover_text("Slower: whisper times every word, so the cues break exactly on speech");
654        ui.end_row();
655        ui.label("Prompt").on_hover_text("Names, jargon and punctuation style hints for whisper — not commands");
656        ui.add(egui::TextEdit::singleline(&mut st.prompt).desired_width(220.0).hint_text("vocabulary hints…"));
657        ui.end_row();
658        if st.words || !st.raw_words.is_empty() {
659            ui.label("Pause split");
660            ui.add(DragValue::new(&mut st.group.max_gap).range(0.1..=5.0).speed(0.05).suffix(" s"))
661                .on_hover_text("A silence longer than this starts a new sentence");
662            ui.end_row();
663            ui.label("Break on");
664            ui.add(egui::TextEdit::singleline(&mut st.group.punct).desired_width(60.0).hint_text("none"))
665                .on_hover_text("A word ending with any of these characters ends the sentence");
666            ui.end_row();
667            ui.label("Max words");
668            let mut mw = st.group.max_words;
669            if ui
670                .add(DragValue::new(&mut mw).range(0..=40).custom_formatter(|v, _| {
671                    if v < 1.0 {
672                        "off".into()
673                    } else {
674                        format!("{v:.0}")
675                    }
676                }))
677                .on_hover_text("Cap a sentence at this many words (0 = no cap)")
678                .changed()
679            {
680                st.group.max_words = mw;
681            }
682            ui.end_row();
683            ui.label("Sentence chars");
684            ui.add(DragValue::new(&mut st.group.max_chars).range(30..=300).suffix(" chars"))
685                .on_hover_text("A sentence never grows past this many characters");
686            ui.end_row();
687        }
688    });
689
690    let tgt = target(project, selection);
691    let running = st.job.as_ref().is_some_and(|j| !j.progress.is_done());
692    let mut go = false;
693    ui.horizontal_wrapped(|ui| {
694        ui.add_enabled_ui(have && exe.is_some() && tgt.is_some() && !running, |ui| {
695            go = glyph_text_button(ui, Glyph::Mic, "Transcribe & generate subtitles").clicked();
696        });
697        if running && ui.button("Cancel").clicked() {
698            if let Some(j) = &st.job {
699                j.cancel();
700            }
701        }
702        let can_regen = !running && (!st.segments.is_empty() || !st.raw_words.is_empty());
703        if ui
704            .add_enabled(can_regen, Button::new("Regenerate cues"))
705            .on_hover_text("Rebuild the cues from the last transcript with the knobs above — no re-transcription")
706            .clicked()
707        {
708            once(undone, undo, project);
709            st.status = generate(st, project);
710            resp.edited = true;
711        }
712        if tgt.is_none() {
713            ui.weak("Select a clip to transcribe.");
714        }
715    });
716    if let (true, Some(t)) = (go, &tgt) {
717        st.segments.clear();
718        st.groups.clear();
719        st.marks.clear();
720        st.status.clear();
721        st.raw_words.clear();
722        // a fresh run appends to whatever cues exist — only a Regenerate replaces its own
723        st.generated.clear();
724        st.clip = Some(t.clip);
725        st.map = (t.offset, t.scale);
726        st.job = Some(transcribe::start(transcribe::Options {
727            path: t.path.clone(),
728            src_start: t.src_start,
729            src_duration: t.src_dur,
730            model: file.to_string(),
731            language: st.language.clone(),
732            words: st.words,
733            prompt: st.prompt.clone(),
734        }));
735    }
736
737    let done = st.job.as_ref().is_some_and(|j| j.progress.is_done());
738    if let Some(j) = &st.job {
739        ui.add(egui::ProgressBar::new(j.progress.fraction()).show_percentage().text(j.progress.status()));
740        if !done {
741            ui.ctx().request_repaint_after(Duration::from_millis(150));
742        }
743    }
744    if done {
745        let job = st.job.take().expect("done implies a job");
746        st.status = match job.progress.error() {
747            Some(e) => e,
748            None => {
749                let mut segs = job.segments();
750                transcribe::retime(&mut segs, st.map.0, st.map.1);
751                if st.words {
752                    // one word per segment: keep the raw words so "Regenerate cues" can regroup them
753                    st.raw_words = segs.iter().map(|s| (s.start, s.end, s.text.clone())).collect();
754                } else {
755                    st.raw_words.clear();
756                    st.segments = segs;
757                    st.groups = transcribe::duplicate_takes(&st.segments, st.threshold, TAKE_WINDOW);
758                }
759                once(undone, undo, project);
760                let msg = generate(st, project);
761                resp.edited = true;
762                msg
763            }
764        };
765    }
766    if !st.status.is_empty() {
767        ui.weak(&st.status);
768    }
769
770    if st.segments.is_empty() {
771        return;
772    }
773    ui.separator();
774    let dups: usize = st.groups.iter().map(|g| g.len() - 1).sum();
775    ui.horizontal_wrapped(|ui| {
776        ui.label("Double takes");
777        if ui.add(Slider::new(&mut st.threshold, 0.5..=1.0).fixed_decimals(2).text("similarity")).changed() {
778            st.groups = transcribe::duplicate_takes(&st.segments, st.threshold, TAKE_WINDOW);
779        }
780        ui.weak(format!("{dups} repeated"));
781    });
782    ui.horizontal_wrapped(|ui| {
783        if ui.add_enabled(dups > 0, Button::new("Mark on timeline")).clicked() {
784            once(undone, undo, project);
785            for id in st.marks.drain(..) {
786                project.remove_marker(id);
787            }
788            st.marks = mark_dups(project, &st.segments, &st.groups);
789            st.status = format!("marked {} take(s)", st.marks.len());
790            resp.edited = true;
791        }
792        if ui
793            .add_enabled(dups > 0 && st.clip.is_some(), Button::new("Cut the duplicates"))
794            .on_hover_text("Ripples every take but the last one out, and moves the cues with it")
795            .clicked()
796        {
797            once(undone, undo, project);
798            let n = cut_dups(project, st);
799            st.status = format!("cut {n} clip(s)");
800            resp.edited = true;
801        }
802    });
803    for (shown, &i) in st.groups.iter().flat_map(|g| &g[..g.len() - 1]).enumerate() {
804        if shown == 6 {
805            ui.weak(format!("… and {} more", dups - 6));
806            break;
807        }
808        if let Some(s) = st.segments.get(i) {
809            ui.weak(format!("{}  {}", crate::ui::duration_text(s.start), transcribe::short_label(&s.text, 64)));
810        }
811    }
812}
813
814/// Import an .srt/.vtt via rfd; asks replace (Yes) or append (No) when cues already exist.
815fn import_dialog(
816    project: &mut Project,
817    undone: &mut bool,
818    undo: &mut dyn FnMut(&Project),
819    resp: &mut SubtitlesResponse,
820) {
821    let Some(path) = rfd::FileDialog::new().add_filter("Subtitles", &["srt", "vtt"]).pick_file() else { return };
822    let Ok(text) = std::fs::read_to_string(&path) else { return };
823    let cues = crate::engine::subtitles::parse(&text);
824    if cues.is_empty() {
825        return;
826    }
827    let replace = project.subtitles.is_empty()
828        || rfd::MessageDialog::new()
829            .set_title("Import subtitles")
830            .set_description("Replace the existing subtitles? (No = append)")
831            .set_buttons(rfd::MessageButtons::YesNo)
832            .show()
833            == rfd::MessageDialogResult::Yes;
834    once(undone, undo, project);
835    apply_import(project, &cues, replace);
836    resp.edited = true;
837}
838
839fn export_dialog(project: &Project, vtt: bool) {
840    if project.subtitles.is_empty() {
841        return;
842    }
843    let (ext, name) = if vtt { ("vtt", "WebVTT") } else { ("srt", "SubRip") };
844    let Some(path) =
845        rfd::FileDialog::new().add_filter(name, &[ext]).set_file_name(format!("{}.{ext}", project.name)).save_file()
846    else {
847        return;
848    };
849    let cues = &project.subtitles;
850    let text = if vtt { crate::engine::subtitles::to_vtt(cues) } else { crate::engine::subtitles::to_srt(cues) };
851    let _ = std::fs::write(path, text);
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use crate::model::{Asset, AudioStreamInfo, ClipKind};
858
859    #[test]
860    fn add_at_creates_cue_at_playhead() {
861        let mut p = Project::new();
862        let id = add_at(&mut p, 3.5);
863        let c = p.cue_at(3.5).expect("cue at playhead");
864        assert_eq!(c.id, id);
865        assert!((c.start - 3.5).abs() < 1e-9 && (c.end - 5.5).abs() < 1e-9);
866        assert_eq!(c.text, "Subtitle");
867    }
868
869    #[test]
870    fn import_replace_and_append() {
871        let mut p = Project::new();
872        p.add_cue(0.0, 1.0, "old");
873        let cues = vec![(2.0, 3.0, "b".to_string()), (0.5, 1.5, "a".to_string())];
874        apply_import(&mut p, &cues, false);
875        assert_eq!(p.subtitles.len(), 3);
876        assert_eq!(p.subtitles[0].text, "old"); // kept + sorted
877        apply_import(&mut p, &cues, true);
878        assert_eq!(p.subtitles.len(), 2);
879        assert_eq!(p.subtitles[0].text, "a");
880    }
881
882    /// Runs engine::subtitles::parse on a small SRT.
883    #[test]
884    fn import_parses_srt() {
885        let srt = "1\n00:00:01,000 --> 00:00:02,500\nHello\n\n2\n00:00:03,000 --> 00:00:04,000\nWorld\n";
886        let cues = crate::engine::subtitles::parse(srt);
887        assert_eq!(cues.len(), 2);
888        assert!((cues[0].0 - 1.0).abs() < 1e-3 && (cues[0].1 - 2.5).abs() < 1e-3);
889        assert_eq!(cues[0].2, "Hello");
890        let mut p = Project::new();
891        apply_import(&mut p, &cues, true);
892        assert_eq!(p.subtitles.len(), 2);
893    }
894
895    /// Headless: panel lays out with cues and reports nothing without interaction.
896    #[test]
897    fn show_headless() {
898        let mut p = Project::new();
899        p.add_cue(0.0, 1.0, "a");
900        p.add_cue(2.0, 4.0, "b");
901        let palette = Palette::new(true, egui::Color32::WHITE);
902        let fonts = vec!["Segoe UI".to_string()];
903        // the transcribe section draws too: no model, no whisper.exe, no selection — only its hints
904        let mut state = SubtitlesState {
905            show_style: true,
906            transcribe: TranscribeState { open: true, ..Default::default() },
907            ..Default::default()
908        };
909        let mut playhead = 2.5; // inside cue "b" → highlighted row with Split button
910        let ctx = egui::Context::default();
911        for _ in 0..2 {
912            let _ = ctx.run(egui::RawInput::default(), |ctx| {
913                egui::CentralPanel::default().show(ctx, |ui| {
914                    let mut undo = |_: &Project| panic!("no undo without edits");
915                    let r = show(ui, &mut state, &mut p, &mut playhead, &[], &fonts, &palette, &mut undo);
916                    assert!(!r.edited && !r.seeked);
917                });
918            });
919        }
920        assert_eq!(p.subtitles.len(), 2);
921        assert!(state.transcribe.job.is_none() && state.transcribe.segments.is_empty());
922    }
923
924    /// A clip with an asset, on V1 + A1, running 0..10 s of the source.
925    fn clip_project(speed: f64) -> (Project, Id) {
926        let mut p = Project::new();
927        let aid = p.add_asset(Asset {
928            id: 0,
929            path: "C:/take.mp4".into(),
930            kind: ClipKind::Video,
931            duration: 10.0,
932            width: 320,
933            height: 240,
934            fps: 30.0,
935            audio_streams: vec![AudioStreamInfo { channels: 2, sample_rate: 48000, ..Default::default() }],
936            codec: String::new(),
937            folder: String::new(),
938            tags: Vec::new(),
939            label: 0,
940            description: String::new(),
941        });
942        p.insert_asset_clips(aid, 0.0, Some(0));
943        let id = p.tracks[0].clips[0].id;
944        if speed != 1.0 {
945            p.set_speed(&[id], speed, false);
946        }
947        (p, id)
948    }
949
950    #[test]
951    fn regenerate_replaces_its_own_cues_and_keeps_the_rest() {
952        let mut p = Project::new();
953        let manual = p.add_cue(50.0, 51.0, "hand-written");
954        let mut st = TranscribeState {
955            raw_words: vec![(0.0, 0.4, "One".into()), (0.5, 0.9, "two.".into()), (1.2, 1.6, "Three.".into())],
956            ..Default::default()
957        };
958        generate(&mut st, &mut p);
959        assert_eq!(st.segments.len(), 2, "grouped on punctuation");
960        let n = p.subtitles.len();
961        assert!(p.subtitles.iter().any(|c| c.id == manual));
962        // tighter knobs: every word its own sentence — same word data, no re-transcription
963        st.group.max_words = 1;
964        generate(&mut st, &mut p);
965        assert_eq!(st.segments.len(), 3);
966        assert!(p.subtitles.iter().any(|c| c.id == manual), "manual cue survives");
967        assert_eq!(p.subtitles.len(), n + 1, "old generated cues were replaced, not stacked");
968    }
969
970    #[test]
971    fn split_cue_makes_two_halves_with_the_same_text() {
972        let mut p = Project::new();
973        let id = p.add_cue(1.0, 3.0, "hello");
974        assert!(p.split_cue(id, 0.5).is_none(), "outside the cue");
975        assert!(p.split_cue(id, 1.01).is_none(), "too close to the edge");
976        let right = p.split_cue(id, 2.0).expect("split");
977        assert_eq!(p.subtitles.len(), 2);
978        assert!((p.subtitles[0].end - 2.0).abs() < 1e-9 && (p.subtitles[1].start - 2.0).abs() < 1e-9);
979        assert_eq!(p.subtitles[1].id, right);
980        assert_eq!(p.subtitles[1].text, "hello");
981    }
982
983    #[test]
984    fn cues_convert_to_text_clips() {
985        let mut p = Project::new();
986        let a = p.add_cue(1.0, 2.0, "first");
987        p.add_cue(3.0, 4.0, "second");
988        assert_eq!(p.cues_to_text_clips(Some(&[a])), 1);
989        assert_eq!(p.subtitles.len(), 1, "the converted cue is gone");
990        let track = p.tracks.iter().find(|t| t.name == "Subtitles").expect("subtitle track");
991        assert_eq!(track.clips.len(), 1);
992        let c = &track.clips[0];
993        assert_eq!(c.kind, ClipKind::Text);
994        assert_eq!(c.text.as_ref().unwrap().text, "first");
995        assert!((c.start - 1.0).abs() < 1e-9 && (c.duration - 1.0).abs() < 1e-9);
996        assert_eq!(p.cues_to_text_clips(None), 1, "convert-all reuses the track");
997        assert!(p.subtitles.is_empty());
998        assert_eq!(p.tracks.iter().filter(|t| t.name == "Subtitles").count(), 1);
999    }
1000
1001    #[test]
1002    fn target_maps_source_time_onto_the_timeline() {
1003        let (p, id) = clip_project(1.0);
1004        assert!(target(&p, &[]).is_none(), "nothing selected");
1005        let t = target(&p, &[id]).expect("a clip with footage");
1006        assert_eq!((t.src_start, t.src_dur, t.offset, t.scale), (0.0, 10.0, 0.0, 1.0));
1007        // at 2x the clip is 5 s of timeline over 10 s of source, so the transcript is squeezed by half
1008        let (p, id) = clip_project(2.0);
1009        let t = target(&p, &[id]).expect("a clip with footage");
1010        assert!((t.src_dur - 10.0).abs() < 1e-6 && (t.scale - 0.5).abs() < 1e-6, "{:?}", (t.src_dur, t.scale));
1011        // a text clip has no footage to listen to
1012        let mut p = Project::new();
1013        let txt = p.add_text_clip(0.0, 2.0);
1014        assert!(target(&p, &[txt]).is_none());
1015    }
1016
1017    #[test]
1018    fn duplicate_takes_are_marked_then_cut_with_the_cues() {
1019        let (mut p, id) = clip_project(1.0);
1020        let seg = |a: f64, b: f64, t: &str| Segment { start: a, end: b, text: t.into(), words: Vec::new() };
1021        let mut st = TranscribeState {
1022            clip: Some(id),
1023            segments: vec![
1024                seg(0.0, 2.0, "Welcome to the channel"),
1025                seg(2.5, 4.5, "Welcome to the channel"), // retake
1026                seg(5.0, 7.0, "Welcome to the channel"), // keeper
1027                seg(7.5, 9.5, "Now the actual video"),
1028            ],
1029            ..Default::default()
1030        };
1031        st.groups = transcribe::duplicate_takes(&st.segments, st.threshold, TAKE_WINDOW);
1032        assert_eq!(st.groups, vec![vec![0, 1, 2]]);
1033
1034        st.marks = mark_dups(&mut p, &st.segments, &st.groups);
1035        assert_eq!(st.marks.len(), 2, "the two flubbed takes, not the keeper");
1036        let m = p.markers.iter().find(|m| m.id == st.marks[0]).expect("marker");
1037        assert_eq!(m.note, "Welcome to the channel", "the marker is described by what was said");
1038        assert!(m.duration > 0.0 && m.t == 0.0);
1039
1040        // cues over the whole transcript, then the cut takes the duplicates and drags the rest left
1041        for s in &st.segments {
1042            p.add_cue(s.start, s.end, s.text.clone());
1043        }
1044        let n = cut_dups(&mut p, &mut st);
1045        assert!(n >= 2, "clips removed: {n}");
1046        assert!(p.markers.is_empty(), "our markers went with the cut");
1047        assert!((p.duration() - 6.0).abs() < 0.05, "the two takes (4 s) are gone: {}", p.duration());
1048        assert_eq!(p.subtitles.len(), 2, "the duplicated cues went too");
1049        assert!((p.subtitles[0].start - 1.0).abs() < 1e-6, "the keeper moved up: {:?}", p.subtitles[0]);
1050        assert_eq!(p.subtitles[1].text, "Now the actual video");
1051        assert_eq!(st.segments.len(), 2, "the transcript follows the cut");
1052        assert!(st.groups.is_empty(), "nothing is a duplicate any more");
1053    }
1054}