implemented 15,12,18 didn't check 18

This commit is contained in:
2026-05-04 16:37:25 -06:00
parent 0c7a4c94c2
commit 90b1999a57
9 changed files with 402 additions and 10 deletions

View File

@ -1,13 +1,16 @@
import { useState, useCallback } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
import { Download, Loader2, Zap, Cog, Info, Volume2 } from 'lucide-react';
import type { ExportOptions } from '../types/project';
export default function ExportDialog() {
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, setExportedAudioPath, getKeepSegments } =
useEditorStore();
const hasCuts = cutRanges.length > 0;
const [isNormalizing, setIsNormalizing] = useState(false);
const [normalizeTarget, setNormalizeTarget] = useState(-14);
const [normalizeResult, setNormalizeResult] = useState<string | null>(null);
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
mode: 'fast',
@ -78,6 +81,41 @@ export default function ExportDialog() {
}
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
const handleNormalize = useCallback(async () => {
if (!videoPath) return;
setIsNormalizing(true);
setNormalizeResult(null);
try {
const res = await fetch(`${backendUrl}/audio/normalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input_path: videoPath,
target_lufs: normalizeTarget,
output_path: '',
}),
});
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(detail);
}
const data = await res.json();
setExportedAudioPath(data.output_path);
setNormalizeResult(`Normalized to ${data.target_lufs} LUFS → ${data.output_path.split('/').pop() || 'done'}`);
} catch (err) {
console.error('Normalize error:', err);
setNormalizeResult(`Error: ${err instanceof Error ? err.message : 'Normalization failed'}`);
} finally {
setIsNormalizing(false);
}
}, [videoPath, backendUrl, normalizeTarget, setExportedAudioPath]);
return (
<div className="p-4 space-y-5">
<h3 className="text-sm font-semibold">Export Video</h3>
@ -129,6 +167,46 @@ export default function ExportDialog() {
]}
/>
{/* Audio normalization */}
<div className="space-y-2 border-t border-editor-border pt-3">
<h4 className="text-xs font-semibold flex items-center gap-1.5">
<Volume2 className="w-3.5 h-3.5" />
Audio Normalization
</h4>
<p className="text-[10px] text-editor-text-muted leading-relaxed">
Normalize loudness to a target LUFS level. YouTube uses <strong>-14 LUFS</strong>,
Spotify uses <strong>-16 LUFS</strong>, broadcast uses <strong>-23 LUFS</strong>.
</p>
<div className="flex items-center gap-2">
<select
value={normalizeTarget}
onChange={(e) => setNormalizeTarget(Number(e.target.value))}
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"
>
<option value={-14}>YouTube (-14 LUFS)</option>
<option value={-16}>Spotify (-16 LUFS)</option>
<option value={-23}>Broadcast (-23 LUFS)</option>
<option value={-11}>Loud (-11 LUFS)</option>
<option value={-9}>Very Loud (-9 LUFS)</option>
</select>
<button
onClick={handleNormalize}
disabled={isNormalizing || !videoPath}
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"
>
{isNormalizing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Volume2 className="w-3 h-3" />
)}
{isNormalizing ? 'Normalizing...' : 'Normalize'}
</button>
</div>
{normalizeResult && (
<p className="text-[10px] text-editor-success">{normalizeResult}</p>
)}
</div>
{/* Audio enhancement */}
<label className="flex items-center gap-2 cursor-pointer">
<input

View File

@ -7,6 +7,18 @@ import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react';
export default function SettingsPanel() {
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
const { backendUrl, zonePreviewPaddingSeconds, setZonePreviewPaddingSeconds } = useEditorStore();
const CONFIDENCE_THRESHOLD_KEY = 'talkedit:confidenceThreshold';
const [confidenceThreshold, setConfidenceThresholdState] = useState(() => {
const stored = typeof window !== 'undefined' ? Number(window.localStorage.getItem(CONFIDENCE_THRESHOLD_KEY)) : 0;
return Number.isFinite(stored) ? stored : 0.6;
});
const setConfidenceThreshold = (value: number) => {
const clamped = Math.max(0, Math.min(1, value));
setConfidenceThresholdState(clamped);
if (typeof window !== 'undefined') {
window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped));
}
};
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
@ -66,6 +78,40 @@ export default function SettingsPanel() {
</div>
</ProviderSection>
{/* Confidence threshold */}
<div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Low-Confidence Word Threshold</label>
<p className="text-[10px] text-editor-text-muted leading-relaxed">
Words with confidence below this value are highlighted with an orange dotted underline.
Whisper often gets homophones and proper nouns wrong at low confidence.
</p>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.05}
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(Number(e.target.value))}
className="flex-1 h-1.5"
/>
<input
type="number"
min={0}
max={1}
step={0.05}
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(Math.max(0, Math.min(1, Number(e.target.value) || 0)))}
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
/>
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-editor-text-muted">Show all</span>
<span className="font-medium text-editor-text">{confidenceThreshold.toFixed(2)}</span>
<span className="text-editor-text-muted">Strict</span>
</div>
</div>
{/* Default provider selector */}
<div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>

View File

@ -49,6 +49,10 @@ export default function TranscriptEditor({
const [searchQuery, setSearchQuery] = useState('');
const [activeMatchIdx, setActiveMatchIdx] = useState(0);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const updateWordText = useEditorStore((s) => s.updateWordText);
const [editingWordIndex, setEditingWordIndex] = useState<number | null>(null);
const [editText, setEditText] = useState('');
const editInputRef = useRef<HTMLInputElement | null>(null);
const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]);
const matchIndices = useMemo(() => {
@ -224,6 +228,61 @@ export default function TranscriptEditor({
[setSelectedWordIndices],
);
const startEditing = useCallback((index: number) => {
const word = words[index];
if (!word) return;
setEditingWordIndex(index);
setEditText(word.word);
requestAnimationFrame(() => {
editInputRef.current?.focus();
editInputRef.current?.select();
});
}, [words]);
const commitEdit = useCallback(() => {
if (editingWordIndex === null) return;
const trimmed = editText.trim();
if (trimmed && trimmed !== words[editingWordIndex]?.word) {
updateWordText(editingWordIndex, trimmed);
}
setEditingWordIndex(null);
setEditText('');
}, [editingWordIndex, editText, words, updateWordText]);
const cancelEdit = useCallback(() => {
setEditingWordIndex(null);
setEditText('');
}, []);
const handleWordDoubleClick = useCallback((index: number) => {
if (cutMode || muteMode || gainMode || speedMode) return;
startEditing(index);
}, [cutMode, muteMode, gainMode, speedMode, startEditing]);
// Focus edit input when it appears
useEffect(() => {
if (editingWordIndex !== null && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingWordIndex]);
// Global key handler for edit mode
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (editingWordIndex === null) return;
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [editingWordIndex, commitEdit, cancelEdit]);
const cutSelectedWords = useCallback(() => {
if (selectedWordIndices.length === 0) return;
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
@ -319,15 +378,25 @@ export default function TranscriptEditor({
const isSearchMatch = matchSet.has(globalIndex);
const isActiveSearchMatch = matchIndices.length > 0 && matchIndices[safeActiveMatchIdx] === globalIndex;
const isEditing = globalIndex === editingWordIndex;
// Low-confidence highlighting
const CONFIDENCE_THRESHOLD_KEY = 'talkedit:confidenceThreshold';
const storedThreshold = typeof window !== 'undefined' ? Number(window.localStorage.getItem(CONFIDENCE_THRESHOLD_KEY)) : 0;
const confidenceThreshold = Number.isFinite(storedThreshold) ? storedThreshold : 0.6;
const isLowConfidence = word.confidence > 0 && word.confidence < confidenceThreshold && !cutRange && !muteRange && !gainRange && !speedRange;
const confidencePct = word.confidence > 0 ? Math.round(word.confidence * 100) : null;
return (
<span
key={globalIndex}
id={`word-${globalIndex}`}
data-word-index={globalIndex}
title={`${word.start.toFixed(2)}s — Ctrl+click to seek`}
title={`${word.start.toFixed(2)}s — confidence: ${confidencePct !== null ? confidencePct + '%' : 'N/A'}${isLowConfidence ? ' ⚠️ Low confidence' : ''} — Ctrl+click to seek, double-click to edit`}
onMouseDown={(e) => handleWordMouseDown(globalIndex, e)}
onMouseEnter={() => handleWordMouseEnter(globalIndex)}
onMouseLeave={() => setHoveredWordIndex(null)}
onDoubleClick={() => handleWordDoubleClick(globalIndex)}
className={`
relative px-[2px] py-[1px] rounded cursor-pointer transition-colors
${cutRange ? 'bg-red-500/20 text-red-100' : ''}
@ -343,9 +412,21 @@ export default function TranscriptEditor({
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
${isLowConfidence ? 'border-b border-dashed border-orange-400/60' : ''}
`}
>
{word.word}{' '}
{isEditing ? (
<input
ref={editInputRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={commitEdit}
className="w-24 px-1 py-0 text-xs bg-editor-bg border border-editor-accent rounded text-editor-text focus:outline-none"
style={{ minWidth: `${Math.max(word.word.length * 8, 48)}px` }}
/>
) : (
<>{word.word}{' '}</>
)}
{(cutRange || muteRange || gainRange || speedRange) && isHovered && (
<button
onClick={(e) => {
@ -367,7 +448,7 @@ export default function TranscriptEditor({
</div>
);
},
[segments, selectedSet, matchSet, matchIndices, safeActiveMatchIdx, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode],
[segments, selectedSet, matchSet, matchIndices, safeActiveMatchIdx, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode, editingWordIndex, editText, editInputRef, handleWordDoubleClick, commitEdit, setEditText],
);
return (

View File

@ -11,7 +11,7 @@ import { invoke } from '@tauri-apps/api/core';
import { open, save } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
const backendPort = import.meta.env.VITE_BACKEND_PORT || '8642';
const backendPort = import.meta.env.VITE_BACKEND_PORT || '8000';
const backendUrl = `http://127.0.0.1:${backendPort}`;
const VIDEO_FILTERS = [

View File

@ -67,6 +67,7 @@ interface EditorActions {
setHoveredWordIndex: (index: number | null) => void;
deleteSelectedWords: () => void;
deleteWordRange: (startIndex: number, endIndex: number) => void;
updateWordText: (index: number, text: string) => void;
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
addMuteRange: (start: number, end: number) => void;
addGainRange: (start: number, end: number, gainDb: number) => void;
@ -262,6 +263,33 @@ export const useEditorStore = create<EditorState & EditorActions>()(
get().addCutRange(words[startIndex].start, words[endIndex].end);
},
updateWordText: (index, text) => {
const { words, segments } = get();
if (index < 0 || index >= words.length) return;
const newWords = words.map((w, i) =>
i === index ? { ...w, word: text } : w
);
// Also update the corresponding segment's words and text
let globalIdx = 0;
const newSegments = segments.map((seg) => {
const start = globalIdx;
globalIdx += seg.words.length;
if (index >= start && index < start + seg.words.length) {
const localIdx = index - start;
const updatedSegWords = seg.words.map((w, i) =>
i === localIdx ? { ...w, word: text } : w
);
return {
...seg,
words: updatedSegWords,
text: updatedSegWords.map((w) => w.word).join(' '),
};
}
return seg;
});
set({ words: newWords, segments: newSegments });
},
addCutRange: (start, end, trimGroupId) => {
const { cutRanges } = get();
const newRange: CutRange = {