diff --git a/README.md b/README.md index 7c0af25..cfe0c6d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,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 hub** β€” video (MP4/WebM, 720p–4K), audio (M4A/MP3/WAV), transcript (TXT/MD), or subtitles (SRT/VTT/JSON) - 🧹 **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, @@ -41,7 +42,7 @@ builds auto-update from GitHub Releases. Prefer the browser? Use the - πŸ”΄ **Cut edges** β€” drag either edge of a cut to trim independently of Whisper timestamps; double-click to reset - ⚑ **Live preview** β€” playback skips your cuts in real time -- πŸ“¦ **In-browser / desktop export** β€” frame-accurate MP4 (video) or M4A (audio) with ffmpeg.wasm +- πŸ“¦ **In-browser / desktop export** β€” frame-accurate re-encode with ffmpeg.wasm - 🎧 **Audio files** β€” edit podcasts, voice notes, and interviews the same way as video - πŸ–₯️ **Desktop app** β€” macOS, Windows, and Linux via Electron (signed + notarized on Mac) diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 8d3a09e..a78d440 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -1,50 +1,191 @@ "use client"; import { useCallback, useMemo, useState } from "react"; -import { Download, X } from "lucide-react"; +import { + Captions, + Download, + FileText, + Film, + Music, + X, +} from "lucide-react"; import { useEditorStore } from "@/lib/store"; import { formatTime, getEditedDuration, getKeepRanges } from "@/lib/edits"; -import { exportAudio, exportVideo } from "@/lib/ffmpeg"; +import { + exportAudio, + exportVideo, + type AudioExportFormat, + type VideoExportFormat, + type VideoExportResolution, +} from "@/lib/ffmpeg"; +import { + downloadTranscript, + type SubtitleFormat, + type TranscriptDocFormat, +} from "@/lib/serializeTranscript"; import { useCutRanges } from "@/hooks/useCutRanges"; +type ExportTab = "video" | "audio" | "transcript" | "subtitles"; + +const VIDEO_FORMATS: { value: VideoExportFormat; label: string }[] = [ + { value: "mp4", label: "MP4" }, + { value: "webm", label: "WebM" }, +]; + +const VIDEO_RESOLUTIONS: { value: VideoExportResolution; label: string }[] = [ + { value: "original", label: "Original" }, + { value: "720", label: "720p" }, + { value: "1080", label: "1080p" }, + { value: "2160", label: "4K" }, +]; + +const AUDIO_FORMATS: { value: AudioExportFormat; label: string }[] = [ + { value: "m4a", label: "M4A" }, + { value: "mp3", label: "MP3" }, + { value: "wav", label: "WAV" }, +]; + +const TRANSCRIPT_FORMATS: { value: TranscriptDocFormat; label: string }[] = [ + { value: "txt", label: "Plain text" }, + { value: "md", label: "Markdown" }, +]; + +const SUBTITLE_FORMATS: { value: SubtitleFormat; label: string }[] = [ + { value: "srt", label: "SRT" }, + { value: "vtt", label: "VTT" }, + { value: "json", label: "JSON" }, +]; + export default function ExportDialog() { const open = useEditorStore((s) => s.exportOpen); const setOpen = useEditorStore((s) => s.setExportOpen); const videoFile = useEditorStore((s) => s.videoFile); const mediaKind = useEditorStore((s) => s.mediaKind); const duration = useEditorStore((s) => s.duration); + const words = useEditorStore((s) => s.words); const hasAudioTrack = useEditorStore((s) => s.audio !== null); const status = useEditorStore((s) => s.status); const setStatus = useEditorStore((s) => s.setStatus); const exportUrl = useEditorStore((s) => s.exportUrl); const setExportUrl = useEditorStore((s) => s.setExportUrl); + const isAudioProject = mediaKind === "audio"; + const [tab, setTab] = useState("video"); + const [videoFormat, setVideoFormat] = useState("mp4"); + const [resolution, setResolution] = useState("original"); + const [audioFormat, setAudioFormat] = useState("m4a"); + const [transcriptFormat, setTranscriptFormat] = + useState("txt"); + const [subtitleFormat, setSubtitleFormat] = useState("srt"); const [progress, setProgress] = useState(0); const [error, setError] = useState(null); const cuts = useCutRanges(); - const editedDuration = useMemo(() => getEditedDuration(cuts, duration), [cuts, duration]); + const editedDuration = useMemo( + () => getEditedDuration(cuts, duration), + [cuts, duration] + ); const exporting = status === "exporting"; - const isAudio = mediaKind === "audio"; - const ext = isAudio ? "m4a" : "mp4"; - const formatLabel = isAudio ? "M4A" : "MP4"; + const hasWords = words.length > 0; + + // Fall back when the remembered tab isn't valid for this project. + const activeTab: ExportTab = + tab === "video" && isAudioProject + ? "audio" + : tab === "audio" && !hasAudioTrack + ? isAudioProject + ? "transcript" + : "video" + : (tab === "transcript" || tab === "subtitles") && !hasWords + ? isAudioProject + ? "audio" + : "video" + : tab; + + const baseName = videoFile + ? videoFile.name.replace(/\.[^.]+$/, "") + : "edited"; - const fileName = videoFile - ? videoFile.name.replace(/\.[^.]+$/, "") + `.edited.${ext}` - : `edited.${ext}`; + const mediaExt = + activeTab === "audio" + ? audioFormat + : videoFormat === "webm" + ? "webm" + : "mp4"; + const mediaFileName = `${baseName}.edited.${mediaExt}`; - const start = useCallback(async () => { + const clearMediaExport = useCallback(() => { + const prev = useEditorStore.getState().exportUrl; + if (prev) URL.revokeObjectURL(prev); + setExportUrl(null); + setProgress(0); + }, [setExportUrl]); + + const selectTab = useCallback( + (next: ExportTab) => { + setTab((prev) => { + if (prev === next) return prev; + clearMediaExport(); + setError(null); + return next; + }); + }, + [clearMediaExport] + ); + + const setVideoFormatOption = useCallback( + (value: VideoExportFormat) => { + setVideoFormat((prev) => { + if (prev === value) return prev; + clearMediaExport(); + return value; + }); + }, + [clearMediaExport] + ); + + const setResolutionOption = useCallback( + (value: VideoExportResolution) => { + setResolution((prev) => { + if (prev === value) return prev; + clearMediaExport(); + return value; + }); + }, + [clearMediaExport] + ); + + const setAudioFormatOption = useCallback( + (value: AudioExportFormat) => { + setAudioFormat((prev) => { + if (prev === value) return prev; + clearMediaExport(); + return value; + }); + }, + [clearMediaExport] + ); + + const startMediaExport = useCallback(async () => { if (!videoFile) return; + if (activeTab === "video" && isAudioProject) return; + if (activeTab === "audio" && !hasAudioTrack) return; + setError(null); setProgress(0); setStatus("exporting"); try { const keeps = getKeepRanges(cuts, duration); - const blob = isAudio - ? await exportAudio(videoFile, keeps, editedDuration, setProgress) - : await exportVideo(videoFile, keeps, editedDuration, setProgress, { - withAudio: hasAudioTrack, - }); + const blob = + activeTab === "audio" + ? await exportAudio(videoFile, keeps, editedDuration, setProgress, { + format: audioFormat, + }) + : await exportVideo(videoFile, keeps, editedDuration, setProgress, { + withAudio: hasAudioTrack, + format: videoFormat, + resolution, + }); const prev = useEditorStore.getState().exportUrl; if (prev) URL.revokeObjectURL(prev); setExportUrl(URL.createObjectURL(blob)); @@ -55,17 +196,85 @@ export default function ExportDialog() { } }, [ videoFile, - isAudio, + activeTab, + isAudioProject, hasAudioTrack, cuts, duration, editedDuration, + audioFormat, + videoFormat, + resolution, setStatus, setExportUrl, ]); + const exportText = useCallback( + (kind: "transcript" | "subtitles") => { + if (!hasWords) return; + const format = kind === "transcript" ? transcriptFormat : subtitleFormat; + try { + downloadTranscript(words, format, baseName, { + duration, + cuts, + }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Export failed."); + } + }, + [ + hasWords, + words, + transcriptFormat, + subtitleFormat, + baseName, + duration, + cuts, + ] + ); + if (!open) return null; + const tabs: { + id: ExportTab; + label: string; + icon: typeof Film; + disabled?: boolean; + title?: string; + }[] = [ + { + id: "video", + label: "Video", + icon: Film, + disabled: isAudioProject, + title: isAudioProject + ? "Video export isn’t available for audio-only projects" + : undefined, + }, + { + id: "audio", + label: "Audio", + icon: Music, + disabled: !hasAudioTrack, + title: !hasAudioTrack ? "This file has no audio track" : undefined, + }, + { + id: "transcript", + label: "Transcript", + icon: FileText, + disabled: !hasWords, + title: !hasWords ? "Transcribe or import a transcript first" : undefined, + }, + { + id: "subtitles", + label: "Subtitles", + icon: Captions, + disabled: !hasWords, + title: !hasWords ? "Transcribe or import a transcript first" : undefined, + }, + ]; + // app-no-drag: the backdrop covers the draggable top bar, so it needs to take // clicks (dismiss) rather than letting them move the window. return ( @@ -74,12 +283,12 @@ export default function ExportDialog() { onClick={() => !exporting && setOpen(false)} >
e.stopPropagation()} >

- Export {isAudio ? "audio" : "video"} + Export

-
-
-

Original

-

- {formatTime(duration)} -

+
+ {tabs.map(({ id, label, icon: Icon, disabled, title }) => { + const selected = activeTab === id; + return ( + + ); + })} +
+ + {(activeTab === "video" || activeTab === "audio") && ( +
+ + +
-
-

Cuts

-

- {cuts.length} + )} + + {activeTab === "video" && ( +

+ + +
+ )} + + {activeTab === "audio" && ( +
+ +
+ )} + + {activeTab === "transcript" && ( +
+ +

+ Speaker-labeled text with cuts removed. No timestamps.

-
-

Edited

-

- {formatTime(editedDuration)} + )} + + {activeTab === "subtitles" && ( +

+ +

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

-
+ )} {error && ( -

{error}

+

+ {error} +

)} - {exporting ? ( -
-
- Rendering in your browser… - {Math.round(progress * 100)}% + {(activeTab === "video" || activeTab === "audio") && + (exporting ? ( +
+
+ + Rendering in your browser… + + + {Math.round(progress * 100)}% + +
+
+
+
+

+ Re-encoding with ffmpeg.wasm β€” longer files take a while. +

-
-
+ ) : exportUrl ? ( +
+ + + Download {mediaFileName} + +
-

- Re-encoding with ffmpeg.wasm β€” longer files take a while. -

-
- ) : exportUrl ? ( -
- - - Download {fileName} - + ) : ( -
- ) : ( + ))} + + {activeTab === "transcript" && ( )} + + {activeTab === "subtitles" && ( + + )} +
+
+ ); +} + +function Stat({ + label, + value, + accent = false, +}: { + label: string; + value: string; + accent?: boolean; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +function OptionGroup({ + label, + value, + options, + disabled, + onChange, +}: { + label: string; + value: T; + options: { value: T; label: string }[]; + disabled?: boolean; + onChange: (value: T) => void; +}) { + return ( +
+

+ {label} +

+
+ {options.map((opt) => { + const selected = value === opt.value; + return ( + + ); + })}
); diff --git a/lib/ffmpeg.ts b/lib/ffmpeg.ts index 43f9c0a..9cc96c5 100644 --- a/lib/ffmpeg.ts +++ b/lib/ffmpeg.ts @@ -95,6 +95,44 @@ export async function extractAudio(file: File): Promise { return new Float32Array(buf as ArrayBuffer); } +/** Container / codec presets for video export. */ +export type VideoExportFormat = "mp4" | "webm"; + +/** Target output height. `"original"` keeps the source resolution. */ +export type VideoExportResolution = "original" | "720" | "1080" | "2160"; + +/** Container / codec presets for audio-only export. */ +export type AudioExportFormat = "m4a" | "mp3" | "wav"; + +export interface VideoExportOptions { + /** When false, render a silent video (source has no audio track). */ + withAudio?: boolean; + format?: VideoExportFormat; + resolution?: VideoExportResolution; +} + +export interface AudioExportOptions { + format?: AudioExportFormat; +} + +const VIDEO_HEIGHT: Record, number> = { + "720": 720, + "1080": 1080, + "2160": 2160, +}; + +/** + * Scale filter that fits inside the target height without upscaling, keeping + * even dimensions (required by libx264 / libvpx). + */ +function scaleFilter(resolution: VideoExportResolution): string | null { + if (resolution === "original") return null; + const h = VIDEO_HEIGHT[resolution]; + // Never upscale: cap height at source ih. force_original_aspect_ratio keeps + // width proportional; the second scale snaps to even sizes. + return `scale=-2:'min(ih,${h})',scale=trunc(iw/2)*2:trunc(ih/2)*2`; +} + /** * Render the edited video: keep only `keepRanges` of the original media and * concatenate them. Re-encodes so cuts land exactly on word boundaries @@ -106,14 +144,19 @@ export async function exportVideo( keepRanges: TimeRange[], editedDuration: number, onProgress: (ratio: number) => void, - { withAudio = true }: { withAudio?: boolean } = {} + { + withAudio = true, + format = "mp4", + resolution = "original", + }: VideoExportOptions = {} ): Promise { if (keepRanges.length === 0) { throw new Error("Everything has been deleted β€” nothing to export."); } const ffmpeg = await getFFmpeg(); const input = await ensureInput(ffmpeg, file); - const out = "output.mp4"; + const out = format === "webm" ? "output.webm" : "output.mp4"; + const scale = scaleFilter(resolution); const parts: string[] = []; const labels: string[] = []; @@ -127,11 +170,15 @@ export async function exportVideo( labels[labels.length - 1] += `[a${i}]`; } }); - const filter = + let filter = parts.join(";") + `;${labels.join("")}concat=n=${keepRanges.length}:v=1:a=${ withAudio ? 1 : 0 }[outv]${withAudio ? "[outa]" : ""}`; + const videoMap = scale ? "[vout]" : "[outv]"; + if (scale) { + filter += `;[outv]${scale}[vout]`; + } const progressHandler = ({ time }: { progress: number; time: number }) => { // `time` is the output timestamp in microseconds. @@ -140,23 +187,39 @@ export async function exportVideo( }; ffmpeg.on("progress", progressHandler); try { + const codecArgs = + format === "webm" + ? [ + "-c:v", "libvpx-vp9", + "-crf", "35", + "-b:v", "0", + "-row-mt", "1", + "-cpu-used", "8", + ...(withAudio ? ["-c:a", "libopus", "-b:a", "128k"] : []), + ] + : [ + "-c:v", "libx264", + "-preset", "ultrafast", + "-crf", "22", + ...(withAudio ? ["-c:a", "aac", "-b:a", "192k"] : []), + "-movflags", "+faststart", + ]; + const code = await ffmpeg.exec([ "-i", input, "-filter_complex", filter, - "-map", "[outv]", + "-map", videoMap, ...(withAudio ? ["-map", "[outa]"] : ["-an"]), - "-c:v", "libx264", - "-preset", "ultrafast", - "-crf", "22", - ...(withAudio ? ["-c:a", "aac", "-b:a", "192k"] : []), - "-movflags", "+faststart", + ...codecArgs, "-y", out, ]); if (code !== 0) throw new Error("Export failed while rendering the video."); const data = (await ffmpeg.readFile(out)) as Uint8Array; await ffmpeg.deleteFile(out); const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); - return new Blob([buf as ArrayBuffer], { type: "video/mp4" }); + return new Blob([buf as ArrayBuffer], { + type: format === "webm" ? "video/webm" : "video/mp4", + }); } finally { ffmpeg.off("progress", progressHandler); } @@ -164,20 +227,22 @@ export async function exportVideo( /** * Render an edited audio-only file: keep only `keepRanges` and concatenate - * them into an M4A (AAC) container. + * them. Works for both audio projects and the audio track of a video file. */ export async function exportAudio( file: File, keepRanges: TimeRange[], editedDuration: number, - onProgress: (ratio: number) => void + onProgress: (ratio: number) => void, + { format = "m4a" }: AudioExportOptions = {} ): Promise { if (keepRanges.length === 0) { throw new Error("Everything has been deleted β€” nothing to export."); } const ffmpeg = await getFFmpeg(); const input = await ensureInput(ffmpeg, file); - const out = "output.m4a"; + const out = + format === "mp3" ? "output.mp3" : format === "wav" ? "output.wav" : "output.m4a"; const parts: string[] = []; const labels: string[] = []; @@ -197,20 +262,31 @@ export async function exportAudio( }; ffmpeg.on("progress", progressHandler); try { + const codecArgs = + format === "mp3" + ? ["-c:a", "libmp3lame", "-b:a", "192k"] + : format === "wav" + ? ["-c:a", "pcm_s16le"] + : ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]; + const code = await ffmpeg.exec([ "-i", input, "-filter_complex", filter, "-map", "[outa]", - "-c:a", "aac", - "-b:a", "192k", - "-movflags", "+faststart", + ...codecArgs, "-y", out, ]); if (code !== 0) throw new Error("Export failed while rendering the audio."); const data = (await ffmpeg.readFile(out)) as Uint8Array; await ffmpeg.deleteFile(out); const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); - return new Blob([buf as ArrayBuffer], { type: "audio/mp4" }); + const mime = + format === "mp3" + ? "audio/mpeg" + : format === "wav" + ? "audio/wav" + : "audio/mp4"; + return new Blob([buf as ArrayBuffer], { type: mime }); } finally { ffmpeg.off("progress", progressHandler); } diff --git a/lib/serializeTranscript.ts b/lib/serializeTranscript.ts new file mode 100644 index 0000000..f8bed8c --- /dev/null +++ b/lib/serializeTranscript.ts @@ -0,0 +1,275 @@ +import { getCutRanges, originalToEdited } from "./edits"; +import { groupWordsBySpeaker } from "./transcript"; +import type { TimeRange, Word } from "./types"; + +/** Timed caption / subtitle formats (importable). */ +export type SubtitleFormat = "srt" | "vtt" | "json"; + +/** Untimed transcript document formats. */ +export type TranscriptDocFormat = "txt" | "md"; + +export type TranscriptFormat = SubtitleFormat | TranscriptDocFormat; + +export interface SerializeOptions { + /** + * When true (default for SRT/VTT/TXT/MD), omit cut words and remap times onto + * the edited timeline so captions sync with the exported media. + * JSON ignores this and always writes the full word list for round-trips. + */ + editedTimeline?: boolean; + /** Media duration in seconds; used when remapping onto the edited timeline. */ + duration?: number; + /** + * Precomputed cut ranges (deleted words βˆͺ manual cuts). Preferred so caption + * times match the media export. Falls back to word-derived cuts when omitted. + */ + cuts?: TimeRange[]; +} + +interface Cue { + start: number; + end: number; + text: string; + speaker: number; +} + +/** Split a cue when the gap between consecutive words exceeds this (seconds). */ +const CUE_GAP = 0.75; + +const MIME: Record = { + srt: "application/x-subrip", + vtt: "text/vtt", + json: "application/json", + txt: "text/plain", + md: "text/markdown", +}; + +export function transcriptMime(format: TranscriptFormat): string { + return MIME[format]; +} + +export function transcriptExtension(format: TranscriptFormat): string { + return `.${format}`; +} + +/** + * Serialize editor words to a subtitle or transcript document format. + * SRT/VTT group consecutive same-speaker words into cues (splitting on gaps). + * TXT/MD write speaker-labeled turns without timestamps. + * JSON writes `{ "words": [...] }` matching `parseTranscript` import. + */ +export function serializeTranscript( + words: Word[], + format: TranscriptFormat, + options: SerializeOptions = {} +): string { + if (format === "json") return serializeJson(words); + if (format === "txt" || format === "md") { + return serializeDocument(words, format, options); + } + + const editedTimeline = options.editedTimeline !== false; + const prepared = prepareCaptionWords(words, editedTimeline, options); + if (prepared.length === 0) { + throw new Error("No words to export."); + } + const cues = wordsToCues(prepared); + return format === "vtt" ? serializeVtt(cues) : serializeSrt(cues); +} + +/** Trigger a browser download of the serialized transcript / subtitles. */ +export function downloadTranscript( + words: Word[], + format: TranscriptFormat, + filename: string, + options: SerializeOptions = {} +): void { + const text = serializeTranscript(words, format, options); + const blob = new Blob([text], { type: `${MIME[format]};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename.endsWith(`.${format}`) + ? filename + : `${filename}${transcriptExtension(format)}`; + a.click(); + URL.revokeObjectURL(url); +} + +function serializeJson(words: Word[]): string { + return JSON.stringify( + { + words: words.map((w) => ({ + text: w.text, + start: roundTime(w.start), + end: roundTime(w.end), + speaker: w.speaker, + deleted: w.deleted, + })), + }, + null, + 2 + ); +} + +function serializeDocument( + words: Word[], + format: TranscriptDocFormat, + options: SerializeOptions +): string { + const editedTimeline = options.editedTimeline !== false; + const prepared = prepareCaptionWords(words, editedTimeline, options); + if (prepared.length === 0) { + throw new Error("No words to export."); + } + + const turns = groupWordsBySpeaker(prepared); + if (format === "txt") { + return ( + turns + .map((turn) => { + const label = + turn.speaker >= 0 ? `Speaker ${turn.speaker + 1}` : "Speaker"; + const text = turn.words.map((w) => w.text).join(" "); + return `${label}: ${text}`; + }) + .join("\n\n") + "\n" + ); + } + + return ( + turns + .map((turn) => { + const label = + turn.speaker >= 0 ? `Speaker ${turn.speaker + 1}` : "Speaker"; + const text = turn.words.map((w) => w.text).join(" "); + return `**${label}**\n\n${text}`; + }) + .join("\n\n") + "\n" + ); +} + +function prepareCaptionWords( + words: Word[], + editedTimeline: boolean, + options: SerializeOptions +): Word[] { + const duration = options.duration ?? 0; + const cuts = + options.cuts ?? + getCutRanges(words, duration > 0 ? duration : Infinity); + + const kept = words.filter((w) => isWordKept(w, cuts)); + if (!editedTimeline || kept.length === 0 || cuts.length === 0) return kept; + + return kept.map((w) => ({ + ...w, + start: originalToEdited(w.start, cuts), + end: originalToEdited(w.end, cuts), + })); +} + +/** Keep words that are not deleted and whose midpoint survives the cut list. */ +function isWordKept(word: Word, cuts: TimeRange[]): boolean { + if (word.deleted) return false; + const mid = (word.start + word.end) / 2; + return !cuts.some((c) => mid >= c.start && mid < c.end); +} + +function wordsToCues(words: Word[]): Cue[] { + const cues: Cue[] = []; + let batch: Word[] = []; + + const flush = () => { + if (batch.length === 0) return; + cues.push({ + start: batch[0].start, + end: Math.max(batch[batch.length - 1].end, batch[0].start + 0.02), + text: batch.map((w) => w.text).join(" "), + speaker: batch[0].speaker, + }); + batch = []; + }; + + for (const w of words) { + const last = batch[batch.length - 1]; + if ( + last && + (w.speaker !== last.speaker || w.start - last.end > CUE_GAP) + ) { + flush(); + } + batch.push(w); + } + flush(); + return cues; +} + +function serializeSrt(cues: Cue[]): string { + return ( + cues + .map((cue, i) => { + const lines = [ + String(i + 1), + `${formatSrtTimestamp(cue.start)} --> ${formatSrtTimestamp(cue.end)}`, + ]; + if (cue.speaker >= 0) { + lines.push(`Speaker ${cue.speaker + 1}: ${cue.text}`); + } else { + lines.push(cue.text); + } + return lines.join("\n"); + }) + .join("\n\n") + "\n" + ); +} + +function serializeVtt(cues: Cue[]): string { + const body = cues + .map((cue) => { + const timing = `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}`; + const text = + cue.speaker >= 0 + ? `${cue.text}` + : cue.text; + return `${timing}\n${text}`; + }) + .join("\n\n"); + return `WEBVTT\n\n${body}\n`; +} + +/** SRT timestamps use a comma decimal separator: `HH:MM:SS,mmm`. */ +export function formatSrtTimestamp(seconds: number): string { + return formatCaptionTimestamp(seconds, ","); +} + +/** WebVTT timestamps use a dot decimal separator: `HH:MM:SS.mmm`. */ +export function formatVttTimestamp(seconds: number): string { + return formatCaptionTimestamp(seconds, "."); +} + +function formatCaptionTimestamp(seconds: number, decimal: "," | "."): string { + const t = Math.max(0, seconds); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const s = Math.floor(t % 60); + const ms = Math.round((t - Math.floor(t)) * 1000); + // Carry if rounding pushed ms to 1000. + const carry = ms === 1000 ? 1 : 0; + const msClamped = ms === 1000 ? 0 : ms; + const s2 = s + carry; + const m2 = m + Math.floor(s2 / 60); + const h2 = h + Math.floor(m2 / 60); + return ( + `${pad(h2, 2)}:${pad(m2 % 60, 2)}:${pad(s2 % 60, 2)}` + + `${decimal}${pad(msClamped, 3)}` + ); +} + +function pad(n: number, width: number): string { + return String(n).padStart(width, "0"); +} + +function roundTime(t: number): number { + return Math.round(t * 1000) / 1000; +} diff --git a/tests/serialize-transcript-test.ts b/tests/serialize-transcript-test.ts new file mode 100644 index 0000000..68a4c10 --- /dev/null +++ b/tests/serialize-transcript-test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for transcript / subtitle export (and round-trips). + * Run: npx tsx tests/serialize-transcript-test.ts + */ +import { parseTranscript } from "../lib/parseTranscript"; +import { + formatSrtTimestamp, + formatVttTimestamp, + serializeTranscript, +} from "../lib/serializeTranscript"; +import type { Word } from "../lib/types"; + +function assert(cond: boolean, msg: string) { + if (!cond) throw new Error(msg); +} + +function near(a: number, b: number, eps = 1e-3): boolean { + return Math.abs(a - b) <= eps; +} + +const sample: Word[] = [ + { id: 0, text: "Hello", start: 1, end: 1.4, speaker: 0, deleted: false }, + { id: 1, text: "world", start: 1.4, end: 2, speaker: 0, deleted: false }, + { id: 2, text: "um", start: 2.1, end: 2.3, speaker: 0, deleted: true }, + { id: 3, text: "How", start: 3.5, end: 3.8, speaker: 1, deleted: false }, + { id: 4, text: "are", start: 3.8, end: 4.1, speaker: 1, deleted: false }, + { id: 5, text: "you", start: 4.1, end: 4.5, speaker: 1, deleted: false }, +]; + +{ + assert(formatSrtTimestamp(1) === "00:00:01,000", "srt ts 1s"); + assert(formatSrtTimestamp(3661.5) === "01:01:01,500", "srt ts hh"); + assert(formatVttTimestamp(1.25) === "00:00:01.250", "vtt ts"); + console.log("timestamps: ok"); +} + +{ + const srt = serializeTranscript(sample, "srt", { duration: 10 }); + assert(srt.includes("1\n"), "srt has cue index"); + assert(srt.includes("00:00:01,000 --> 00:00:02,000"), `srt timing\n${srt}`); + assert(srt.includes("Speaker 1: Hello world"), `srt speaker label\n${srt}`); + assert(srt.includes("Speaker 2: How are you"), `srt speaker 2\n${srt}`); + assert(!srt.includes("um"), "srt omits deleted"); + assert(/Speaker 2:/.test(srt), "srt second speaker present"); + console.log("srt export: ok"); +} + +{ + const vtt = serializeTranscript(sample, "vtt", { duration: 10 }); + assert(vtt.startsWith("WEBVTT"), "vtt header"); + assert(vtt.includes("Hello world"), `vtt voice\n${vtt}`); + assert(vtt.includes("How are you"), `vtt voice 2\n${vtt}`); + assert(!vtt.includes("um"), "vtt omits deleted"); + console.log("vtt export: ok"); +} + +{ + const json = serializeTranscript(sample, "json"); + const data = JSON.parse(json); + assert(Array.isArray(data.words), "json words array"); + assert(data.words.length === 6, "json keeps deleted words"); + assert(data.words[2].deleted === true && data.words[2].text === "um", "json deleted flag"); + assert(data.words[0].speaker === 0, "json speaker"); + console.log("json export: ok"); +} + +{ + const txt = serializeTranscript(sample, "txt", { duration: 10 }); + assert(txt.includes("Speaker 1: Hello world"), `txt speaker 1\n${txt}`); + assert(txt.includes("Speaker 2: How are you"), `txt speaker 2\n${txt}`); + assert(!txt.includes("um"), "txt omits deleted"); + console.log("txt export: ok"); +} + +{ + const md = serializeTranscript(sample, "md", { duration: 10 }); + assert(md.includes("**Speaker 1**"), `md speaker 1\n${md}`); + assert(md.includes("Hello world"), "md text 1"); + assert(md.includes("**Speaker 2**"), "md speaker 2"); + assert(md.includes("How are you"), "md text 2"); + assert(!md.includes("um"), "md omits deleted"); + console.log("md export: ok"); +} + +{ + // JSON round-trip preserves text, times, speakers, deleted. + const json = serializeTranscript(sample, "json"); + const back = parseTranscript(json, "roundtrip.json"); + assert(back.length === sample.length, "json round-trip length"); + for (let i = 0; i < sample.length; i++) { + assert(back[i].text === sample[i].text, `json rt text ${i}`); + assert(near(back[i].start, sample[i].start), `json rt start ${i}`); + assert(near(back[i].end, sample[i].end), `json rt end ${i}`); + assert(back[i].speaker === sample[i].speaker, `json rt speaker ${i}`); + assert(back[i].deleted === sample[i].deleted, `json rt deleted ${i}`); + } + console.log("json round-trip: ok"); +} + +{ + // SRT round-trip (no deletes, original timeline) keeps cue text/times/speakers. + const clean: Word[] = sample + .filter((w) => !w.deleted) + .map((w, i) => ({ ...w, id: i })); + const srt = serializeTranscript(clean, "srt", { + editedTimeline: false, + duration: 10, + }); + const back = parseTranscript(srt, "roundtrip.srt"); + assert(back.map((w) => w.text).join(" ") === "Hello world How are you", "srt rt text"); + assert(back[0].speaker === 0 && back[2].speaker === 1, "srt rt speakers"); + assert(near(back[0].start, 1), `srt rt start ${back[0].start}`); + assert(near(back[back.length - 1].end, 4.5), `srt rt end ${back[back.length - 1].end}`); + console.log("srt round-trip: ok"); +} + +{ + const clean: Word[] = sample + .filter((w) => !w.deleted) + .map((w, i) => ({ ...w, id: i })); + const vtt = serializeTranscript(clean, "vtt", { + editedTimeline: false, + duration: 10, + }); + const back = parseTranscript(vtt, "roundtrip.vtt"); + assert(back.map((w) => w.text).join(" ") === "Hello world How are you", "vtt rt text"); + assert(back[0].speaker === 0 && back[2].speaker === 1, "vtt rt speakers"); + console.log("vtt round-trip: ok"); +} + +{ + let threw = false; + try { + serializeTranscript( + sample.map((w) => ({ ...w, deleted: true })), + "srt", + { duration: 10 } + ); + } catch { + threw = true; + } + assert(threw, "empty kept words should throw"); + console.log("empty export: ok"); +} + +{ + // Edited timeline remaps times after a cut. + const words: Word[] = [ + { id: 0, text: "Keep", start: 0, end: 1, speaker: 0, deleted: false }, + { id: 1, text: "Cut", start: 1, end: 2, speaker: 0, deleted: true }, + { id: 2, text: "Later", start: 3, end: 4, speaker: 0, deleted: false }, + ]; + const srt = serializeTranscript(words, "srt", { duration: 5, editedTimeline: true }); + // Cut [1,2) removes 1s; "Later" 3β†’4 becomes 2β†’3 on edited timeline. + // MERGE_GAP is 0.35 β€” gap from cut end 2 to next word 3 is 1s, so no merge expansion. + assert(srt.includes("00:00:00,000 --> 00:00:01,000"), `edited first cue\n${srt}`); + assert(srt.includes("00:00:02,000 --> 00:00:03,000"), `edited second cue\n${srt}`); + assert(srt.includes("Keep") && srt.includes("Later") && !srt.includes("Cut"), "edited text"); + console.log("edited timeline: ok"); +} + +{ + // Manual cuts (passed explicitly) omit covered words even when not deleted. + const words: Word[] = [ + { id: 0, text: "Keep", start: 0, end: 1, speaker: 0, deleted: false }, + { id: 1, text: "Trimmed", start: 1, end: 2, speaker: 0, deleted: false }, + { id: 2, text: "Later", start: 3, end: 4, speaker: 0, deleted: false }, + ]; + const txt = serializeTranscript(words, "txt", { + duration: 5, + cuts: [{ start: 1, end: 2 }], + }); + assert(txt.includes("Keep"), "manual cut keeps first"); + assert(txt.includes("Later"), "manual cut keeps later"); + assert(!txt.includes("Trimmed"), "manual cut omits trimmed word"); + console.log("manual cuts: ok"); +} + +console.log("ALL SERIALIZE TRANSCRIPT TESTS PASSED");