From 71bf1e183bb0f12c128fd5ff71767d5f5d7f9a83 Mon Sep 17 00:00:00 2001 From: advanivivek Date: Wed, 29 Jul 2026 03:57:27 +0530 Subject: [PATCH] feat(timeline): draggable cut edges and scroll-to-zoom Trim cuts precisely, independent of Whisper word timestamps, and move around the waveform faster. - Drag either edge of a cut region to extend or shorten it - Vertical wheel and pinch zoom at the cursor; horizontal scroll pans - Drag scrubs the preview to the edge; double-click an edge to reset Cuts still derive from deleted words. Each cut carries a stable key plus an optional edge override (cutAdjustments). getEffectiveCuts applies overrides in one place, so the timeline, playback, and export stay in sync. Overrides are undoable and saved with the project. --- components/ExportDialog.tsx | 8 +- components/MediaPreview.tsx | 8 +- components/Timeline.tsx | 208 +++++++++++++++++++++++++++++++++++- lib/autosave.ts | 1 + lib/edits.ts | 61 ++++++++++- lib/projects.ts | 5 +- lib/store.ts | 84 ++++++++++++--- lib/types.ts | 22 ++++ 8 files changed, 369 insertions(+), 28 deletions(-) diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 2799031..29a0dc6 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { Download, X } from "lucide-react"; import { useEditorStore } from "@/lib/store"; -import { formatTime, getCutRanges, getEditedDuration, getKeepRanges } from "@/lib/edits"; +import { formatTime, getEditedDuration, getEffectiveCuts, getKeepRanges } from "@/lib/edits"; import { exportAudio, exportVideo } from "@/lib/ffmpeg"; export default function ExportDialog() { @@ -13,6 +13,7 @@ export default function ExportDialog() { const mediaKind = useEditorStore((s) => s.mediaKind); const words = useEditorStore((s) => s.words); const duration = useEditorStore((s) => s.duration); + const cutAdjustments = useEditorStore((s) => s.cutAdjustments); const status = useEditorStore((s) => s.status); const setStatus = useEditorStore((s) => s.setStatus); const exportUrl = useEditorStore((s) => s.exportUrl); @@ -21,7 +22,10 @@ export default function ExportDialog() { const [progress, setProgress] = useState(0); const [error, setError] = useState(null); - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getEffectiveCuts(words, duration, cutAdjustments), + [words, duration, cutAdjustments] + ); const editedDuration = useMemo(() => getEditedDuration(cuts, duration), [cuts, duration]); const exporting = status === "exporting"; const isAudio = mediaKind === "audio"; diff --git a/components/MediaPreview.tsx b/components/MediaPreview.tsx index 7ba773a..6dfa10e 100644 --- a/components/MediaPreview.tsx +++ b/components/MediaPreview.tsx @@ -6,8 +6,8 @@ import { useEditorStore } from "@/lib/store"; import { cutRangeAt, formatTime, - getCutRanges, getEditedDuration, + getEffectiveCuts, originalToEdited, } from "@/lib/edits"; @@ -17,6 +17,7 @@ export default function MediaPreview() { const mediaKind = useEditorStore((s) => s.mediaKind); const words = useEditorStore((s) => s.words); const duration = useEditorStore((s) => s.duration); + const cutAdjustments = useEditorStore((s) => s.cutAdjustments); const playing = useEditorStore((s) => s.playing); const currentTime = useEditorStore((s) => s.currentTime); const setVideoEl = useEditorStore((s) => s.setVideoEl); @@ -26,7 +27,10 @@ export default function MediaPreview() { const mediaRef = useRef(null); const isAudio = mediaKind === "audio"; - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getEffectiveCuts(words, duration, cutAdjustments), + [words, duration, cutAdjustments] + ); const cutsRef = useRef(cuts); useEffect(() => { cutsRef.current = cuts; diff --git a/components/Timeline.tsx b/components/Timeline.tsx index c6874d5..d0d0eff 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -1,14 +1,29 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { Maximize2, ZoomIn, ZoomOut } from "lucide-react"; import { useEditorStore } from "@/lib/store"; -import { formatTime, getCutRanges } from "@/lib/edits"; +import { formatTime, getEffectiveCuts } from "@/lib/edits"; const RULER_H = 20; const LABELS_H = 20; const SAMPLE_RATE = 16000; const TICK_STEPS = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; +const MIN_ZOOM = 1; +const MAX_ZOOM = 256; +/** Wheel-zoom sensitivity (higher = faster zoom per scroll tick). */ +const ZOOM_SPEED = 0.0028; +/** Grab width (px) of a cut-edge handle's hit area. */ +const HANDLE_HIT = 14; +/** Smallest cut a drag may leave, so edges never invert. */ +const MIN_CUT = 0.03; export default function Timeline() { const audio = useEditorStore((s) => s.audio); @@ -16,8 +31,12 @@ export default function Timeline() { const duration = useEditorStore((s) => s.duration); const currentTime = useEditorStore((s) => s.currentTime); const playing = useEditorStore((s) => s.playing); + const cutAdjustments = useEditorStore((s) => s.cutAdjustments); - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getEffectiveCuts(words, duration, cutAdjustments), + [words, duration, cutAdjustments] + ); const outerRef = useRef(null); const scrollRef = useRef(null); @@ -32,6 +51,23 @@ export default function Timeline() { const pps = fitPps * zoom; const totalWidth = Math.max(width, duration * pps); + // Live mirrors for the imperative wheel/drag handlers (avoid stale closures). + const ppsRef = useRef(pps); + const zoomRef = useRef(zoom); + const widthRef = useRef(width); + const durationRef = useRef(duration); + const cutsRef = useRef(cuts); + useEffect(() => { + ppsRef.current = pps; + zoomRef.current = zoom; + widthRef.current = width; + durationRef.current = duration; + cutsRef.current = cuts; + }); + + // Scroll position to apply after a wheel-zoom re-renders the track width. + const pendingScrollRef = useRef(null); + useEffect(() => { const el = outerRef.current; if (!el) return; @@ -122,6 +158,51 @@ export default function Timeline() { } }, [currentTime, playing, pps, width]); + // Vertical wheel / pinch zooms (anchored at the pointer so the point under + // the cursor stays put); horizontal trackpad side-scroll pans the track by + // letting the browser scroll the overflow-x container natively. Registered + // as a non-passive listener so the zoom branch can preventDefault. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (durationRef.current <= 0) return; + // Horizontal intent → pan: don't preventDefault, let native scroll run. + if (!e.ctrlKey && Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; + e.preventDefault(); + const curZoom = zoomRef.current; + const curPps = ppsRef.current; + if (curPps <= 0) return; + const rect = el.getBoundingClientRect(); + const pointerX = e.clientX - rect.left; + const tAnchor = (el.scrollLeft + pointerX) / curPps; + const nextZoom = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, curZoom * Math.exp(-e.deltaY * ZOOM_SPEED)) + ); + if (nextZoom === curZoom) return; + const fit = + widthRef.current > 0 && durationRef.current > 0 + ? widthRef.current / durationRef.current + : 50; + const nextPps = fit * nextZoom; + pendingScrollRef.current = Math.max(0, tAnchor * nextPps - pointerX); + setZoom(nextZoom); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + // Apply the anchor-preserving scroll once the wheel-zoom has re-rendered the + // (wider/narrower) track. Runs before paint to avoid a visible jump. + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el || pendingScrollRef.current == null) return; + el.scrollLeft = pendingScrollRef.current; + pendingScrollRef.current = null; + setScrollLeft(el.scrollLeft); + }, [zoom]); + const seekFromPointer = useCallback( (clientX: number) => { const el = scrollRef.current; @@ -153,6 +234,74 @@ export default function Timeline() { [seekFromPointer] ); + // --- Cut-edge handle dragging ------------------------------------------ + const dragRef = useRef<{ key: number; edge: "start" | "end"; pushed: boolean } | null>( + null + ); + + const timeFromClientX = useCallback((clientX: number) => { + const el = scrollRef.current; + if (!el || ppsRef.current <= 0) return 0; + const rect = el.getBoundingClientRect(); + return (clientX - rect.left + el.scrollLeft) / ppsRef.current; + }, []); + + const onHandleDown = useCallback( + (e: React.PointerEvent, key: number, edge: "start" | "end") => { + e.stopPropagation(); + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { key, edge, pushed: false }; + }, + [] + ); + + const onHandleMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + e.stopPropagation(); + const list = cutsRef.current; + const idx = list.findIndex((c) => c.key === drag.key); + if (idx === -1) return; + const cut = list[idx]; + const prev = list[idx - 1]; + const next = list[idx + 1]; + let t = timeFromClientX(e.clientX); + if (drag.edge === "start") { + const lo = prev ? prev.end : 0; + const hi = Math.max(lo, cut.end - MIN_CUT); + t = Math.min(Math.max(t, lo), hi); + } else { + const hi = next ? next.start : durationRef.current; + const lo = Math.min(hi, cut.start + MIN_CUT); + t = Math.min(Math.max(t, lo), hi); + } + const { adjustCut, videoEl, setCurrentTime } = useEditorStore.getState(); + adjustCut(drag.key, drag.edge, t, !drag.pushed); + drag.pushed = true; + // Scrub the preview to the edge for precise, audible/visible trimming. + if (videoEl) videoEl.currentTime = t; + setCurrentTime(t); + }, + [timeFromClientX] + ); + + const onHandleUp = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + const el = e.currentTarget as HTMLElement; + if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId); + }, []); + + const onHandleReset = useCallback( + (e: React.MouseEvent, key: number) => { + e.stopPropagation(); + useEditorStore.getState().resetCutAdjust(key); + }, + [] + ); + // Word labels for the visible window (only when zoomed in enough to read). const visibleWords = useMemo(() => { if (pps < 18) return []; @@ -161,7 +310,16 @@ export default function Timeline() { return words.filter((w) => w.end >= t0 && w.start <= t1); }, [words, pps, scrollLeft, width]); + // Only mount handles for cuts intersecting the viewport (bounds DOM nodes). + const visibleCuts = useMemo(() => { + if (width === 0) return cuts; + const t0 = scrollLeft / pps - 1; + const t1 = (scrollLeft + width) / pps + 1; + return cuts.filter((c) => c.end >= t0 && c.start <= t1); + }, [cuts, pps, scrollLeft, width]); + const playheadX = currentTime * pps - scrollLeft; + const handleTop = RULER_H; return (