improved zone handling

This commit is contained in:
2026-04-15 18:00:34 -06:00
parent 84edddded8
commit 17874587a4
6 changed files with 124 additions and 36 deletions

View File

@ -108,6 +108,34 @@ Must-have plan is complete when all are true:
2. Feature PRs without spec updates are blocked. 2. Feature PRs without spec updates are blocked.
3. Backend router contracts cover core success and error paths. 3. Backend router contracts cover core success and error paths.
4. Frontend has at least one stable test command integrated into validation. 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. 5. AI policy + diagnostics workflow are active.
## Current State Summary ## Current State Summary

View File

@ -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. - [ ] [#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 ## 🟢 Lower Priority — Differentiating / power features

View File

@ -8,7 +8,7 @@ import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel'; import SettingsPanel from './components/SettingsPanel';
import DevPanel from './components/DevPanel'; import DevPanel from './components/DevPanel';
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel'; import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
import VolumePanel from './components/VolumePanel'; import ZoneEditor from './components/ZoneEditor';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { import {
Film, Film,
@ -20,15 +20,15 @@ import {
Save, Save,
Scissors, Scissors,
VolumeX, VolumeX,
Volume2,
SlidersHorizontal, SlidersHorizontal,
FilePlus2, FilePlus2,
RefreshCw, RefreshCw,
Grid3x3,
} from 'lucide-react'; } from 'lucide-react';
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath'; 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() { export default function App() {
const { const {
@ -459,17 +459,29 @@ export default function App() {
onClick={handleMute} onClick={handleMute}
active={muteMode} active={muteMode}
/> />
<div className="flex items-center gap-1">
<ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />}
label="Gain Zone"
onClick={handleGain}
active={gainMode}
/>
<input
type="number"
min={-24}
max={24}
step={0.5}
value={gainModeDb}
onChange={(e) => 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"
/>
</div>
<ToolbarButton <ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />} icon={<Grid3x3 className="w-4 h-4" />}
label="Gain Zone" label="Zones"
onClick={handleGain} active={activePanel === 'zones'}
active={gainMode} onClick={() => togglePanel('zones')}
/>
<ToolbarButton
icon={<Volume2 className="w-4 h-4" />}
label="Volume"
active={activePanel === 'volume'}
onClick={() => togglePanel('volume')}
disabled={!videoPath} disabled={!videoPath}
/> />
<ToolbarButton <ToolbarButton
@ -571,7 +583,12 @@ export default function App() {
</div> </div>
</div> </div>
) : words.length > 0 ? ( ) : words.length > 0 ? (
<TranscriptEditor /> <TranscriptEditor
cutMode={cutMode}
muteMode={muteMode}
gainMode={gainMode}
gainModeDb={gainModeDb}
/>
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm"> <div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm">
No transcript yet No transcript yet
@ -589,13 +606,8 @@ export default function App() {
{/* Right panel (AI / Export / Settings) */} {/* Right panel (AI / Export / Settings) */}
{activePanel && ( {activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0"> <div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
{activePanel === 'volume' && ( {activePanel === 'zones' && (
<VolumePanel <ZoneEditor />
gainMode={gainMode}
onToggleGainMode={handleGain}
timelineGainDb={gainModeDb}
onTimelineGainDbChange={setGainModeDb}
/>
)} )}
{activePanel === 'silence' && <SilenceTrimmerPanel />} {activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />} {activePanel === 'ai' && <AIPanel />}

View File

@ -3,7 +3,14 @@ import { useEditorStore } from '../store/editorStore';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { Scissors, VolumeX, SlidersHorizontal, RotateCcw } from 'lucide-react'; 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 words = useEditorStore((s) => s.words);
const segments = useEditorStore((s) => s.segments); const segments = useEditorStore((s) => s.segments);
const deletedRanges = useEditorStore((s) => s.deletedRanges); const deletedRanges = useEditorStore((s) => s.deletedRanges);
@ -26,6 +33,8 @@ export default function TranscriptEditor() {
const selectionStart = useRef<number | null>(null); const selectionStart = useRef<number | null>(null);
const wasDragging = useRef(false); const wasDragging = useRef(false);
const virtuosoRef = useRef<any>(null); const virtuosoRef = useRef<any>(null);
const zoneDragStart = useRef<number | null>(null);
const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null);
const deletedSet = useMemo(() => { const deletedSet = useMemo(() => {
const s = new Set<number>(); const s = new Set<number>();
@ -74,6 +83,14 @@ export default function TranscriptEditor() {
} }
return; return;
} }
if (cutMode || muteMode || gainMode) {
zoneDragStart.current = index;
setZoneDragRange({ start: index, end: index });
selectionStart.current = null;
return;
}
wasDragging.current = false; wasDragging.current = false;
if (e.shiftKey && selectedWordIndices.length > 0) { if (e.shiftKey && selectedWordIndices.length > 0) {
const first = selectedWordIndices[0]; const first = selectedWordIndices[0];
@ -87,12 +104,19 @@ export default function TranscriptEditor() {
setSelectedWordIndices([index]); setSelectedWordIndices([index]);
} }
}, },
[words, selectedWordIndices, setSelectedWordIndices], [words, selectedWordIndices, setSelectedWordIndices, cutMode, muteMode, gainMode],
); );
const handleWordMouseEnter = useCallback( const handleWordMouseEnter = useCallback(
(index: number) => { (index: number) => {
setHoveredWordIndex(index); setHoveredWordIndex(index);
if (zoneDragStart.current !== null) {
setZoneDragRange({
start: Math.min(zoneDragStart.current, index),
end: Math.max(zoneDragStart.current, index),
});
return;
}
if (selectionStart.current !== null) { if (selectionStart.current !== null) {
wasDragging.current = true; wasDragging.current = true;
const start = Math.min(selectionStart.current, index); const start = Math.min(selectionStart.current, index);
@ -106,8 +130,19 @@ export default function TranscriptEditor() {
); );
const handleMouseUp = useCallback(() => { 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; selectionStart.current = null;
}, []); }, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, addCutRange, addMuteRange, addGainRange]);
const handleClickOutside = useCallback( const handleClickOutside = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -148,8 +183,8 @@ export default function TranscriptEditor() {
const sorted = [...selectedWordIndices].sort((a, b) => a - b); const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start; const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end; const endTime = words[sorted[sorted.length - 1]].end;
addGainRange(startTime, endTime, 3); addGainRange(startTime, endTime, gainModeDb);
}, [selectedWordIndices, words, addGainRange]); }, [selectedWordIndices, words, addGainRange, gainModeDb]);
const getCutRangeForWord = useCallback( const getCutRangeForWord = useCallback(
(wordIndex: number) => { (wordIndex: number) => {
@ -196,6 +231,9 @@ export default function TranscriptEditor() {
const isSelected = selectedSet.has(globalIndex); const isSelected = selectedSet.has(globalIndex);
const isActive = globalIndex === activeWordIndex; const isActive = globalIndex === activeWordIndex;
const isHovered = globalIndex === hoveredWordIndex; const isHovered = globalIndex === hoveredWordIndex;
const isZoneDragSelected = zoneDragRange
? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end
: false;
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null; const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
const cutRange = getCutRangeForWord(globalIndex); const cutRange = getCutRangeForWord(globalIndex);
const muteRange = getMuteRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex);
@ -216,6 +254,9 @@ export default function TranscriptEditor() {
${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${cutRange ? 'bg-red-500/20 text-red-100' : ''}
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
${gainRange ? 'bg-amber-500/20 text-amber-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' : ''} ${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-hover' : ''} ${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange ? 'bg-editor-word-hover' : ''}
@ -253,7 +294,7 @@ export default function TranscriptEditor() {
</div> </div>
); );
}, },
[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 ( 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" 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"
> >
<SlidersHorizontal className="w-3 h-3" /> <SlidersHorizontal className="w-3 h-3" />
Gain (+3 dB) Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
</button> </button>
</div> </div>
)} )}

View File

@ -147,6 +147,7 @@ export default function WaveformTimeline({
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const selectionStartRef = useRef<number | null>(null); const selectionStartRef = useRef<number | null>(null);
const selectionEndRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null); const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null); const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null); const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null);
@ -809,6 +810,7 @@ export default function WaveformTimeline({
// Range selection mode // Range selection mode
const startTime = clientXToTime(e.clientX); const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime; selectionStartRef.current = startTime;
selectionEndRef.current = startTime;
setSelectionStart(startTime); setSelectionStart(startTime);
setSelectionEnd(startTime); setSelectionEnd(startTime);
isDraggingRef.current = true; isDraggingRef.current = true;
@ -817,6 +819,7 @@ export default function WaveformTimeline({
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return; if (!isDraggingRef.current) return;
const currentTime = clientXToTime(ev.clientX); const currentTime = clientXToTime(ev.clientX);
selectionEndRef.current = currentTime;
setSelectionEnd(currentTime); setSelectionEnd(currentTime);
}; };
@ -824,21 +827,23 @@ export default function WaveformTimeline({
isDraggingRef.current = false; isDraggingRef.current = false;
setIsDragging(false); setIsDragging(false);
if (selectionStartRef.current !== null && selectionEnd !== null) { if (selectionStartRef.current !== null && selectionEndRef.current !== null) {
const start = Math.min(selectionStartRef.current, selectionEnd); const start = Math.min(selectionStartRef.current, selectionEndRef.current);
const end = Math.max(selectionStartRef.current, selectionEnd); const end = Math.max(selectionStartRef.current, selectionEndRef.current);
const minDuration = 0.01;
if (cutMode) { if (end - start >= minDuration && cutMode) {
addCutRange(start, end); addCutRange(start, end);
} else if (muteMode) { } else if (end - start >= minDuration && muteMode) {
addMuteRange(start, end); addMuteRange(start, end);
} else if (gainMode) { } else if (end - start >= minDuration && gainMode) {
addGainRange(start, end, gainModeDb); addGainRange(start, end, gainModeDb);
} }
} }
// Reset selection // Reset selection
selectionStartRef.current = null; selectionStartRef.current = null;
selectionEndRef.current = null;
setSelectionStart(null); setSelectionStart(null);
setSelectionEnd(null); setSelectionEnd(null);
@ -868,7 +873,7 @@ export default function WaveformTimeline({
window.addEventListener('mouseup', onUp); 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 // Handle keyboard shortcuts for zone editing

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/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"} {"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"}