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..c9ba6b9 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,13 @@ 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; scroll to zoom, side-scroll to pan +- ✂️ **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 +- 🔴 **Cut edges** — drag either edge of a cut to trim independently of Whisper + timestamps; double-click to reset - ⚡ **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..5061b66 100644 --- a/app/globals.css +++ b/app/globals.css @@ -42,3 +42,24 @@ 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; +} + +.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/ImportTranscriptOption.tsx b/components/ImportTranscriptOption.tsx index 75f10f0..25228b2 100644 --- a/components/ImportTranscriptOption.tsx +++ b/components/ImportTranscriptOption.tsx @@ -108,37 +108,8 @@ export default function ImportTranscriptOption() { return ( <> - { - menuRef.current = ctx; - const current = useEditorStore.getState().model; - if (isWhisperModel(current)) { - previousModelRef.current = current; - } - // Do not set model to "import" until a file is chosen. Close the menu - // before the OS dialog so cancel cannot leave it pinned open. - pickGenRef.current += 1; - setPicking(true); - setError(null); - ctx.closeMenu(); - requestAnimationFrame(() => fileRef.current?.click()); - }} - > - - - - {/* Sibling of the option button — never nest inside + + +
- +
setScrollLeft(e.currentTarget.scrollLeft)} - onPointerDown={onPointerDown} + onPointerDown={onBackgroundPointerDown} onPointerMove={onPointerMove} - className="scrollbar-thin absolute inset-0 cursor-col-resize touch-none overflow-x-auto overflow-y-hidden select-none" + onPointerLeave={onPointerLeave} + className="scrollbar-thin absolute inset-0 touch-none overflow-x-auto overflow-y-hidden select-none" + style={{ cursor: dragging ? "col-resize" : "default" }} >
- {visibleWords.map((w) => ( - - {w.text} - - ))} + {/* Split markers between touching clips — hover to reveal "join" */} + {splits.map((b) => { + const hovered = hoveredSplitId === b.id; + return ( +
+ {hovered && ( + + )} +
+ ); + })} + + {/* Clip trim handles (selected or hovered) */} + {clips.map((clip) => { + const active = + clip.index === selectedClipIndex || clip.index === hoveredClipIndex; + if (!active) return null; + const selected = clip.index === selectedClipIndex; + return ( +
+
startTrimDrag(e, clip, "in")} + className="tl-trim-handle absolute z-[6] -translate-x-1/2 cursor-ew-resize" + style={{ + left: clip.start * pps, + top: RULER_H + WORDBAR_H + 4, + bottom: 4, + opacity: selected ? 1 : 0.7, + }} + title="Trim clip start" + > +
+
+
startTrimDrag(e, clip, "out")} + className="tl-trim-handle absolute z-[6] -translate-x-1/2 cursor-ew-resize" + style={{ + left: clip.end * pps, + top: RULER_H + WORDBAR_H + 4, + bottom: 4, + opacity: selected ? 1 : 0.7, + }} + title="Trim clip end" + > +
+
+ {selected && ( +
+ )} +
+ ); + })} + + {/* Wordbar chips */} + {visibleWords.map((w) => { + const wWidth = Math.max(6, (w.end - w.start) * pps - 1); + const hovered = hoveredWordId === w.id; + const cutOut = isWordCutOut(w, cuts); + const wordSelected = selectedWordIds.includes(w.id); + const showWordHandles = showHandles && (hovered || wWidth > 28); + return ( +
setHoveredWordId(w.id)} + onPointerLeave={() => + setHoveredWordId((id) => (id === w.id ? null : id)) + } + onPointerDown={(e) => { + // Clicking the chip body seeks to word start (not a bound drag) + if ((e.target as HTMLElement).dataset.edge) return; + e.stopPropagation(); + seekTo(w.start); + const store = useEditorStore.getState(); + // Select the word (the transcript mirrors this) and the clip + // it sits in. + store.setSelectedWords([w.id]); + const clip = clips.find( + (c) => w.start >= c.start && w.start < c.end + ); + store.setSelectedClipIndex(clip?.index ?? null); + }} + > + + {w.text} + + {showWordHandles && ( + <> + startWordDrag(e, w, "start")} + className="tl-word-handle absolute inset-y-0 left-0 z-10 w-1.5 cursor-ew-resize" + > + + + startWordDrag(e, w, "end")} + className="tl-word-handle absolute inset-y-0 right-0 z-10 w-1.5 cursor-ew-resize" + > + + + + )} +
+ ); + })}
- {playheadX >= 0 && playheadX <= width && ( + {playheadX >= -2 && playheadX <= width + 2 && (
-
+
+
+ )} + + {pps < WORD_VIS_PPS && ready && ( +
+ Zoom in to edit word timing
)}
diff --git a/components/TopBar.tsx b/components/TopBar.tsx index 1e2b8f0..c8443ed 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -17,7 +17,7 @@ export default function TopBar() { const setExportOpen = useEditorStore((s) => s.setExportOpen); return ( -
+
+ ); +}); + interface SelectionInfo { ids: number[]; anyDeleted: boolean; @@ -78,6 +128,9 @@ interface SelectionInfo { export default function TranscriptPanel() { 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 status = useEditorStore((s) => s.status); const progress = useEditorStore((s) => s.progress); const partialText = useEditorStore((s) => s.partialText); @@ -88,9 +141,35 @@ export default function TranscriptPanel() { const restoreWords = useEditorStore((s) => s.restoreWords); const correctWords = useEditorStore((s) => s.correctWords); const importWords = useEditorStore((s) => s.importWords); + const removeSceneBoundary = useEditorStore((s) => s.removeSceneBoundary); + const selectedWordIds = useEditorStore((s) => s.selectedWordIds); + const setSelectedWords = useEditorStore((s) => s.setSelectedWords); const playing = useEditorStore((s) => s.playing); const activeWordId = useEditorStore((s) => findActiveWordId(s.words, s.currentTime)); + const cuts = useMemo( + () => getCutRanges(words, duration, manualCuts), + [words, duration, manualCuts] + ); + const cutOutIds = useMemo(() => { + const ids = new Set(); + for (const w of words) { + if (isWordCutOut(w, cuts)) ids.add(w.id); + } + return ids; + }, [words, cuts]); + + // Splits get a joinable edit boundary in the transcript, like the timeline's + // marker. Splits at the edge of a skipped region are inert and hidden in both. + const splitBeforeWordId = useMemo( + () => + mapSplitsToWords( + words, + getActiveSceneBoundaries(sceneBoundaries, getKeepRanges(cuts, duration)) + ), + [sceneBoundaries, cuts, duration, words] + ); + const containerRef = useRef(null); const scrollRef = useRef(null); const importInputRef = useRef(null); @@ -116,7 +195,7 @@ export default function TranscriptPanel() { return out; }, [words]); - const deletedCount = useMemo(() => words.filter((w) => w.deleted).length, [words]); + const deletedCount = useMemo(() => cutOutIds.size, [cutOutIds]); const fillerIds = useMemo(() => findFillerWordIds(words), [words]); const removeFillers = useCallback(() => { @@ -148,6 +227,13 @@ export default function TranscriptPanel() { [words.length, importWords] ); + const joinSplit = useCallback( + (id: number) => { + removeSceneBoundary(id); + }, + [removeSceneBoundary] + ); + const seekToWord = useCallback((word: Word) => { const { videoEl, setCurrentTime } = useEditorStore.getState(); if (videoEl) videoEl.currentTime = word.start + 0.001; @@ -159,27 +245,78 @@ export default function TranscriptPanel() { // The native ::selection highlight is made transparent over the words, and // the marking is done imperatively so dragging doesn't re-render the panel. const markedRef = useRef>(new Set()); + // True while the current selection came from clicking a single word. There is + // no native range in that case, so the collapsed-selection branch of the + // selectionchange handler must not wipe it. + const clickSelectionRef = useRef(false); + + const clearMarks = useCallback(() => { + for (const el of markedRef.current) el.removeAttribute("data-sel"); + markedRef.current.clear(); + }, []); + + const clearSelection = useCallback(() => { + clearMarks(); + clickSelectionRef.current = false; + setSelection(null); + setSelectedWords([]); + window.getSelection()?.removeAllRanges(); + }, [clearMarks, setSelectedWords]); + + // Clicking a word seeks to it and selects it, so the toolbar and the + // Delete/Backspace shortcut work on single words too — not just drags. + const handleWordClick = useCallback( + (word: Word, el: HTMLElement) => { + const nativeSel = window.getSelection(); + // A drag ends with a click on the word under the cursor; leave the + // range-based selection (and the playhead) alone in that case. + if (nativeSel && !nativeSel.isCollapsed) return; + seekToWord(word); + const container = containerRef.current; + if (!container) return; + clearMarks(); + el.setAttribute("data-sel", ""); + markedRef.current.add(el); + clickSelectionRef.current = true; + const rect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const cutOut = cutOutIds.has(word.id); + setSelection({ + ids: [word.id], + anyDeleted: cutOut, + anyKept: !cutOut, + top: rect.top - containerRect.top - 44, + left: Math.max(8, rect.left - containerRect.left + rect.width / 2), + }); + setSelectedWords([word.id]); + }, + [seekToWord, clearMarks, cutOutIds, setSelectedWords] + ); + useEffect(() => { - const clearMarks = () => { - for (const el of markedRef.current) el.removeAttribute("data-sel"); - markedRef.current.clear(); - }; const handler = () => { // Keep the highlight frozen on the words being corrected. if (correctingRef.current) return; const container = containerRef.current; const sel = window.getSelection(); if (!container || !sel || sel.isCollapsed || sel.rangeCount === 0) { + // A click collapses the native selection; that must not clear the + // single-word selection the click itself is about to create. + if (clickSelectionRef.current) return; clearMarks(); setSelection(null); + setSelectedWords([]); return; } const range = sel.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { + if (clickSelectionRef.current) return; clearMarks(); setSelection(null); + setSelectedWords([]); return; } + clickSelectionRef.current = false; const wordMap = new Map(words.map((w) => [w.id, w])); const ids: number[] = []; let anyDeleted = false; @@ -192,7 +329,7 @@ export default function TranscriptPanel() { el.setAttribute("data-sel", ""); marked.add(el); const w = wordMap.get(id); - if (w?.deleted) anyDeleted = true; + if (w && cutOutIds.has(w.id)) anyDeleted = true; else anyKept = true; } }); @@ -202,6 +339,7 @@ export default function TranscriptPanel() { markedRef.current = marked; if (ids.length === 0) { setSelection(null); + setSelectedWords([]); return; } const rect = range.getBoundingClientRect(); @@ -213,27 +351,84 @@ export default function TranscriptPanel() { top: rect.top - containerRect.top - 44, left: Math.max(8, rect.left - containerRect.left + rect.width / 2), }); + setSelectedWords(ids); }; document.addEventListener("selectionchange", handler); return () => { - clearMarks(); + // A click selection has no native range to track, so keep its highlight + // across the re-subscriptions this effect goes through. + if (!clickSelectionRef.current) clearMarks(); document.removeEventListener("selectionchange", handler); }; - }, [words]); + }, [words, cutOutIds, clearMarks, setSelectedWords]); + + // A click-based selection has no native range, so nothing else would drop it: + // clear it when the next mousedown lands outside the words and the toolbar. + // Only presses inside this panel count — the timeline owns its own clearing, + // and clicking a word chip there must not wipe the selection it just made. + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!clickSelectionRef.current || correctingRef.current) return; + const target = e.target as HTMLElement | null; + if (!target || !scrollRef.current?.contains(target)) return; + if (target.closest("[data-wid], [data-transcript-toolbar]")) return; + clearSelection(); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [clearSelection]); + + // Mirror a selection made elsewhere (the timeline wordbar) into this panel: + // highlight the words, scroll them into view and place the toolbar. Selections + // that originated here already match, so this is a no-op for them. + useEffect(() => { + if (correctingRef.current) return; + const container = containerRef.current; + if (!container) return; + const shown = selection?.ids ?? []; + if ( + shown.length === selectedWordIds.length && + shown.every((id, i) => id === selectedWordIds[i]) + ) { + return; + } + const els = selectedWordIds + .map((id) => container.querySelector(`[data-wid="${id}"]`)) + .filter((el): el is HTMLElement => el !== null); + clearMarks(); + if (els.length === 0) { + clickSelectionRef.current = false; + setSelection(null); + return; + } + for (const el of els) { + el.setAttribute("data-sel", ""); + markedRef.current.add(el); + } + clickSelectionRef.current = true; + els[0].scrollIntoView({ block: "nearest" }); + const rect = els[0].getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + setSelection({ + ids: selectedWordIds, + anyDeleted: selectedWordIds.some((id) => cutOutIds.has(id)), + anyKept: selectedWordIds.some((id) => !cutOutIds.has(id)), + top: rect.top - containerRect.top - 44, + left: Math.max(8, rect.left - containerRect.left + rect.width / 2), + }); + }, [selectedWordIds, selection, cutOutIds, clearMarks]); const cutSelection = useCallback(() => { if (!selection) return; deleteWords(selection.ids); - window.getSelection()?.removeAllRanges(); - setSelection(null); - }, [selection, deleteWords]); + clearSelection(); + }, [selection, deleteWords, clearSelection]); const restoreSelection = useCallback(() => { if (!selection) return; restoreWords(selection.ids); - window.getSelection()?.removeAllRanges(); - setSelection(null); - }, [selection, restoreWords]); + clearSelection(); + }, [selection, restoreWords, clearSelection]); const openCorrect = useCallback(() => { if (!selection) return; @@ -243,6 +438,7 @@ export default function TranscriptPanel() { .map((w) => w.text) .join(" "); correctingRef.current = true; + clickSelectionRef.current = false; setCorrectText(text); setCorrecting({ ids: selection.ids, @@ -278,20 +474,22 @@ export default function TranscriptPanel() { return () => document.removeEventListener("mousedown", handler); }, [correcting, closeCorrect]); - // Delete / Backspace cuts the selected words. + // Delete / Backspace cuts the selected words. Driven by the shared selection so + // it works for words picked in the timeline wordbar too. useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.key !== "Delete" && e.key !== "Backspace") return; + if (e.key !== "Delete" && e.key !== "Backspace" && e.key !== "Escape") return; const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return; - if (!selection || selection.ids.length === 0) return; + if (selectedWordIds.length === 0) return; e.preventDefault(); - cutSelection(); + if (e.key !== "Escape") deleteWords(selectedWordIds); + clearSelection(); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [selection, cutSelection]); + }, [selectedWordIds, deleteWords, clearSelection]); // Keep the active word in view during playback. useEffect(() => { @@ -309,24 +507,19 @@ export default function TranscriptPanel() { Transcript
- {status === "ready" && ( - - select words and press ⌫ to cut - - )} {deletedCount > 0 && ( - - {deletedCount} word{deletedCount === 1 ? "" : "s"} cut + + {deletedCount} word{deletedCount === 1 ? "" : "s"} )} {status === "ready" && fillerIds.length > 0 && ( )} {(status === "ready" || status === "error" || status === "transcribing") && ( @@ -334,7 +527,7 @@ export default function TranscriptPanel() {
@@ -406,7 +596,9 @@ export default function TranscriptPanel() { {status === "ready" && (
{turns.map((turn, i) => { - const visible = showDeleted ? turn.words : turn.words.filter((w) => !w.deleted); + const visible = showDeleted + ? turn.words + : turn.words.filter((w) => !cutOutIds.has(w.id)); if (visible.length === 0) return null; return (
@@ -417,14 +609,22 @@ export default function TranscriptPanel() { Speaker {turn.speaker + 1}

- {visible.map((w) => ( - - ))} + {visible.map((w) => { + const split = splitBeforeWordId.get(w.id); + return ( + + {split && ( + + )} + + + ); + })}

); @@ -434,6 +634,7 @@ export default function TranscriptPanel() { {selection && !correcting && (
e.preventDefault()} diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 1e67843..40d6ce1 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -112,9 +112,9 @@ function RecentProjects({ type="button" disabled={busyId !== null} onClick={() => onOpen(p.id)} - className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left transition hover:bg-zinc-50 disabled:opacity-60" + className="flex cursor-pointer min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left transition hover:bg-zinc-50 disabled:opacity-60" > - + {p.name} diff --git a/lib/autosave.ts b/lib/autosave.ts index 8c611e3..c322d6b 100644 --- a/lib/autosave.ts +++ b/lib/autosave.ts @@ -3,7 +3,7 @@ */ import { useEditorStore } from "./store"; -import { getProject, putProject } from "./projects"; +import { putProject } from "./projects"; const DEBOUNCE_MS = 500; @@ -51,11 +51,8 @@ async function writeSnapshot() { if (!s.videoFile || !s.mediaKind || s.words.length === 0) return; try { - let createdAt: number | undefined; - if (s.projectId) { - const existing = await getProject(s.projectId); - createdAt = existing?.createdAt; - } + // putProject preserves createdAt for an existing id within its own + // transaction, so no separate read pass here. const id = await putProject({ id: s.projectId ?? undefined, name: s.videoFile.name, @@ -64,9 +61,10 @@ async function writeSnapshot() { model: s.model, words: s.words, showDeleted: s.showDeleted, + manualCuts: s.manualCuts, + sceneBoundaries: s.sceneBoundaries, media: s.videoFile, mediaType: s.videoFile.type, - createdAt, }); if (useEditorStore.getState().projectId !== id) { useEditorStore.setState({ projectId: id }); diff --git a/lib/edits.ts b/lib/edits.ts index 303c999..7b4d063 100644 --- a/lib/edits.ts +++ b/lib/edits.ts @@ -1,14 +1,26 @@ -import type { TimeRange, Word } from "./types"; +import type { + ClipSegment, + ManualCut, + SceneBoundary, + TimeRange, + Word, +} from "./types"; /** Padding (s) applied when merging adjacent deleted words into one cut. */ const MERGE_GAP = 0.35; +/** Minimum kept clip length after a trim (seconds). */ +export const MIN_CLIP_DURATION = 0.05; + +/** Ignore splits this close to an existing edge (seconds). */ +export const SPLIT_EPSILON = 0.04; + /** - * Compute the ranges of the original media that have been cut, by merging - * runs of consecutive deleted words. The silence between two adjacent deleted - * words is cut too, so deleting a phrase removes it cleanly. + * Compute cut ranges from deleted words only (silence between adjacent deleted + * words is included). A cut is derived purely from its words' bounds — drag a + * word's edge in the wordbar to move the cut edge with it. */ -export function getCutRanges(words: Word[], duration: number): TimeRange[] { +export function getWordCutRanges(words: Word[], duration: number): TimeRange[] { const ranges: TimeRange[] = []; for (let i = 0; i < words.length; i++) { const w = words[i]; @@ -21,9 +33,22 @@ export function getCutRanges(words: Word[], duration: number): TimeRange[] { } ranges.push({ start, end }); } - // Merge ranges separated by less than MERGE_GAP to avoid audible slivers. + return mergeCutRanges(ranges, duration); +} + +/** Merge overlapping / near-adjacent ranges and clamp to [0, duration]. */ +export function mergeCutRanges(ranges: TimeRange[], duration: number): TimeRange[] { + if (ranges.length === 0) return []; + const sorted = [...ranges] + .map((r) => ({ + start: Math.max(0, Math.min(duration, r.start)), + end: Math.max(0, Math.min(duration, r.end)), + })) + .filter((r) => r.end - r.start > 1e-4) + .sort((a, b) => a.start - b.start); + const merged: TimeRange[] = []; - for (const r of ranges) { + for (const r of sorted) { const last = merged[merged.length - 1]; if (last && r.start - last.end < MERGE_GAP) { last.end = Math.max(last.end, r.end); @@ -31,10 +56,21 @@ export function getCutRanges(words: Word[], duration: number): TimeRange[] { merged.push({ ...r }); } } - return merged.map((r) => ({ - start: Math.max(0, r.start), - end: Math.min(duration, r.end), - })); + return merged; +} + +/** + * All cut ranges: deleted-word spans ∪ manual blade/trim cuts. + * Manual cuts may be passed as bare TimeRanges or ManualCut objects. + */ +export function getCutRanges( + words: Word[], + duration: number, + manualCuts: Array = [] +): TimeRange[] { + const fromWords = getWordCutRanges(words, duration); + const fromManual = manualCuts.map((c) => ({ start: c.start, end: c.end })); + return mergeCutRanges([...fromWords, ...fromManual], duration); } /** Invert cut ranges into the ranges of the original media that remain. */ @@ -49,6 +85,81 @@ export function getKeepRanges(cuts: TimeRange[], duration: number): TimeRange[] return keeps; } +/** + * Subdivide keep ranges by scene boundaries into selectable clip segments. + * Boundaries that fall inside a cut are ignored for geometry. + */ +export function getClipSegments( + keepRanges: TimeRange[], + sceneBoundaries: SceneBoundary[] +): ClipSegment[] { + const times = sceneBoundaries + .map((b) => b.time) + .sort((a, b) => a - b); + + const clips: ClipSegment[] = []; + for (const keep of keepRanges) { + const splits = times.filter( + (t) => t > keep.start + SPLIT_EPSILON && t < keep.end - SPLIT_EPSILON + ); + let cursor = keep.start; + for (const t of splits) { + clips.push({ + id: `c-${cursor.toFixed(4)}-${t.toFixed(4)}`, + start: cursor, + end: t, + index: clips.length, + }); + cursor = t; + } + clips.push({ + id: `c-${cursor.toFixed(4)}-${keep.end.toFixed(4)}`, + start: cursor, + end: keep.end, + index: clips.length, + }); + } + return clips; +} + +/** + * Scene boundaries that actually divide two touching clips — the ones worth + * showing a split marker for. A boundary sitting at the edge of a skipped region + * is inert: the gap already separates the clips, and its own edges are what you + * would drag. Matches the filter getClipSegments applies. + */ +export function getActiveSceneBoundaries( + boundaries: SceneBoundary[], + keepRanges: TimeRange[] +): SceneBoundary[] { + return boundaries.filter((b) => + keepRanges.some( + (k) => b.time > k.start + SPLIT_EPSILON && b.time < k.end - SPLIT_EPSILON + ) + ); +} + +/** + * Place each split in the transcript: the word it reads as sitting in front of. + * Returns wordId → boundary. A split landing mid-word snaps to the nearer side so + * the marker always falls in a gap between words; one in trailing silence has no + * word to anchor to and is left out (the timeline still shows it). + */ +export function mapSplitsToWords( + words: Word[], + boundaries: SceneBoundary[] +): Map { + const map = new Map(); + for (const b of boundaries) { + let i = words.findIndex((w) => w.start >= b.time); + if (i === -1) i = words.length; + const prev = words[i - 1]; + if (prev && b.time < prev.end && b.time < (prev.start + prev.end) / 2) i -= 1; + if (i < words.length) map.set(words[i].id, b); + } + return map; +} + /** Duration of the edited video (sum of kept ranges). */ export function getEditedDuration(cuts: TimeRange[], duration: number): number { const cut = cuts.reduce((acc, r) => acc + (r.end - r.start), 0); @@ -73,6 +184,326 @@ export function cutRangeAt(t: number, cuts: TimeRange[]): TimeRange | null { return null; } +/** + * Whether a word is fully removed from the edited media: either explicitly + * deleted, or entirely covered by an effective cut (e.g. a blade/trim cut). + */ +export function isWordCutOut(word: Word, cuts: TimeRange[]): boolean { + if (word.deleted) return true; + if (word.end - word.start <= 1e-4) return false; + for (const c of cuts) { + if (word.start >= c.start - 1e-4 && word.end <= c.end + 1e-4) return true; + } + return false; +} + +/** Mark words whose full span lies inside [from, to) as deleted. */ +export function deleteWordsCoveredBy( + words: Word[], + from: number, + to: number +): Word[] { + if (to <= from) return words; + let changed = false; + const next = words.map((w) => { + if (w.deleted) return w; + if (w.start >= from - 1e-4 && w.end <= to + 1e-4) { + changed = true; + return { ...w, deleted: true }; + } + return w; + }); + return changed ? next : words; +} + +/** Restore deleted words whose full span lies inside [from, to). */ +export function restoreWordsCoveredBy( + words: Word[], + from: number, + to: number +): Word[] { + if (to <= from) return words; + let changed = false; + const next = words.map((w) => { + if (!w.deleted) return w; + if (w.start >= from - 1e-4 && w.end <= to + 1e-4) { + changed = true; + return { ...w, deleted: false }; + } + return w; + }); + return changed ? next : words; +} + +/** Find the clip containing time t, if any. */ +export function clipAt(t: number, clips: ClipSegment[]): ClipSegment | null { + for (const c of clips) { + if (t >= c.start && t < c.end) return c; + // Include exact end of last clip / exact start + if (t === c.end && Math.abs(c.end - c.start) > 0) { + // Prefer matching start of next; fall through + } + } + // Exact end of media: last clip + for (let i = clips.length - 1; i >= 0; i--) { + if (t === clips[i].end) return clips[i]; + } + return null; +} + +/** + * Whether a split is allowed at time t (inside a keep region, not too close + * to existing edges or scene boundaries). + */ +export function canSplitAt( + t: number, + duration: number, + cuts: TimeRange[], + sceneBoundaries: SceneBoundary[] +): boolean { + if (t <= SPLIT_EPSILON || t >= duration - SPLIT_EPSILON) return false; + if (cutRangeAt(t, cuts)) return false; + for (const cut of cuts) { + if (Math.abs(t - cut.start) < SPLIT_EPSILON || Math.abs(t - cut.end) < SPLIT_EPSILON) { + return false; + } + } + for (const b of sceneBoundaries) { + if (Math.abs(t - b.time) < SPLIT_EPSILON) return false; + } + return true; +} + +/** + * Clamp a word's new bounds against neighbors. + * Returns updated start/end; may steal time from adjacent words when expanding. + */ +export function clampWordBounds( + words: Word[], + index: number, + nextStart: number, + nextEnd: number, + duration: number +): { start: number; end: number; neighborPatches: Array<{ index: number; start?: number; end?: number }> } { + const w = words[index]; + const minDur = 0.02; + let start = Math.max(0, Math.min(nextStart, nextEnd - minDur)); + let end = Math.min(duration, Math.max(nextEnd, start + minDur)); + + const patches: Array<{ index: number; start?: number; end?: number }> = []; + + const prev = index > 0 ? words[index - 1] : null; + const next = index < words.length - 1 ? words[index + 1] : null; + + // Hard floor: don't cross into previous word's start + if (prev) { + const floor = prev.start + minDur; + if (start < floor) start = floor; + // If we expand left into prev, shrink prev.end + if (start < prev.end) { + patches.push({ index: index - 1, end: start }); + } + } + + if (next) { + const ceil = next.end - minDur; + if (end > ceil) end = ceil; + if (end > next.start) { + patches.push({ index: index + 1, start: end }); + } + } + + if (end - start < minDur) { + end = start + minDur; + } + + // Re-apply if patches would make neighbor invalid — prefer keeping this word's intent + void w; + return { start, end, neighborPatches: patches }; +} + +/** + * Apply word-bound drag: returns a new words array with the target (and + * possibly neighbors) updated. + */ +export function applyWordBounds( + words: Word[], + wordId: number, + nextStart: number, + nextEnd: number, + duration: number +): Word[] | null { + const index = words.findIndex((w) => w.id === wordId); + if (index < 0) return null; + const { start, end, neighborPatches } = clampWordBounds( + words, + index, + nextStart, + nextEnd, + duration + ); + const cur = words[index]; + if (Math.abs(cur.start - start) < 1e-4 && Math.abs(cur.end - end) < 1e-4 && neighborPatches.length === 0) { + return null; + } + const next = words.map((w) => ({ ...w })); + next[index] = { ...next[index], start, end }; + for (const p of neighborPatches) { + next[p.index] = { + ...next[p.index], + ...(p.start !== undefined ? { start: p.start } : {}), + ...(p.end !== undefined ? { end: p.end } : {}), + }; + } + return next; +} + +/** + * Shrink or remove manual cuts overlapping [from, to). + * When a cut is split into two remnants, `nextId` supplies fresh ids. + */ +export function shrinkManualCuts( + manualCuts: ManualCut[], + from: number, + to: number, + nextId = 1_000_000 +): { cuts: ManualCut[]; nextId: number } { + if (to <= from) return { cuts: manualCuts, nextId }; + const out: ManualCut[] = []; + let id = nextId; + for (const c of manualCuts) { + if (c.end <= from || c.start >= to) { + out.push(c); + continue; + } + if (c.start >= from && c.end <= to) continue; + if (c.start < from && c.end > to) { + out.push({ ...c, end: from }); + out.push({ id: id++, start: to, end: c.end }); + continue; + } + if (c.start < from) { + out.push({ ...c, end: from }); + continue; + } + if (c.end > to) { + out.push({ ...c, start: to }); + } + } + return { cuts: out, nextId: id }; +} + +/** Add a manual cut and merge with existing ones that touch. */ +export function addManualCut( + manualCuts: ManualCut[], + start: number, + end: number, + nextId: number +): { cuts: ManualCut[]; nextId: number } { + if (end - start < 1e-4) return { cuts: manualCuts, nextId }; + const merged = mergeCutRanges( + [...manualCuts.map((c) => ({ start: c.start, end: c.end })), { start, end }], + Number.POSITIVE_INFINITY + ); + // Rebuild with stable-ish ids: keep old ids when a merged range covers an old cut's midpoint + let id = nextId; + const cuts: ManualCut[] = merged.map((r) => { + const existing = manualCuts.find( + (c) => c.start >= r.start - 1e-4 && c.end <= r.end + 1e-4 + ); + if (existing) return { id: existing.id, start: r.start, end: r.end }; + return { id: id++, start: r.start, end: r.end }; + }); + return { cuts, nextId: id }; +} + +/** + * Range a trim drag may move one clip edge through: from shrinking the clip to + * MIN_CLIP_DURATION, out to fully reclaiming the adjacent gap — but never past + * it, since beyond the gap lies the neighbouring clip's own edge. + */ +export function trimEdgeBounds( + clip: ClipSegment, + edge: "in" | "out", + cuts: TimeRange[] +): { lo: number; hi: number } { + const eps = 1e-3; + const gap = cuts.find((c) => + edge === "in" + ? Math.abs(c.end - clip.start) < eps + : Math.abs(c.start - clip.end) < eps + ); + const lo = + edge === "in" ? (gap ? gap.start : clip.start) : clip.start + MIN_CLIP_DURATION; + const hi = + edge === "in" ? clip.end - MIN_CLIP_DURATION : gap ? gap.end : clip.end; + return { lo: Math.min(lo, hi), hi: Math.max(lo, hi) }; +} + +/** + * Carry a split point that sits exactly on a dragged edge along with it. + * A boundary left behind at the old position keeps splitting the keep range once + * the edge moves past it, which slices the reclaimed span off into its own + * orphan clip instead of letting it join the clip being trimmed. + */ +export function carrySceneBoundaries( + boundaries: SceneBoundary[], + from: number, + to: number +): SceneBoundary[] { + if (!boundaries.some((b) => Math.abs(b.time - from) < SPLIT_EPSILON)) { + return boundaries; + } + const moved = boundaries + .map((b) => (Math.abs(b.time - from) < SPLIT_EPSILON ? { ...b, time: to } : b)) + .sort((a, b) => a.time - b.time); + + // Closing a gap completely can land two split points on top of each other. + const out: SceneBoundary[] = []; + for (const b of moved) { + const last = out[out.length - 1]; + if (last && b.time - last.time < SPLIT_EPSILON) continue; + out.push(b); + } + return out; +} + +/** + * Move one kept-region edge from `from` to `to`. `edge` names the side that + * stays kept ("out" = the clip left of the edge, "in" = the clip right of it), + * which is what decides whether the move cuts the span away or reclaims it. + * Edges are addressed by time rather than by clip index because a trim can merge + * or split clips — and renumber them — while the drag is still in progress. + */ +export function trimEdgeResult( + words: Word[], + manualCuts: ManualCut[], + edge: "in" | "out", + from: number, + to: number, + nextCutId: number +): { words: Word[]; manualCuts: ManualCut[]; nextCutId: number } | null { + if (Math.abs(to - from) < 1e-4) return null; + const lo = Math.min(from, to); + const hi = Math.max(from, to); + const cutting = edge === "out" ? to < from : to > from; + + if (cutting) { + const { cuts, nextId } = addManualCut(manualCuts, lo, hi, nextCutId); + return { + words: deleteWordsCoveredBy(words, lo, hi), + manualCuts: cuts, + nextCutId: nextId, + }; + } + const { cuts, nextId } = shrinkManualCuts(manualCuts, lo, hi, nextCutId); + return { + words: restoreWordsCoveredBy(words, lo, hi), + manualCuts: cuts, + nextCutId: nextId, + }; +} + /** Format seconds as m:ss.d (or h:mm:ss for long media). */ export function formatTime(seconds: number): string { if (!isFinite(seconds) || seconds < 0) seconds = 0; diff --git a/lib/projects.ts b/lib/projects.ts index f420e11..22d6e89 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -9,7 +9,7 @@ import type { ModelChoice } from "./models"; import { isModelChoice } from "./models"; import type { MediaKind } from "./media"; -import type { Word } from "./types"; +import type { ManualCut, SceneBoundary, Word } from "./types"; const DB_NAME = "rescript-projects"; const DB_VERSION = 1; @@ -29,6 +29,10 @@ export interface ProjectMeta { export interface ProjectRecord extends ProjectMeta { words: Word[]; showDeleted: boolean; + /** Blade/trim cuts not owned by deleted words (optional for older saves). */ + manualCuts?: ManualCut[]; + /** Scene split points in original media time (optional for older saves). */ + sceneBoundaries?: SceneBoundary[]; /** Original media bytes. */ media: Blob; /** MIME type used when reconstructing a File. */ @@ -40,8 +44,22 @@ export type ProjectWrite = Omit createdAt?: number; }; +// One shared connection for the page. Opening (and closing) a fresh one per +// call churned connections — every autosave paid an open handshake, and DevTools +// lists the database once per open, which looks like duplicate stores. +let dbPromise: Promise | null = null; +let liveDb: IDBDatabase | null = null; + +/** Drop the cached handle so the next call reopens. */ +function forgetDb(db: IDBDatabase) { + if (liveDb !== db) return; + liveDb = null; + dbPromise = null; +} + function openDb(): Promise { - return new Promise((resolve, reject) => { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { if (typeof indexedDB === "undefined") { reject(new Error("IndexedDB is not available.")); return; @@ -54,9 +72,24 @@ function openDb(): Promise { store.createIndex("updatedAt", "updatedAt", { unique: false }); } }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error ?? new Error("Failed to open projects DB.")); + req.onsuccess = () => { + const db = req.result; + liveDb = db; + // A held-open connection blocks another tab's upgrade, and the browser can + // force-close it when reclaiming storage — invalidate the cache for both. + db.onversionchange = () => { + db.close(); + forgetDb(db); + }; + db.onclose = () => forgetDb(db); + resolve(db); + }; + req.onerror = () => { + dbPromise = null; + reject(req.error ?? new Error("Failed to open projects DB.")); + }; }); + return dbPromise; } function idbReq(req: IDBRequest): Promise { @@ -77,93 +110,90 @@ function txDone(tx: IDBTransaction): Promise { /** List projects newest-first (metadata only — no media/words payloads). */ export async function listProjects(): Promise { const db = await openDb(); - try { - const tx = db.transaction(STORE, "readonly"); - const store = tx.objectStore(STORE); - const rows = await idbReq(store.getAll() as IDBRequest); - await txDone(tx); - return rows - .map((r) => ({ - id: r.id, - name: r.name, - mediaKind: r.mediaKind, - duration: r.duration, - model: r.model, - updatedAt: r.updatedAt, - createdAt: r.createdAt, - })) - .sort((a, b) => b.updatedAt - a.updatedAt); - } finally { - db.close(); - } + const tx = db.transaction(STORE, "readonly"); + const store = tx.objectStore(STORE); + const rows = await idbReq(store.getAll() as IDBRequest); + await txDone(tx); + return rows + .map((r) => ({ + id: r.id, + name: r.name, + mediaKind: r.mediaKind, + duration: r.duration, + model: r.model, + updatedAt: r.updatedAt, + createdAt: r.createdAt, + })) + .sort((a, b) => b.updatedAt - a.updatedAt); } export async function getProject(id: string): Promise { const db = await openDb(); - try { - const tx = db.transaction(STORE, "readonly"); - const row = await idbReq( - tx.objectStore(STORE).get(id) as IDBRequest - ); - await txDone(tx); - return row ?? null; - } finally { - db.close(); - } + const tx = db.transaction(STORE, "readonly"); + const row = await idbReq( + tx.objectStore(STORE).get(id) as IDBRequest + ); + await txDone(tx); + return row ?? null; } /** Insert or replace a project, then prune to MAX_PROJECTS. Returns the id. */ export async function putProject(input: ProjectWrite): Promise { const now = Date.now(); + const id = input.id ?? crypto.randomUUID(); + const db = await openDb(); + const tx = db.transaction(STORE, "readwrite"); + const store = tx.objectStore(STORE); + + // Read createdAt back in the same transaction as the write, so overlapping + // saves can't interleave and lose it (and so a save is a single transaction). + let createdAt = input.createdAt; + if (createdAt === undefined && input.id !== undefined) { + const existing = await idbReq(store.get(id) as IDBRequest); + createdAt = existing?.createdAt; + } + const record: ProjectRecord = { - id: input.id ?? crypto.randomUUID(), + id, name: input.name, mediaKind: input.mediaKind, duration: input.duration, model: isModelChoice(input.model) ? input.model : "base", words: input.words, showDeleted: input.showDeleted, + manualCuts: input.manualCuts ?? [], + sceneBoundaries: input.sceneBoundaries ?? [], media: input.media, mediaType: input.mediaType, - createdAt: input.createdAt ?? now, + createdAt: createdAt ?? now, updatedAt: now, }; - const db = await openDb(); - try { - const tx = db.transaction(STORE, "readwrite"); - const store = tx.objectStore(STORE); - store.put(record); - - // Prune oldest beyond the cap (never delete the record we just wrote). - const all = await idbReq(store.getAll() as IDBRequest); - if (all.length > MAX_PROJECTS) { - const sorted = [...all].sort((a, b) => a.updatedAt - b.updatedAt); - let excess = all.length - MAX_PROJECTS; - for (const row of sorted) { - if (excess <= 0) break; - if (row.id === record.id) continue; - store.delete(row.id); - excess--; - } - } - - await txDone(tx); - return record.id; - } finally { - db.close(); + store.put(record); + + // Prune oldest beyond the cap (never delete the record we just wrote). The + // updatedAt index yields primary keys oldest-first, so pruning doesn't have to + // deserialize every stored media blob the way getAll() would. + const keys = await idbReq( + store.index("updatedAt").getAllKeys() as IDBRequest + ); + let excess = keys.length - MAX_PROJECTS; + for (const key of keys) { + if (excess <= 0) break; + if (key === id) continue; + store.delete(key); + excess--; } + + await txDone(tx); + return id; } export async function deleteProject(id: string): Promise { const db = await openDb(); - try { - const tx = db.transaction(STORE, "readwrite"); - tx.objectStore(STORE).delete(id); - await txDone(tx); - } finally { - db.close(); - } + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(id); + await txDone(tx); } /** Reconstruct a File from a stored project for preview/export. */ diff --git a/lib/store.ts b/lib/store.ts index 47e5cf9..3b0262b 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -1,7 +1,24 @@ "use client"; import { create } from "zustand"; -import type { EditorStatus, ProgressInfo, Word } from "./types"; +import type { + EditSnapshot, + EditorStatus, + ManualCut, + ProgressInfo, + SceneBoundary, + Word, +} from "./types"; +import { + applyWordBounds, + canSplitAt, + carrySceneBoundaries, + getClipSegments, + getCutRanges, + getKeepRanges, + shrinkManualCuts, + trimEdgeResult, +} from "./edits"; import { isModelChoice, isWhisperModel, @@ -55,9 +72,25 @@ interface EditorState { // Transcript / edits words: Word[]; + manualCuts: ManualCut[]; + sceneBoundaries: SceneBoundary[]; showDeleted: boolean; - past: Word[][]; - future: Word[][]; + past: EditSnapshot[]; + future: EditSnapshot[]; + /** Selected timeline clip index, or null. */ + selectedClipIndex: number | null; + /** + * Words selected in the transcript or the timeline wordbar. Shared so both + * views highlight the same selection and the same shortcuts apply. + */ + selectedWordIds: number[]; + nextManualCutId: number; + nextBoundaryId: number; + /** + * When true, subsequent edit mutations coalesce into the undo entry + * created by `beginGesture` (one undo step per drag). + */ + gestureActive: boolean; // Playback (mirrored from the