added save as

This commit is contained in:
2026-04-15 20:51:24 -06:00
parent af8e0cf6eb
commit f121d71f5f
6 changed files with 319 additions and 88 deletions

View File

@ -1,8 +1,9 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import { useEditorStore } from '../store/editorStore';
import { AlertTriangle } from 'lucide-react';
const RULER_H = 20; // px reserved at top of canvas for the time ruler
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
type WaveformData = {
samples: Float32Array;
@ -103,6 +104,117 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
return { major, minor };
}
type TimelineSegment = {
kind: 'keep' | 'cut';
sourceStart: number;
sourceEnd: number;
displayStart: number;
displayEnd: number;
};
function mergeCutRanges(ranges: Array<{ start: number; end: number }>, maxDuration: number) {
const sorted = ranges
.map((range) => ({
start: Math.max(0, Math.min(maxDuration, range.start)),
end: Math.max(0, Math.min(maxDuration, range.end)),
}))
.filter((range) => range.end > range.start)
.sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
for (const range of sorted) {
const last = merged[merged.length - 1];
if (!last || range.start > last.end) {
merged.push({ ...range });
} else {
last.end = Math.max(last.end, range.end);
}
}
return merged;
}
function buildTimelineSegments(sourceDuration: number, cutRanges: Array<{ start: number; end: number }>) {
if (sourceDuration <= 0) {
return { segments: [] as TimelineSegment[], displayDuration: 0 };
}
const mergedCuts = mergeCutRanges(cutRanges, sourceDuration);
const segments: TimelineSegment[] = [];
let sourceCursor = 0;
let displayCursor = 0;
for (const cutRange of mergedCuts) {
if (cutRange.start > sourceCursor) {
const keepDuration = cutRange.start - sourceCursor;
segments.push({
kind: 'keep',
sourceStart: sourceCursor,
sourceEnd: cutRange.start,
displayStart: displayCursor,
displayEnd: displayCursor + keepDuration,
});
displayCursor += keepDuration;
}
const cutDisplayDuration = Math.min(cutRange.end - cutRange.start, COLLAPSED_CUT_DISPLAY_SECONDS);
segments.push({
kind: 'cut',
sourceStart: cutRange.start,
sourceEnd: cutRange.end,
displayStart: displayCursor,
displayEnd: displayCursor + cutDisplayDuration,
});
displayCursor += cutDisplayDuration;
sourceCursor = cutRange.end;
}
if (sourceCursor < sourceDuration) {
const keepDuration = sourceDuration - sourceCursor;
segments.push({
kind: 'keep',
sourceStart: sourceCursor,
sourceEnd: sourceDuration,
displayStart: displayCursor,
displayEnd: displayCursor + keepDuration,
});
displayCursor += keepDuration;
}
return { segments, displayDuration: displayCursor };
}
function sourceToDisplayTime(time: number, segments: TimelineSegment[], sourceDuration: number) {
if (segments.length === 0) return Math.max(0, Math.min(sourceDuration, time));
const clampedTime = Math.max(0, Math.min(sourceDuration, time));
for (const segment of segments) {
if (clampedTime <= segment.sourceEnd || segment === segments[segments.length - 1]) {
const sourceSpan = Math.max(segment.sourceEnd - segment.sourceStart, 1e-6);
const displaySpan = segment.displayEnd - segment.displayStart;
const ratio = (clampedTime - segment.sourceStart) / sourceSpan;
return segment.displayStart + ratio * displaySpan;
}
}
return segments[segments.length - 1].displayEnd;
}
function displayToSourceTime(time: number, segments: TimelineSegment[], displayDuration: number, sourceDuration: number) {
if (segments.length === 0) return Math.max(0, Math.min(sourceDuration, time));
const clampedTime = Math.max(0, Math.min(displayDuration, time));
for (const segment of segments) {
if (clampedTime <= segment.displayEnd || segment === segments[segments.length - 1]) {
const displaySpan = Math.max(segment.displayEnd - segment.displayStart, 1e-6);
const sourceSpan = segment.sourceEnd - segment.sourceStart;
const ratio = (clampedTime - segment.displayStart) / displaySpan;
return segment.sourceStart + ratio * sourceSpan;
}
}
return sourceDuration;
}
export default function WaveformTimeline({
cutMode,
muteMode,
@ -165,6 +277,12 @@ export default function WaveformTimeline({
const [showGainZones, setShowGainZones] = useState(true);
const [showSpeedZones, setShowSpeedZones] = useState(true);
const sourceDuration = duration || waveformDataRef.current?.duration || 0;
const { segments: timelineSegments, displayDuration } = useMemo(
() => buildTimelineSegments(sourceDuration, cutRanges),
[sourceDuration, cutRanges],
);
useEffect(() => {
if (!videoUrl || !videoPath) return;
setAudioError(null);
@ -261,9 +379,10 @@ export default function WaveformTimeline({
const width = rect.width;
const height = rect.height;
const dur = waveformData.duration;
const timelineDur = displayDuration || dur;
const zoom = zoomRef.current;
const scroll = scrollSecsRef.current;
const pxPerSec = (width * zoom) / dur;
const pxPerSec = (width * zoom) / timelineDur;
const sampleRate = waveformData.sampleRate;
const channelData = waveformData.samples;
@ -323,8 +442,8 @@ export default function WaveformTimeline({
// Draw cut ranges (red overlays)
for (const range of showCutZones ? cutRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
ctx.fillStyle = isSelected ? 'rgba(239, 68, 68, 0.5)' : 'rgba(239, 68, 68, 0.3)';
@ -348,8 +467,8 @@ export default function WaveformTimeline({
// Draw mute ranges (blue overlays)
for (const range of showMuteZones ? muteRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.3)';
@ -373,8 +492,8 @@ export default function WaveformTimeline({
// Draw gain ranges (amber overlays)
for (const range of showGainZones ? gainRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - 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)';
@ -397,8 +516,8 @@ export default function WaveformTimeline({
// Draw speed ranges (emerald overlays)
for (const range of showSpeedZones ? speedRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
ctx.fillStyle = isSelected ? 'rgba(16, 185, 129, 0.55)' : 'rgba(16, 185, 129, 0.35)';
@ -432,8 +551,8 @@ export default function WaveformTimeline({
// Draw selection overlay (when in zone mode)
if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) {
const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec;
const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec;
const x1 = (sourceToDisplayTime(Math.min(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec;
const x2 = (sourceToDisplayTime(Math.max(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec;
const fillColor = cutMode
? 'rgba(239, 68, 68, 0.5)'
: muteMode
@ -457,8 +576,8 @@ export default function WaveformTimeline({
ctx.lineWidth = 1;
for (let x = 0; x < width; x++) {
const tStart = scroll + x / pxPerSec;
const tEnd = scroll + (x + 1) / pxPerSec;
const tStart = displayToSourceTime(scroll + x / pxPerSec, timelineSegments, timelineDur, dur);
const tEnd = displayToSourceTime(scroll + (x + 1) / pxPerSec, timelineSegments, timelineDur, dur);
const sStart = Math.floor(tStart * sampleRate);
const sEnd = Math.min(Math.ceil(tEnd * sampleRate), channelData.length);
if (sStart >= channelData.length) break;
@ -486,10 +605,12 @@ export default function WaveformTimeline({
gainMode,
speedMode,
selectedZone,
displayDuration,
showCutZones,
showMuteZones,
showGainZones,
showSpeedZones,
timelineSegments,
]);
// Keep the ref in sync with the latest drawStaticWaveform closure
@ -515,6 +636,7 @@ export default function WaveformTimeline({
const video = document.querySelector('video') as HTMLVideoElement | null;
const dur = waveformDataRef.current?.duration ?? 0;
const timelineDur = displayDuration || dur;
const dpr = window.devicePixelRatio || 1;
const rect = headCanvas.getBoundingClientRect();
@ -529,17 +651,18 @@ export default function WaveformTimeline({
ctx.clearRect(0, 0, width, height);
if (dur > 0 && video) {
const pxPerSec = (width * zoomRef.current) / dur;
let px = (video.currentTime - scrollSecsRef.current) * pxPerSec;
const pxPerSec = (width * zoomRef.current) / timelineDur;
const displayTime = sourceToDisplayTime(video.currentTime, timelineSegments, dur);
let px = (displayTime - scrollSecsRef.current) * pxPerSec;
// If the playhead is off-screen (e.g. after a seek from the transcript),
// scroll so it's centered and redraw the static waveform layer.
if (px < 0 || px > width) {
const visibleSecs = width / pxPerSec;
const maxScroll = Math.max(0, dur - visibleSecs);
scrollSecsRef.current = Math.max(0, Math.min(maxScroll, video.currentTime - visibleSecs / 2));
const maxScroll = Math.max(0, timelineDur - visibleSecs);
scrollSecsRef.current = Math.max(0, Math.min(maxScroll, displayTime - visibleSecs / 2));
drawStaticWaveformRef.current();
px = (video.currentTime - scrollSecsRef.current) * pxPerSec;
px = (displayTime - scrollSecsRef.current) * pxPerSec;
}
ctx.beginPath();
@ -555,7 +678,7 @@ export default function WaveformTimeline({
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [videoUrl]);
}, [videoUrl, displayDuration, timelineSegments]);
useEffect(() => {
const observer = new ResizeObserver(() => {
@ -572,28 +695,29 @@ export default function WaveformTimeline({
const dur = waveformDataRef.current?.duration;
if (!dur) return;
const width = canvas.getBoundingClientRect().width;
const timelineDur = displayDuration || dur;
if (e.ctrlKey || e.metaKey) {
// Zoom around the cursor position
const mouseX = e.clientX - canvas.getBoundingClientRect().left;
const pxPerSecBefore = (width * zoomRef.current) / dur;
const pxPerSecBefore = (width * zoomRef.current) / timelineDur;
const timeCursor = scrollSecsRef.current + mouseX / pxPerSecBefore;
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
zoomRef.current = Math.max(1, Math.min(100, zoomRef.current * factor));
const pxPerSecAfter = (width * zoomRef.current) / dur;
const pxPerSecAfter = (width * zoomRef.current) / timelineDur;
scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter;
} else {
// Scroll horizontally
const pxPerSec = (width * zoomRef.current) / dur;
const pxPerSec = (width * zoomRef.current) / timelineDur;
scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5;
}
// Clamp scroll
const pxPerSec = (width * zoomRef.current) / dur;
const maxScroll = Math.max(0, dur - width / pxPerSec);
const pxPerSec = (width * zoomRef.current) / timelineDur;
const maxScroll = Math.max(0, timelineDur - width / pxPerSec);
scrollSecsRef.current = Math.max(0, Math.min(scrollSecsRef.current, maxScroll));
drawStaticWaveform();
}, [drawStaticWaveform]);
}, [displayDuration, drawStaticWaveform]);
const seekToClientX = useCallback((clientX: number) => {
const canvas = headCanvasRef.current;
@ -601,12 +725,14 @@ export default function WaveformTimeline({
if (!canvas || !dur) return;
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const pxPerSec = (rect.width * zoomRef.current) / dur;
const newTime = Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec));
const timelineDur = displayDuration || dur;
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
const displayTime = Math.max(0, Math.min(timelineDur, scrollSecsRef.current + x / pxPerSec));
const newTime = displayToSourceTime(displayTime, timelineSegments, timelineDur, dur);
setCurrentTime(newTime);
const video = document.querySelector('video') as HTMLVideoElement | null;
if (video) video.currentTime = newTime;
}, [setCurrentTime]);
}, [displayDuration, setCurrentTime, timelineSegments]);
const clientXToTime = useCallback((clientX: number): number => {
const canvas = headCanvasRef.current;
@ -614,9 +740,11 @@ export default function WaveformTimeline({
if (!canvas || !dur) return 0;
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const pxPerSec = (rect.width * zoomRef.current) / dur;
return Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec));
}, []);
const timelineDur = displayDuration || dur;
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
const displayTime = Math.max(0, Math.min(timelineDur, scrollSecsRef.current + x / pxPerSec));
return displayToSourceTime(displayTime, timelineSegments, timelineDur, dur);
}, [displayDuration, timelineSegments]);
const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => {
const dur = waveformDataRef.current?.duration;
@ -626,7 +754,8 @@ export default function WaveformTimeline({
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const pxPerSec = (rect.width * zoomRef.current) / dur;
const timelineDur = displayDuration || dur;
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
const scroll = scrollSecsRef.current;
const waveTop = RULER_H + 1;
const waveH = canvas.height - waveTop;
@ -638,8 +767,8 @@ export default function WaveformTimeline({
// Check cut ranges
for (const range of showCutZones ? cutRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
if (forHover && isSelected) {
@ -680,8 +809,8 @@ export default function WaveformTimeline({
// Check mute ranges
for (const range of showMuteZones ? muteRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
if (forHover && isSelected) {
@ -722,8 +851,8 @@ export default function WaveformTimeline({
// Check gain ranges
for (const range of showGainZones ? gainRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
if (forHover && isSelected) {
@ -757,8 +886,8 @@ export default function WaveformTimeline({
// Check speed ranges
for (const range of showSpeedZones ? speedRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
if (forHover && isSelected) {
@ -791,7 +920,7 @@ export default function WaveformTimeline({
}
return null;
}, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
}, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones, displayDuration, timelineSegments]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) return; // Don't change cursor while dragging