more polish
This commit is contained in:
@ -646,7 +646,7 @@ export default function App() {
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
label="Gain Zone"
|
||||
label="Sound Gain"
|
||||
onClick={handleGain}
|
||||
active={gainMode}
|
||||
disabled={!canEdit}
|
||||
@ -667,7 +667,7 @@ export default function App() {
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={<Gauge className="w-4 h-4" />}
|
||||
label="Speed Zone"
|
||||
label="Speed Adjust"
|
||||
onClick={handleSpeed}
|
||||
active={speedMode}
|
||||
disabled={!canEdit}
|
||||
@ -685,9 +685,10 @@ export default function App() {
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||
<ToolbarButton
|
||||
icon={<Grid3x3 className="w-4 h-4" />}
|
||||
label="Zones"
|
||||
label="Edit Zones"
|
||||
active={activePanel === 'zones'}
|
||||
onClick={() => togglePanel('zones')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
@ -695,7 +696,7 @@ export default function App() {
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Pause Trim"
|
||||
label="Trim Silence"
|
||||
active={activePanel === 'silence'}
|
||||
onClick={() => togglePanel('silence')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
@ -703,7 +704,7 @@ export default function App() {
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<MapPin className="w-4 h-4" />}
|
||||
label="Markers"
|
||||
label="Chapter Marks"
|
||||
active={activePanel === 'markers'}
|
||||
onClick={() => togglePanel('markers')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
@ -711,7 +712,7 @@ export default function App() {
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Music className="w-4 h-4" />}
|
||||
label="Music"
|
||||
label="Bkg. Music"
|
||||
active={activePanel === 'music'}
|
||||
onClick={() => togglePanel('music')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
@ -719,12 +720,13 @@ export default function App() {
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
label="Append"
|
||||
label="Add Clips"
|
||||
active={activePanel === 'append'}
|
||||
onClick={() => togglePanel('append')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
title="Append additional video clips — concatenate multiple files during export"
|
||||
/>
|
||||
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
||||
<select
|
||||
value={whisperModel}
|
||||
@ -751,19 +753,11 @@ export default function App() {
|
||||
<option value="distil-medium.en">distil-medium.en</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleReprocessProject}
|
||||
disabled={isTranscribing || !videoPath || !canEdit}
|
||||
title="Re-run transcription with the selected Whisper model — replaces current transcript"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
|
||||
Reprocess
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||
<ToolbarButton
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label="AI"
|
||||
label="AI Tools"
|
||||
active={activePanel === 'ai'}
|
||||
onClick={() => togglePanel('ai')}
|
||||
disabled={words.length === 0 || !canEdit}
|
||||
@ -774,7 +768,7 @@ export default function App() {
|
||||
label="Export"
|
||||
active={activePanel === 'export'}
|
||||
onClick={() => togglePanel('export')}
|
||||
disabled={words.length === 0}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Settings className="w-4 h-4" />}
|
||||
@ -800,6 +794,7 @@ export default function App() {
|
||||
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
||||
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
||||
onMouseDown={startSplitDrag}
|
||||
title="Drag to resize"
|
||||
/>
|
||||
|
||||
{/* Transcript */}
|
||||
@ -877,6 +872,7 @@ export default function App() {
|
||||
<div
|
||||
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
||||
onMouseDown={startSidebarDrag}
|
||||
title="Drag to resize"
|
||||
/>
|
||||
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
|
||||
{activePanel === 'zones' && (
|
||||
@ -886,7 +882,7 @@ export default function App() {
|
||||
{activePanel === 'markers' && <MarkersPanel />}
|
||||
{activePanel === 'music' && <BackgroundMusicPanel />}
|
||||
{activePanel === 'append' && <AppendClipPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { useAIStore } from '../store/aiStore';
|
||||
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react';
|
||||
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw } from 'lucide-react';
|
||||
import type { ClipSuggestion } from '../types/project';
|
||||
|
||||
export default function AIPanel() {
|
||||
interface AIPanelProps {
|
||||
onReprocess: () => void;
|
||||
whisperModel: string;
|
||||
setWhisperModel: (model: string) => void;
|
||||
}
|
||||
|
||||
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
|
||||
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
|
||||
const {
|
||||
defaultProvider,
|
||||
@ -20,10 +26,12 @@ export default function AIPanel() {
|
||||
setProcessing,
|
||||
} = useAIStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'filler' | 'clips'>('filler');
|
||||
const [activeTab, setActiveTab] = useState<'filler' | 'clips' | 'reprocess'>('filler');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const detectFillers = useCallback(async () => {
|
||||
if (words.length === 0) return;
|
||||
setError(null);
|
||||
setProcessing(true, 'Detecting filler words...');
|
||||
try {
|
||||
const config = providers[defaultProvider];
|
||||
@ -41,11 +49,15 @@ export default function AIPanel() {
|
||||
custom_filler_words: customFillerWords || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Filler detection failed');
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `Filler detection failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setFillerResult(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : 'Filler detection failed');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@ -53,6 +65,7 @@ export default function AIPanel() {
|
||||
|
||||
const createClips = useCallback(async () => {
|
||||
if (words.length === 0) return;
|
||||
setError(null);
|
||||
setProcessing(true, 'Finding best clip segments...');
|
||||
try {
|
||||
const config = providers[defaultProvider];
|
||||
@ -75,11 +88,15 @@ export default function AIPanel() {
|
||||
target_duration: 60,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Clip creation failed');
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `Clip creation failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setClipSuggestions(data.clips || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : 'Clip creation failed');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@ -159,6 +176,13 @@ export default function AIPanel() {
|
||||
label="Create Clips"
|
||||
title="Find the best segments for social media clips"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'reprocess'}
|
||||
onClick={() => setActiveTab('reprocess')}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
label="Reprocess"
|
||||
title="Re-run transcription with a different Whisper model"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
@ -184,7 +208,7 @@ export default function AIPanel() {
|
||||
onClick={detectFillers}
|
||||
disabled={isProcessing || words.length === 0}
|
||||
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
@ -199,6 +223,17 @@ export default function AIPanel() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={detectFillers}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fillerResult && fillerResult.fillerWords.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -214,7 +249,7 @@ export default function AIPanel() {
|
||||
<Check className="w-3 h-3" /> Apply All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFillerResult(null)}
|
||||
onClick={() => { setFillerResult(null); setError(null); }}
|
||||
title="Clear detected filler word results without applying"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
|
||||
>
|
||||
@ -254,7 +289,7 @@ export default function AIPanel() {
|
||||
onClick={createClips}
|
||||
disabled={isProcessing || words.length === 0}
|
||||
title="Analyze transcript to find the most engaging 20-60 second segments for social media"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
@ -269,6 +304,17 @@ export default function AIPanel() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={createClips}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{clipSuggestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{clipSuggestions.map((clip, i) => (
|
||||
@ -292,7 +338,7 @@ export default function AIPanel() {
|
||||
onClick={() => handleExportClip(clip, i)}
|
||||
disabled={exportingClipIndex === i}
|
||||
title="Export just this segment as a standalone video file"
|
||||
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-50 transition-colors"
|
||||
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{exportingClipIndex === i ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
@ -308,6 +354,52 @@ export default function AIPanel() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'reprocess' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Re-run transcription with a different model — replaces the current transcript entirely.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Whisper Model
|
||||
</label>
|
||||
<select
|
||||
value={whisperModel}
|
||||
onChange={(e) => setWhisperModel(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
>
|
||||
<optgroup label="Multilingual (any language)">
|
||||
<option value="tiny">tiny — ~75 MB · fastest, low accuracy</option>
|
||||
<option value="base">base — ~140 MB · fast, decent accuracy</option>
|
||||
<option value="small">small — ~460 MB · good balance</option>
|
||||
<option value="medium">medium — ~1.5 GB · better accuracy</option>
|
||||
<option value="large-v2">large-v2 — ~2.9 GB · high accuracy</option>
|
||||
<option value="large-v3">large-v3 — ~2.9 GB · best overall ★</option>
|
||||
<option value="large-v3-turbo">large-v3-turbo — ~1.6 GB · fast + accurate ★</option>
|
||||
<option value="distil-large-v3">distil-large-v3 — ~1.5 GB · fast, near large-v3 quality</option>
|
||||
</optgroup>
|
||||
<optgroup label="English-only (faster & more accurate for English)">
|
||||
<option value="tiny.en">tiny.en — ~75 MB · fastest English</option>
|
||||
<option value="base.en">base.en — ~140 MB · fast English</option>
|
||||
<option value="small.en">small.en — ~460 MB · good English</option>
|
||||
<option value="medium.en">medium.en — ~1.5 GB · great English</option>
|
||||
<option value="distil-small.en">distil-small.en — ~190 MB · fast English ★</option>
|
||||
<option value="distil-medium.en">distil-medium.en — ~750 MB · best fast English ★</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={onReprocess}
|
||||
disabled={isProcessing || words.length === 0}
|
||||
title="Re-run transcription with the selected model — this will replace all current words"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reprocess Transcript
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -507,7 +507,7 @@ export default function ExportDialog() {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !videoPath}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-semibold transition-colors"
|
||||
title="Start export with current settings"
|
||||
>
|
||||
{isExporting ? (
|
||||
|
||||
@ -189,7 +189,7 @@ function LicenseActivateDialog({
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={activating || !keyValue.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{activating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
|
||||
@ -100,7 +100,7 @@ export default function MarkersPanel() {
|
||||
</div>
|
||||
|
||||
{/* Marker list */}
|
||||
{timelineMarkers.length > 0 && (
|
||||
{timelineMarkers.length > 0 ? (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{timelineMarkers.map((m) => (
|
||||
<div
|
||||
@ -130,6 +130,12 @@ export default function MarkersPanel() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded border border-dashed border-editor-border text-center">
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
No markers yet. Press I and O on the timeline to set mark in/out points, then add a marker here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
|
||||
@ -190,7 +190,7 @@ export default function SilenceTrimmerPanel() {
|
||||
<button
|
||||
onClick={detectSilence}
|
||||
disabled={isDetecting || !videoPath}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||
title="Scan the entire audio track for silent pauses"
|
||||
>
|
||||
{isDetecting ? (
|
||||
|
||||
@ -1,174 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Trash2, Volume2 } from 'lucide-react';
|
||||
|
||||
interface VolumePanelProps {
|
||||
gainMode: boolean;
|
||||
onToggleGainMode: () => void;
|
||||
timelineGainDb: number;
|
||||
onTimelineGainDbChange: (gainDb: number) => void;
|
||||
}
|
||||
|
||||
export default function VolumePanel({
|
||||
gainMode,
|
||||
onToggleGainMode,
|
||||
timelineGainDb,
|
||||
onTimelineGainDbChange,
|
||||
}: VolumePanelProps) {
|
||||
const {
|
||||
words,
|
||||
selectedWordIndices,
|
||||
globalGainDb,
|
||||
gainRanges,
|
||||
setGlobalGainDb,
|
||||
addGainRange,
|
||||
updateGainRange,
|
||||
removeGainRange,
|
||||
} = useEditorStore();
|
||||
|
||||
const [selectionGainDb, setSelectionGainDb] = useState(3);
|
||||
|
||||
const canApplySelection = selectedWordIndices.length > 0;
|
||||
|
||||
const selectedRange = useMemo(() => {
|
||||
if (!canApplySelection) return null;
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
if (!startWord || !endWord) return null;
|
||||
return {
|
||||
start: startWord.start,
|
||||
end: endWord.end,
|
||||
};
|
||||
}, [canApplySelection, selectedWordIndices, words]);
|
||||
|
||||
const applySelectionGain = () => {
|
||||
if (!selectedRange) return;
|
||||
addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Volume2 className="w-4 h-4" />
|
||||
Volume / Gain
|
||||
</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Apply global gain or per-selection gain ranges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Global Gain (dB)</label>
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={globalGainDb}
|
||||
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-editor-text-muted">-24 dB</span>
|
||||
<span className="font-medium text-editor-text">{globalGainDb.toFixed(1)} dB</span>
|
||||
<span className="text-editor-text-muted">+24 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Timeline Gain Zone (dB)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={timelineGainDb}
|
||||
onChange={(e) => onTimelineGainDbChange(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
||||
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={onToggleGainMode}
|
||||
className={`px-3 py-1.5 text-xs rounded transition-colors ${
|
||||
gainMode
|
||||
? 'bg-editor-accent text-white hover:bg-editor-accent-hover'
|
||||
: 'bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30'
|
||||
}`}
|
||||
>
|
||||
{gainMode ? 'Exit Zone Mode' : 'Add Gain Zones'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-editor-text-muted">
|
||||
In gain zone mode, drag on the timeline to create a zone with this dB value.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={selectionGainDb}
|
||||
onChange={(e) => setSelectionGainDb(Number(e.target.value) || 0)}
|
||||
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={applySelectionGain}
|
||||
disabled={!canApplySelection || !selectedRange}
|
||||
className="px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40"
|
||||
>
|
||||
Apply To Selection
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-editor-text-muted">
|
||||
{canApplySelection
|
||||
? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
|
||||
: 'Select transcript words to apply a gain range.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{gainRanges.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<div className="text-xs font-medium">Gain Ranges</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
|
||||
{gainRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs flex items-center gap-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{range.start.toFixed(2)}s - {range.end.toFixed(2)}s
|
||||
</div>
|
||||
<div className="text-editor-text-muted">{range.id}</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={range.gainDb}
|
||||
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||
className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
|
||||
title="Gain dB"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeGainRange(range.id)}
|
||||
className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
|
||||
title="Delete gain range"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1314,6 +1314,13 @@ export default function WaveformTimeline({
|
||||
{audioError}
|
||||
</pre>
|
||||
</div>
|
||||
) : !waveformDataRef.current ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-editor-border border-t-indigo-400" />
|
||||
<span className="text-xs text-editor-text-muted">Loading waveform...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 relative flex flex-col">
|
||||
{showThumbnails && thumbnailFrames.size > 0 && (
|
||||
|
||||
@ -94,7 +94,7 @@ export default function ZoneEditor() {
|
||||
case 'cut':
|
||||
return 'border-red-500/40 bg-red-500/5';
|
||||
case 'mute':
|
||||
return 'border-orange-500/40 bg-orange-500/5';
|
||||
return 'border-blue-500/40 bg-blue-500/20';
|
||||
case 'gain':
|
||||
return 'border-amber-500/40 bg-amber-500/5';
|
||||
case 'speed':
|
||||
@ -212,7 +212,7 @@ export default function ZoneEditor() {
|
||||
onClick={() => setViewMode('mute')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
viewMode === 'mute'
|
||||
? 'bg-orange-500/30 text-orange-500'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
title="Show only Mute zones"
|
||||
@ -245,7 +245,7 @@ export default function ZoneEditor() {
|
||||
</div>
|
||||
|
||||
{totalZones === 0 ? (
|
||||
<div className="p-4 rounded border border-dashed border-editor-border text-center">
|
||||
<div className="p-4 rounded-lg border border-dashed border-editor-border text-center">
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
No zones yet. Create zones from the toolbar or by highlighting words.
|
||||
</p>
|
||||
@ -264,13 +264,12 @@ export default function ZoneEditor() {
|
||||
<div
|
||||
key={range.id}
|
||||
onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
|
||||
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||
</div>
|
||||
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
||||
<button
|
||||
@ -292,7 +291,7 @@ export default function ZoneEditor() {
|
||||
{/* Mute Zones */}
|
||||
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-orange-500/80 flex items-center gap-2">
|
||||
<div className="text-xs font-semibold text-blue-400 flex items-center gap-2">
|
||||
<Volume2 className="w-3.5 h-3.5" />
|
||||
Mute Zones ({muteRanges.length})
|
||||
</div>
|
||||
@ -301,21 +300,20 @@ export default function ZoneEditor() {
|
||||
<div
|
||||
key={range.id}
|
||||
onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-orange-400 border-orange-400/80 bg-orange-500/12' : ''}`}
|
||||
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-blue-400 border-blue-400/80 bg-blue-500/20' : ''}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||
</div>
|
||||
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
|
||||
{renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeZone('mute', range.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="p-1 rounded hover:bg-blue-500/20 text-blue-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete mute zone"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@ -326,12 +324,12 @@ export default function ZoneEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gain Zones */}
|
||||
{/* Sound Gain */}
|
||||
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
Gain Zones ({gainRanges.length})
|
||||
Sound Gain ({gainRanges.length})
|
||||
</div>
|
||||
|
||||
{/* Global Gain Slider */}
|
||||
@ -366,7 +364,7 @@ export default function ZoneEditor() {
|
||||
<div
|
||||
key={range.id}
|
||||
onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
|
||||
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
@ -404,19 +402,19 @@ export default function ZoneEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speed Zones */}
|
||||
{/* Speed Adjust */}
|
||||
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
|
||||
<Gauge className="w-3.5 h-3.5" />
|
||||
Speed Zones ({speedRanges.length})
|
||||
Speed Adjust ({speedRanges.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{speedRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
|
||||
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
|
||||
@ -187,11 +187,23 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
|
||||
)
|
||||
.join('');
|
||||
|
||||
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;" onclick="event.stopPropagation()">
|
||||
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;position:relative;" onclick="event.stopPropagation()">
|
||||
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
||||
<table style="font-size:13px">${rows}</table>
|
||||
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings • Press ? to close</p>
|
||||
<button id="cheatsheet-close" style="position:absolute;top:12px;right:16px;background:none;border:none;color:#94a3b8;font-size:18px;cursor:pointer;line-height:1;padding:4px;">×</button>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const closeBtn = overlay.querySelector('#cheatsheet-close') as HTMLButtonElement;
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove();
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user