able to re-transcribe
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }];
|
||||
|
||||
Reference in New Issue
Block a user