From 7dfe1b829e56e523a9a91ab472a2f649579801ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 14:23:59 +0000 Subject: [PATCH 01/14] Add Descript-style word timing and clip split/trim on the timeline Word-boundary drag lets users fix ASR bleed (e.g. uh overlapping hello). Split (S) inserts scene boundaries that subdivide keep ranges into clips; selected clips expose trim handles that create manual cuts merged into preview and export. Undo coalesces drag gestures into a single step. Co-authored-by: Wassim Gharbi --- PLAN.md | 8 +- README.md | 6 +- app/globals.css | 36 +++ components/Editor.tsx | 12 +- components/ExportDialog.tsx | 6 +- components/MediaPreview.tsx | 6 +- components/Timeline.tsx | 510 ++++++++++++++++++++++++++++++++---- lib/autosave.ts | 2 + lib/edits.ts | 334 ++++++++++++++++++++++- lib/projects.ts | 8 +- lib/store.ts | 273 +++++++++++++++++-- lib/types.ts | 35 +++ scripts/edits-test.ts | 100 +++++++ 13 files changed, 1233 insertions(+), 103 deletions(-) create mode 100644 scripts/edits-test.ts diff --git a/PLAN.md b/PLAN.md index 5343700..b63cba2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -96,14 +96,14 @@ Everything else is derived: 5. ✅ Editing core: word selection → cut/restore, undo/redo, playback that skips cuts, show/hide deleted words. 6. ✅ Export: keep-range concat render to MP4 with progress, download. +7. ✅ Flexible timeline editing: word-boundary drag, Split at playhead + (scene boundaries), clip trim handles, manual cuts merged into export. ## Future work -- Correct/retype words (text overrides for captions), speaker renaming. -- Filler-word detection ("um", "uh") with one-click removal. - Larger Whisper variants + language selection UI; local model import for air-gapped first runs. - Smarter export: stream-copy for keyframe-aligned segments, WebCodecs-based rendering for speed. -- Multi-clip projects, captions burn-in, audio-only export. -- Persist projects to IndexedDB / OPFS so refreshes keep edits. +- Multi-clip projects (reorder scenes), captions burn-in. +- Gap clips / insert silence between words. diff --git a/README.md b/README.md index 5d579fd..bc9c2cb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,11 @@ the media. Export the final cut — without your file ever leaving your device. - 📥 **Import your own transcript** — skip Whisper and edit with an SRT, VTT, or JSON caption file - 🧹 **Filler removal** — one-click cut of "um", "uh", and similar fillers - 🗣️ **Speaker diarization** — the transcript is grouped by speaker -- 🎬 **Timeline** — waveform, word labels, cut regions, playhead, zoom +- 🎬 **Timeline** — waveform, wordbar with draggable timing handles, Split, + cut regions, playhead, zoom +- ✂️ **Split & trim** — blade clips at the playhead; drag clip edges to refine + cuts beyond word boundaries +- 🎯 **Word timing** — zoom in and drag a word's edges when ASR alignment is off - ⚡ **Live preview** — playback skips your cuts in real time - 📦 **In-browser export** — frame-accurate MP4 (video) or M4A (audio) with ffmpeg.wasm - 🎧 **Audio files** — edit podcasts, voice notes, and interviews the same way as video diff --git a/app/globals.css b/app/globals.css index ec2e8a2..a53aff0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -42,3 +42,39 @@ body { .scrollbar-thin::-webkit-scrollbar-track { background: transparent; } + +/* Timeline: split confirmation flash */ +@keyframes tl-split-flash { + 0% { + box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.55); + } + 100% { + box-shadow: 0 0 0 10px rgba(251, 191, 36, 0); + } +} +.tl-split-flash { + animation: tl-split-flash 0.42s ease-out; +} + +@keyframes tl-playhead-pulse { + 0%, + 100% { + transform: translateX(-50%) scale(1); + opacity: 1; + } + 50% { + transform: translateX(-50%) scale(1.35); + opacity: 0.75; + } +} +.tl-playhead-split { + animation: tl-playhead-pulse 1.6s ease-in-out infinite; +} + +.tl-trim-handle:hover > div { + transform: scaleX(1.4); +} +.tl-word-handle:hover > span { + opacity: 1 !important; + width: 2px; +} diff --git a/components/Editor.tsx b/components/Editor.tsx index e25628d..0470382 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -46,7 +46,7 @@ export default function Editor() { })(); }, [videoFile, skipTranscription, transcribe]); - // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo. + // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split. useEffect(() => { const handler = (e: KeyboardEvent) => { const target = e.target as HTMLElement; @@ -61,6 +61,16 @@ export default function Editor() { e.preventDefault(); if (e.shiftKey) s.redo(); else s.undo(); + } else if ( + e.key.toLowerCase() === "s" && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + s.status === "ready" && + !s.exportOpen + ) { + e.preventDefault(); + s.splitAtPlayhead(); } }; document.addEventListener("keydown", handler); diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 6eb383a..2eb223e 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -12,6 +12,7 @@ export default function ExportDialog() { const videoFile = useEditorStore((s) => s.videoFile); const mediaKind = useEditorStore((s) => s.mediaKind); const words = useEditorStore((s) => s.words); + const manualCuts = useEditorStore((s) => s.manualCuts); const duration = useEditorStore((s) => s.duration); const status = useEditorStore((s) => s.status); const setStatus = useEditorStore((s) => s.setStatus); @@ -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( + () => getCutRanges(words, duration, manualCuts), + [words, duration, manualCuts] + ); 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 d778cba..ba7a035 100644 --- a/components/MediaPreview.tsx +++ b/components/MediaPreview.tsx @@ -16,6 +16,7 @@ export default function MediaPreview() { const videoFile = useEditorStore((s) => s.videoFile); const mediaKind = useEditorStore((s) => s.mediaKind); const words = useEditorStore((s) => s.words); + const manualCuts = useEditorStore((s) => s.manualCuts); const duration = useEditorStore((s) => s.duration); const playing = useEditorStore((s) => s.playing); const currentTime = useEditorStore((s) => s.currentTime); @@ -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( + () => getCutRanges(words, duration, manualCuts), + [words, duration, manualCuts] + ); const cutsRef = useRef(cuts); useEffect(() => { cutsRef.current = cuts; diff --git a/components/Timeline.tsx b/components/Timeline.tsx index d89b756..6d226b9 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -1,36 +1,81 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Maximize2, ZoomIn, ZoomOut } from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { Maximize2, Scissors, ZoomIn, ZoomOut } from "lucide-react"; import { useEditorStore } from "@/lib/store"; -import { formatTime, getCutRanges } from "@/lib/edits"; +import { + canSplitAt, + formatTime, + getClipSegments, + getCutRanges, + getKeepRanges, +} from "@/lib/edits"; +import type { Word } from "@/lib/types"; -const RULER_H = 20; -const LABELS_H = 20; +const RULER_H = 18; +const WORDBAR_H = 28; const SAMPLE_RATE = 16000; const TICK_STEPS = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; +/** Pixels-per-second below which word chips hide (too dense). */ +const WORD_VIS_PPS = 22; +/** Pixels-per-second above which edge handles appear on words. */ +const HANDLE_VIS_PPS = 40; + +type DragKind = + | { type: "seek" } + | { type: "word"; wordId: number; edge: "start" | "end"; origStart: number; origEnd: number } + | { type: "trim"; clipIndex: number; edge: "in" | "out" }; export default function Timeline() { const audio = useEditorStore((s) => s.audio); const words = useEditorStore((s) => s.words); + const manualCuts = useEditorStore((s) => s.manualCuts); + const sceneBoundaries = useEditorStore((s) => s.sceneBoundaries); const duration = useEditorStore((s) => s.duration); const currentTime = useEditorStore((s) => s.currentTime); const playing = useEditorStore((s) => s.playing); + const selectedClipIndex = useEditorStore((s) => s.selectedClipIndex); + const status = useEditorStore((s) => s.status); - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getCutRanges(words, duration, manualCuts), + [words, duration, manualCuts] + ); + const keeps = useMemo(() => getKeepRanges(cuts, duration), [cuts, duration]); + const clips = useMemo( + () => getClipSegments(keeps, sceneBoundaries), + [keeps, sceneBoundaries] + ); + const splitOk = useMemo( + () => canSplitAt(currentTime, duration, cuts, sceneBoundaries), + [currentTime, duration, cuts, sceneBoundaries] + ); const outerRef = useRef(null); const scrollRef = useRef(null); const canvasRef = useRef(null); + const dragRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [splitFlash, setSplitFlash] = useState(false); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); - const [zoom, setZoom] = useState(1); // multiplier over "fit" + const [zoom, setZoom] = useState(1); + const [hoveredWordId, setHoveredWordId] = useState(null); + const [hoveredClipIndex, setHoveredClipIndex] = useState(null); const fitPps = duration > 0 && width > 0 ? width / duration : 50; const pps = fitPps * zoom; const totalWidth = Math.max(width, duration * pps); + const ready = status === "ready" && duration > 0; useEffect(() => { const el = outerRef.current; @@ -43,7 +88,7 @@ export default function Timeline() { return () => ro.disconnect(); }, []); - // Draw ruler + waveform + cut overlay for the visible window. + // Draw ruler + waveform + cut overlay + clip tint for the visible window. useEffect(() => { const canvas = canvasRef.current; if (!canvas || width === 0 || height === 0) return; @@ -55,10 +100,14 @@ export default function Timeline() { ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); - const trackTop = RULER_H + LABELS_H; + const trackTop = RULER_H + WORDBAR_H; const trackH = height - trackTop; const midY = trackTop + trackH / 2; + // Soft track wash + ctx.fillStyle = "#fafafa"; + ctx.fillRect(0, trackTop, width, trackH); + // Ruler ctx.fillStyle = "#a1a1aa"; ctx.font = "9px ui-sans-serif, system-ui"; @@ -78,18 +127,57 @@ export default function Timeline() { ctx.lineTo(width, RULER_H - 0.5); ctx.stroke(); + // Wordbar lane background + ctx.fillStyle = "#f4f4f5"; + ctx.fillRect(0, RULER_H, width, WORDBAR_H); + ctx.strokeStyle = "#ececef"; + ctx.beginPath(); + ctx.moveTo(0, RULER_H + WORDBAR_H - 0.5); + ctx.lineTo(width, RULER_H + WORDBAR_H - 0.5); + ctx.stroke(); + if (!audio || duration === 0) return; + // Clip selection / hover washes on waveform + for (const clip of clips) { + const x0 = clip.start * pps - scrollLeft; + const x1 = clip.end * pps - scrollLeft; + if (x1 < 0 || x0 > width) continue; + const selected = clip.index === selectedClipIndex; + const hovered = clip.index === hoveredClipIndex && !selected; + if (selected) { + ctx.fillStyle = "rgba(99, 102, 241, 0.10)"; + ctx.fillRect(x0, trackTop, x1 - x0, trackH); + } else if (hovered) { + ctx.fillStyle = "rgba(99, 102, 241, 0.05)"; + ctx.fillRect(x0, trackTop, x1 - x0, trackH); + } + } + // Cut range backgrounds for (const cut of cuts) { const x0 = cut.start * pps - scrollLeft; const x1 = cut.end * pps - scrollLeft; if (x1 < 0 || x0 > width) continue; - ctx.fillStyle = "rgba(254, 226, 226, 0.85)"; + ctx.fillStyle = "rgba(254, 226, 226, 0.78)"; ctx.fillRect(x0, trackTop, x1 - x0, trackH); + // subtle hatch + ctx.save(); + ctx.beginPath(); + ctx.rect(x0, trackTop, x1 - x0, trackH); + ctx.clip(); + ctx.strokeStyle = "rgba(252, 165, 165, 0.45)"; + ctx.lineWidth = 1; + for (let x = x0 - trackH; x < x1 + trackH; x += 6) { + ctx.beginPath(); + ctx.moveTo(x, trackTop); + ctx.lineTo(x + trackH, trackTop + trackH); + ctx.stroke(); + } + ctx.restore(); } - // Waveform: per-column min/max, sampled with a stride to bound work. + // Waveform const samplesPerPx = SAMPLE_RATE / pps; const stride = Math.max(1, Math.floor(samplesPerPx / 40)); for (let x = 0; x < width; x++) { @@ -109,7 +197,18 @@ export default function Timeline() { const h = Math.max(1, (max - min) * trackH * 0.45); ctx.fillRect(x, midY - h / 2, 1, h); } - }, [audio, cuts, duration, pps, scrollLeft, width, height]); + }, [ + audio, + cuts, + clips, + duration, + pps, + scrollLeft, + width, + height, + selectedClipIndex, + hoveredClipIndex, + ]); // Keep the playhead visible while playing. useEffect(() => { @@ -122,56 +221,191 @@ export default function Timeline() { } }, [currentTime, playing, pps, width]); - const seekFromPointer = useCallback( + const timeFromClientX = useCallback( (clientX: number) => { const el = scrollRef.current; - if (!el) return; + if (!el) return 0; const rect = el.getBoundingClientRect(); - const t = Math.min( + return Math.min( Math.max(0, (clientX - rect.left + el.scrollLeft) / pps), duration ); - const { videoEl, setCurrentTime } = useEditorStore.getState(); - if (videoEl) videoEl.currentTime = t; - setCurrentTime(t); }, [pps, duration] ); - const onPointerDown = useCallback( - (e: React.PointerEvent) => { + const seekTo = useCallback((t: number) => { + const { videoEl, setCurrentTime } = useEditorStore.getState(); + if (videoEl) videoEl.currentTime = t; + setCurrentTime(t); + }, []); + + const endDrag = useCallback(() => { + if (dragRef.current) { + useEditorStore.getState().endGesture(); + dragRef.current = null; + setDragging(false); + } + }, []); + + useEffect(() => { + const onUp = () => endDrag(); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + return () => { + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + }, [endDrag]); + + const onPointerMove = useCallback( + (e: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) { + // Hover clip under cursor (waveform area) + const t = timeFromClientX(e.clientX); + const clip = clips.find((c) => t >= c.start && t < c.end); + setHoveredClipIndex(clip?.index ?? null); + return; + } + const t = timeFromClientX(e.clientX); + const store = useEditorStore.getState(); + + if (drag.type === "seek") { + seekTo(t); + return; + } + if (drag.type === "word") { + if (drag.edge === "start") { + store.adjustWordBounds(drag.wordId, t, drag.origEnd); + } else { + store.adjustWordBounds(drag.wordId, drag.origStart, t); + } + return; + } + if (drag.type === "trim") { + store.trimClipEdge(drag.clipIndex, drag.edge, t); + } + }, + [clips, seekTo, timeFromClientX] + ); + + const onBackgroundPointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button !== 0) return; + // Ignore if clicking interactive chrome (handles / chips set their own drag) + const target = e.target as HTMLElement; + if (target.closest("[data-tl-interactive]")) return; + + const t = timeFromClientX(e.clientX); + const clip = clips.find((c) => t >= c.start && t < c.end); + useEditorStore.getState().setSelectedClipIndex(clip?.index ?? null); + dragRef.current = { type: "seek" }; + setDragging(true); e.currentTarget.setPointerCapture(e.pointerId); - seekFromPointer(e.clientX); + seekTo(t); }, - [seekFromPointer] + [clips, seekTo, timeFromClientX] ); - const onPointerMove = useCallback( - (e: React.PointerEvent) => { - if (e.buttons & 1) seekFromPointer(e.clientX); + const startWordDrag = useCallback( + (e: ReactPointerEvent, word: Word, edge: "start" | "end") => { + e.stopPropagation(); + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + useEditorStore.getState().beginGesture(); + dragRef.current = { + type: "word", + wordId: word.id, + edge, + origStart: word.start, + origEnd: word.end, + }; + setDragging(true); + }, + [] + ); + + const startTrimDrag = useCallback( + (e: ReactPointerEvent, clipIndex: number, edge: "in" | "out") => { + e.stopPropagation(); + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + const store = useEditorStore.getState(); + store.setSelectedClipIndex(clipIndex); + store.beginGesture(); + dragRef.current = { type: "trim", clipIndex, edge }; + setDragging(true); }, - [seekFromPointer] + [] ); - // Word labels for the visible window (only when zoomed in enough to read). + const doSplit = useCallback(() => { + const ok = useEditorStore.getState().splitAtPlayhead(); + if (!ok) return; + setSplitFlash(true); + window.setTimeout(() => setSplitFlash(false), 420); + }, []); + + // Word labels for the visible window const visibleWords = useMemo(() => { - if (pps < 18) return []; + if (pps < WORD_VIS_PPS) return []; const t0 = scrollLeft / pps - 1; const t1 = (scrollLeft + width) / pps + 1; return words.filter((w) => w.end >= t0 && w.start <= t1); }, [words, pps, scrollLeft, width]); const playheadX = currentTime * pps - scrollLeft; + const showHandles = pps >= HANDLE_VIS_PPS; return ( -