From 450c03662abde1b1e381d790f8cfb1b3d8f5b01f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 16:21:38 +0000 Subject: [PATCH] Add SRT, VTT, and JSON transcript export Serialize the word-level transcript to caption formats with speaker labels. SRT/VTT omit cuts and remap times onto the edited timeline; JSON keeps the full word list for round-trip re-import. Export is available from the transcript toolbar and the media export dialog. Co-authored-by: Wassim Gharbi --- README.md | 1 + components/ExportDialog.tsx | 53 ++++++- components/TranscriptPanel.tsx | 79 +++++++++- lib/serializeTranscript.ts | 211 +++++++++++++++++++++++++++ scripts/serialize-transcript-test.ts | 146 ++++++++++++++++++ 5 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 lib/serializeTranscript.ts create mode 100644 scripts/serialize-transcript-test.ts diff --git a/README.md b/README.md index 5d579fd..e0a8052 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ the media. Export the final cut โ€” without your file ever leaving your device. - ๐Ÿ”’ **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, word labels, cut regions, playhead, zoom diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 6eb383a..725c435 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); @@ -28,9 +38,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; @@ -52,6 +63,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; return ( @@ -143,6 +166,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 bfc790e..701ef4e 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -1,7 +1,17 @@ "use client"; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Eye, EyeOff, FileText, Pencil, RotateCcw, Scissors, WandSparkles, X } from "lucide-react"; +import { + Download, + Eye, + EyeOff, + FileText, + Pencil, + RotateCcw, + Scissors, + WandSparkles, + X, +} from "lucide-react"; import { useEditorStore } from "@/lib/store"; import { findFillerWordIds } from "@/lib/fillers"; import { @@ -9,6 +19,10 @@ import { parseTranscriptFile, TRANSCRIPT_ACCEPT, } from "@/lib/parseTranscript"; +import { + downloadTranscript, + type TranscriptFormat, +} from "@/lib/serializeTranscript"; import type { SpeakerTurn, Word } from "@/lib/types"; export const SPEAKER_COLORS = [ @@ -89,11 +103,15 @@ export default function TranscriptPanel() { const correctWords = useEditorStore((s) => s.correctWords); const importWords = useEditorStore((s) => s.importWords); 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 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[]; @@ -148,6 +166,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 seekToWord = useCallback((word: Word) => { const { videoEl, setCurrentTime } = useEditorStore.getState(); if (videoEl) videoEl.currentTime = word.start + 0.001; @@ -352,6 +398,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 }) => ( + + ))} +
+ )} +
+ )}