From 25f3e9eadebbe23b7cf4a477f9282285775097e9 Mon Sep 17 00:00:00 2001 From: Abdullah <125509310+aboodAJ@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:22:42 +0100 Subject: [PATCH] =?UTF-8?q?Captions:=20File=20=E2=96=B8=20Export=20?= =?UTF-8?q?=E2=96=B8=20Captions=E2=80=A6=20asks=20for=20the=20file=20forma?= =?UTF-8?q?t=20(#3365)=20(#3523)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu item, and the Text panel's Export captions…, opens Caption Export Settings with a File Format menu of every format captions.export writes. OK opens the save panel with only that format's filter and a matching name. A name typed with another caption format's extension is written in that format (it is the file the save panel asked about); any other name gets the chosen format's extension. If a file with that new name already exists, nothing is written: with WebVTT chosen, typing "keep" when keep.vtt exists leaves keep.vtt alone. With a path the command still exports directly. Also fixes a panic: captions.export with params that are not an object indexed into them. Fixes #3365 Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/app/files.rs | 15 -- crates/ui-egui/src/menus.rs | 8 +- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/panels/caption_export.rs | 169 ++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/state.rs | 5 + crates/ui-egui/tests/it/caption_export_ui.rs | 258 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/captions.md | 13 +- 11 files changed, 455 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/panels/caption_export.rs create mode 100644 crates/ui-egui/tests/it/caption_export_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 843ffe1de..b3748521e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2494,6 +2494,7 @@ dependencies = [ "egui", "egui_kittest", "filmcraft-audio-dsp", + "filmcraft-captions", "filmcraft-codecs", "filmcraft-color", "filmcraft-edit", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3fefeb8b5..ed17b2955 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,6 +21,7 @@ filmcraft-tts = { workspace = true } filmcraft-edit = { workspace = true } filmcraft-render = { workspace = true } filmcraft-text = { workspace = true } +filmcraft-captions = { workspace = true } filmcraft-audio-dsp = { workspace = true } filmcraft-engine = { workspace = true } filmcraft-export = { workspace = true } diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 7e2bd0f07..5613ab795 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -67,21 +67,6 @@ impl FilmcraftApp { let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; app.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) }), - "captions.export" => { - let name = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); - let suggested = format!("{}.srt", name.replace(' ', "_")); - let dialog = FileDialog::save_as( - "Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", - &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], - &suggested, - ); - let mut p = params.clone(); - self.pick(dialog, move |app, paths| { - let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - p["path"] = json!(path); - app.session.execute("captions.export", p).map_err(|e| e.to_string()) - }) - } _ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) { Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params), None => Err(format!("no dialog for {id}")), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6be0e334f..109d8e2cd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -468,6 +468,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; return crate::panels::interchange_export::open(app, ctx, id); } + // File ▸ Export ▸ Captions… (and the Text panel's Export captions…) opens the caption export + // settings (#3365); with a path it exports directly. + "captions.export" if params.get("path").is_none() => { + filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; + return crate::panels::caption_export::open(app, ctx, ¶ms); + } // File ▸ Export ▸ Media… opens the Export mode, like ⌘M (#382); with a path it exports directly. "file.exportMedia" if params.get("path").is_none() => { filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; @@ -598,7 +604,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: || (id == "file.saveCopy" && params.get("path").is_none()) || (id == "file.open" && params.get("path").is_none()) || (id == "file.save" && params.get("path").is_none() && app.session.path.is_none()) - || (matches!(id, "captions.import" | "captions.export") && params.get("path").is_none()) + || (id == "captions.import" && params.get("path").is_none()) || (id == "graphics.newFromFile" && params.get("path").is_none()) || (crate::EXPORT_SAVE_DIALOGS.iter().any(|(c, ..)| *c == id) && params.get("path").is_none()) { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8a3e93ddc..a2449e57d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2,6 +2,7 @@ //! effects) is carried in egui temp data so the timeline/monitors can accept drops. pub mod audio_fx_editor; +pub mod caption_export; pub mod clip_dialogs; pub mod color_dialogs; pub mod dialogs; diff --git a/crates/ui-egui/src/panels/caption_export.rs b/crates/ui-egui/src/panels/caption_export.rs new file mode 100644 index 000000000..36273865d --- /dev/null +++ b/crates/ui-egui/src/panels/caption_export.rs @@ -0,0 +1,169 @@ +//! File ▸ Export ▸ Captions… and the Text panel's Export captions… button: the caption export +//! settings dialog (#3365). The File Format menu lists every format `captions.export` writes; OK +//! asks for the file name with only that format's filter and extension (the host's save panel) and +//! runs `captions.export` with it. A name typed with another caption format's extension is written +//! in that format, since that is the file the save dialog asked about; any other name gets the chosen +//! format's extension ([`destination`]). If a file with that new name already exists, nothing is +//! written: the save dialog only asked about replacing the name as typed (with WebVTT chosen, typing +//! `keep` when `keep.vtt` exists leaves `keep.vtt` alone). The format written last +//! (`caption_export_format` in the UI state) is offered the next time. With a `path` the command runs +//! directly (agents). +//! +//! Automation ids: `captionExport.format`, `captionExport.format.option.` (`srt`, `vtt`, `scc`, +//! `mcc`, `stl`, `ttml`, `dfxp`), `captionExport.ok`, `captionExport.cancel`. + +use crate::widgets::CenteredDialog as _; +use filmcraft_captions::Format; +use serde_json::{Map, Value, json}; + +use crate::FilmcraftApp; +use crate::native_dialogs::FileDialog; + +#[derive(Clone, Debug)] +struct Draft { + format: Format, + /// The command's other parameters (`track`, `dropFrame` …), passed on unchanged. + params: Map, +} + +fn draft_id() -> egui::Id { + egui::Id::unique("caption-export-draft") +} + +/// Open the dialog with `captions.export`'s parameters (no `path`). A `format` among them is +/// selected; otherwise the format chosen last, or SRT. +pub fn open(app: &FilmcraftApp, ctx: &egui::Context, params: &Value) -> Result { + let given = params.get("format").and_then(Value::as_str).and_then(Format::from_name); + let format = given.or_else(|| app.ui.caption_export_format.as_deref().and_then(Format::from_name)).unwrap_or(Format::Srt); + let params = params.as_object().cloned().unwrap_or_default(); + ctx.data_mut(|m| m.insert_temp(draft_id(), Some(Draft { format, params }))); + Ok(json!({"dialog": "captionExport"})) +} + +pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; + let mut close = false; + let mut apply = false; + let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); + let shown = tl!("Caption Export Settings"); + egui::Window::new(shown).id(egui::Id::unique("Caption Export Settings")).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { + ui.set_min_width(340.0); + egui::Grid::new("caption-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("File Format")); + let r = craft_ui::ComboBox::from_id_salt("caption-export-format").selected_text(d.format.name()).width(240.0).show_ui(ui, |ui| { + for f in Format::ALL { + let o = ui.selectable_value(&mut d.format, f, f.name()); + elems.push((format!("captionExport.format.option.{}", f.extension()), o.rect, f.name().into())); + } + }); + elems.push(("captionExport.format".into(), r.response.rect, d.format.name().into())); + ui.end_row(); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("captionExport.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + close = true; + } + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("captionExport.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + }); + }); + for (id, r, l) in elems { + app.auto.add(&id, r, &l); + } + if crate::widgets::escape_closes(ctx) { + close = true; + } + if apply { + close = true; + let ext = d.format.extension(); + app.ui.caption_export_format = Some(ext.to_string()); + let name = app.session.state.active_sequence.and_then(|s| app.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); + let stem = if name.trim().is_empty() { "Captions".to_string() } else { name.replace([' ', '/', '\\', ':'], "_") }; + let dialog = FileDialog::save_as(d.format.name(), &[ext], &format!("{stem}.{ext}")); + if dialog.available(&app.hooks) { + let chosen = d.format; + let mut p = d.params.clone(); + app.pick_ui(dialog, move |app, paths| { + let Some(answered) = paths.into_iter().next() else { return }; + let (path, format) = destination(&answered, chosen); + // the save dialog asked before replacing what was typed, not the name with the + // extension added: `keep` typed when `keep.vtt` exists must not overwrite keep.vtt + if path != answered { + match app.session.services.file_size(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => { + let name = std::path::Path::new(&path).file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); + app.ui.status = tlf!("{name} already exists, so nothing was exported. To replace it, choose it in the save dialog.", name); + return; + } + Err(e) => { + app.ui.status = format!("Cannot check export destination: {e}"); + return; + } + } + } + app.ui.caption_export_format = Some(format.extension().to_string()); + p.insert("format".into(), json!(format.extension())); + p.insert("path".into(), json!(path)); + match app.session.execute("captions.export", Value::Object(p)) { + Ok(v) => { + let path = v["path"].as_str().unwrap_or_default(); + app.ui.status = if format == chosen { + tlf!("Exported {path}", path) + } else { + tlf!("Exported {path} as {format}, from its file extension", path, format = format.name()) + }; + } + Err(e) => app.ui.status = e.to_string(), + } + }); + } else { + app.ui.status = "no save dialog available: run the command with a `path`".into(); + } + } + ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) })); +} + +/// Where to write, and in which format, from the save dialog's answer and the chosen format. The +/// GTK file chooser behind the Linux portal returns a name exactly as typed, having asked before +/// replacing that name only, and Windows can keep an extension the user typed, so: +/// - a caption format's extension, in any case (`subs.srt`, `SUBS.VTT`), is the user's choice: that +/// file, in that format; +/// - any other name gets the chosen format's extension: `subs` and `subs.` give `subs.vtt`, and +/// `subs.final` gives `subs.final.vtt`. +fn destination(path: &str, chosen: Format) -> (String, Format) { + let typed = std::path::Path::new(path).extension().and_then(|e| e.to_str()); + match typed.and_then(|e| Format::ALL.into_iter().find(|f| f.extension().eq_ignore_ascii_case(e))) { + Some(format) => (path.to_string(), format), + None => (format!("{}.{}", path.trim_end_matches('.'), chosen.extension()), chosen), + } +} + +#[cfg(test)] +mod tests { + use super::destination; + use filmcraft_captions::Format; + + #[test] + fn a_saved_name_gives_the_file_and_its_format() { + for (typed, saved, format) in [ + ("subs", "subs.vtt", Format::WebVtt), + ("subs.", "subs.vtt", Format::WebVtt), + ("subs.vtt", "subs.vtt", Format::WebVtt), + ("SUBS.VTT", "SUBS.VTT", Format::WebVtt), + ("subs.srt", "subs.srt", Format::Srt), + ("Subs.SCC", "Subs.SCC", Format::Scc), + ("subs.final", "subs.final.vtt", Format::WebVtt), + ("/home/a.b/subs", "/home/a.b/subs.vtt", Format::WebVtt), + ("/home/a.b/Main Edit.ttml", "/home/a.b/Main Edit.ttml", Format::Ttml), + ] { + assert_eq!(destination(typed, Format::WebVtt), (saved.to_string(), format), "{typed}"); + } + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 4ea69e5c5..d607cccc7 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -20,6 +20,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { crate::panels::remix::show(app, ctx); crate::links::show_failed(app, ctx); crate::panels::interchange_export::show(app, ctx); + crate::panels::caption_export::show(app, ctx); crate::panels::frame_export::show(app, ctx); crate::panels::project_dialogs::show(app, ctx); crate::panels::media_browser::dialogs(app, ctx); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e2aad6dcd..66cd98638 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -531,6 +531,10 @@ pub struct UiState { /// Delete Tracks dialog draft. #[serde(default)] pub delete_tracks: DeleteTracksDraft, + /// File ▸ Export ▸ Captions…: the file format chosen last (its extension, e.g. `vtt`), offered + /// first the next time. + #[serde(default)] + pub caption_export_format: Option, /// Sequence Settings dialog draft. #[serde(default)] pub sequence_settings: SequenceSettingsDraft, @@ -1010,6 +1014,7 @@ impl Default for UiState { audio_gain: AudioGainDraft::default(), add_tracks: AddTracksDraft::default(), delete_tracks: DeleteTracksDraft::default(), + caption_export_format: None, sequence_settings: SequenceSettingsDraft::default(), transition_duration: TransitionDurationDraft::default(), gfx_edit: None, diff --git a/crates/ui-egui/tests/it/caption_export_ui.rs b/crates/ui-egui/tests/it/caption_export_ui.rs new file mode 100644 index 000000000..e5562b0d0 --- /dev/null +++ b/crates/ui-egui/tests/it/caption_export_ui.rs @@ -0,0 +1,258 @@ +//! Headless UI tests of File ▸ Export ▸ Captions… (#3365): the settings dialog picks the format, +//! the host's save panel is asked for only that format's filter, extension and name, and +//! `captions.export` writes it. Driven by automation id through the control channel. With +//! `FILMCRAFT_UI_SHOTS=` the test also renders the UI with wgpu and writes PNGs there. + +use std::sync::mpsc::{Sender, channel}; +use std::sync::{Arc, Mutex}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +/// What the save panel was asked for: filter label, extensions, suggested name. +type Asked = Arc, String)>>>; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + asked: Asked, + /// The name the user types in the save panel next (else the suggested one is kept). + typed: Arc>>, + shots: Option, +} + +impl Driver { + fn new(session: Session, save_to: std::path::PathBuf) -> Self { + let (tx, rx) = channel(); + let asked = Asked::default(); + let log = asked.clone(); + let typed = Arc::>>::default(); + let answer = typed.clone(); + let mut app = FilmcraftApp::new(session).with_control(rx); + app.hooks.pick_save_as = Some(Box::new(move |filter, exts, suggested| { + log.lock().unwrap().push((filter.to_string(), exts.iter().map(|e| e.to_string()).collect(), suggested.to_string())); + let name = answer.lock().unwrap().take().unwrap_or_else(|| suggested.to_string()); + Some(save_to.join(name).to_string_lossy().into_owned()) + })); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, asked, typed, shots }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } + + fn elements(&mut self, prefix: &str) -> Vec<(String, String)> { + let v = self.ok("ui.elements", json!({"prefix": prefix})); + v.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap().to_string(), e["label"].as_str().unwrap_or_default().to_string())).collect() + } + + fn ids(&mut self, prefix: &str) -> Vec { + self.elements(prefix).into_iter().map(|(id, _)| id).collect() + } + + /// The format the dialog's File Format menu shows. + fn shown_format(&mut self) -> String { + let els = self.elements("captionExport.format"); + els.into_iter().find(|(id, _)| id == "captionExport.format").map(|(_, label)| label).expect("the File Format menu") + } + + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + self.frames(4); + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("caption-export-{name}.png"))).unwrap(); + } + + /// Export WebVTT through the dialog, typing `name` in the save panel. + fn export_vtt_as(&mut self, name: &str) { + *self.typed.lock().unwrap() = Some(name.to_string()); + self.ok("ui.menu.invoke", json!({"id": "captions.export", "params": {"format": "vtt"}})); + self.frames(3); + self.ok("ui.click", json!({"id": "captionExport.ok"})); + self.frames(4); + } + + fn status(&mut self) -> String { + self.ok("ui.inspect", json!({}))["ui"]["status"].as_str().unwrap_or_default().to_string() + } + + fn asked(&self) -> Vec<(String, Vec, String)> { + self.asked.lock().unwrap().clone() + } +} + +/// A sequence "Main Edit" with one caption at one second. +fn session() -> Session { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name": "Main Edit", "audio": 1, "video": 1})).unwrap(); + s.execute("captions.add", json!({"text": "Hello captions", "seconds": 1.0})).unwrap(); + s +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("fc-capx-ui-{name}-{}", std::process::id())); + // start empty: files a failed run left under a reused process id would change what the tests see + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn caption_export_dialog_chooses_the_format() { + let dir = tmp("format"); + let mut d = Driver::new(session(), dir.clone()); + let items = d.ok("ui.menu.list", json!({})); + let item = items.as_array().unwrap().iter().find(|i| i["id"] == "captions.export").expect("captions.export in the menus"); + assert_eq!(item["path"], json!(["File", "Export"])); + + // the menu opens the settings, not the save panel + let r = d.ok("ui.menu.invoke", json!({"id": "captions.export"})); + assert_eq!(r["dialog"], "captionExport"); + d.frames(3); + assert!(d.asked().is_empty(), "no save panel before OK"); + let ids = d.ids("captionExport."); + for id in ["format", "ok", "cancel"] { + assert!(ids.contains(&format!("captionExport.{id}")), "captionExport.{id} missing: {ids:?}"); + } + assert_eq!(d.shown_format(), "SubRip (SRT)"); + d.shot("dialog"); + + // every format captions.export writes is offered + d.ok("ui.click", json!({"id": "captionExport.format"})); + d.frames(3); + let options = d.ids("captionExport.format.option."); + d.shot("formats"); + for ext in ["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"] { + assert!(options.contains(&format!("captionExport.format.option.{ext}")), "{ext} missing: {options:?}"); + } + + // WebVTT: the save panel offers only .vtt and suggests the sequence's name with it + d.ok("ui.click", json!({"id": "captionExport.format.option.vtt"})); + d.frames(2); + assert_eq!(d.shown_format(), "WebVTT"); + d.ok("ui.click", json!({"id": "captionExport.ok"})); + d.frames(4); + assert!(d.ids("captionExport.").is_empty(), "the dialog closes"); + assert_eq!(d.asked(), [("WebVTT".to_string(), vec!["vtt".to_string()], "Main_Edit.vtt".to_string())]); + let vtt = std::fs::read_to_string(dir.join("Main_Edit.vtt")).expect("Main_Edit.vtt written"); + assert!(vtt.starts_with("WEBVTT") && vtt.contains("Hello captions"), "{vtt}"); + + // the choice is remembered, in the UI state agents read; Cancel asks for no file + assert_eq!(d.ok("ui.inspect", json!({}))["ui"]["caption_export_format"], "vtt"); + d.ok("ui.menu.invoke", json!({"id": "captions.export"})); + d.frames(3); + assert_eq!(d.shown_format(), "WebVTT"); + d.ok("ui.click", json!({"id": "captionExport.cancel"})); + d.frames(3); + assert!(d.ids("captionExport.").is_empty(), "Cancel closes the dialog"); + assert_eq!(d.asked().len(), 1, "Cancel opens no save panel"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn caption_export_keeps_the_command_parameters() { + let dir = tmp("params"); + let mut d = Driver::new(session(), dir.clone()); + + // a `format` is preselected; the other parameters (here non-drop-frame timecode) reach the export + d.ok("ui.menu.invoke", json!({"id": "captions.export", "params": {"format": "scc", "dropFrame": false}})); + d.frames(3); + assert_eq!(d.shown_format(), "Scenarist SCC (CEA-608)"); + d.ok("ui.click", json!({"id": "captionExport.ok"})); + d.frames(4); + assert_eq!(d.asked().last().map(|a| a.2.clone()), Some("Main_Edit.scc".to_string())); + let scc = std::fs::read_to_string(dir.join("Main_Edit.scc")).expect("Main_Edit.scc written"); + assert!(scc.starts_with("Scenarist_SCC") && !scc.contains(';'), "non-drop-frame timecode: {scc}"); + + // with a path the command exports directly (agents); drop-frame stays the default + let p = dir.join("direct.scc").to_string_lossy().into_owned(); + let v = d.ok("ui.menu.invoke", json!({"id": "captions.export", "params": {"path": p}})); + assert!(v.get("dialog").is_none(), "{v}"); + assert!(std::fs::read_to_string(&p).expect("direct.scc written").contains(';'), "drop-frame timecode"); + assert_eq!(d.asked().len(), 1, "no save panel for a path"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn caption_export_survives_parameters_that_are_not_an_object() { + let dir = tmp("hostile"); + let mut d = Driver::new(session(), dir.clone()); + for (i, params) in [json!("oops"), json!([1, 2]), json!(7)].into_iter().enumerate() { + let name = format!("hostile-{i}.srt"); + *d.typed.lock().unwrap() = Some(name.clone()); + d.ok("ui.menu.invoke", json!({"id": "captions.export", "params": params})); + d.frames(3); + d.ok("ui.click", json!({"id": "captionExport.ok"})); + d.frames(4); + assert!(dir.join(&name).exists(), "export {i} did not write {name}"); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn caption_export_writes_the_file_the_save_panel_named_and_never_replaces_one_unasked() { + let dir = tmp("names"); + let mut d = Driver::new(session(), dir.clone()); + let read = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap_or_default(); + + // typed without the extension: it is added + d.export_vtt_as("subs"); + assert!(read("subs.vtt").starts_with("WEBVTT"), "subs.vtt written as WebVTT"); + assert!(!dir.join("subs").exists()); + + // typed with another caption format's extension: the file the save panel asked about, in that + // format, offered first the next time + d.export_vtt_as("subs.srt"); + let srt = read("subs.srt"); + assert!(srt.starts_with("1\n") && srt.contains(" --> ") && !srt.contains("WEBVTT"), "subs.srt written as SubRip: {srt}"); + assert!(!dir.join("subs.srt.vtt").exists()); + assert!(d.status().contains("as SubRip (SRT)"), "{}", d.status()); + assert_eq!(d.ok("ui.inspect", json!({}))["ui"]["caption_export_format"], "srt"); + + // the extension would name a file the save panel never asked about: it is left alone + std::fs::write(dir.join("keep.vtt"), "precious").unwrap(); + d.export_vtt_as("keep"); + assert_eq!(read("keep.vtt"), "precious", "not overwritten"); + assert!(d.status().contains("keep.vtt already exists"), "{}", d.status()); + + // chosen in the save panel by its full name (the panel asked): it is replaced + d.export_vtt_as("keep.vtt"); + assert!(read("keep.vtt").starts_with("WEBVTT"), "replaced"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 31a8ec902..683117db7 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,6 +1,7 @@ mod audio51_ui; mod audio_fx_editor_ui; mod caption_copy_paste_ui; +mod caption_export_ui; mod caption_list_follow_ui; mod caption_option_drag_ui; mod caption_properties_ui; diff --git a/docs/captions.md b/docs/captions.md index e4212fe7d..e708bf74b 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -27,8 +27,17 @@ version 2; version 1 projects open with no caption tracks). `File ▸ Import` (or dropping a file) recognises caption files by extension and content and adds a new caption track to the open sequence (a sequence is created when none is open). Cue times are -snapped to the sequence's frames. `File ▸ Export ▸ Captions…` writes the chosen caption track in -the format given by the file extension (`.srt`, `.vtt`, `.scc`). +snapped to the sequence's frames. `File ▸ Export ▸ Captions…` (and **Export captions…** in the Text +panel) opens Caption Export Settings: pick the File Format (SRT, WebVTT, SCC, MCC, EBU STL, TTML or +DFXP; the last choice is offered next time), then the file name in a save panel that offers only +that format. A name typed with another caption format's extension (`subs.srt` with WebVTT chosen) +is written in that format, since that is the file the save panel asked about; any other name gets +the chosen format's extension (`subs` becomes `subs.vtt`). If a file with that new name already +exists, nothing is exported, because the save panel only asked about replacing the name as typed. +For example, with WebVTT chosen, typing `keep` when `keep.vtt` already exists leaves `keep.vtt` +untouched and the status line says so; choosing `keep.vtt` itself in the save panel, which then asks +before replacing it, replaces it. Given a `path` (agents, the CLI), `captions.export` takes the +format from `format` or from the file extension. Details of each format and the SCC encoder are in [`crates/captions/README.md`](../crates/captions/README.md).