Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 50 additions & 3 deletions components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -149,6 +172,30 @@ export default function ExportDialog() {
Export {formatLabel}
</button>
)}

{words.length > 0 && !exporting && (
<div className="mt-5 border-t border-zinc-100 pt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-400">
Transcript
</p>
<p className="mb-3 text-xs text-zinc-500">
SRT and VTT use the edited timeline (cuts applied). JSON keeps the
full word list for re-import.
</p>
<div className="grid grid-cols-3 gap-2">
{TRANSCRIPT_FORMATS.map(({ format, label }) => (
<button
key={format}
onClick={() => exportCaption(format)}
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-zinc-200 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50"
>
<Download size={13} className="shrink-0 text-zinc-400" />
{label}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
Expand Down
68 changes: 68 additions & 0 deletions components/TranscriptPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Download,
Eye,
EyeOff,
FileText,
Expand All @@ -19,6 +20,10 @@
parseTranscriptFile,
TRANSCRIPT_ACCEPT,
} from "@/lib/parseTranscript";
import {
downloadTranscript,
type TranscriptFormat,
} from "@/lib/serializeTranscript";
import type { SpeakerTurn, Word } from "@/lib/types";
import {
getActiveSceneBoundaries,
Expand Down Expand Up @@ -130,7 +135,7 @@
const words = useEditorStore((s) => s.words);
const manualCuts = useEditorStore((s) => s.manualCuts);
const sceneBoundaries = useEditorStore((s) => s.sceneBoundaries);
const duration = useEditorStore((s) => s.duration);

Check failure on line 138 in components/TranscriptPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Cannot redeclare block-scoped variable 'duration'.
const status = useEditorStore((s) => s.status);
const progress = useEditorStore((s) => s.progress);
const partialText = useEditorStore((s) => s.partialText);
Expand All @@ -145,6 +150,8 @@
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);

Check failure on line 154 in components/TranscriptPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Cannot redeclare block-scoped variable 'duration'.
const activeWordId = useEditorStore((s) => findActiveWordId(s.words, s.currentTime));

const cuts = useMemo(
Expand Down Expand Up @@ -173,6 +180,8 @@
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [selection, setSelection] = useState<SelectionInfo | null>(null);
const [correcting, setCorrecting] = useState<{
ids: number[];
Expand Down Expand Up @@ -227,6 +236,34 @@
[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);
Expand Down Expand Up @@ -545,6 +582,37 @@
/>
</>
)}
{status === "ready" && words.length > 0 && (
<div ref={exportMenuRef} className="relative">
<button
onClick={() => setExportMenuOpen((o) => !o)}
title="Export transcript as SRT, VTT, or JSON"
className="flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs text-zinc-500 transition hover:bg-zinc-100"
>
<Download size={14} />
<span className="hidden sm:inline">Export</span>
</button>
{exportMenuOpen && (
<div className="absolute right-0 top-full z-30 mt-1 min-w-[9.5rem] rounded-xl border border-zinc-200 bg-white py-1 shadow-lg shadow-zinc-900/10">
{(
[
{ format: "srt", label: "SRT" },
{ format: "vtt", label: "WebVTT" },
{ format: "json", label: "JSON" },
] as const
).map(({ format, label }) => (
<button
key={format}
onClick={() => handleExportTranscript(format)}
className="flex w-full items-center px-3 py-1.5 text-left text-xs text-zinc-700 transition hover:bg-zinc-50"
>
{label}
</button>
))}
</div>
)}
</div>
)}
<button
onClick={toggleShowDeleted}
title={showDeleted ? "Hide deleted words" : "Show deleted words"}
Expand Down
Loading
Loading