diff --git a/README.md b/README.md index a16563f..6f12e7c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ builds auto-update from GitHub Releases. Prefer the browser? Use the - ๐Ÿ”’ **Private by design** โ€” no server, no auth, no uploads; all media processing happens on-device - ๐Ÿ“ **Word-level editing** โ€” select words, press โŒซ, the cut follows the text - ๐Ÿ“ฅ **Import your own transcript** โ€” skip Whisper and edit with an SRT, VTT, or JSON caption file +- ๐Ÿ“ค **Export the transcript** โ€” download SRT, WebVTT, or JSON (captions follow your cuts) - ๐Ÿงน **Filler removal** โ€” one-click cut of "um", "uh", and similar fillers - ๐Ÿ—ฃ๏ธ **Speaker diarization** โ€” the transcript is grouped by speaker - ๐ŸŽฌ **Timeline** โ€” waveform, wordbar with draggable timing handles, Split, diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 525a421..4b84c59 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -5,6 +5,16 @@ import { Download, X } from "lucide-react"; import { useEditorStore } from "@/lib/store"; import { formatTime, getCutRanges, getEditedDuration, getKeepRanges } from "@/lib/edits"; import { exportAudio, exportVideo } from "@/lib/ffmpeg"; +import { + downloadTranscript, + type TranscriptFormat, +} from "@/lib/serializeTranscript"; + +const TRANSCRIPT_FORMATS: { format: TranscriptFormat; label: string }[] = [ + { format: "srt", label: "SRT" }, + { format: "vtt", label: "VTT" }, + { format: "json", label: "JSON" }, +]; export default function ExportDialog() { const open = useEditorStore((s) => s.exportOpen); @@ -32,9 +42,10 @@ export default function ExportDialog() { const ext = isAudio ? "m4a" : "mp4"; const formatLabel = isAudio ? "M4A" : "MP4"; - const fileName = videoFile - ? videoFile.name.replace(/\.[^.]+$/, "") + `.edited.${ext}` - : `edited.${ext}`; + const baseName = videoFile + ? videoFile.name.replace(/\.[^.]+$/, "") + : "edited"; + const fileName = `${baseName}.edited.${ext}`; const start = useCallback(async () => { if (!videoFile) return; @@ -56,6 +67,18 @@ export default function ExportDialog() { } }, [videoFile, isAudio, cuts, duration, editedDuration, setStatus, setExportUrl]); + const exportCaption = useCallback( + (format: TranscriptFormat) => { + if (words.length === 0) return; + try { + downloadTranscript(words, format, baseName, { duration }); + } catch (err) { + setError(err instanceof Error ? err.message : "Transcript export failed."); + } + }, + [words, baseName, duration] + ); + if (!open) return null; // app-no-drag: the backdrop covers the draggable top bar, so it needs to take @@ -149,6 +172,30 @@ export default function ExportDialog() { Export {formatLabel} )} + + {words.length > 0 && !exporting && ( +
+

+ Transcript +

+

+ SRT and VTT use the edited timeline (cuts applied). JSON keeps the + full word list for re-import. +

+
+ {TRANSCRIPT_FORMATS.map(({ format, label }) => ( + + ))} +
+
+ )} ); diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index 9535946..57a6b5f 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Download, Eye, EyeOff, FileText, @@ -19,6 +20,10 @@ import { parseTranscriptFile, TRANSCRIPT_ACCEPT, } from "@/lib/parseTranscript"; +import { + downloadTranscript, + type TranscriptFormat, +} from "@/lib/serializeTranscript"; import type { SpeakerTurn, Word } from "@/lib/types"; import { getActiveSceneBoundaries, @@ -145,6 +150,8 @@ export default function TranscriptPanel() { const selectedWordIds = useEditorStore((s) => s.selectedWordIds); const setSelectedWords = useEditorStore((s) => s.setSelectedWords); const playing = useEditorStore((s) => s.playing); + const videoFile = useEditorStore((s) => s.videoFile); + const duration = useEditorStore((s) => s.duration); const activeWordId = useEditorStore((s) => findActiveWordId(s.words, s.currentTime)); const cuts = useMemo( @@ -173,6 +180,8 @@ export default function TranscriptPanel() { const containerRef = useRef(null); const scrollRef = useRef(null); const importInputRef = useRef(null); + const exportMenuRef = useRef(null); + const [exportMenuOpen, setExportMenuOpen] = useState(false); const [selection, setSelection] = useState(null); const [correcting, setCorrecting] = useState<{ ids: number[]; @@ -227,6 +236,34 @@ export default function TranscriptPanel() { [words.length, importWords] ); + const handleExportTranscript = useCallback( + (format: TranscriptFormat) => { + if (words.length === 0) return; + const base = videoFile + ? videoFile.name.replace(/\.[^.]+$/, "") + : "transcript"; + try { + downloadTranscript(words, format, base, { duration }); + setExportMenuOpen(false); + } catch (err) { + console.error(err); + alert(err instanceof Error ? err.message : "Could not export transcript."); + } + }, + [words, videoFile, duration] + ); + + useEffect(() => { + if (!exportMenuOpen) return; + const handler = (e: MouseEvent) => { + if (!exportMenuRef.current?.contains(e.target as Node)) { + setExportMenuOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [exportMenuOpen]); + const joinSplit = useCallback( (id: number) => { removeSceneBoundary(id); @@ -545,6 +582,37 @@ export default function TranscriptPanel() { /> )} + {status === "ready" && words.length > 0 && ( +
+ + {exportMenuOpen && ( +
+ {( + [ + { format: "srt", label: "SRT" }, + { format: "vtt", label: "WebVTT" }, + { format: "json", label: "JSON" }, + ] as const + ).map(({ format, label }) => ( + + ))} +
+ )} +
+ )}