added cut and mute zones

This commit is contained in:
2026-04-03 11:14:31 -06:00
parent d7bc6ea74d
commit 585262c3e7
13 changed files with 554 additions and 117 deletions

View File

@ -24,7 +24,7 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
return { major, minor };
}
export default function WaveformTimeline() {
export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boolean; muteMode: boolean }) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -35,7 +35,11 @@ export default function WaveformTimeline() {
const backendUrl = useEditorStore((s) => s.backendUrl);
const duration = useEditorStore((s) => s.duration);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange);
const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
@ -46,6 +50,9 @@ export default function WaveformTimeline() {
const drawStaticWaveformRef = useRef<() => void>(() => {});
const isDraggingRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const selectionStartRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
useEffect(() => {
if (!videoUrl || !videoPath) return;
@ -205,6 +212,35 @@ export default function WaveformTimeline() {
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw cut ranges (red overlays)
for (const range of cutRanges) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
ctx.fillStyle = 'rgba(239, 68, 68, 0.3)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw mute ranges (blue overlays)
for (const range of muteRanges) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
ctx.fillStyle = 'rgba(59, 130, 246, 0.3)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw selection overlay (when in cut/mute mode)
if ((cutMode || muteMode) && 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)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
// Add border
ctx.strokeStyle = cutMode ? '#ef4444' : '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
}
const mid = waveTop + waveH / 2;
ctx.beginPath();
ctx.strokeStyle = '#4a4d5e';
@ -228,7 +264,7 @@ export default function WaveformTimeline() {
ctx.lineTo(x, mid + max * amp);
}
ctx.stroke();
}, [deletedRanges]);
}, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode]);
// Keep the ref in sync with the latest drawStaticWaveform closure
useEffect(() => {
@ -347,27 +383,82 @@ export default function WaveformTimeline() {
if (video) video.currentTime = newTime;
}, [setCurrentTime]);
const clientXToTime = useCallback((clientX: number): number => {
const buffer = audioBufferRef.current;
const canvas = headCanvasRef.current;
if (!canvas || !buffer) return 0;
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const pxPerSec = (rect.width * zoomRef.current) / buffer.duration;
return Math.max(0, Math.min(buffer.duration, scrollSecsRef.current + x / pxPerSec));
}, []);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
isDraggingRef.current = true;
setIsDragging(true);
seekToClientX(e.clientX);
if (cutMode || muteMode) {
// Range selection mode
const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime;
setSelectionStart(startTime);
setSelectionEnd(startTime);
isDraggingRef.current = true;
setIsDragging(true);
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return;
seekToClientX(ev.clientX);
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return;
const currentTime = clientXToTime(ev.clientX);
setSelectionEnd(currentTime);
};
const onUp = () => {
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 (cutMode) {
addCutRange(start, end);
} else if (muteMode) {
addMuteRange(start, end);
}
}
// Reset selection
selectionStartRef.current = null;
setSelectionStart(null);
setSelectionEnd(null);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
} else {
// Normal seek mode
isDraggingRef.current = true;
setIsDragging(true);
seekToClientX(e.clientX);
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return;
seekToClientX(ev.clientX);
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
},
[seekToClientX],
[cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd],
);
if (!videoUrl) {