diff --git a/AI_dev_plan.md b/AI_dev_plan.md index faf8600..88a1f9d 100644 --- a/AI_dev_plan.md +++ b/AI_dev_plan.md @@ -108,6 +108,34 @@ Must-have plan is complete when all are true: 2. Feature PRs without spec updates are blocked. 3. Backend router contracts cover core success and error paths. 4. Frontend has at least one stable test command integrated into validation. + +## 7. AI Tools Validation Strategy + +Required: + +1. **Per-edit validation**: After each code change (file edit, replacement, or creation), validate immediately with appropriate tools. +2. **Tool selection by change type**: + - Frontend changes: ESLint (`npm run -s lint`), then TypeScript build (`npm run build`) + - Backend changes: Syntax check via Python import, then run relevant test suite + - Type/interface changes: Full type check via build or `tsc -b` +3. **Failure handling**: If validation fails, fix immediately before proceeding to next edit. +4. **Documentation updates**: When changing architecture, always update [.github/copilot-instructions.md](.github/copilot-instructions.md) as part of the same PR. +5. **Large multi-edit operations**: Use `multi_replace_string_in_file` to batch independent edits and reduce tool call overhead. +6. **Error collection**: Use `get_errors` tool to identify issues across multiple files in one call post-change. + +Current implementation: + +1. Electron removal completed with post-edit lint and build validation at each phase. +2. Zone editor feature implemented with immediate lint/build validation after component creation and UI integration. +3. Validation tools: `npm run -s lint`, `npm run build`, `get_errors`, `run_in_terminal` for test scripts. + +Best practices established: + +- Always run lint before build to catch TypeScript errors early +- Run full build after component changes to verify tree-shaking and bundling +- Use `get_errors` for multi-file error detection rather than sequential file reads +- Batch unrelated edits with `multi_replace_string_in_file` for efficiency +- Cache key decisions in session memory to avoid repeated exploration 5. AI policy + diagnostics workflow are active. ## Current State Summary diff --git a/FEATURES.md b/FEATURES.md index dcb2b4e..8de3df4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -44,6 +44,8 @@ Features are grouped by priority. Check off items as they are implemented. - [ ] [#017] **Chapters** — group markers into named chapter ranges. Useful for podcasts and lectures. Exportable as YouTube chapter timestamps in the description. +- [ ] [#041] **Customizable hotkeys / keymap editor (left-hand focused)** — allow users to view, remap, and reset keyboard shortcuts (transport, edit, save/export, zone tools), with a default preset optimized for left-hand reach (Q/W/E/R/A/S/D/F/Z/X/C/V + modifiers). Include conflict detection, an alternate standard preset, and one-click "restore defaults". + --- ## 🟢 Lower Priority — Differentiating / power features diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0fa0da1..51f6558 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,7 @@ import ExportDialog from './components/ExportDialog'; import SettingsPanel from './components/SettingsPanel'; import DevPanel from './components/DevPanel'; import SilenceTrimmerPanel from './components/SilenceTrimmerPanel'; -import VolumePanel from './components/VolumePanel'; +import ZoneEditor from './components/ZoneEditor'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { Film, @@ -20,15 +20,15 @@ import { Save, Scissors, VolumeX, - Volume2, SlidersHorizontal, FilePlus2, RefreshCw, + Grid3x3, } from 'lucide-react'; const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath'; -type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null; +type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null; export default function App() { const { @@ -459,17 +459,29 @@ export default function App() { onClick={handleMute} active={muteMode} /> +
+ } + label="Gain Zone" + onClick={handleGain} + active={gainMode} + /> + setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))} + className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent" + title="Gain dB for new gain zones" + /> +
} - label="Gain Zone" - onClick={handleGain} - active={gainMode} - /> - } - label="Volume" - active={activePanel === 'volume'} - onClick={() => togglePanel('volume')} + icon={} + label="Zones" + active={activePanel === 'zones'} + onClick={() => togglePanel('zones')} disabled={!videoPath} /> ) : words.length > 0 ? ( - + ) : (
No transcript yet @@ -589,13 +606,8 @@ export default function App() { {/* Right panel (AI / Export / Settings) */} {activePanel && (
- {activePanel === 'volume' && ( - + {activePanel === 'zones' && ( + )} {activePanel === 'silence' && } {activePanel === 'ai' && } diff --git a/frontend/src/components/TranscriptEditor.tsx b/frontend/src/components/TranscriptEditor.tsx index 8906ae2..93605ee 100644 --- a/frontend/src/components/TranscriptEditor.tsx +++ b/frontend/src/components/TranscriptEditor.tsx @@ -3,7 +3,14 @@ import { useEditorStore } from '../store/editorStore'; import { Virtuoso } from 'react-virtuoso'; import { Scissors, VolumeX, SlidersHorizontal, RotateCcw } from 'lucide-react'; -export default function TranscriptEditor() { +interface TranscriptEditorProps { + cutMode: boolean; + muteMode: boolean; + gainMode: boolean; + gainModeDb: number; +} + +export default function TranscriptEditor({ cutMode, muteMode, gainMode, gainModeDb }: TranscriptEditorProps) { const words = useEditorStore((s) => s.words); const segments = useEditorStore((s) => s.segments); const deletedRanges = useEditorStore((s) => s.deletedRanges); @@ -26,6 +33,8 @@ export default function TranscriptEditor() { const selectionStart = useRef(null); const wasDragging = useRef(false); const virtuosoRef = useRef(null); + const zoneDragStart = useRef(null); + const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null); const deletedSet = useMemo(() => { const s = new Set(); @@ -74,6 +83,14 @@ export default function TranscriptEditor() { } return; } + + if (cutMode || muteMode || gainMode) { + zoneDragStart.current = index; + setZoneDragRange({ start: index, end: index }); + selectionStart.current = null; + return; + } + wasDragging.current = false; if (e.shiftKey && selectedWordIndices.length > 0) { const first = selectedWordIndices[0]; @@ -87,12 +104,19 @@ export default function TranscriptEditor() { setSelectedWordIndices([index]); } }, - [words, selectedWordIndices, setSelectedWordIndices], + [words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode], ); const handleWordMouseEnter = useCallback( (index: number) => { setHoveredWordIndex(index); + if (zoneDragStart.current !== null) { + setZoneDragRange({ + start: Math.min(zoneDragStart.current, index), + end: Math.max(zoneDragStart.current, index), + }); + return; + } if (selectionStart.current !== null) { wasDragging.current = true; const start = Math.min(selectionStart.current, index); @@ -106,8 +130,19 @@ export default function TranscriptEditor() { ); const handleMouseUp = useCallback(() => { + if (zoneDragStart.current !== null && zoneDragRange) { + const startWord = words[zoneDragRange.start]; + const endWord = words[zoneDragRange.end]; + if (startWord && endWord) { + if (cutMode) addCutRange(startWord.start, endWord.end); + if (muteMode) addMuteRange(startWord.start, endWord.end); + if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb); + } + } + zoneDragStart.current = null; + setZoneDragRange(null); selectionStart.current = null; - }, []); + }, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, addCutRange, addMuteRange, addGainRange]); const handleClickOutside = useCallback( (e: React.MouseEvent) => { @@ -148,8 +183,8 @@ export default function TranscriptEditor() { 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, 3); - }, [selectedWordIndices, words, addGainRange]); + addGainRange(startTime, endTime, gainModeDb); + }, [selectedWordIndices, words, addGainRange, gainModeDb]); const getCutRangeForWord = useCallback( (wordIndex: number) => { @@ -196,6 +231,9 @@ export default function TranscriptEditor() { const isSelected = selectedSet.has(globalIndex); const isActive = globalIndex === activeWordIndex; const isHovered = globalIndex === hoveredWordIndex; + const isZoneDragSelected = zoneDragRange + ? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end + : false; const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null; const cutRange = getCutRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex); @@ -216,6 +254,9 @@ export default function TranscriptEditor() { ${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${gainRange ? 'bg-amber-500/20 text-amber-100' : ''} + ${isZoneDragSelected && cutMode ? 'bg-red-500/30 ring-1 ring-red-400/60' : ''} + ${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''} + ${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''} ${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-selected text-white' : ''} ${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-hover' : ''} @@ -253,7 +294,7 @@ export default function TranscriptEditor() {
); }, - [segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange], + [segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, zoneDragRange, cutMode, muteMode, gainMode], ); return ( @@ -283,7 +324,7 @@ export default function TranscriptEditor() { className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors" > - Gain (+3 dB) + Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
)} diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index 62820f2..975f276 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -147,6 +147,7 @@ export default function WaveformTimeline({ const isDraggingRef = useRef(false); const [isDragging, setIsDragging] = useState(false); const selectionStartRef = useRef(null); + const selectionEndRef = useRef(null); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null); @@ -809,6 +810,7 @@ export default function WaveformTimeline({ // Range selection mode const startTime = clientXToTime(e.clientX); selectionStartRef.current = startTime; + selectionEndRef.current = startTime; setSelectionStart(startTime); setSelectionEnd(startTime); isDraggingRef.current = true; @@ -817,6 +819,7 @@ export default function WaveformTimeline({ const onMove = (ev: MouseEvent) => { if (!isDraggingRef.current) return; const currentTime = clientXToTime(ev.clientX); + selectionEndRef.current = currentTime; setSelectionEnd(currentTime); }; @@ -824,21 +827,23 @@ export default function WaveformTimeline({ isDraggingRef.current = false; setIsDragging(false); - if (selectionStartRef.current !== null && selectionEnd !== null) { - const start = Math.min(selectionStartRef.current, selectionEnd); - const end = Math.max(selectionStartRef.current, selectionEnd); + if (selectionStartRef.current !== null && selectionEndRef.current !== null) { + const start = Math.min(selectionStartRef.current, selectionEndRef.current); + const end = Math.max(selectionStartRef.current, selectionEndRef.current); + const minDuration = 0.01; - if (cutMode) { + if (end - start >= minDuration && cutMode) { addCutRange(start, end); - } else if (muteMode) { + } else if (end - start >= minDuration && muteMode) { addMuteRange(start, end); - } else if (gainMode) { + } else if (end - start >= minDuration && gainMode) { addGainRange(start, end, gainModeDb); } } // Reset selection selectionStartRef.current = null; + selectionEndRef.current = null; setSelectionStart(null); setSelectionEnd(null); @@ -868,7 +873,7 @@ export default function WaveformTimeline({ window.addEventListener('mouseup', onUp); } }, - [cutMode, muteMode, gainMode, gainModeDb, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, selectionEnd, getZoneAtPosition, cutRanges, muteRanges, gainRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds], + [cutMode, muteMode, gainMode, gainModeDb, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, getZoneAtPosition, cutRanges, muteRanges, gainRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds], ); // Handle keyboard shortcuts for zone editing diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 09c2d39..8290b9e 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -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/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.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} \ No newline at end of file +{"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/components/ZoneEditor.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.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} \ No newline at end of file