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 }) => (
+
+ ))}
+
+ )}
+
+ )}