2026-04-15 20:51:24 -06:00
|
|
|
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
2026-03-03 06:31:04 -05:00
|
|
|
import { useEditorStore } from '../store/editorStore';
|
2026-03-28 15:09:56 -06:00
|
|
|
import { AlertTriangle } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
2026-04-15 20:51:24 -06:00
|
|
|
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
2026-03-28 15:09:56 -06:00
|
|
|
|
2026-04-09 01:50:19 -06:00
|
|
|
type WaveformData = {
|
|
|
|
|
samples: Float32Array;
|
|
|
|
|
sampleRate: number;
|
|
|
|
|
duration: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function parsePcm16Wav(arrayBuffer: ArrayBuffer): WaveformData {
|
|
|
|
|
const view = new DataView(arrayBuffer);
|
|
|
|
|
if (view.byteLength < 44) {
|
|
|
|
|
throw new Error('WAV file too small');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = (offset: number, length: number) => {
|
|
|
|
|
let s = '';
|
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
|
|
|
s += String.fromCharCode(view.getUint8(offset + i));
|
|
|
|
|
}
|
|
|
|
|
return s;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (text(0, 4) !== 'RIFF' || text(8, 4) !== 'WAVE') {
|
|
|
|
|
throw new Error('Not a RIFF/WAVE file');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fmtOffset = -1;
|
|
|
|
|
let dataOffset = -1;
|
|
|
|
|
let dataSize = 0;
|
|
|
|
|
let offset = 12;
|
|
|
|
|
|
|
|
|
|
while (offset + 8 <= view.byteLength) {
|
|
|
|
|
const chunkId = text(offset, 4);
|
|
|
|
|
const chunkSize = view.getUint32(offset + 4, true);
|
|
|
|
|
const chunkDataStart = offset + 8;
|
|
|
|
|
|
|
|
|
|
if (chunkId === 'fmt ') {
|
|
|
|
|
fmtOffset = chunkDataStart;
|
|
|
|
|
} else if (chunkId === 'data') {
|
|
|
|
|
dataOffset = chunkDataStart;
|
|
|
|
|
dataSize = chunkSize;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
offset = chunkDataStart + chunkSize + (chunkSize % 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fmtOffset < 0 || dataOffset < 0) {
|
|
|
|
|
throw new Error('Missing WAV fmt/data chunk');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const audioFormat = view.getUint16(fmtOffset, true);
|
|
|
|
|
const channels = view.getUint16(fmtOffset + 2, true);
|
|
|
|
|
const sampleRate = view.getUint32(fmtOffset + 4, true);
|
|
|
|
|
const bitsPerSample = view.getUint16(fmtOffset + 14, true);
|
|
|
|
|
|
|
|
|
|
if (audioFormat !== 1 || bitsPerSample !== 16) {
|
|
|
|
|
throw new Error(`Unsupported WAV format (format=${audioFormat}, bits=${bitsPerSample})`);
|
|
|
|
|
}
|
|
|
|
|
if (channels < 1) {
|
|
|
|
|
throw new Error('Invalid channel count in WAV');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bytesPerSample = bitsPerSample / 8;
|
|
|
|
|
const frameCount = Math.floor(dataSize / (channels * bytesPerSample));
|
|
|
|
|
const samples = new Float32Array(frameCount);
|
|
|
|
|
|
|
|
|
|
let p = dataOffset;
|
|
|
|
|
for (let i = 0; i < frameCount; i++) {
|
|
|
|
|
const sample = view.getInt16(p, true);
|
|
|
|
|
samples[i] = sample / 32768;
|
|
|
|
|
p += channels * bytesPerSample;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
samples,
|
|
|
|
|
sampleRate,
|
|
|
|
|
duration: frameCount / sampleRate,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 15:09:56 -06:00
|
|
|
function formatTime(secs: number): string {
|
|
|
|
|
const m = Math.floor(secs / 60);
|
|
|
|
|
const s = secs % 60;
|
|
|
|
|
if (m > 0) return `${m}:${String(Math.floor(s)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`;
|
|
|
|
|
return `${s.toFixed(1)}s`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickInterval(pxPerSec: number): { major: number; minor: number } {
|
|
|
|
|
const NICE = [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
|
|
|
let major = NICE[NICE.length - 1];
|
|
|
|
|
for (const n of NICE) {
|
|
|
|
|
if (n * pxPerSec >= 70) { major = n; break; }
|
|
|
|
|
}
|
|
|
|
|
let minor = major;
|
|
|
|
|
for (const n of NICE) {
|
|
|
|
|
if (n * pxPerSec >= 6 && n < major) { minor = n; }
|
|
|
|
|
}
|
|
|
|
|
return { major, minor };
|
|
|
|
|
}
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-04-15 20:51:24 -06:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:36:21 -06:00
|
|
|
export default function WaveformTimeline({
|
|
|
|
|
cutMode,
|
|
|
|
|
muteMode,
|
|
|
|
|
gainMode,
|
|
|
|
|
gainModeDb,
|
2026-04-15 19:54:39 -06:00
|
|
|
speedMode,
|
|
|
|
|
speedModeValue,
|
2026-04-15 16:36:21 -06:00
|
|
|
}: {
|
|
|
|
|
cutMode: boolean;
|
|
|
|
|
muteMode: boolean;
|
|
|
|
|
gainMode: boolean;
|
|
|
|
|
gainModeDb: number;
|
2026-04-15 19:54:39 -06:00
|
|
|
speedMode: boolean;
|
|
|
|
|
speedModeValue: number;
|
2026-04-15 16:36:21 -06:00
|
|
|
}) {
|
2026-03-03 06:31:04 -05:00
|
|
|
const waveCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const headCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [audioError, setAudioError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const videoUrl = useEditorStore((s) => s.videoUrl);
|
|
|
|
|
const videoPath = useEditorStore((s) => s.videoPath);
|
2026-03-28 12:26:45 -06:00
|
|
|
const backendUrl = useEditorStore((s) => s.backendUrl);
|
2026-03-03 06:31:04 -05:00
|
|
|
const duration = useEditorStore((s) => s.duration);
|
2026-04-03 11:14:31 -06:00
|
|
|
const cutRanges = useEditorStore((s) => s.cutRanges);
|
|
|
|
|
const muteRanges = useEditorStore((s) => s.muteRanges);
|
2026-04-15 16:36:21 -06:00
|
|
|
const gainRanges = useEditorStore((s) => s.gainRanges);
|
2026-04-15 19:54:39 -06:00
|
|
|
const speedRanges = useEditorStore((s) => s.speedRanges);
|
2026-03-03 06:31:04 -05:00
|
|
|
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
2026-04-03 11:14:31 -06:00
|
|
|
const addCutRange = useEditorStore((s) => s.addCutRange);
|
|
|
|
|
const addMuteRange = useEditorStore((s) => s.addMuteRange);
|
2026-04-15 16:36:21 -06:00
|
|
|
const addGainRange = useEditorStore((s) => s.addGainRange);
|
2026-04-15 19:54:39 -06:00
|
|
|
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
|
2026-04-03 11:36:08 -06:00
|
|
|
const updateCutRange = useEditorStore((s) => s.updateCutRange);
|
|
|
|
|
const updateMuteRange = useEditorStore((s) => s.updateMuteRange);
|
2026-04-15 16:36:21 -06:00
|
|
|
const updateGainRangeBounds = useEditorStore((s) => s.updateGainRangeBounds);
|
2026-04-15 19:54:39 -06:00
|
|
|
const updateSpeedRangeBounds = useEditorStore((s) => s.updateSpeedRangeBounds);
|
2026-04-03 11:36:08 -06:00
|
|
|
const removeCutRange = useEditorStore((s) => s.removeCutRange);
|
|
|
|
|
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
2026-04-15 16:36:21 -06:00
|
|
|
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
2026-04-15 19:54:39 -06:00
|
|
|
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-04-09 01:50:19 -06:00
|
|
|
const waveformDataRef = useRef<WaveformData | null>(null);
|
2026-03-28 15:09:56 -06:00
|
|
|
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
|
|
|
|
|
const scrollSecsRef = useRef(0); // seconds scrolled from left
|
2026-03-03 06:31:04 -05:00
|
|
|
const rafRef = useRef(0);
|
2026-03-30 18:36:41 -06:00
|
|
|
// Ref so the RAF loop can call drawStaticWaveform without a stale closure
|
|
|
|
|
const drawStaticWaveformRef = useRef<() => void>(() => {});
|
|
|
|
|
const isDraggingRef = useRef(false);
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
2026-04-03 11:14:31 -06:00
|
|
|
const selectionStartRef = useRef<number | null>(null);
|
2026-04-15 18:00:34 -06:00
|
|
|
const selectionEndRef = useRef<number | null>(null);
|
2026-04-03 11:14:31 -06:00
|
|
|
const [selectionStart, setSelectionStart] = useState<number | null>(null);
|
|
|
|
|
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
|
2026-04-15 19:54:39 -06:00
|
|
|
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string} | null>(null);
|
2026-04-03 11:36:08 -06:00
|
|
|
const [hoverCursor, setHoverCursor] = useState<string>('crosshair');
|
2026-04-15 19:54:39 -06:00
|
|
|
const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain' | 'speed', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
|
2026-04-15 16:36:21 -06:00
|
|
|
const [showCutZones, setShowCutZones] = useState(true);
|
|
|
|
|
const [showMuteZones, setShowMuteZones] = useState(true);
|
|
|
|
|
const [showGainZones, setShowGainZones] = useState(true);
|
2026-04-15 19:54:39 -06:00
|
|
|
const [showSpeedZones, setShowSpeedZones] = useState(true);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-04-15 20:51:24 -06:00
|
|
|
const sourceDuration = duration || waveformDataRef.current?.duration || 0;
|
|
|
|
|
const { segments: timelineSegments, displayDuration } = useMemo(
|
|
|
|
|
() => buildTimelineSegments(sourceDuration, cutRanges),
|
|
|
|
|
[sourceDuration, cutRanges],
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!videoUrl || !videoPath) return;
|
|
|
|
|
setAudioError(null);
|
|
|
|
|
|
2026-04-09 01:50:19 -06:00
|
|
|
let cancelled = false;
|
|
|
|
|
const controller = new AbortController();
|
2026-03-03 06:31:04 -05:00
|
|
|
const loadAudio = async () => {
|
2026-04-09 01:36:28 -06:00
|
|
|
const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
2026-03-03 06:31:04 -05:00
|
|
|
try {
|
2026-03-28 12:26:45 -06:00
|
|
|
const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`;
|
2026-04-09 01:36:28 -06:00
|
|
|
console.log('[WaveformTimeline] req=', requestId, 'backendUrl=', backendUrl, 'videoPath=', videoPath);
|
|
|
|
|
console.log('[WaveformTimeline] req=', requestId, 'fetching=', waveformUrl);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-04-09 01:36:28 -06:00
|
|
|
const startedAt = performance.now();
|
2026-04-09 01:50:19 -06:00
|
|
|
const response = await fetch(waveformUrl, { signal: controller.signal });
|
|
|
|
|
if (cancelled) return;
|
2026-04-09 01:36:28 -06:00
|
|
|
const elapsedMs = Math.round(performance.now() - startedAt);
|
2026-03-28 12:26:45 -06:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const body = await response.text().catch(() => '');
|
|
|
|
|
console.error(
|
2026-04-09 01:36:28 -06:00
|
|
|
`[WaveformTimeline] req=${requestId} fetch failed — HTTP ${response.status} ${response.statusText}`,
|
|
|
|
|
{
|
|
|
|
|
url: waveformUrl,
|
|
|
|
|
decodedPath: videoPath,
|
|
|
|
|
elapsedMs,
|
|
|
|
|
body,
|
|
|
|
|
}
|
2026-03-28 12:26:45 -06:00
|
|
|
);
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contentType = response.headers.get('content-type') ?? 'unknown';
|
|
|
|
|
const contentLength = response.headers.get('content-length');
|
|
|
|
|
console.log(
|
2026-04-09 01:36:28 -06:00
|
|
|
`[WaveformTimeline] req=${requestId} fetch ok — content-type: ${contentType}, size: ${contentLength ?? 'unknown'} bytes, elapsed: ${elapsedMs}ms`
|
2026-03-28 12:26:45 -06:00
|
|
|
);
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
2026-04-09 01:50:19 -06:00
|
|
|
if (cancelled) return;
|
2026-04-09 01:36:28 -06:00
|
|
|
console.log(`[WaveformTimeline] req=${requestId} arrayBuffer size: ${arrayBuffer.byteLength} bytes`);
|
2026-03-28 12:26:45 -06:00
|
|
|
|
|
|
|
|
if (arrayBuffer.byteLength === 0) {
|
|
|
|
|
throw new Error('Server returned an empty file');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 01:50:19 -06:00
|
|
|
const waveformData = parsePcm16Wav(arrayBuffer);
|
2026-03-28 12:26:45 -06:00
|
|
|
|
|
|
|
|
console.log(
|
2026-04-09 01:50:19 -06:00
|
|
|
`[WaveformTimeline] req=${requestId} parsed wav ok — duration: ${waveformData.duration.toFixed(2)}s, ` +
|
|
|
|
|
`sampleRate: ${waveformData.sampleRate}Hz, samples: ${waveformData.samples.length}`
|
2026-03-28 12:26:45 -06:00
|
|
|
);
|
2026-04-09 01:50:19 -06:00
|
|
|
if (cancelled) return;
|
|
|
|
|
waveformDataRef.current = waveformData;
|
2026-04-15 17:13:56 -06:00
|
|
|
drawStaticWaveformRef.current();
|
2026-03-03 06:31:04 -05:00
|
|
|
} catch (err) {
|
2026-04-09 01:50:19 -06:00
|
|
|
if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) {
|
|
|
|
|
console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-09 01:36:28 -06:00
|
|
|
console.error('[WaveformTimeline] waveform load failed', {
|
|
|
|
|
requestId,
|
|
|
|
|
error: err,
|
|
|
|
|
videoPath,
|
|
|
|
|
backendUrl,
|
|
|
|
|
encodedPath: encodeURIComponent(videoPath ?? ''),
|
|
|
|
|
});
|
2026-03-28 15:09:56 -06:00
|
|
|
const waveformUrl2 = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath ?? '')}`;
|
|
|
|
|
setAudioError(`Waveform unavailable — ${err instanceof Error ? err.message : 'audio could not be decoded'} [URL: ${waveformUrl2}]`);
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadAudio();
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-04-09 01:50:19 -06:00
|
|
|
cancelled = true;
|
|
|
|
|
controller.abort();
|
2026-03-03 06:31:04 -05:00
|
|
|
};
|
2026-03-28 12:26:45 -06:00
|
|
|
}, [videoUrl, videoPath, backendUrl]);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
const drawStaticWaveform = useCallback(() => {
|
|
|
|
|
const canvas = waveCanvasRef.current;
|
2026-04-09 01:50:19 -06:00
|
|
|
const waveformData = waveformDataRef.current;
|
|
|
|
|
if (!canvas || !waveformData) return;
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
canvas.width = rect.width * dpr;
|
|
|
|
|
canvas.height = rect.height * dpr;
|
|
|
|
|
ctx.scale(dpr, dpr);
|
|
|
|
|
|
|
|
|
|
const width = rect.width;
|
|
|
|
|
const height = rect.height;
|
2026-04-09 01:50:19 -06:00
|
|
|
const dur = waveformData.duration;
|
2026-04-15 20:51:24 -06:00
|
|
|
const timelineDur = displayDuration || dur;
|
2026-03-28 15:09:56 -06:00
|
|
|
const zoom = zoomRef.current;
|
|
|
|
|
const scroll = scrollSecsRef.current;
|
2026-04-15 20:51:24 -06:00
|
|
|
const pxPerSec = (width * zoom) / timelineDur;
|
2026-04-09 01:50:19 -06:00
|
|
|
const sampleRate = waveformData.sampleRate;
|
|
|
|
|
const channelData = waveformData.samples;
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
|
|
2026-03-28 15:09:56 -06:00
|
|
|
// --- Ruler background ---
|
|
|
|
|
ctx.fillStyle = '#13141f';
|
|
|
|
|
ctx.fillRect(0, 0, width, RULER_H);
|
|
|
|
|
|
|
|
|
|
// Separator line
|
|
|
|
|
ctx.strokeStyle = '#2a2d3e';
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.moveTo(0, RULER_H);
|
|
|
|
|
ctx.lineTo(width, RULER_H);
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
// --- Ruler ticks & labels ---
|
|
|
|
|
const { major, minor } = pickInterval(pxPerSec);
|
|
|
|
|
const visibleDur = width / pxPerSec;
|
|
|
|
|
|
|
|
|
|
// Minor ticks
|
|
|
|
|
const minorStart = Math.floor(scroll / minor) * minor;
|
|
|
|
|
ctx.strokeStyle = '#3a3d52';
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
for (let t = minorStart; t <= scroll + visibleDur + minor; t = Math.round((t + minor) * 1e6) / 1e6) {
|
|
|
|
|
const x = (t - scroll) * pxPerSec;
|
|
|
|
|
if (x < 0 || x > width) continue;
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.moveTo(x, RULER_H);
|
|
|
|
|
ctx.lineTo(x, RULER_H * 0.45);
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Major ticks + labels
|
|
|
|
|
const majorStart = Math.floor(scroll / major) * major;
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
ctx.font = `9px "JetBrains Mono", "Courier New", monospace`;
|
|
|
|
|
ctx.textBaseline = 'top';
|
|
|
|
|
for (let t = majorStart; t <= scroll + visibleDur + major; t = Math.round((t + major) * 1e6) / 1e6) {
|
|
|
|
|
const x = (t - scroll) * pxPerSec;
|
|
|
|
|
if (x < -50 || x > width + 50) continue;
|
|
|
|
|
ctx.strokeStyle = '#4a4f6a';
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.moveTo(x, RULER_H);
|
|
|
|
|
ctx.lineTo(x, 0);
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
if (x >= 2 && x < width - 2) {
|
|
|
|
|
ctx.fillStyle = '#6b7280';
|
|
|
|
|
ctx.fillText(formatTime(t), x + 3, 2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Waveform ---
|
|
|
|
|
const waveTop = RULER_H + 1;
|
|
|
|
|
const waveH = height - waveTop;
|
|
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
// Draw cut ranges (red overlays)
|
2026-04-15 16:36:21 -06:00
|
|
|
for (const range of showCutZones ? cutRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-03 11:36:08 -06:00
|
|
|
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = isSelected ? 'rgba(239, 68, 68, 0.5)' : 'rgba(239, 68, 68, 0.3)';
|
2026-04-03 11:14:31 -06:00
|
|
|
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
|
2026-04-03 11:36:08 -06:00
|
|
|
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
ctx.strokeStyle = '#ef4444';
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
// Draw resize handles
|
|
|
|
|
ctx.fillStyle = '#ef4444';
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
}
|
2026-04-03 11:14:31 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw mute ranges (blue overlays)
|
2026-04-15 16:36:21 -06:00
|
|
|
for (const range of showMuteZones ? muteRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-03 11:36:08 -06:00
|
|
|
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.3)';
|
2026-04-03 11:14:31 -06:00
|
|
|
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
|
2026-04-03 11:36:08 -06:00
|
|
|
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
ctx.strokeStyle = '#3b82f6';
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
// Draw resize handles
|
|
|
|
|
ctx.fillStyle = '#3b82f6';
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
}
|
2026-04-03 11:14:31 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:36:21 -06:00
|
|
|
// Draw gain ranges (amber overlays)
|
|
|
|
|
for (const range of showGainZones ? gainRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-15 16:36:21 -06:00
|
|
|
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = isSelected ? 'rgba(245, 158, 11, 0.55)' : 'rgba(245, 158, 11, 0.35)';
|
|
|
|
|
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
ctx.strokeStyle = '#f59e0b';
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#f59e0b';
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:54:39 -06:00
|
|
|
// Draw speed ranges (emerald overlays)
|
|
|
|
|
for (const range of showSpeedZones ? speedRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-15 19:54:39 -06:00
|
|
|
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = isSelected ? 'rgba(16, 185, 129, 0.55)' : 'rgba(16, 185, 129, 0.35)';
|
|
|
|
|
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
ctx.strokeStyle = '#10b981';
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#10b981';
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const centerX = (x1 + x2) / 2;
|
|
|
|
|
ctx.fillStyle = '#d1fae5';
|
|
|
|
|
ctx.font = '10px sans-serif';
|
|
|
|
|
ctx.textAlign = 'center';
|
2026-04-15 20:17:05 -06:00
|
|
|
ctx.textBaseline = 'top';
|
2026-04-15 19:54:39 -06:00
|
|
|
if (centerX > 12 && centerX < width - 12) {
|
2026-04-15 20:17:05 -06:00
|
|
|
ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + 4);
|
2026-04-15 19:54:39 -06:00
|
|
|
}
|
|
|
|
|
ctx.textAlign = 'start';
|
|
|
|
|
ctx.textBaseline = 'alphabetic';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:36:21 -06:00
|
|
|
// Draw selection overlay (when in zone mode)
|
2026-04-15 19:54:39 -06:00
|
|
|
if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const x1 = (sourceToDisplayTime(Math.min(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const x2 = (sourceToDisplayTime(Math.max(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-15 16:36:21 -06:00
|
|
|
const fillColor = cutMode
|
|
|
|
|
? 'rgba(239, 68, 68, 0.5)'
|
|
|
|
|
: muteMode
|
|
|
|
|
? 'rgba(59, 130, 246, 0.5)'
|
2026-04-15 19:54:39 -06:00
|
|
|
: gainMode
|
|
|
|
|
? 'rgba(245, 158, 11, 0.5)'
|
|
|
|
|
: 'rgba(16, 185, 129, 0.5)';
|
|
|
|
|
const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : gainMode ? '#f59e0b' : '#10b981';
|
2026-04-15 16:36:21 -06:00
|
|
|
ctx.fillStyle = fillColor;
|
2026-04-03 11:14:31 -06:00
|
|
|
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
|
|
|
|
|
// Add border
|
2026-04-15 16:36:21 -06:00
|
|
|
ctx.strokeStyle = strokeColor;
|
2026-04-03 11:14:31 -06:00
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 15:09:56 -06:00
|
|
|
const mid = waveTop + waveH / 2;
|
2026-03-03 06:31:04 -05:00
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.strokeStyle = '#4a4d5e';
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
|
|
|
|
|
for (let x = 0; x < width; x++) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const tStart = displayToSourceTime(scroll + x / pxPerSec, timelineSegments, timelineDur, dur);
|
|
|
|
|
const tEnd = displayToSourceTime(scroll + (x + 1) / pxPerSec, timelineSegments, timelineDur, dur);
|
2026-03-28 15:09:56 -06:00
|
|
|
const sStart = Math.floor(tStart * sampleRate);
|
|
|
|
|
const sEnd = Math.min(Math.ceil(tEnd * sampleRate), channelData.length);
|
|
|
|
|
if (sStart >= channelData.length) break;
|
|
|
|
|
|
|
|
|
|
let min = 0, max = 0;
|
|
|
|
|
for (let i = sStart; i < sEnd; i++) {
|
2026-03-03 06:31:04 -05:00
|
|
|
if (channelData[i] < min) min = channelData[i];
|
|
|
|
|
if (channelData[i] > max) max = channelData[i];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 15:09:56 -06:00
|
|
|
const amp = (waveH / 2) * 0.9;
|
|
|
|
|
ctx.moveTo(x, mid + min * amp);
|
|
|
|
|
ctx.lineTo(x, mid + max * amp);
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
ctx.stroke();
|
2026-04-15 16:36:21 -06:00
|
|
|
}, [
|
|
|
|
|
cutRanges,
|
|
|
|
|
muteRanges,
|
|
|
|
|
gainRanges,
|
2026-04-15 19:54:39 -06:00
|
|
|
speedRanges,
|
2026-04-15 16:36:21 -06:00
|
|
|
selectionStart,
|
|
|
|
|
selectionEnd,
|
|
|
|
|
cutMode,
|
|
|
|
|
muteMode,
|
|
|
|
|
gainMode,
|
2026-04-15 19:54:39 -06:00
|
|
|
speedMode,
|
2026-04-15 16:36:21 -06:00
|
|
|
selectedZone,
|
2026-04-15 20:51:24 -06:00
|
|
|
displayDuration,
|
2026-04-15 16:36:21 -06:00
|
|
|
showCutZones,
|
|
|
|
|
showMuteZones,
|
|
|
|
|
showGainZones,
|
2026-04-15 19:54:39 -06:00
|
|
|
showSpeedZones,
|
2026-04-15 20:51:24 -06:00
|
|
|
timelineSegments,
|
2026-04-15 16:36:21 -06:00
|
|
|
]);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-03-30 18:36:41 -06:00
|
|
|
// Keep the ref in sync with the latest drawStaticWaveform closure
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
drawStaticWaveformRef.current = drawStaticWaveform;
|
|
|
|
|
}, [drawStaticWaveform]);
|
|
|
|
|
|
2026-04-15 20:17:05 -06:00
|
|
|
// Redraw static layer when cutRanges change
|
2026-03-03 06:31:04 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
drawStaticWaveform();
|
|
|
|
|
}, [drawStaticWaveform]);
|
|
|
|
|
|
|
|
|
|
// Lightweight RAF loop for playhead only -- reads video.currentTime directly,
|
|
|
|
|
// never triggers React re-renders
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const headCanvas = headCanvasRef.current;
|
|
|
|
|
const waveCanvas = waveCanvasRef.current;
|
|
|
|
|
if (!headCanvas || !waveCanvas) return;
|
|
|
|
|
|
|
|
|
|
const tick = () => {
|
|
|
|
|
const ctx = headCanvas.getContext('2d');
|
|
|
|
|
if (!ctx) { rafRef.current = requestAnimationFrame(tick); return; }
|
|
|
|
|
|
|
|
|
|
const video = document.querySelector('video') as HTMLVideoElement | null;
|
2026-04-09 01:50:19 -06:00
|
|
|
const dur = waveformDataRef.current?.duration ?? 0;
|
2026-04-15 20:51:24 -06:00
|
|
|
const timelineDur = displayDuration || dur;
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
const rect = headCanvas.getBoundingClientRect();
|
|
|
|
|
if (headCanvas.width !== waveCanvas.width || headCanvas.height !== waveCanvas.height) {
|
|
|
|
|
headCanvas.width = rect.width * dpr;
|
|
|
|
|
headCanvas.height = rect.height * dpr;
|
|
|
|
|
}
|
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
|
|
|
|
|
|
const width = rect.width;
|
|
|
|
|
const height = rect.height;
|
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
|
|
|
|
|
|
if (dur > 0 && video) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const pxPerSec = (width * zoomRef.current) / timelineDur;
|
|
|
|
|
const displayTime = sourceToDisplayTime(video.currentTime, timelineSegments, dur);
|
|
|
|
|
let px = (displayTime - scrollSecsRef.current) * pxPerSec;
|
2026-03-30 18:36:41 -06:00
|
|
|
|
|
|
|
|
// 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;
|
2026-04-15 20:51:24 -06:00
|
|
|
const maxScroll = Math.max(0, timelineDur - visibleSecs);
|
|
|
|
|
scrollSecsRef.current = Math.max(0, Math.min(maxScroll, displayTime - visibleSecs / 2));
|
2026-03-30 18:36:41 -06:00
|
|
|
drawStaticWaveformRef.current();
|
2026-04-15 20:51:24 -06:00
|
|
|
px = (displayTime - scrollSecsRef.current) * pxPerSec;
|
2026-03-28 15:09:56 -06:00
|
|
|
}
|
2026-03-30 18:36:41 -06:00
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.strokeStyle = '#6366f1';
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
ctx.moveTo(px, 0);
|
|
|
|
|
ctx.lineTo(px, height);
|
|
|
|
|
ctx.stroke();
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rafRef.current = requestAnimationFrame(tick);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
rafRef.current = requestAnimationFrame(tick);
|
|
|
|
|
return () => cancelAnimationFrame(rafRef.current);
|
2026-04-15 20:51:24 -06:00
|
|
|
}, [videoUrl, displayDuration, timelineSegments]);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
|
|
|
drawStaticWaveform();
|
|
|
|
|
});
|
|
|
|
|
if (containerRef.current) observer.observe(containerRef.current);
|
|
|
|
|
return () => observer.disconnect();
|
|
|
|
|
}, [drawStaticWaveform]);
|
|
|
|
|
|
2026-03-28 15:09:56 -06:00
|
|
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const canvas = waveCanvasRef.current;
|
2026-04-09 01:50:19 -06:00
|
|
|
if (!canvas) return;
|
|
|
|
|
const dur = waveformDataRef.current?.duration;
|
|
|
|
|
if (!dur) return;
|
2026-03-28 15:09:56 -06:00
|
|
|
const width = canvas.getBoundingClientRect().width;
|
2026-04-15 20:51:24 -06:00
|
|
|
const timelineDur = displayDuration || dur;
|
2026-03-28 15:09:56 -06:00
|
|
|
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
|
// Zoom around the cursor position
|
|
|
|
|
const mouseX = e.clientX - canvas.getBoundingClientRect().left;
|
2026-04-15 20:51:24 -06:00
|
|
|
const pxPerSecBefore = (width * zoomRef.current) / timelineDur;
|
2026-03-28 15:09:56 -06:00
|
|
|
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));
|
2026-04-15 20:51:24 -06:00
|
|
|
const pxPerSecAfter = (width * zoomRef.current) / timelineDur;
|
2026-03-28 15:09:56 -06:00
|
|
|
scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter;
|
|
|
|
|
} else {
|
|
|
|
|
// Scroll horizontally
|
2026-04-15 20:51:24 -06:00
|
|
|
const pxPerSec = (width * zoomRef.current) / timelineDur;
|
2026-03-28 15:09:56 -06:00
|
|
|
scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clamp scroll
|
2026-04-15 20:51:24 -06:00
|
|
|
const pxPerSec = (width * zoomRef.current) / timelineDur;
|
|
|
|
|
const maxScroll = Math.max(0, timelineDur - width / pxPerSec);
|
2026-03-28 15:09:56 -06:00
|
|
|
scrollSecsRef.current = Math.max(0, Math.min(scrollSecsRef.current, maxScroll));
|
|
|
|
|
drawStaticWaveform();
|
2026-04-15 20:51:24 -06:00
|
|
|
}, [displayDuration, drawStaticWaveform]);
|
2026-03-28 15:09:56 -06:00
|
|
|
|
2026-03-30 18:36:41 -06:00
|
|
|
const seekToClientX = useCallback((clientX: number) => {
|
|
|
|
|
const canvas = headCanvasRef.current;
|
2026-04-09 01:50:19 -06:00
|
|
|
const dur = waveformDataRef.current?.duration;
|
|
|
|
|
if (!canvas || !dur) return;
|
2026-03-30 18:36:41 -06:00
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
const x = clientX - rect.left;
|
2026-04-15 20:51:24 -06:00
|
|
|
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);
|
2026-03-30 18:36:41 -06:00
|
|
|
setCurrentTime(newTime);
|
|
|
|
|
const video = document.querySelector('video') as HTMLVideoElement | null;
|
|
|
|
|
if (video) video.currentTime = newTime;
|
2026-04-15 20:51:24 -06:00
|
|
|
}, [displayDuration, setCurrentTime, timelineSegments]);
|
2026-03-30 18:36:41 -06:00
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
const clientXToTime = useCallback((clientX: number): number => {
|
|
|
|
|
const canvas = headCanvasRef.current;
|
2026-04-09 01:50:19 -06:00
|
|
|
const dur = waveformDataRef.current?.duration;
|
|
|
|
|
if (!canvas || !dur) return 0;
|
2026-04-03 11:14:31 -06:00
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
const x = clientX - rect.left;
|
2026-04-15 20:51:24 -06:00
|
|
|
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]);
|
2026-04-03 11:14:31 -06:00
|
|
|
|
2026-04-03 11:36:08 -06:00
|
|
|
const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => {
|
2026-04-09 01:50:19 -06:00
|
|
|
const dur = waveformDataRef.current?.duration;
|
2026-04-03 11:36:08 -06:00
|
|
|
const canvas = waveCanvasRef.current;
|
2026-04-09 01:50:19 -06:00
|
|
|
if (!canvas || !dur) return null;
|
2026-04-03 11:36:08 -06:00
|
|
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
const x = clientX - rect.left;
|
|
|
|
|
const y = clientY - rect.top;
|
2026-04-15 20:51:24 -06:00
|
|
|
const timelineDur = displayDuration || dur;
|
|
|
|
|
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
|
2026-04-03 11:36:08 -06:00
|
|
|
const scroll = scrollSecsRef.current;
|
|
|
|
|
const waveTop = RULER_H + 1;
|
|
|
|
|
const waveH = canvas.height - waveTop;
|
|
|
|
|
|
|
|
|
|
// Check if click is in waveform area
|
|
|
|
|
if (y < waveTop || y > waveTop + waveH) return null;
|
|
|
|
|
|
|
|
|
|
const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click
|
|
|
|
|
|
|
|
|
|
// Check cut ranges
|
2026-04-15 16:36:21 -06:00
|
|
|
for (const range of showCutZones ? cutRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-03 11:36:08 -06:00
|
|
|
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
if (forHover && isSelected) {
|
|
|
|
|
// For hover on selected zones, check edges
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else if (!forHover) {
|
|
|
|
|
// For click detection, check handles and body
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
// For selected zones, allow clicking on edges for resizing
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// For unselected zones, check small handle circles
|
|
|
|
|
// Check start handle
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
// Check end handle
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Check range body
|
|
|
|
|
if (x >= rangeX1 && x <= rangeX2) {
|
|
|
|
|
return { type: 'cut' as const, id: range.id, edge: 'move' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check mute ranges
|
2026-04-15 16:36:21 -06:00
|
|
|
for (const range of showMuteZones ? muteRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-03 11:36:08 -06:00
|
|
|
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
if (forHover && isSelected) {
|
|
|
|
|
// For hover on selected zones, check edges
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else if (!forHover) {
|
|
|
|
|
// For click detection, check handles and body
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
// For selected zones, allow clicking on edges for resizing
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// For unselected zones, check small handle circles
|
|
|
|
|
// Check start handle
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
// Check end handle
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Check range body
|
|
|
|
|
if (x >= rangeX1 && x <= rangeX2) {
|
|
|
|
|
return { type: 'mute' as const, id: range.id, edge: 'move' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 16:36:21 -06:00
|
|
|
// Check gain ranges
|
|
|
|
|
for (const range of showGainZones ? gainRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-15 16:36:21 -06:00
|
|
|
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
if (forHover && isSelected) {
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else if (!forHover) {
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (x >= rangeX1 && x <= rangeX2) {
|
|
|
|
|
return { type: 'gain' as const, id: range.id, edge: 'move' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:54:39 -06:00
|
|
|
// Check speed ranges
|
|
|
|
|
for (const range of showSpeedZones ? speedRanges : []) {
|
2026-04-15 20:51:24 -06:00
|
|
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
|
|
|
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
2026-04-15 19:54:39 -06:00
|
|
|
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
|
|
|
|
|
|
|
|
|
|
if (forHover && isSelected) {
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else if (!forHover) {
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'start' as const };
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'end' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (x >= rangeX1 && x <= rangeX2) {
|
|
|
|
|
return { type: 'speed' as const, id: range.id, edge: 'move' as const };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:36:08 -06:00
|
|
|
return null;
|
2026-04-15 20:51:24 -06:00
|
|
|
}, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones, displayDuration, timelineSegments]);
|
2026-04-03 11:36:08 -06:00
|
|
|
|
|
|
|
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
if (isDragging) return; // Don't change cursor while dragging
|
|
|
|
|
|
|
|
|
|
const zoneHit = getZoneAtPosition(e.clientX, e.clientY, true);
|
|
|
|
|
if (zoneHit && selectedZone && zoneHit.id === selectedZone.id) {
|
|
|
|
|
if (zoneHit.edge === 'start' || zoneHit.edge === 'end') {
|
|
|
|
|
setHoverCursor('ew-resize');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setHoverCursor('crosshair');
|
|
|
|
|
}, [isDragging, getZoneAtPosition, selectedZone]);
|
|
|
|
|
|
2026-03-30 18:36:41 -06:00
|
|
|
const handleMouseDown = useCallback(
|
2026-03-03 06:31:04 -05:00
|
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
2026-03-30 18:36:41 -06:00
|
|
|
e.preventDefault();
|
2026-04-03 11:14:31 -06:00
|
|
|
|
2026-04-03 11:36:08 -06:00
|
|
|
// Check if clicking on a zone
|
|
|
|
|
const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
|
|
|
|
|
if (zoneHit) {
|
|
|
|
|
if (zoneHit.edge === 'move') {
|
|
|
|
|
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
|
|
|
|
|
editingZoneRef.current = zoneHit;
|
|
|
|
|
}
|
|
|
|
|
isDraggingRef.current = true;
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
|
|
|
|
|
const startTime = clientXToTime(e.clientX);
|
2026-04-15 16:36:21 -06:00
|
|
|
const originalRange = zoneHit.type === 'cut'
|
2026-04-03 11:36:08 -06:00
|
|
|
? cutRanges.find(r => r.id === zoneHit.id)
|
2026-04-15 16:36:21 -06:00
|
|
|
: zoneHit.type === 'mute'
|
|
|
|
|
? muteRanges.find(r => r.id === zoneHit.id)
|
2026-04-15 19:54:39 -06:00
|
|
|
: zoneHit.type === 'gain'
|
|
|
|
|
? gainRanges.find(r => r.id === zoneHit.id)
|
|
|
|
|
: speedRanges.find(r => r.id === zoneHit.id);
|
2026-04-03 11:36:08 -06:00
|
|
|
|
|
|
|
|
if (!originalRange) return;
|
|
|
|
|
|
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
|
|
|
if (!isDraggingRef.current || !editingZoneRef.current) return;
|
|
|
|
|
const currentTime = clientXToTime(ev.clientX);
|
|
|
|
|
const delta = currentTime - startTime;
|
|
|
|
|
const minZoneDuration = 0.05;
|
|
|
|
|
|
|
|
|
|
let newStart = originalRange.start;
|
|
|
|
|
let newEnd = originalRange.end;
|
|
|
|
|
|
|
|
|
|
if (editingZoneRef.current.edge === 'start') {
|
|
|
|
|
newStart = Math.max(0, Math.min(originalRange.end - minZoneDuration, originalRange.start + delta));
|
|
|
|
|
newEnd = originalRange.end; // Keep end fixed when dragging start
|
|
|
|
|
} else if (editingZoneRef.current.edge === 'end') {
|
|
|
|
|
newStart = originalRange.start; // Keep start fixed when dragging end
|
|
|
|
|
newEnd = Math.min(duration, Math.max(newStart + minZoneDuration, originalRange.end + delta));
|
|
|
|
|
} else if (editingZoneRef.current.edge === 'move') {
|
|
|
|
|
const zoneDuration = originalRange.end - originalRange.start;
|
|
|
|
|
const maxStart = Math.max(0, duration - zoneDuration);
|
|
|
|
|
newStart = Math.max(0, Math.min(maxStart, originalRange.start + delta));
|
|
|
|
|
newEnd = newStart + zoneDuration;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure valid range
|
|
|
|
|
if (newStart < newEnd) {
|
|
|
|
|
if (editingZoneRef.current.type === 'cut') {
|
|
|
|
|
updateCutRange(editingZoneRef.current.id, newStart, newEnd);
|
2026-04-15 16:36:21 -06:00
|
|
|
} else if (editingZoneRef.current.type === 'mute') {
|
2026-04-03 11:36:08 -06:00
|
|
|
updateMuteRange(editingZoneRef.current.id, newStart, newEnd);
|
2026-04-15 19:54:39 -06:00
|
|
|
} else if (editingZoneRef.current.type === 'gain') {
|
2026-04-15 16:36:21 -06:00
|
|
|
updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd);
|
2026-04-15 19:54:39 -06:00
|
|
|
} else {
|
|
|
|
|
updateSpeedRangeBounds(editingZoneRef.current.id, newStart, newEnd);
|
2026-04-03 11:36:08 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onUp = () => {
|
|
|
|
|
isDraggingRef.current = false;
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
editingZoneRef.current = null;
|
|
|
|
|
window.removeEventListener('mousemove', onMove);
|
|
|
|
|
window.removeEventListener('mouseup', onUp);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('mousemove', onMove);
|
|
|
|
|
window.addEventListener('mouseup', onUp);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear selection if clicking elsewhere
|
|
|
|
|
setSelectedZone(null);
|
|
|
|
|
|
2026-04-15 19:54:39 -06:00
|
|
|
if (cutMode || muteMode || gainMode || speedMode) {
|
2026-04-03 11:14:31 -06:00
|
|
|
// Range selection mode
|
|
|
|
|
const startTime = clientXToTime(e.clientX);
|
|
|
|
|
selectionStartRef.current = startTime;
|
2026-04-15 18:00:34 -06:00
|
|
|
selectionEndRef.current = startTime;
|
2026-04-03 11:14:31 -06:00
|
|
|
setSelectionStart(startTime);
|
|
|
|
|
setSelectionEnd(startTime);
|
|
|
|
|
isDraggingRef.current = true;
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
|
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
|
|
|
if (!isDraggingRef.current) return;
|
|
|
|
|
const currentTime = clientXToTime(ev.clientX);
|
2026-04-15 18:00:34 -06:00
|
|
|
selectionEndRef.current = currentTime;
|
2026-04-03 11:14:31 -06:00
|
|
|
setSelectionEnd(currentTime);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onUp = () => {
|
|
|
|
|
isDraggingRef.current = false;
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
|
2026-04-15 18:00:34 -06:00
|
|
|
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;
|
2026-04-03 11:14:31 -06:00
|
|
|
|
2026-04-15 18:00:34 -06:00
|
|
|
if (end - start >= minDuration && cutMode) {
|
2026-04-03 11:14:31 -06:00
|
|
|
addCutRange(start, end);
|
2026-04-15 18:00:34 -06:00
|
|
|
} else if (end - start >= minDuration && muteMode) {
|
2026-04-03 11:14:31 -06:00
|
|
|
addMuteRange(start, end);
|
2026-04-15 18:00:34 -06:00
|
|
|
} else if (end - start >= minDuration && gainMode) {
|
2026-04-15 16:36:21 -06:00
|
|
|
addGainRange(start, end, gainModeDb);
|
2026-04-15 19:54:39 -06:00
|
|
|
} else if (end - start >= minDuration && speedMode) {
|
|
|
|
|
addSpeedRange(start, end, speedModeValue);
|
2026-04-03 11:14:31 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset selection
|
|
|
|
|
selectionStartRef.current = null;
|
2026-04-15 18:00:34 -06:00
|
|
|
selectionEndRef.current = null;
|
2026-04-03 11:14:31 -06:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-03 06:31:04 -05:00
|
|
|
},
|
2026-04-15 19:54:39 -06:00
|
|
|
[cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, addSpeedRange, getZoneAtPosition, cutRanges, muteRanges, gainRanges, speedRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds, updateSpeedRangeBounds],
|
2026-03-03 06:31:04 -05:00
|
|
|
);
|
|
|
|
|
|
2026-04-03 11:36:08 -06:00
|
|
|
// Handle keyboard shortcuts for zone editing
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
2026-04-03 11:38:58 -06:00
|
|
|
const target = e.target as HTMLElement | null;
|
|
|
|
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:36:08 -06:00
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
setSelectedZone(null);
|
|
|
|
|
editingZoneRef.current = null;
|
|
|
|
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
|
|
|
if (selectedZone) {
|
|
|
|
|
e.preventDefault();
|
2026-04-03 11:38:58 -06:00
|
|
|
e.stopPropagation();
|
|
|
|
|
e.stopImmediatePropagation();
|
2026-04-03 11:36:08 -06:00
|
|
|
if (selectedZone.type === 'cut') {
|
|
|
|
|
removeCutRange(selectedZone.id);
|
2026-04-15 16:36:21 -06:00
|
|
|
} else if (selectedZone.type === 'mute') {
|
2026-04-03 11:36:08 -06:00
|
|
|
removeMuteRange(selectedZone.id);
|
2026-04-15 19:54:39 -06:00
|
|
|
} else if (selectedZone.type === 'gain') {
|
2026-04-15 16:36:21 -06:00
|
|
|
removeGainRange(selectedZone.id);
|
2026-04-15 19:54:39 -06:00
|
|
|
} else {
|
|
|
|
|
removeSpeedRange(selectedZone.id);
|
2026-04-03 11:36:08 -06:00
|
|
|
}
|
|
|
|
|
setSelectedZone(null);
|
|
|
|
|
editingZoneRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-03 11:38:58 -06:00
|
|
|
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
2026-04-15 19:54:39 -06:00
|
|
|
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]);
|
2026-04-15 16:36:21 -06:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedZone) return;
|
|
|
|
|
if (selectedZone.type === 'cut' && !showCutZones) setSelectedZone(null);
|
|
|
|
|
if (selectedZone.type === 'mute' && !showMuteZones) setSelectedZone(null);
|
|
|
|
|
if (selectedZone.type === 'gain' && !showGainZones) setSelectedZone(null);
|
2026-04-15 19:54:39 -06:00
|
|
|
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
|
|
|
|
|
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
|
2026-04-03 11:36:08 -06:00
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
if (!videoUrl) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
|
|
|
|
|
Load a video to see the waveform
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={containerRef} className="w-full h-full flex flex-col">
|
2026-04-15 16:36:21 -06:00
|
|
|
<div className="flex items-center justify-between px-3 py-1 shrink-0 gap-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
|
|
|
|
|
Timeline
|
|
|
|
|
</span>
|
|
|
|
|
{cutMode && <span className="text-[10px] text-red-400">Cut mode</span>}
|
|
|
|
|
{muteMode && <span className="text-[10px] text-blue-400">Mute mode</span>}
|
|
|
|
|
{gainMode && <span className="text-[10px] text-amber-400">Gain mode ({gainModeDb.toFixed(1)} dB)</span>}
|
2026-04-15 19:54:39 -06:00
|
|
|
{speedMode && <span className="text-[10px] text-emerald-400">Speed mode ({speedModeValue.toFixed(2)}x)</span>}
|
2026-04-15 16:36:21 -06:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowCutZones((v) => !v)}
|
|
|
|
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
|
|
|
|
title="Toggle cut zones"
|
|
|
|
|
>
|
|
|
|
|
Cut
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowMuteZones((v) => !v)}
|
|
|
|
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
|
|
|
|
title="Toggle mute zones"
|
|
|
|
|
>
|
|
|
|
|
Mute
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowGainZones((v) => !v)}
|
|
|
|
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
|
|
|
|
title="Toggle gain zones"
|
|
|
|
|
>
|
|
|
|
|
Gain
|
|
|
|
|
</button>
|
2026-04-15 19:54:39 -06:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowSpeedZones((v) => !v)}
|
|
|
|
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
|
|
|
|
title="Toggle speed zones"
|
|
|
|
|
>
|
|
|
|
|
Speed
|
|
|
|
|
</button>
|
2026-04-15 16:36:21 -06:00
|
|
|
<span className="text-[10px] text-editor-text-muted">
|
|
|
|
|
Scroll · Ctrl+Scroll to zoom
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>
|
|
|
|
|
{audioError ? (
|
2026-04-09 01:50:19 -06:00
|
|
|
<div className="flex-1 flex items-start justify-center gap-2 text-editor-text-muted text-xs p-3 overflow-auto">
|
|
|
|
|
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
|
|
|
|
|
<pre
|
|
|
|
|
className="select-text cursor-text whitespace-pre-wrap break-all leading-relaxed"
|
|
|
|
|
title="Highlight this text to copy"
|
|
|
|
|
>
|
|
|
|
|
{audioError}
|
|
|
|
|
</pre>
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex-1 relative">
|
|
|
|
|
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
|
|
|
|
<canvas
|
|
|
|
|
ref={headCanvasRef}
|
2026-04-03 11:36:08 -06:00
|
|
|
className="absolute inset-0 w-full h-full"
|
|
|
|
|
style={{ cursor: isDragging ? 'grabbing' : hoverCursor }}
|
2026-03-30 18:36:41 -06:00
|
|
|
onMouseDown={handleMouseDown}
|
2026-04-03 11:36:08 -06:00
|
|
|
onMouseMove={handleMouseMove}
|
2026-03-28 15:09:56 -06:00
|
|
|
onWheel={handleWheel}
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|