improved tools for ai

This commit is contained in:
2026-04-15 16:36:21 -06:00
parent 4f90750497
commit d11e26cf2d
25 changed files with 2618 additions and 33 deletions

26
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,26 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'off',
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,20 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tauri-apps/cli": "^2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.58.2",
"vite": "^6.0.0"
}
}

View File

@ -22,6 +22,7 @@ import {
Scissors,
VolumeX,
Volume2,
SlidersHorizontal,
FilePlus2,
RefreshCw,
} from 'lucide-react';
@ -57,6 +58,7 @@ export default function App() {
selectedWordIndices,
addCutRange,
addMuteRange,
addGainRange,
} = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null);
@ -64,6 +66,8 @@ export default function App() {
const [whisperModel, setWhisperModel] = useState('base');
const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false);
const [gainMode, setGainMode] = useState(false);
const [gainModeDb, setGainModeDb] = useState(3);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
@ -133,12 +137,13 @@ export default function App() {
useKeyboardShortcuts();
// Handle Escape key to exit cut/mute modes
// Handle Escape key to exit timeline zone modes
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setCutMode(false);
setMuteMode(false);
setGainMode(false);
}
};
@ -236,6 +241,7 @@ export default function App() {
setManualPath('');
setCutMode(false);
setMuteMode(false);
setGainMode(false);
});
};
@ -348,6 +354,7 @@ export default function App() {
// Toggle cut mode
setCutMode(!cutMode);
setMuteMode(false); // Exit mute mode
setGainMode(false); // Exit gain mode
}
};
@ -362,6 +369,20 @@ export default function App() {
// Toggle mute mode
setMuteMode(!muteMode);
setCutMode(false); // Exit cut mode
setGainMode(false); // Exit gain mode
}
};
const handleGain = () => {
if (selectedWordIndices.length > 0) {
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addGainRange(startTime, endTime, gainModeDb);
} else {
setGainMode(!gainMode);
setCutMode(false);
setMuteMode(false);
}
};
@ -518,6 +539,12 @@ export default function App() {
onClick={handleMute}
active={muteMode}
/>
<ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />}
label="Gain Zone"
onClick={handleGain}
active={gainMode}
/>
<ToolbarButton
icon={<Volume2 className="w-4 h-4" />}
label="Volume"
@ -635,14 +662,21 @@ export default function App() {
{/* Waveform timeline */}
<div className="h-32 border-t border-editor-border shrink-0">
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} />
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} gainMode={gainMode} gainModeDb={gainModeDb} />
</div>
</div>
{/* Right panel (AI / Export / Settings) */}
{activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
{activePanel === 'volume' && <VolumePanel />}
{activePanel === 'volume' && (
<VolumePanel
gainMode={gainMode}
onToggleGainMode={handleGain}
timelineGainDb={gainModeDb}
onTimelineGainDbChange={setGainModeDb}
/>
)}
{activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />}
{activePanel === 'export' && <ExportDialog />}

View File

@ -2,7 +2,19 @@ import { useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Trash2, Volume2 } from 'lucide-react';
export default function VolumePanel() {
interface VolumePanelProps {
gainMode: boolean;
onToggleGainMode: () => void;
timelineGainDb: number;
onTimelineGainDbChange: (gainDb: number) => void;
}
export default function VolumePanel({
gainMode,
onToggleGainMode,
timelineGainDb,
onTimelineGainDbChange,
}: VolumePanelProps) {
const {
words,
selectedWordIndices,
@ -65,6 +77,34 @@ export default function VolumePanel() {
</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">

View File

@ -103,7 +103,17 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
return { major, minor };
}
export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boolean; muteMode: boolean }) {
export default function WaveformTimeline({
cutMode,
muteMode,
gainMode,
gainModeDb,
}: {
cutMode: boolean;
muteMode: boolean;
gainMode: boolean;
gainModeDb: number;
}) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -116,13 +126,17 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange);
const addGainRange = useEditorStore((s) => s.addGainRange);
const updateCutRange = useEditorStore((s) => s.updateCutRange);
const updateMuteRange = useEditorStore((s) => s.updateMuteRange);
const updateGainRangeBounds = useEditorStore((s) => s.updateGainRangeBounds);
const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange);
const waveformDataRef = useRef<WaveformData | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
@ -135,10 +149,13 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const selectionStartRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute', id: string} | null>(null);
const [editingZone, setEditingZone] = useState<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null);
const [editingZone, setEditingZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const [hoverCursor, setHoverCursor] = useState<string>('crosshair');
const editingZoneRef = useRef<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const [showCutZones, setShowCutZones] = useState(true);
const [showMuteZones, setShowMuteZones] = useState(true);
const [showGainZones, setShowGainZones] = useState(true);
useEffect(() => {
if (!videoUrl || !videoPath) return;
@ -304,7 +321,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
}
// Draw cut ranges (red overlays)
for (const range of cutRanges) {
for (const range of showCutZones ? cutRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
@ -329,7 +346,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
}
// Draw mute ranges (blue overlays)
for (const range of muteRanges) {
for (const range of showMuteZones ? muteRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
@ -353,15 +370,45 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
}
}
// Draw selection overlay (when in cut/mute mode)
if ((cutMode || muteMode) && selectionStart !== null && selectionEnd !== null) {
// Draw gain ranges (amber overlays)
for (const range of showGainZones ? gainRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
ctx.fillStyle = isSelected ? 'rgba(245, 158, 11, 0.55)' : 'rgba(245, 158, 11, 0.35)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
if (isSelected) {
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
ctx.fill();
}
}
// Draw selection overlay (when in zone mode)
if ((cutMode || muteMode || gainMode) && selectionStart !== null && selectionEnd !== null) {
const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec;
const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec;
ctx.fillStyle = cutMode ? 'rgba(239, 68, 68, 0.5)' : 'rgba(59, 130, 246, 0.5)';
const fillColor = cutMode
? 'rgba(239, 68, 68, 0.5)'
: muteMode
? 'rgba(59, 130, 246, 0.5)'
: 'rgba(245, 158, 11, 0.5)';
const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : '#f59e0b';
ctx.fillStyle = fillColor;
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
// Add border
ctx.strokeStyle = cutMode ? '#ef4444' : '#3b82f6';
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
}
@ -389,7 +436,21 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
ctx.lineTo(x, mid + max * amp);
}
ctx.stroke();
}, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode, selectedZone]);
}, [
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
selectionStart,
selectionEnd,
cutMode,
muteMode,
gainMode,
selectedZone,
showCutZones,
showMuteZones,
showGainZones,
]);
// Keep the ref in sync with the latest drawStaticWaveform closure
useEffect(() => {
@ -537,7 +598,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click
// Check cut ranges
for (const range of cutRanges) {
for (const range of showCutZones ? cutRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
@ -579,7 +640,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
}
// Check mute ranges
for (const range of muteRanges) {
for (const range of showMuteZones ? muteRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
@ -620,8 +681,43 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
}
}
// Check gain ranges
for (const range of showGainZones ? gainRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
if (forHover && isSelected) {
if (Math.abs(x - rangeX1) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
}
} else if (!forHover) {
if (isSelected) {
if (Math.abs(x - rangeX1) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
}
} else {
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
}
}
if (x >= rangeX1 && x <= rangeX2) {
return { type: 'gain' as const, id: range.id, edge: 'move' as const };
}
}
}
return null;
}, [cutRanges, muteRanges, selectedZone]);
}, [cutRanges, muteRanges, gainRanges, selectedZone, showCutZones, showMuteZones, showGainZones]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) return; // Don't change cursor while dragging
@ -654,9 +750,11 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
setIsDragging(true);
const startTime = clientXToTime(e.clientX);
const originalRange = zoneHit.type === 'cut'
const originalRange = zoneHit.type === 'cut'
? cutRanges.find(r => r.id === zoneHit.id)
: muteRanges.find(r => r.id === zoneHit.id);
: zoneHit.type === 'mute'
? muteRanges.find(r => r.id === zoneHit.id)
: gainRanges.find(r => r.id === zoneHit.id);
if (!originalRange) return;
@ -686,8 +784,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
if (newStart < newEnd) {
if (editingZoneRef.current.type === 'cut') {
updateCutRange(editingZoneRef.current.id, newStart, newEnd);
} else {
} else if (editingZoneRef.current.type === 'mute') {
updateMuteRange(editingZoneRef.current.id, newStart, newEnd);
} else {
updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd);
}
}
};
@ -710,7 +810,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
setSelectedZone(null);
setEditingZone(null);
if (cutMode || muteMode) {
if (cutMode || muteMode || gainMode) {
// Range selection mode
const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime;
@ -737,6 +837,8 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
addCutRange(start, end);
} else if (muteMode) {
addMuteRange(start, end);
} else if (gainMode) {
addGainRange(start, end, gainModeDb);
}
}
@ -771,7 +873,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
window.addEventListener('mouseup', onUp);
}
},
[cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd, getZoneAtPosition],
[cutMode, muteMode, gainMode, gainModeDb, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, selectionEnd, getZoneAtPosition, cutRanges, muteRanges, gainRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds],
);
// Handle keyboard shortcuts for zone editing
@ -793,8 +895,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
e.stopImmediatePropagation();
if (selectedZone.type === 'cut') {
removeCutRange(selectedZone.id);
} else {
} else if (selectedZone.type === 'mute') {
removeMuteRange(selectedZone.id);
} else {
removeGainRange(selectedZone.id);
}
setSelectedZone(null);
setEditingZone(null);
@ -806,7 +910,14 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [selectedZone, removeCutRange, removeMuteRange]);
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange]);
useEffect(() => {
if (!selectedZone) return;
if (selectedZone.type === 'cut' && !showCutZones) setSelectedZone(null);
if (selectedZone.type === 'mute' && !showMuteZones) setSelectedZone(null);
if (selectedZone.type === 'gain' && !showGainZones) setSelectedZone(null);
}, [selectedZone, showCutZones, showMuteZones, showGainZones]);
if (!videoUrl) {
return (
@ -818,13 +929,41 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
return (
<div ref={containerRef} className="w-full h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-1 shrink-0">
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
Timeline
</span>
<span className="text-[10px] text-editor-text-muted">
Scroll · Ctrl+Scroll to zoom
</span>
<div className="flex items-center justify-between px-3 py-1 shrink-0 gap-3">
<div className="flex items-center gap-2">
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
Timeline
</span>
{cutMode && <span className="text-[10px] text-red-400">Cut mode</span>}
{muteMode && <span className="text-[10px] text-blue-400">Mute mode</span>}
{gainMode && <span className="text-[10px] text-amber-400">Gain mode ({gainModeDb.toFixed(1)} dB)</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCutZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle cut zones"
>
Cut
</button>
<button
onClick={() => setShowMuteZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle mute zones"
>
Mute
</button>
<button
onClick={() => setShowGainZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle gain zones"
>
Gain
</button>
<span className="text-[10px] text-editor-text-muted">
Scroll · Ctrl+Scroll to zoom
</span>
</div>
</div>
{audioError ? (
<div className="flex-1 flex items-start justify-center gap-2 text-editor-text-muted text-xs p-3 overflow-auto">

View File

@ -65,6 +65,7 @@ interface EditorActions {
addGainRange: (start: number, end: number, gainDb: number) => void;
updateCutRange: (id: string, start: number, end: number) => void;
updateMuteRange: (id: string, start: number, end: number) => void;
updateGainRangeBounds: (id: string, start: number, end: number) => void;
updateGainRange: (id: string, gainDb: number) => void;
removeCutRange: (id: string) => void;
removeMuteRange: (id: string) => void;
@ -299,6 +300,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
});
},
updateGainRangeBounds: (id, start, end) => {
const { gainRanges } = get();
set({
gainRanges: gainRanges.map((r) =>
r.id === id ? { ...r, start, end } : r
),
});
},
updateGainRange: (id, gainDb) => {
const { gainRanges } = get();
set({

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/WaveformTimeline.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.ts","./src/types/project.ts"],"errors":true,"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}