diff --git a/src/components/AudioWaveform.tsx b/src/components/AudioWaveform.tsx new file mode 100644 index 00000000..f1bbd8f4 --- /dev/null +++ b/src/components/AudioWaveform.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState, memo } from "react"; +import { ZoomIn, ZoomOut } from "lucide-react"; + +interface Props { + samples: number[]; + duration: number; + currentTime: number; + trimStart: number; + trimEnd: number | null; + loading: boolean; + hasAudio: boolean; + onTrimStartChange: (sec: number) => void; + onTrimEndChange: (sec: number) => void; + onSeek: (sec: number) => void; +} + +const BAR_HEIGHT_RATIO = 0.85; +const MIN_ZOOM = 1; +const MAX_ZOOM = 20; +const HANDLE_WIDTH = 12; + +function getCssVar(name: string, fallback: string): string { + if (typeof window === "undefined") return fallback; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +const Playhead = memo(function Playhead({ position }: { position: number }) { + return ( +
+
+
+ ); +}); + +export default function AudioWaveform({ + samples, + duration, + currentTime, + trimStart, + trimEnd, + loading, + hasAudio, + onTrimStartChange, + onTrimEndChange, + onSeek, +}: Props) { + const canvasRef = useRef(null); + const waveformRef = useRef(null); + const [zoom, setZoom] = useState(MIN_ZOOM); + const [scrollLeft, setScrollLeft] = useState(0); + const dragging = useRef<"start" | "end" | "playhead" | null>(null); + const rafRef = useRef(null); + const hoverTimeRef = useRef(null); + + const effectiveDuration = duration > 0 ? duration : 1; + const trimEndValue = trimEnd ?? effectiveDuration; + + const visibleDuration = effectiveDuration / zoom; + const clampedScrollLeft = Math.max(0, Math.min(scrollLeft, effectiveDuration - visibleDuration)); + const scrollPct = clampedScrollLeft / effectiveDuration; + const visiblePct = visibleDuration / effectiveDuration; + + const xToSeconds = useCallback( + (clientX: number) => { + const el = waveformRef.current; + if (!el) return 0; + const { left, width } = el.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - left) / width)); + return clampedScrollLeft + ratio * visibleDuration; + }, + [clampedScrollLeft, visibleDuration] + ); + + const applyDrag = useCallback( + (clientX: number) => { + const sec = xToSeconds(clientX); + if (dragging.current === "start") { + const clamped = Math.min(sec, trimEndValue - 0.1); + onTrimStartChange(Math.max(0, clamped)); + } else if (dragging.current === "end") { + const clamped = Math.max(sec, trimStart + 0.1); + onTrimEndChange(Math.min(effectiveDuration, clamped)); + } else if (dragging.current === "playhead") { + onSeek(Math.max(0, Math.min(sec, effectiveDuration))); + } + }, + [xToSeconds, trimEndValue, trimStart, effectiveDuration, onTrimStartChange, onTrimEndChange, onSeek] + ); + + useEffect(() => { + const onMove = (e: MouseEvent | TouchEvent) => { + let clientX: number; + if ("touches" in e) { + const touch = e.touches[0]; + if (!touch) return; + clientX = touch.clientX; + } else { + clientX = e.clientX; + } + applyDrag(clientX); + }; + + const onUp = () => { + dragging.current = null; + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove, { passive: false }); + document.addEventListener("touchend", onUp); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); + }; + }, [applyDrag]); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio ?? 1; + const rect = canvas.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const midY = h / 2; + const accentColor = getCssVar("--accent", "#4f6ef7"); + const bgColor = getCssVar("--surface", "#111"); + const mutedColor = getCssVar("--muted", "#888"); + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, w, h); + + if (!hasAudio || samples.length === 0 || loading) { + ctx.beginPath(); + ctx.strokeStyle = mutedColor; + ctx.globalAlpha = 0.4; + ctx.lineWidth = 1.5; + ctx.moveTo(0, midY); + ctx.lineTo(w, midY); + ctx.stroke(); + ctx.globalAlpha = 1; + return; + } + + const startSample = Math.max(0, Math.floor((clampedScrollLeft / effectiveDuration) * samples.length)); + const visibleSamples = Math.max(1, Math.ceil((visibleDuration / effectiveDuration) * samples.length)); + const visibleSamplesArr = samples.slice(startSample, startSample + visibleSamples); + + const barWidth = w / visibleSamplesArr.length; + + const inTrimColor = accentColor; + const outTrimColor = "rgba(255,255,255,0.15)"; + + for (let i = 0; i < visibleSamplesArr.length; i++) { + const amplitude = visibleSamplesArr[i] ?? 0; + const barHeight = Math.max(amplitude * (h * BAR_HEIGHT_RATIO * 2), 1); + const x = i * barWidth; + + const secAtBar = clampedScrollLeft + (i / visibleSamplesArr.length) * visibleDuration; + const isInTrim = secAtBar >= trimStart && secAtBar <= trimEndValue; + + ctx.fillStyle = isInTrim ? inTrimColor : outTrimColor; + ctx.globalAlpha = isInTrim ? 0.7 : 0.4; + + if (barWidth < 3) { + ctx.fillRect(x, midY - barHeight / 2, 1, barHeight); + } else { + ctx.fillRect(x, midY - barHeight / 2, Math.max(barWidth - 0.5, 0.5), barHeight); + } + } + + ctx.globalAlpha = 1; + + // Trim region highlight background + const trimStartX = ((trimStart - clampedScrollLeft) / visibleDuration) * w; + const trimEndX = ((trimEndValue - clampedScrollLeft) / visibleDuration) * w; + + if (trimStartX > 0) { + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.fillRect(0, 0, trimStartX, h); + } + + if (trimEndX < w) { + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.fillRect(trimEndX, 0, w - trimEndX, h); + } + + // Trim border lines + ctx.strokeStyle = accentColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + + if (trimStartX >= 0 && trimStartX <= w) { + ctx.beginPath(); + ctx.moveTo(trimStartX, 0); + ctx.lineTo(trimStartX, h); + ctx.stroke(); + } + + if (trimEndX >= 0 && trimEndX <= w) { + ctx.beginPath(); + ctx.moveTo(trimEndX, 0); + ctx.lineTo(trimEndX, h); + ctx.stroke(); + } + + // Time ruler ticks + const tickInterval = getTickInterval(visibleDuration); + const startTick = Math.ceil(clampedScrollLeft / tickInterval) * tickInterval; + ctx.font = "9px monospace"; + ctx.textAlign = "center"; + ctx.fillStyle = mutedColor; + + for (let t = startTick; t <= clampedScrollLeft + visibleDuration; t += tickInterval) { + const xPos = ((t - clampedScrollLeft) / visibleDuration) * w; + if (xPos < 0 || xPos > w) continue; + ctx.fillRect(xPos, h - 6, 0.5, 6); + ctx.fillText(formatTime(t), xPos, h - 8); + } + + // Playhead + const playheadX = ((currentTime - clampedScrollLeft) / visibleDuration) * w; + if (playheadX >= 0 && playheadX <= w) { + ctx.strokeStyle = accentColor; + ctx.lineWidth = 2; + ctx.shadowColor = accentColor; + ctx.shadowBlur = 4; + ctx.beginPath(); + ctx.moveTo(playheadX, 0); + ctx.lineTo(playheadX, h); + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.beginPath(); + ctx.arc(playheadX, 4, 4, 0, Math.PI * 2); + ctx.fillStyle = accentColor; + ctx.fill(); + } + + // Hover time indicator + if (hoverTimeRef.current !== null) { + const hoverX = ((hoverTimeRef.current - clampedScrollLeft) / visibleDuration) * w; + if (hoverX >= 0 && hoverX <= w) { + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.font = "9px monospace"; + ctx.textAlign = "center"; + ctx.fillText(formatTime(hoverTimeRef.current), hoverX, 10); + } + } + }, [samples, effectiveDuration, clampedScrollLeft, visibleDuration, trimStart, trimEndValue, currentTime, hasAudio, loading]); + + // Playhead RAF loop + useEffect(() => { + let running = true; + + const tick = () => { + if (!running) return; + draw(); + rafRef.current = requestAnimationFrame(tick); + }; + + rafRef.current = requestAnimationFrame(tick); + return () => { + running = false; + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [draw]); + + const handleZoomIn = () => setZoom((z) => Math.min(MAX_ZOOM, z * 1.5)); + const handleZoomOut = () => setZoom((z) => Math.max(MIN_ZOOM, z / 1.5)); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + if (e.deltaY > 0) { + setZoom((z) => Math.max(MIN_ZOOM, z / 1.3)); + } else { + setZoom((z) => Math.min(MAX_ZOOM, z * 1.3)); + } + } else { + const maxScroll = Math.max(0, effectiveDuration - visibleDuration); + setScrollLeft((s) => + Math.max(0, Math.min(s + (e.deltaX > 0 ? visibleDuration * 0.1 : -visibleDuration * 0.1), maxScroll)) + ); + } + }, + [effectiveDuration, visibleDuration] + ); + + const handleCanvasClick = useCallback( + (e: React.MouseEvent) => { + if (dragging.current) return; + const sec = xToSeconds(e.clientX); + onSeek(Math.max(0, Math.min(sec, effectiveDuration))); + }, + [xToSeconds, effectiveDuration, onSeek] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const rect = waveformRef.current?.getBoundingClientRect(); + if (!rect) return; + const ratio = (e.clientX - rect.left) / rect.width; + const sec = clampedScrollLeft + ratio * visibleDuration; + hoverTimeRef.current = Math.max(0, Math.min(sec, effectiveDuration)); + }, + [clampedScrollLeft, visibleDuration, effectiveDuration] + ); + + const handleMouseLeave = useCallback(() => { + hoverTimeRef.current = null; + }, []); + + if (loading) { + return ( +
+
+ {Array.from({ length: 48 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + const startPct = trimStart / effectiveDuration; + const endPct = trimEndValue / effectiveDuration; + + return ( +
+ {/* Zoom controls + time info */} +
+
+ + + {zoom.toFixed(1)}x + + +
+
+ {formatTime(trimStart)} + + {formatTime(trimEndValue)} +
+
+ + {/* Waveform + trim handles */} +
+ + + {/* Trim start handle */} +
{ dragging.current = "start"; }} + onTouchStart={() => { dragging.current = "start"; }} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onTrimStartChange(Math.max(0, trimStart - 0.1)); + if (e.key === "ArrowRight") onTrimStartChange(Math.min(trimEndValue - 0.1, trimStart + 0.1)); + }} + > +
+
+ + + +
+
+
+ + {/* Trim end handle */} +
{ dragging.current = "end"; }} + onTouchStart={() => { dragging.current = "end"; }} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onTrimEndChange(Math.max(trimStart + 0.1, trimEndValue - 0.1)); + if (e.key === "ArrowRight") onTrimEndChange(Math.min(effectiveDuration, trimEndValue + 0.1)); + }} + > +
+
+ + + +
+
+
+ + {/* Scrollbar */} + {zoom > MIN_ZOOM && ( +
+
+
+ )} +
+ +
+ {formatTime(clampedScrollLeft)} + {formatTime(currentTime)} / {formatTime(effectiveDuration)} + {formatTime(Math.min(clampedScrollLeft + visibleDuration, effectiveDuration))} +
+
+ ); +} + +function getTickInterval(visibleDuration: number): number { + if (visibleDuration <= 5) return 1; + if (visibleDuration <= 15) return 2; + if (visibleDuration <= 30) return 5; + if (visibleDuration <= 60) return 10; + if (visibleDuration <= 300) return 30; + return 60; +} diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index d48bdd69..f36f0e03 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -1,11 +1,11 @@ "use client"; import { EditRecipe } from "@/lib/types"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect } from "react"; import { AlertCircle } from "lucide-react"; import { formatDuration } from "@/lib/utils"; import { useAudioWaveform } from "@/hooks/useAudioWaveform"; -import WaveformCanvas from "@/components/WaveformCanvas"; +import AudioWaveform from "@/components/AudioWaveform"; const MIN_CLIP_DURATION = 0.1; @@ -14,9 +14,11 @@ interface Props { onChange: (patch: Partial) => void; duration: number; file: File | null; + currentTime: number; + onSeek: (time: number) => void; } -export default function TrimControl({ recipe, onChange, duration, file }: Props) { +export default function TrimControl({ recipe, onChange, duration, file, currentTime, onSeek }: Props) { const [invalidStart, setStart] = useState(false); const [invalidEnd, setEnd] = useState(false); const [startErrorMsg, setStartErrorMsg] = useState(""); @@ -34,62 +36,6 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart; - - const trackRef = useRef(null); - const dragging = useRef<"start" | "end" | null>(null); - - const xToSeconds = useCallback((clientX: number) => { - const track = trackRef.current; - if (!track || duration <= 0) return 0; - const { left, width } = track.getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (clientX - left) / width)); - return parseFloat((ratio * duration).toFixed(1)); - }, [duration]); - - const applyDrag = useCallback((clientX: number) => { - const seconds = xToSeconds(clientX); - if (dragging.current === "start") { - const clamped = Math.min(seconds, (recipe.trimEnd ?? duration) - 0.1); - onChange({ trimStart: Math.max(0, clamped) }); - } else if (dragging.current === "end") { - const clamped = Math.max(seconds, recipe.trimStart + 0.1); - onChange({ trimEnd: Math.min(duration, clamped) }); - } - }, [xToSeconds, duration, recipe.trimStart, recipe.trimEnd, onChange]); - - useEffect(() => { - const onMove = (e: MouseEvent | TouchEvent) => { - let clientX: number; - - if ("touches" in e) { - const touch = e.touches[0]; - - if (!touch) return; - - clientX = touch.clientX; - } else { - clientX = e.clientX; - } - - applyDrag(clientX); - }; - - const onUp = () => { - dragging.current = null; - }; - - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - document.addEventListener("touchmove", onMove); - document.addEventListener("touchend", onUp); - - return () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.removeEventListener("touchmove", onMove); - document.removeEventListener("touchend", onUp); - }; -}, [applyDrag]); const handleStart = (val: string) => { setStartInput(val); @@ -172,68 +118,32 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) onChange({ trimEnd: n }); }; + const handleTrimStartChange = (sec: number) => { + onChange({ trimStart: sec }); + }; + + const handleTrimEndChange = (sec: number) => { + onChange({ trimEnd: sec }); + }; + const inputClass = "w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"; return (
{duration > 0 && ( -
{ - if (dragging.current) return; - const s = xToSeconds(e.clientX); - onChange({ trimStart: s }); - }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - > -
-
-
{ dragging.current = "start"; }} - onTouchStart={() => { dragging.current = "start"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - /> -
{ dragging.current = "end"; }} - onTouchStart={() => { dragging.current = "end"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, (recipe.trimEnd ?? duration) - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, (recipe.trimEnd ?? duration) + 0.1) }); - }} - /> -
+ )}
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index b1d2e574..e953e6be 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -429,6 +429,8 @@ export default function VideoEditor() { onChange={updateRecipe} duration={duration} file={file} + currentTime={currentTime} + onSeek={seekTo} />