added save as
This commit is contained in:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user