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.
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

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.
- [ ] [#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

View File

@ -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}
/>
<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
icon={<Volume2 className="w-4 h-4" />}
label="Volume"
active={activePanel === 'volume'}
onClick={() => togglePanel('volume')}
icon={<Grid3x3 className="w-4 h-4" />}
label="Zones"
active={activePanel === 'zones'}
onClick={() => togglePanel('zones')}
disabled={!videoPath}
/>
<ToolbarButton
@ -571,7 +583,12 @@ export default function App() {
</div>
</div>
) : 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">
No transcript yet
@ -589,13 +606,8 @@ export default function App() {
{/* Right panel (AI / Export / Settings) */}
{activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
{activePanel === 'volume' && (
<VolumePanel
gainMode={gainMode}
onToggleGainMode={handleGain}
timelineGainDb={gainModeDb}
onTimelineGainDbChange={setGainModeDb}
/>
{activePanel === 'zones' && (
<ZoneEditor />
)}
{activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />}

View File

@ -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<number | null>(null);
const wasDragging = useRef(false);
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 s = new Set<number>();
@ -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() {
</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 (
@ -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"
>
<SlidersHorizontal className="w-3 h-3" />
Gain (+3 dB)
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
</button>
</div>
)}

View File

@ -147,6 +147,7 @@ export default function WaveformTimeline({
const isDraggingRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const selectionStartRef = useRef<number | null>(null);
const selectionEndRef = 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' | '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

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