1use 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
38const MIN_CUE: f64 = 0.1;
40
41const TAKE_WINDOW: f64 = 20.0;
43
44pub struct TranscribeState {
46 pub open: bool,
47 pub model: usize,
49 pub language: String,
50 pub max_chars: usize,
51 pub lines: usize,
53 pub min_dur: f64,
54 pub words: bool,
55 pub prompt: String,
57 pub group: transcribe::GroupOpts,
59 pub raw_words: Vec<(f64, f64, String)>,
61 pub generated: Vec<Id>,
63 pub threshold: f32,
65 pub segments: Vec<Segment>,
67 pub groups: Vec<Vec<usize>>,
69 pub status: String,
70 clip: Option<Id>,
72 map: (f64, f64),
73 marks: Vec<Id>,
75 job: Option<transcribe::Job>,
76 download: Option<Arc<Progress>>,
77 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 pub checked: std::collections::HashSet<Id>,
114 pub show_style: bool,
115 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 pub play: bool,
126 pub open_folder: bool,
128}
129
130fn add_at(project: &mut Project, playhead: f64) -> Id {
132 project.add_cue(playhead, playhead + 2.0, "Subtitle")
133}
134
135fn 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 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 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
486struct Target {
488 clip: Id,
489 path: String,
490 src_start: f64,
491 src_dur: f64,
492 offset: f64,
494 scale: f64,
495}
496
497fn 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
509fn 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
526fn 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
541fn 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
563fn 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
578fn 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 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 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 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
814fn 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"); apply_import(&mut p, &cues, true);
878 assert_eq!(p.subtitles.len(), 2);
879 assert_eq!(p.subtitles[0].text, "a");
880 }
881
882 #[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 #[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 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; 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 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 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 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 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"), seg(5.0, 7.0, "Welcome to the channel"), 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 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}