diff --git a/components/Editor.tsx b/components/Editor.tsx index ea57f58..e119524 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { useEditorStore } from "@/lib/store"; +import { getCutRanges, isWordCutOut } from "@/lib/edits"; import { extractAudio, getFFmpeg } from "@/lib/ffmpeg"; import { VAD_SAMPLE_RATE } from "@/lib/vad"; import { isElectron } from "@/lib/platform"; @@ -176,7 +177,8 @@ export default function Editor() { return () => window.clearTimeout(timer); }, [status]); - // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split. + // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split, + // Delete/Backspace = delete selected clip or restore selected cut / cut words. // Capture phase so Space is ours before a focused + + + +
@@ -657,6 +827,22 @@ export default function Timeline() { ); })} + {/* Selected cut/silence outline */} + {selectedCutIndex != null && cuts[selectedCutIndex] && ( +
+ )} + {/* Clip trim handles (selected or hovered) */} {clips.map((clip) => { const active = @@ -707,12 +893,12 @@ export default function Timeline() {
{selected && (
)} @@ -761,13 +947,31 @@ export default function Timeline() { e.stopPropagation(); seekTo(w.start); const store = useEditorStore.getState(); - // Select the word (the transcript mirrors this) and the clip - // it sits in. + // Select the word (the transcript mirrors this). Kept words + // also select their clip; cut-out words select the cut so + // Delete / Restore can bring them back. store.setSelectedWords([w.id]); - const clip = clips.find( - (c) => w.start >= c.start && w.start < c.end - ); - store.setSelectedClipIndex(clip?.index ?? null); + if (cutOut) { + const cut = cutRangeAt(w.start + (w.end - w.start) / 2, cuts); + const cutIdx = cut + ? cuts.findIndex( + (c) => + Math.abs(c.start - cut.start) < 1e-4 && + Math.abs(c.end - cut.end) < 1e-4 + ) + : -1; + if (cutIdx >= 0) store.setSelectedCutIndex(cutIdx); + else { + store.setSelectedCutIndex(null); + store.setSelectedClipIndex(null); + } + } else { + const clip = clips.find( + (c) => w.start >= c.start && w.start < c.end + ); + store.setSelectedClipIndex(clip?.index ?? null); + store.setSelectedCutIndex(null); + } }} > diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index afa0c2e..2a8e05c 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -284,22 +284,21 @@ export default function TranscriptPanel() { return () => document.removeEventListener("mousedown", handler); }, [correcting, closeCorrect]); - // Delete / Backspace cuts the selected words. Driven by the shared selection so - // it works for words picked in the timeline wordbar too. + // Escape clears the transcript selection chrome. Delete / Backspace are handled + // globally in Editor (cut words restore; kept words / clips delete). useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.key !== "Delete" && e.key !== "Backspace" && e.key !== "Escape") return; + if (e.key !== "Escape") return; const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return; if (selectedWordIds.length === 0) return; e.preventDefault(); - if (e.key !== "Escape") deleteWords(selectedWordIds); clearSelection(); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [selectedWordIds, deleteWords, clearSelection]); + }, [selectedWordIds, clearSelection]); // "@" opens the speaker picker for the current selection. useEffect(() => { diff --git a/lib/edits.ts b/lib/edits.ts index 0397b2e..eb0b7ac 100644 --- a/lib/edits.ts +++ b/lib/edits.ts @@ -470,6 +470,37 @@ export function carrySceneBoundaries( return out; } +/** + * Restore media in the given cut ranges: shrink overlapping manual cuts and + * undelete words fully covered by each range. Inverse of cutting a clip / silence. + */ +export function restoreRangesResult( + words: Word[], + manualCuts: ManualCut[], + ranges: TimeRange[], + nextCutId: number +): { words: Word[]; manualCuts: ManualCut[]; nextCutId: number } | null { + const usable = ranges.filter((r) => r.end - r.start > 1e-4); + if (usable.length === 0) return null; + + let nextWords = words; + let cuts = manualCuts; + let id = nextCutId; + let changed = false; + + for (const r of usable) { + const shrunk = shrinkManualCuts(cuts, r.start, r.end, id); + const restored = restoreWordsCoveredBy(nextWords, r.start, r.end); + if (shrunk.cuts !== cuts || restored !== nextWords) changed = true; + cuts = shrunk.cuts; + id = shrunk.nextId; + nextWords = restored; + } + + if (!changed) return null; + return { words: nextWords, manualCuts: cuts, nextCutId: id }; +} + /** * 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), diff --git a/lib/store.ts b/lib/store.ts index 207a76c..6387cc5 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -22,6 +22,7 @@ import { getCutRanges, getKeepRanges, PLAYHEAD_EPSILON_S, + restoreRangesResult, shrinkManualCuts, trimEdgeResult, } from "./edits"; @@ -101,6 +102,11 @@ interface EditorState { future: EditSnapshot[]; /** Selected timeline clip index, or null. */ selectedClipIndex: number | null; + /** + * Selected cut-range index (from `getCutRanges`), or null. + * Used to restore a deleted clip / silence section from the timeline. + */ + selectedCutIndex: number | null; /** * Words selected in the transcript or the timeline wordbar. Shared so both * views highlight the same selection and the same shortcuts apply. @@ -173,6 +179,12 @@ interface EditorState { restoreWords: (ids: number[]) => void; /** Cut arbitrary time ranges (e.g. detected silences) as manual cuts. */ cutRanges: (ranges: TimeRange[]) => void; + /** Restore arbitrary cut ranges (manual cuts + covered deleted words). */ + restoreRanges: (ranges: TimeRange[]) => void; + /** Cut the currently selected timeline clip out of the edited media. */ + deleteSelectedClip: () => boolean; + /** Restore the currently selected cut range (deleted clip / silence). */ + restoreSelectedCut: () => boolean; /** Replace the selected (contiguous) words with corrected text. */ correctWords: (ids: number[], text: string) => void; /** Nudge a word's start/end on the timeline (may steal time from neighbors). */ @@ -190,6 +202,7 @@ interface EditorState { */ trimEdge: (edge: "in" | "out", from: number, to: number) => void; setSelectedClipIndex: (index: number | null) => void; + setSelectedCutIndex: (index: number | null) => void; setSelectedWords: (ids: number[]) => void; /** Start a drag gesture so subsequent edits share one undo entry. */ beginGesture: () => void; @@ -256,6 +269,7 @@ function pushEdit( | "manualCuts" | "sceneBoundaries" | "selectedClipIndex" + | "selectedCutIndex" | "selectedWordIds" | "nextManualCutId" | "nextBoundaryId" @@ -301,6 +315,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: 1, nextBoundaryId: 1, @@ -344,6 +359,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: 1, nextBoundaryId: 1, @@ -388,6 +404,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: maxId(manualCuts, 1), nextBoundaryId: maxId(sceneBoundaries, 1), @@ -442,6 +459,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], }); if (get().status === "ready") bumpAutosave(); @@ -466,6 +484,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], partialText: "", error: null, @@ -568,6 +587,7 @@ export const useEditorStore = create((set, get) => ({ words: words.map((w) => idSet.has(w.id) && !w.deleted ? { ...w, deleted: true } : w ), + selectedCutIndex: null, }); }, cutRanges: (ranges) => { @@ -583,7 +603,54 @@ export const useEditorStore = create((set, get) => ({ nextManualCutId = added.nextId; words = deleteWordsCoveredBy(words, r.start, r.end); } - pushEdit(get, set, { words, manualCuts, nextManualCutId }); + pushEdit(get, set, { + words, + manualCuts, + nextManualCutId, + selectedClipIndex: null, + selectedCutIndex: null, + selectedWordIds: [], + }); + }, + restoreRanges: (ranges) => { + const s = get(); + const result = restoreRangesResult( + s.words, + s.manualCuts, + ranges, + s.nextManualCutId + ); + if (!result) return; + pushEdit(get, set, { + words: result.words, + manualCuts: result.manualCuts, + nextManualCutId: result.nextCutId, + selectedClipIndex: null, + selectedCutIndex: null, + selectedWordIds: [], + }); + }, + deleteSelectedClip: () => { + const s = get(); + if (s.selectedClipIndex == null || s.duration <= 0) return false; + const cuts = getCutRanges(s.words, s.duration, s.manualCuts); + const clips = getClipSegments( + getKeepRanges(cuts, s.duration), + s.sceneBoundaries + ); + const clip = clips.find((c) => c.index === s.selectedClipIndex); + if (!clip || clip.end - clip.start <= 1e-4) return false; + get().cutRanges([{ start: clip.start, end: clip.end }]); + return true; + }, + restoreSelectedCut: () => { + const s = get(); + if (s.selectedCutIndex == null || s.duration <= 0) return false; + const cuts = getCutRanges(s.words, s.duration, s.manualCuts); + const cut = cuts[s.selectedCutIndex]; + if (!cut || cut.end - cut.start <= 1e-4) return false; + get().restoreRanges([{ start: cut.start, end: cut.end }]); + return true; }, restoreWords: (ids) => { if (ids.length === 0) return; @@ -615,6 +682,7 @@ export const useEditorStore = create((set, get) => ({ words, manualCuts, nextManualCutId, + selectedCutIndex: null, }); }, correctWords: (ids, text) => { @@ -693,6 +761,7 @@ export const useEditorStore = create((set, get) => ({ pushEdit(get, set, { sceneBoundaries: sceneBoundaries.filter((b) => b.id !== id), selectedClipIndex: null, + selectedCutIndex: null, }); }, @@ -731,12 +800,29 @@ export const useEditorStore = create((set, get) => ({ sceneBoundaries, nextManualCutId: result.nextCutId, selectedClipIndex: owner?.index ?? s.selectedClipIndex, + selectedCutIndex: null, }); }, - setSelectedClipIndex: (selectedClipIndex) => set({ selectedClipIndex }), + setSelectedClipIndex: (selectedClipIndex) => + set({ + selectedClipIndex, + ...(selectedClipIndex != null ? { selectedCutIndex: null } : {}), + }), + + setSelectedCutIndex: (selectedCutIndex) => + set({ + selectedCutIndex, + ...(selectedCutIndex != null ? { selectedClipIndex: null } : {}), + }), - setSelectedWords: (selectedWordIds) => set({ selectedWordIds }), + setSelectedWords: (selectedWordIds) => + set({ + selectedWordIds, + // A fresh word selection from the transcript supersedes a prior cut pick. + // Timeline cut-word clicks re-select the cut afterward. + ...(selectedWordIds.length > 0 ? { selectedCutIndex: null } : {}), + }), beginGesture: () => { const s = get(); @@ -776,6 +862,7 @@ export const useEditorStore = create((set, get) => ({ ...future, ], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], gestureActive: false, }); @@ -794,6 +881,7 @@ export const useEditorStore = create((set, get) => ({ future: future.slice(1), past: [...past, { words, speakers, manualCuts, sceneBoundaries }], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], gestureActive: false, }); @@ -855,6 +943,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: 1, nextBoundaryId: 1, diff --git a/tests/edits-test.ts b/tests/edits-test.ts index 52b349b..0dc7c47 100644 --- a/tests/edits-test.ts +++ b/tests/edits-test.ts @@ -14,6 +14,7 @@ import { getWordCutRanges, mapSplitsToWords, MIN_CLIP_DURATION, + restoreRangesResult, trimEdgeBounds, trimEdgeResult, } from "../lib/edits"; @@ -295,4 +296,69 @@ function nearly(a: number, b: number, eps = 1e-3) { assert(nearly(next![0].end, 0.55), "hello keeps its audio"); } +// --- Restoring a cut range undeletes words and shrinks manual cuts --- +{ + const words = [ + word(1, "keep", 0, 1), + word(2, "gone", 1, 2, true), + word(3, "also", 2, 3, true), + word(4, "end", 3, 4), + ]; + const manual: ManualCut[] = [{ id: 1, start: 1, end: 3 }]; + const result = restoreRangesResult(words, manual, [{ start: 1, end: 3 }], 10); + assert(result !== null, "restore ok"); + assert(result!.words[1].deleted === false, "gone restored"); + assert(result!.words[2].deleted === false, "also restored"); + assert(result!.manualCuts.length === 0, "manual cut removed"); + const keeps = getKeepRanges( + getCutRanges(result!.words, 4, result!.manualCuts), + 4 + ); + assert(keeps.length === 1, "one continuous keep after restore"); +} + +// --- Partial restore splits a manual cut (only fully covered words return) --- +{ + const words = [ + word(1, "left", 0, 1, true), + word(2, "mid", 1, 3, true), + word(3, "right", 3, 5, true), + ]; + const manual: ManualCut[] = [{ id: 1, start: 0, end: 5 }]; + const result = restoreRangesResult(words, manual, [{ start: 1, end: 3 }], 10); + assert(result !== null, "partial restore ok"); + assert(result!.words[0].deleted === true, "left stays cut"); + assert(result!.words[1].deleted === false, "mid restored"); + assert(result!.words[2].deleted === true, "right stays cut"); + assert(result!.manualCuts.length === 2, "manual cut split into remnants"); + assert(nearly(result!.manualCuts[0].end, 1), "left remnant"); + assert(nearly(result!.manualCuts[1].start, 3), "right remnant"); +} + +// --- Delete clip then restore that cut brings media back --- +{ + const words = [ + word(1, "a", 0, 2), + word(2, "b", 2, 4), + word(3, "c", 4, 6), + ]; + // Simulate deleting the middle clip [2,4) via a manual cut + covered words. + const cut = trimEdgeResult(words, [], "out", 4, 2, 1); + assert(cut !== null, "cut middle"); + assert(cut!.words[1].deleted === true, "b deleted"); + const restored = restoreRangesResult( + cut!.words, + cut!.manualCuts, + [{ start: 2, end: 4 }], + cut!.nextCutId + ); + assert(restored !== null, "restore middle"); + assert(restored!.words[1].deleted === false, "b back"); + assert( + getKeepRanges(getCutRanges(restored!.words, 6, restored!.manualCuts), 6) + .length === 1, + "fully restored keep" + ); +} + console.log("edits-test: all passed");