able to re-transcribe

This commit is contained in:
2026-05-04 23:54:14 -06:00
parent 137dc80cde
commit 1678d28db7
8 changed files with 346 additions and 9 deletions

View File

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Download, Loader2, Zap, Cog, Info, Volume2 } from 'lucide-react';
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText } from 'lucide-react';
import type { ExportOptions } from '../types/project';
export default function ExportDialog() {
@ -24,6 +24,82 @@ export default function ExportDialog() {
normalizeTarget: -14,
});
const [exportError, setExportError] = useState<string | null>(null);
const [transcriptFormat, setTranscriptFormat] = useState<'txt' | 'srt'>('txt');
const [isTranscribingTranscript, setIsTranscribingTranscript] = useState(false);
const handleTranscriptExport = useCallback(async () => {
if (!videoPath || words.length === 0) return;
const defaultExt = transcriptFormat === 'srt' ? 'srt' : 'txt';
const outputPath = await window.electronAPI?.saveFile({
defaultPath: videoPath.replace(/\.[^.]+$/, `_transcript.${defaultExt}`),
filters: transcriptFormat === 'srt'
? [{ name: 'SRT Subtitles', extensions: ['srt'] }]
: [{ name: 'Text File', extensions: ['txt'] }],
});
if (!outputPath) return;
setIsTranscribingTranscript(true);
try {
// Compute deleted word set
const deletedSet = new Set<number>();
for (const range of cutRanges) {
for (let i = 0; i < words.length; i++) {
if (words[i].start >= range.start && words[i].end <= range.end) {
deletedSet.add(i);
}
}
}
// Generate content entirely on the frontend — no backend needed
let content: string;
if (transcriptFormat === 'srt') {
const lines: string[] = [];
let counter = 1;
const activeWords: Array<[number, typeof words[0]]> = [];
for (let i = 0; i < words.length; i++) {
if (!deletedSet.has(i)) activeWords.push([i, words[i]]);
}
const wordsPerLine = 8;
for (let ci = 0; ci < activeWords.length; ci += wordsPerLine) {
const chunk = activeWords.slice(ci, ci + wordsPerLine);
if (chunk.length === 0) continue;
const startTime = chunk[0][1].start;
const endTime = chunk[chunk.length - 1][1].end;
const fmt = (s: number) => {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
const ms = Math.floor((s % 1) * 1000);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
};
lines.push(String(counter));
lines.push(`${fmt(startTime)} --> ${fmt(endTime)}`);
lines.push(chunk.map(([, w]) => w.word).join(' '));
lines.push('');
counter++;
}
content = lines.join('\n');
} else {
// Plain text
const activeWords: string[] = [];
for (let i = 0; i < words.length; i++) {
if (!deletedSet.has(i)) activeWords.push(words[i].word);
}
content = activeWords.join(' ');
}
// Write directly via Tauri — instant, no backend round-trip
await window.electronAPI?.writeFile(outputPath, content);
} catch (err) {
console.error('Transcript export error:', err);
setExportError(err instanceof Error ? err.message : 'Transcript export failed');
} finally {
setIsTranscribingTranscript(false);
}
}, [videoPath, words, cutRanges, transcriptFormat]);
const HANDLE_EXPORT_filters = useCallback(() => {
const ext = options.format;
@ -220,7 +296,40 @@ export default function ExportDialog() {
]}
/>
{/* Export button */}
{/* Transcript-only export */}
<div className="space-y-2 pt-1 border-t border-editor-border">
<h4 className="text-xs font-semibold flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Export Transcript Only
</h4>
<p className="text-[10px] text-editor-text-muted leading-relaxed">
Export the edited transcript as plain text or SRT without rendering video.
</p>
<div className="flex items-center gap-2">
<select
value={transcriptFormat}
onChange={(e) => setTranscriptFormat(e.target.value as 'txt' | 'srt')}
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
>
<option value="txt">Plain Text (.txt)</option>
<option value="srt">Subtitles (.srt)</option>
</select>
<button
onClick={handleTranscriptExport}
disabled={isTranscribingTranscript || words.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors"
>
{isTranscribingTranscript ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<FileText className="w-3 h-3" />
)}
Export
</button>
</div>
</div>
{/* Export video button */}
<button
onClick={handleExport}
disabled={isExporting || !videoPath}
@ -234,7 +343,7 @@ export default function ExportDialog() {
) : (
<>
<Download className="w-4 h-4" />
Export
Export Video
</>
)}
</button>

View File

@ -1,7 +1,7 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Virtuoso } from 'react-virtuoso';
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X } from 'lucide-react';
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
interface TranscriptEditorProps {
cutMode: boolean;
@ -30,6 +30,9 @@ export default function TranscriptEditor({
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex);
const videoPath = useEditorStore((s) => s.videoPath);
const backendUrl = useEditorStore((s) => s.backendUrl);
const replaceWordRange = useEditorStore((s) => s.replaceWordRange);
const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange);
@ -254,6 +257,50 @@ export default function TranscriptEditor({
setEditText('');
}, []);
const [isReTranscribing, setIsReTranscribing] = useState(false);
const reTranscribeGuard = useRef(false);
const handleReTranscribe = useCallback(async () => {
if (!videoPath || selectedWordIndices.length === 0 || reTranscribeGuard.current) return;
reTranscribeGuard.current = true;
setIsReTranscribing(true);
// Snapshot indices and word timings before the async gap
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startWord = words[sorted[0]];
const endWord = words[sorted[sorted.length - 1]];
if (!startWord || !endWord) {
reTranscribeGuard.current = false;
setIsReTranscribing(false);
return;
}
try {
const res = await fetch(`${backendUrl}/transcribe/segment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: videoPath,
start: startWord.start,
end: endWord.end,
}),
});
if (!res.ok) {
let detail = res.statusText;
try { const body = await res.json(); if (body?.detail) detail = String(body.detail); } catch { /* keep statusText fallback */ }
throw new Error(`Re-transcribe failed: ${detail}`);
}
const data = await res.json();
replaceWordRange(sorted[0], sorted[sorted.length - 1], data.words);
} catch (err) {
console.error('Re-transcribe error:', err);
alert(err instanceof Error ? err.message : 'Re-transcribe failed');
} finally {
reTranscribeGuard.current = false;
setIsReTranscribing(false);
}
}, [videoPath, selectedWordIndices, words, backendUrl, replaceWordRange]);
const handleWordDoubleClick = useCallback((index: number) => {
if (cutMode || muteMode || gainMode || speedMode) return;
startEditing(index);
@ -535,6 +582,15 @@ export default function TranscriptEditor({
<Gauge className="w-3 h-3" />
Speed {speedModeValue.toFixed(2)}x
</button>
<button
onClick={handleReTranscribe}
disabled={isReTranscribing}
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors"
title="Re-run Whisper transcription on this segment"
>
<RefreshCw className={`w-3 h-3 ${isReTranscribing ? 'animate-spin' : ''}`} />
{isReTranscribing ? 'Re-transcribing...' : 'Re-transcribe'}
</button>
</div>
)}
</div>

View File

@ -92,6 +92,7 @@ interface EditorActions {
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
setExporting: (active: boolean, progress?: number) => void;
setZonePreviewPaddingSeconds: (seconds: number) => void;
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
getKeepSegments: () => Array<{ start: number; end: number }>;
getWordAtTime: (time: number) => number;
loadProject: (projectData: any) => void;
@ -473,6 +474,41 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ zonePreviewPaddingSeconds: nextSeconds });
},
replaceWordRange: (startIndex, endIndex, newWords) => {
const { words } = get();
if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return;
// Replace words in the range with new words
const before = words.slice(0, startIndex);
const after = words.slice(endIndex + 1);
const updatedWords = [...before, ...newWords, ...after];
// Rebuild segments from updated words, grouping by speaker
const rebuiltSegments: Segment[] = [];
let wordIdx = 0;
let cumIdx = 0;
while (wordIdx < updatedWords.length) {
const currentSpeaker = updatedWords[wordIdx].speaker;
const groupWords: Word[] = [];
while (wordIdx < updatedWords.length && updatedWords[wordIdx].speaker === currentSpeaker) {
groupWords.push(updatedWords[wordIdx]);
wordIdx++;
}
rebuiltSegments.push({
id: rebuiltSegments.length,
start: groupWords[0].start,
end: groupWords[groupWords.length - 1].end,
text: groupWords.map((w) => w.word).join(' '),
words: groupWords,
speaker: currentSpeaker,
globalStartIndex: cumIdx,
});
cumIdx += groupWords.length;
}
set({ words: updatedWords, segments: rebuiltSegments, selectedWordIndices: [] });
},
getKeepSegments: () => {
const { words, cutRanges, duration } = get();
if (words.length === 0) return [{ start: 0, end: duration }];