diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 075895b..74c0201 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,7 +9,7 @@ //! no serde derives), so the editing entry point takes a local serde-friendly //! [`EditRequest`] that maps 1:1 onto the variants the front end issues in v1. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager, State}; use opentake_core::dto::{ @@ -96,6 +96,66 @@ pub fn export_fcpxml(core: State<'_, AppCore>, path: String) -> Result<(), Strin std::fs::write(&path, xml).map_err(|e| e.to_string()) } +/// Requested subtitle container, projected from the front end. Lower-cased serde +/// tags (`"srt"` / `"vtt"`) match the file extension the user picks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SubtitleFormat { + /// SubRip (`.srt`) — `HH:MM:SS,mmm` timestamps, numbered cues. + #[default] + Srt, + /// WebVTT (`.vtt`) — `HH:MM:SS.mmm` timestamps, `WEBVTT` header. + Vtt, +} + +/// Summary of a completed subtitle export, returned to the front end. `cueCount` +/// lets the UI distinguish "wrote N cues" from "timeline has no captions" (in +/// which case it shows a friendly toast); the file is still written either way — +/// an empty SRT / header-only VTT is the documented contract of the pure layer. +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SubtitleExportSummary { + /// Absolute path the subtitle file was written to. + pub out_path: String, + /// Number of caption cues emitted. + pub cue_count: usize, +} + +/// `export_subtitles`: write the current timeline's caption clips to `path` as a +/// SubRip (`.srt`) or WebVTT (`.vtt`) document. Caption cues are collected from +/// every track via the pure `opentake_domain::subtitle_export` layer (any clip +/// carrying a `caption_group_id` + non-empty `text_content`), serialized, and +/// written to disk. Returns the cue count so the UI can report an empty result. +#[tauri::command] +pub fn export_subtitles( + core: State<'_, AppCore>, + path: String, + format: SubtitleFormat, +) -> Result { + let timeline = core.get_timeline().timeline; + write_subtitles(&timeline, path, format) +} + +/// The subtitle export body, decoupled from Tauri/`AppCore` so it can be driven +/// by a unit test with a hand-built timeline + temp path. The command wrapper +/// only snapshots the live session and delegates here. +fn write_subtitles( + timeline: &opentake_domain::Timeline, + path: String, + format: SubtitleFormat, +) -> Result { + let cue_count = opentake_domain::collect_caption_cues(timeline).len(); + let body = match format { + SubtitleFormat::Srt => opentake_domain::export_srt(timeline), + SubtitleFormat::Vtt => opentake_domain::export_vtt(timeline), + }; + std::fs::write(&path, body).map_err(|e| e.to_string())?; + Ok(SubtitleExportSummary { + out_path: path, + cue_count, + }) +} + /// `can_undo` / `can_redo`: enable/disable the toolbar affordances. #[tauri::command] pub fn can_undo(core: State<'_, AppCore>) -> bool { @@ -881,3 +941,131 @@ mod edit_request_serde_tests { )); } } + +#[cfg(test)] +mod subtitle_export_tests { + use super::{write_subtitles, SubtitleFormat}; + use opentake_domain::{Clip, ClipType, Timeline, Track}; + + /// Build a caption clip: text + caption_group_id set, media_type Text — the + /// two fields `collect_caption_cues` requires to treat a clip as a caption. + fn caption(id: &str, group: &str, start: i32, dur: i32, text: &str) -> Clip { + let mut c = Clip::new(id, "caption", start, dur); + c.media_type = ClipType::Text; + c.caption_group_id = Some(group.to_string()); + c.text_content = Some(text.to_string()); + c + } + + /// A timeline with a single caption track holding `clips`, at the given fps. + fn timeline_with(fps: i32, clips: Vec) -> Timeline { + let mut tl = Timeline::new(); + tl.fps = fps; + let mut t = Track::new("t-cap", ClipType::Text); + t.clips = clips; + tl.tracks.push(t); + tl + } + + /// `SubtitleFormat` must deserialize from the lower-case tags the front end + /// sends (matching the file extension) and default to SRT for bare payloads. + #[test] + fn subtitle_format_deserializes_lowercase_tags() { + assert_eq!( + serde_json::from_str::(r#""srt""#).expect("srt"), + SubtitleFormat::Srt + ); + assert_eq!( + serde_json::from_str::(r#""vtt""#).expect("vtt"), + SubtitleFormat::Vtt + ); + assert_eq!(SubtitleFormat::default(), SubtitleFormat::Srt); + } + + /// The summary returned to the front end must serialize as camelCase + /// (`outPath` / `cueCount`) so the TS mirror lines up. + #[test] + fn summary_serializes_camel_case() { + let summary = super::SubtitleExportSummary { + out_path: "/tmp/x.srt".into(), + cue_count: 2, + }; + let json = serde_json::to_string(&summary).expect("serialize"); + assert!(json.contains("\"outPath\""), "got: {json}"); + assert!(json.contains("\"cueCount\":2"), "got: {json}"); + } + + /// A timeline carrying caption clips exports a non-empty SRT body with one + /// numbered cue per caption, and reports the cue count. + #[test] + fn exports_non_empty_srt_with_cue_count() { + let dir = std::env::temp_dir(); + let path = dir + .join(format!("opentake-subs-{}.srt", std::process::id())) + .to_string_lossy() + .into_owned(); + let tl = timeline_with( + 30, + vec![ + caption("c1", "g1", 30, 30, "Hello"), + caption("c2", "g1", 60, 30, "World"), + ], + ); + + let summary = + write_subtitles(&tl, path.clone(), SubtitleFormat::Srt).expect("srt export ok"); + assert_eq!(summary.cue_count, 2); + assert_eq!(summary.out_path, path); + + let written = std::fs::read_to_string(&path).expect("read back srt"); + let _ = std::fs::remove_file(&path); + assert!(written.contains("Hello")); + assert!(written.contains("World")); + // SRT uses comma timestamps and 1-based indices. + assert!(written.starts_with("1\n"), "got: {written:?}"); + assert!( + written.contains("00:00:01,000 --> 00:00:02,000"), + "got: {written:?}" + ); + } + + /// VTT export always opens with the `WEBVTT` header and uses dot timestamps. + #[test] + fn exports_vtt_with_header() { + let dir = std::env::temp_dir(); + let path = dir + .join(format!("opentake-subs-{}.vtt", std::process::id())) + .to_string_lossy() + .into_owned(); + let tl = timeline_with(30, vec![caption("c1", "g1", 30, 30, "Hello")]); + + let summary = + write_subtitles(&tl, path.clone(), SubtitleFormat::Vtt).expect("vtt export ok"); + assert_eq!(summary.cue_count, 1); + + let written = std::fs::read_to_string(&path).expect("read back vtt"); + let _ = std::fs::remove_file(&path); + assert!(written.starts_with("WEBVTT\n\n"), "got: {written:?}"); + assert!( + written.contains("00:00:01.000 --> 00:00:02.000"), + "got: {written:?}" + ); + } + + /// A timeline with no caption clips writes a (header-only / empty) file and + /// reports `cue_count == 0`, the signal the UI uses for its friendly toast. + #[test] + fn empty_timeline_reports_zero_cues() { + let dir = std::env::temp_dir(); + let path = dir + .join(format!("opentake-subs-empty-{}.srt", std::process::id())) + .to_string_lossy() + .into_owned(); + let tl = Timeline::new(); + + let summary = + write_subtitles(&tl, path.clone(), SubtitleFormat::Srt).expect("empty export ok"); + let _ = std::fs::remove_file(&path); + assert_eq!(summary.cue_count, 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f81dfa..3aa0164 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -138,6 +138,7 @@ pub fn run() { commands::project_save, commands::get_default_project_dir, commands::export_fcpxml, + commands::export_subtitles, commands::check_path_exists, media::import_folder, media::import_media, diff --git a/web/src/components/shell/TitleBar.tsx b/web/src/components/shell/TitleBar.tsx index dc42b52..fe8c335 100644 --- a/web/src/components/shell/TitleBar.tsx +++ b/web/src/components/shell/TitleBar.tsx @@ -8,13 +8,15 @@ * menu, the in-app menu entry point for an environment without a native menu bar. */ -import { Upload, Home, Settings as SettingsIcon, Library, Film } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Upload, Home, Settings as SettingsIcon, Library, Film, Captions } from "lucide-react"; import { Icon } from "../ui/Icon"; import { ViewMenu } from "./ViewMenu"; import { useEditorUiStore } from "../../store/uiStore"; import { useProjectStore } from "../../store/projectStore"; import { useT } from "../../i18n"; import * as api from "../../lib/api"; +import type { SubtitleFormat } from "../../lib/api"; import { saveDialog } from "../../lib/dialog"; const XML_EXT = "xml"; @@ -36,14 +38,48 @@ function defaultXmlName(projectPath: string | null): string { return `${stem || "Timeline"}.${XML_EXT}`; } +/** The open project's base name (without the `.opentake` suffix), or "Timeline". */ +function projectStem(projectPath: string | null): string { + if (!projectPath) return "Timeline"; + const base = projectPath.split(/[\\/]/).pop() ?? projectPath; + return base.replace(/\.opentake$/i, "") || "Timeline"; +} + +/** Ensure a chosen path carries the given subtitle extension (`srt` / `vtt`). */ +function withSubtitleExt(path: string, ext: string): string { + return path.toLowerCase().endsWith(`.${ext}`) ? path : `${path}.${ext}`; +} + export function TitleBar() { const setView = useEditorUiStore((s) => s.setView); const setSettingsOpen = useEditorUiStore((s) => s.setSettingsOpen); const setExportDialogOpen = useEditorUiStore((s) => s.setExportDialogOpen); + const pushToast = useEditorUiStore((s) => s.pushToast); const projectPath = useProjectStore((s) => s.projectPath); const tracks = useProjectStore((s) => s.timeline.tracks); const t = useT(); + // Subtitle-format popover (SRT / VTT). Dismiss on outside click / Escape. + const [subMenuOpen, setSubMenuOpen] = useState(false); + const subMenuRef = useRef(null); + useEffect(() => { + if (!subMenuOpen) return; + const onDown = (e: MouseEvent) => { + if (subMenuRef.current && !subMenuRef.current.contains(e.target as Node)) { + setSubMenuOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setSubMenuOpen(false); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [subMenuOpen]); + // Video export needs something to render: disable the entry when no track // holds a clip (an empty timeline would only encode black frames). const hasClips = tracks.some((track) => track.clips.length > 0); @@ -74,6 +110,48 @@ export function TitleBar() { await api.exportFcpxml(withXmlExt(chosen)); } + /** + * Export the timeline's captions as SubRip (`.srt`) or WebVTT (`.vtt`). Same + * save-panel flow as the XML export, then `export_subtitles`. The backend + * returns the cue count: zero means the timeline carries no caption clips, so + * we surface a friendly "no subtitles" toast instead of a silent empty file. + */ + async function onExportSubtitles(format: SubtitleFormat): Promise { + setSubMenuOpen(false); + const save = await saveDialog(); + if (!save) return; // outside Tauri — no save panel / file system + const dir = projectPath + ? projectPath.replace(/[\\/][^\\/]*$/, "") + : await api.getDefaultProjectDir().catch(() => ""); + const sep = dir && !dir.endsWith("/") ? "/" : ""; + const defaultPath = dir + ? `${dir}${sep}${projectStem(projectPath)}.${format}` + : undefined; + + const chosen = await save({ + title: t(format === "srt" ? "title.exportSrtDialog" : "title.exportVttDialog"), + defaultPath, + filters: [ + { + name: t(format === "srt" ? "title.exportSrtFilter" : "title.exportVttFilter"), + extensions: [format], + }, + ], + }); + if (typeof chosen !== "string") return; // cancelled + + try { + const summary = await api.exportSubtitles(withSubtitleExt(chosen, format), format); + pushToast( + summary.cueCount > 0 + ? t("title.exportSubtitlesDone", { count: summary.cueCount }) + : t("title.exportSubtitlesEmpty"), + ); + } catch { + pushToast(t("title.exportSubtitlesFailed")); + } + } + return (
{t("title.exportVideo")} + + {/* Subtitle export (.srt / .vtt) with a small format popover. */} +
+ + {subMenuOpen && ( +
+ {(["srt", "vtt"] as const).map((fmt) => ( + + ))} +
+ )} +
+