From f211a9520ba387d08a63c819301063771aa43c21 Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:25:53 +0100 Subject: [PATCH 1/7] Add timeline cuts, transcript cuts, undo/redo and editing shortcuts - One shared kept-segment function (clipKeptSegments): automatic pause removal, plus restored pauses, minus user cuts. Export, preview, clip durations and the AI's view of the edit all use it. - Pure edit operations (split, cut, uncut, restore, word ranges, in/out) shared by the timeline, transcript and keyboard. - Per-clip undo/redo over user-owned fields, recorded per save. - Timeline shows removed pauses, restored pauses, cuts, razor points and the selected piece; Premiere-style shortcuts. - Smoke walkthrough now splits, cuts and undoes with real key events. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 + src/main/index.ts | 30 ++++ src/main/pipeline/render.ts | 7 +- src/main/pipeline/visualScore.ts | 10 +- src/renderer/src/components/EditorScreen.tsx | 25 +-- src/renderer/src/components/PreviewPlayer.tsx | 19 ++- .../src/components/TimelineEditor.tsx | 143 ++++++++++++++++++ .../src/components/TranscriptEditor.tsx | 51 ++++++- src/renderer/src/components/TrimBar.tsx | 66 +++++++- src/renderer/src/lib/previewBus.ts | 9 +- src/renderer/src/store.ts | 35 ++++- src/shared/editHistory.ts | 76 ++++++++++ src/shared/editOps.ts | 79 ++++++++++ src/shared/tighten.ts | 66 +++++++- src/shared/types.ts | 11 +- tests/editHistory.test.ts | 48 ++++++ tests/editOps.test.ts | 50 ++++++ tests/manualCuts.test.ts | 57 +++++++ 18 files changed, 739 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/TimelineEditor.tsx create mode 100644 src/shared/editHistory.ts create mode 100644 src/shared/editOps.ts create mode 100644 tests/editHistory.test.ts create mode 100644 tests/editOps.test.ts create mode 100644 tests/manualCuts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a77cc25..4448ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes. ## [Unreleased] +### Added + +- Timeline editing in the clip editor: split at the playhead (S or ⌘K), select a piece and ripple-delete it (Delete), and click a cut to put it back. Automatically removed pauses are shown on the timeline; click one to keep it. +- Cut words straight from the transcript: drag across them and press Delete or "Cut selection". Cut words are struck through. +- Undo and redo for clip edits (⌘Z / ⇧⌘Z), and Premiere-style keys: Space or K play/pause, J/L step a second, arrow keys step a frame, I/O set in and out. +- The trim bar shows how long the clip plays after pauses and cuts. + ### Improved - Show found clips as soon as they are scored; the top clips' layouts finish in the background, each marked "Framing…". A failed background layout no longer fails the run. diff --git a/src/main/index.ts b/src/main/index.ts index 8562d22..935e69b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -98,6 +98,36 @@ async function runSmokeCapture(win: BrowserWindow, dir: string): Promise { await click('[data-testid="clip-thumb"]') await sleep(800) await shot('editor') + // Timeline editing with real key events: two razor splits, select the + // middle piece, ripple-delete it, then undo. + const key = async (keyCode: string, modifiers: Array<'meta' | 'control'> = []): Promise => { + win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + await sleep(250) + } + const hasText = (text: string): Promise => + win.webContents.executeJavaScript(`document.body.innerText.includes(${JSON.stringify(text)})`) + for (let i = 0; i < 4; i++) await key('L') + await key('S') + for (let i = 0; i < 3; i++) await key('L') + await key('S') + await win.webContents.executeJavaScript(`(() => { + const track = document.querySelector('[data-testid="trim-track"]') + const rect = track.getBoundingClientRect() + const splits = [...document.querySelectorAll('[data-testid="trim-track"] .bg-amber-300')] + const x = splits.length >= 2 + ? (splits[0].getBoundingClientRect().left + splits[1].getBoundingClientRect().left) / 2 + : rect.left + rect.width / 2 + track.dispatchEvent(new PointerEvent('pointerdown', { clientX: x, bubbles: true })) + })()`) + await sleep(400) + await key('Delete') + await sleep(600) + if (!(await hasText('1 cut'))) throw new Error('Smoke capture: splitting and deleting a piece did not cut it') + await shot('editor-cut') + await key('Z', [process.platform === 'darwin' ? 'meta' : 'control']) + await sleep(600) + if (await hasText('1 cut')) throw new Error('Smoke capture: undo did not restore the cut piece') // Out to the setup screen, which is where the two modes are chosen. await click('[data-testid="back-button"]') await click('[data-testid="regenerate-button"]') diff --git a/src/main/pipeline/render.ts b/src/main/pipeline/render.ts index 65b0883..729e06d 100644 --- a/src/main/pipeline/render.ts +++ b/src/main/pipeline/render.ts @@ -14,7 +14,7 @@ import type { WatermarkPosition } from '@shared/types' import type { EncoderPreference } from '@shared/types' -import { computeKeptSegments, remapTranscript, TimeMap, type KeptSegment } from '@shared/tighten' +import { clipKeptSegments, remapTranscript, TimeMap, type KeptSegment } from '@shared/tighten' import { focusPanDuration, focusSnaps } from '@shared/focusTrack' import { automaticLayoutShots, clipAllowsAutoZoom, compositionHidesTitle, detailCaptionRanges, layoutBlocksAutoZoom } from '@shared/contentType' import { compositionGraph, fitRegionGraph } from './layoutFilters' @@ -633,10 +633,7 @@ async function render(job: RenderJob): Promise { // Tighten cuts: figure out the kept segments and remap everything that is // timed against the source (captions, B-roll, face track) into the // compacted output timeline. - const segments = - clip.edit.tightenCuts && transcript - ? computeKeptSegments(transcript, start, clip.edit.end, clip.visualStory?.protectedRanges) - : null + const segments = clipKeptSegments(clip, transcript) const map = segments ? new TimeMap(segments) : null const outputDuration = map ? map.outputDuration : duration diff --git a/src/main/pipeline/visualScore.ts b/src/main/pipeline/visualScore.ts index d9f3248..1ec3eeb 100644 --- a/src/main/pipeline/visualScore.ts +++ b/src/main/pipeline/visualScore.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { validRectangle } from '@shared/composition' import type { Clip, ContentRegion, Transcript } from '@shared/types' import { wordsInRange } from '@shared/captionLayout' -import { computeKeptSegments, editedClipDuration, TimeMap } from '@shared/tighten' +import { clipKeptSegments, editedClipDuration, TimeMap } from '@shared/tighten' import { chatJSON, type ChatContentPart } from './openai' import { runAnalysisFfmpeg as runFfmpeg } from './ffmpeg' @@ -189,9 +189,7 @@ export function validatedStoryIssue(value: unknown, transcriptText: string): Sto /** Sample the planned edit, so removed waiting time cannot masquerade as payoff. */ export function plannedClipFrameTimes(clip: Clip, transcript: Transcript, count = 6): number[] { - const ranges = clip.edit.tightenCuts - ? computeKeptSegments(transcript, clip.edit.start, clip.edit.end, clip.visualStory?.protectedRanges) - : null + const ranges = clipKeptSegments(clip, transcript) if (!ranges) return clipFrameTimes(clip.edit.start, clip.edit.end, count) const map = new TimeMap(ranges) return clipFrameTimes(0, map.outputDuration, count).map(t => map.toSource(t)) @@ -199,9 +197,7 @@ export function plannedClipFrameTimes(clip: Clip, transcript: Transcript, count /** Supply the same retained speech as the export, not words inside cut gaps. */ export function plannedClipTranscriptText(clip: Clip, transcript: Transcript): string { - const kept = clip.edit.tightenCuts - ? computeKeptSegments(transcript, clip.edit.start, clip.edit.end, clip.visualStory?.protectedRanges) - : null + const kept = clipKeptSegments(clip, transcript) return wordsInRange(transcript, clip.edit.start, clip.edit.end) .filter(w => !kept || kept.some(r => (w.start + w.end) / 2 >= r.start && (w.start + w.end) / 2 <= r.end)) .map(w => w.text).join(' ') diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx index dbe67f2..0f459f2 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -24,7 +24,8 @@ import { automaticLayoutShots, validLayoutShots } from '@shared/contentType' import { useStore } from '../store' import PreviewPlayer from './PreviewPlayer' import CompositionControls from './CompositionControls' -import TrimBar from './TrimBar' +import TimelineEditor from './TimelineEditor' +import { cutRange } from '@shared/editOps' import ScoreBadge from './ScoreBadge' import TranscriptEditor from './TranscriptEditor' import { ExportButton } from './ClipsScreen' @@ -173,20 +174,18 @@ export default function EditorScreen(): React.JSX.Element { )}
- setLocal({ start, end })} - onCommit={() => { - // Read the live clip: the pointerup closure inside TrimBar was - // created at drag start, so `clip` here would be pre-drag. - const current = useStore - .getState() - .project?.clips.find((c) => c.id === clip.id) - if (current) void updateClip(current) + // Timeline edits never touch layout fields, so they save as-is + // (userClipEdit would mark the layout as manually chosen). + onLocal={(edit) => updateClipLocal({ ...clip, edit })} + onSave={(edit) => { + const live = useStore.getState().project?.clips.find((c) => c.id === clip.id) ?? clip + void updateClip({ ...live, edit }) }} />
@@ -527,6 +526,8 @@ export default function EditorScreen(): React.JSX.Element { transcript={project.transcript} clipStart={clip.edit.start} clipEnd={clip.edit.end} + cuts={clip.edit.cuts} + onCut={(range) => void updateClip({ ...clip, edit: cutRange(clip.edit, range) })} onTrim={(start, end) => { void updateClip({ ...clip, diff --git a/src/renderer/src/components/PreviewPlayer.tsx b/src/renderer/src/components/PreviewPlayer.tsx index e3ced1f..9e289c3 100644 --- a/src/renderer/src/components/PreviewPlayer.tsx +++ b/src/renderer/src/components/PreviewPlayer.tsx @@ -8,7 +8,7 @@ import { groupWords, wordsInRange } from '@shared/captionLayout' -import { computeKeptSegments, TimeMap } from '@shared/tighten' +import { clipKeptSegments, TimeMap } from '@shared/tighten' import { automaticLayoutShots, clipAllowsAutoZoom, compositionHidesTitle, detailCaptionRanges, layoutBlocksAutoZoom } from '@shared/contentType' import { captionPositionAt } from '@shared/contentRegion' import { computeZoomEvents, fitZoomEvents } from '@shared/zoom' @@ -182,11 +182,9 @@ export default function PreviewPlayer({ return () => setScrubHandler(null) }, [setScrubHandler, requestSeek]) - // Mirrors the export's tighten-cuts behaviour by skipping removed spans. - const keptSegments = useMemo(() => { - if (!clip.edit.tightenCuts || !project.transcript) return null - return computeKeptSegments(project.transcript, start, end, clip.visualStory?.protectedRanges) - }, [clip.edit.tightenCuts, clip.visualStory, project.transcript, start, end]) + // Mirrors the export by skipping removed pauses and the user's cuts. + const keptSegments = useMemo(() => clipKeptSegments(clip, project.transcript), + [clip, project.transcript]) const timeMap = useMemo(() => (keptSegments ? new TimeMap(keptSegments) : null), [keptSegments]) const outputDuration = timeMap?.outputDuration ?? duration const outputTime = timeMap ? timeMap.toOutput(time) : Math.max(0, time - start) @@ -275,6 +273,15 @@ export default function PreviewPlayer({ } } + // Keyboard shortcuts play/pause through the bus; the ref keeps the handler current. + const togglePlayRef = useRef(togglePlay) + useEffect(() => { togglePlayRef.current = togglePlay }) + const setToggleHandler = usePreviewBus((s) => s.setToggleHandler) + useEffect(() => { + setToggleHandler(() => togglePlayRef.current()) + return () => setToggleHandler(null) + }, [setToggleHandler]) + const restart = (): void => { requestSeek(start) } diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx new file mode 100644 index 0000000..58d9001 --- /dev/null +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo, useState } from 'react' +import { Redo2, Scissors, SplitSquareHorizontal, Trash2, Undo2 } from 'lucide-react' +import type { Clip, TimelineData, TimeRange } from '@shared/types' +import { autoRemovedRanges, editedClipDuration, normalizeRanges } from '@shared/tighten' +import { cutRange, pieceAt, setTrimEdge, splitAt, timelinePieces, toggleRestored, uncutAt } from '@shared/editOps' +import { historyState } from '@shared/editHistory' +import { usePreviewBus } from '../lib/previewBus' +import { useStore } from '../store' +import TrimBar, { type TimelineMark } from './TrimBar' + +/** Keyboard shortcuts must not fire while the user is typing. */ +function typing(target: EventTarget | null): boolean { + const el = target as HTMLElement | null + return Boolean(el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) +} + +const SHORTCUTS = 'Space/K play · J/L ±1 s · ←/→ frame · I/O in/out · S split · Delete cut piece · ⌘Z undo' + +/** + * Timeline editing on top of the trim bar: razor splits, ripple-deleting a + * piece, restoring automatically removed pauses, undo/redo and + * Premiere-style shortcuts. Every change goes through the shared edit ops + * and `clipKeptSegments`, so preview and export stay identical. + */ +export default function TimelineEditor({ clip, windowStart, windowEnd, fps, timeline, onSave, onLocal }: { + clip: Clip + windowStart: number + windowEnd: number + fps: number + timeline: TimelineData | null + onSave: (edit: Clip['edit']) => void + onLocal: (edit: Clip['edit']) => void +}): React.JSX.Element { + const transcript = useStore((s) => s.project?.transcript ?? null) + const undo = useStore((s) => s.undo) + const redo = useStore((s) => s.redo) + useStore((s) => s.historyVersion) // re-render undo/redo availability + const { canUndo, canRedo } = historyState(clip.id) + const [selected, setSelected] = useState(null) + const edit = clip.edit + + const marks = useMemo(() => { + const restored = normalizeRanges(edit.restored, edit.start, edit.end) + const auto = autoRemovedRanges(clip, transcript) + .filter((r) => !restored.some((k) => k.start < r.end && k.end > r.start)) + return [ + ...auto.map((range) => ({ range, kind: 'auto' as const })), + ...(edit.tightenCuts ? restored.map((range) => ({ range, kind: 'restored' as const })) : []), + ...normalizeRanges(edit.cuts, edit.start, edit.end).map((range) => ({ range, kind: 'cut' as const })) + ] + }, [clip, transcript, edit.restored, edit.cuts, edit.start, edit.end, edit.tightenCuts]) + + const pieces = timelinePieces(edit) + // A selection only means something once the timeline has been split. + const selection = selected && pieces.length > 1 ? selected : null + + const cutSelected = (): void => { + if (!selection) return + onSave(cutRange(edit, selection)) + setSelected(null) + } + const split = (): void => { + const next = splitAt(edit, usePreviewBus.getState().time) + if (next !== edit) onSave(next) + } + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (typing(e.target) || e.altKey || e.defaultPrevented) return + const bus = usePreviewBus.getState() + const mod = e.metaKey || e.ctrlKey + const key = e.key.toLowerCase() + const step = (dt: number): void => bus.seek(Math.max(edit.start, Math.min(edit.end, bus.time + dt))) + let handled = true + if (mod && key === 'z') void (e.shiftKey ? redo(clip.id) : undo(clip.id)) + else if (mod && key === 'y') void redo(clip.id) + else if (mod && key === 'k') split() + else if (mod) handled = false + else if (key === ' ' || key === 'k') bus.togglePlay() + else if (key === 'j') step(-1) + else if (key === 'l') step(1) + else if (key === 'arrowleft') step(e.shiftKey ? -1 : -1 / fps) + else if (key === 'arrowright') step(e.shiftKey ? 1 : 1 / fps) + else if (key === 'i') onSave(setTrimEdge(edit, 'start', Math.max(windowStart, bus.time))) + else if (key === 'o') onSave(setTrimEdge(edit, 'end', Math.min(windowEnd, bus.time))) + else if (key === 's') split() + else if (key === 'delete' || key === 'backspace') cutSelected() + else if (key === 'escape') setSelected(null) + else handled = false + if (handled) e.preventDefault() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }) + + const button = 'flex items-center gap-1 rounded-md border border-surface-600 px-2 py-1 text-[11px] text-zinc-300 transition hover:bg-surface-800 disabled:opacity-40' + return ( +
+ onLocal({ ...edit, start, end })} + onCommit={() => { + // Read the live clip: the drag closure was created at drag start. + const current = useStore.getState().project?.clips.find((c) => c.id === clip.id) + if (current) onSave(current.edit) + }} + marks={marks} + splits={edit.splits} + selected={selection} + onPieceClick={(t) => setSelected(pieceAt(edit, t) ?? null)} + playsFor={editedClipDuration(clip, transcript)} + onMarkClick={(mark) => { + if (mark.kind === 'cut') onSave(uncutAt(edit, (mark.range.start + mark.range.end) / 2)) + else onSave(toggleRestored(edit, mark.range)) + }} + /> +
+ + + + + {(edit.cuts?.length ?? 0) > 0 && ( + + {edit.cuts!.length} cut{edit.cuts!.length === 1 ? '' : 's'} + + )} +
+

{SHORTCUTS}

+
+ ) +} diff --git a/src/renderer/src/components/TranscriptEditor.tsx b/src/renderer/src/components/TranscriptEditor.tsx index adbe0f2..7ba5b7c 100644 --- a/src/renderer/src/components/TranscriptEditor.tsx +++ b/src/renderer/src/components/TranscriptEditor.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Scissors, X } from 'lucide-react' -import type { Transcript } from '@shared/types' +import { Scissors, Trash2, X } from 'lucide-react' +import type { TimeRange, Transcript } from '@shared/types' +import { wordsRange } from '@shared/editOps' import { useStore } from '../store' import { usePreviewBus } from '../lib/previewBus' import { formatTimecode } from '../lib/format' @@ -29,12 +30,18 @@ export default function TranscriptEditor({ transcript, clipStart, clipEnd, - onTrim + onTrim, + cuts = [], + onCut }: { transcript: Transcript clipStart: number clipEnd: number onTrim: (start: number, end: number) => void + /** Source ranges the user has cut; their words are shown struck through. */ + cuts?: TimeRange[] + /** Cut the selected words out of the clip (ripple). */ + onCut?: (range: TimeRange) => void }): React.JSX.Element { const updateTranscriptWord = useStore((s) => s.updateTranscriptWord) const seek = usePreviewBus((s) => s.seek) @@ -65,6 +72,27 @@ export default function TranscriptEditor({ if (editing) inputRef.current?.select() }, [editing]) + const range = selection ? [Math.min(selection.a, selection.b), Math.max(selection.a, selection.b)] : null + const cutSelection = (): void => { + if (!range || !onCut) return + onCut(wordsRange(words, range[0], range[1])) + setSelection(null) + } + // Delete cuts the selected words. Capture phase, so the timeline's + // piece-cut shortcut sees the event as handled. + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + const el = e.target as HTMLElement | null + if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return + if ((e.key === 'Delete' || e.key === 'Backspace') && range && onCut) { + e.preventDefault() + cutSelection() + } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }) + // Word selection drags end wherever the pointer is released. useEffect(() => { const up = (): void => { @@ -84,7 +112,6 @@ export default function TranscriptEditor({ return

No speech in this range.

} - const range = selection ? [Math.min(selection.a, selection.b), Math.max(selection.a, selection.b)] : null const selDuration = range ? words[range[1]].end + TRIM_POST_ROLL_SEC - (words[range[0]].start - TRIM_PRE_ROLL_SEC) : 0 @@ -113,6 +140,8 @@ export default function TranscriptEditor({ } const selected = range !== null && i >= range[0] && i <= range[1] const isSpoken = i === spokenIndex + const mid = (w.start + w.end) / 2 + const isCut = cuts.some((c) => mid >= c.start && mid <= c.end) return ( @@ -169,6 +198,16 @@ export default function TranscriptEditor({ Trim clip to selection ({formatTimecode(selDuration)}) + {onCut && ( + + )}
) : (

- Click a word to jump there · drag across words to trim the clip to them · double-click + Click a word to jump there · drag across words to trim to them or cut them · double-click to fix the transcription (clear a word to hide it from captions).

)} diff --git a/src/renderer/src/components/TrimBar.tsx b/src/renderer/src/components/TrimBar.tsx index 4a53af3..05758d3 100644 --- a/src/renderer/src/components/TrimBar.tsx +++ b/src/renderer/src/components/TrimBar.tsx @@ -1,5 +1,17 @@ import { useCallback, useRef } from 'react' -import type { TimelineData } from '@shared/types' +import type { TimelineData, TimeRange } from '@shared/types' + +/** A span the timeline marks: removed by pause removal, restored by the user, or cut. */ +export interface TimelineMark { range: TimeRange; kind: 'auto' | 'restored' | 'cut' } + +const MARK_STYLE: Record = { + auto: { className: 'bg-[repeating-linear-gradient(135deg,rgba(0,0,0,.7)_0_4px,rgba(255,255,255,.18)_4px_7px)]', + title: 'Pause removed automatically — click to keep it' }, + restored: { className: 'border-x-2 border-dashed border-sky-300/80 bg-sky-300/10', + title: 'Pause kept — click to remove it again' }, + cut: { className: 'bg-[repeating-linear-gradient(135deg,rgba(127,29,29,.8)_0_4px,rgba(248,113,113,.35)_4px_7px)]', + title: 'Cut — click to put it back' } +} import { formatTimecode } from '../lib/format' import { usePreviewBus } from '../lib/previewBus' @@ -16,7 +28,13 @@ export default function TrimBar({ end, timeline, onChange, - onCommit + onCommit, + marks = [], + splits = [], + selected = null, + onMarkClick, + onPieceClick, + playsFor }: { windowStart: number windowEnd: number @@ -25,6 +43,14 @@ export default function TrimBar({ timeline: TimelineData | null onChange: (start: number, end: number) => void onCommit: () => void + marks?: TimelineMark[] + splits?: number[] + selected?: TimeRange | null + onMarkClick?: (mark: TimelineMark) => void + /** Clicking the track selects the piece under the pointer as well as seeking. */ + onPieceClick?: (t: number) => void + /** Playback length after pauses and cuts are removed, when it differs from the trim. */ + playsFor?: number }): React.JSX.Element { const trackRef = useRef(null) const time = usePreviewBus((s) => s.time) @@ -93,8 +119,13 @@ export default function TrimBar({
seek(fromClientX(e.clientX))} + onPointerDown={(e) => { + const t = fromClientX(e.clientX) + seek(t) + onPieceClick?.(t) + }} > {/* Filmstrip */} {timeline && timeline.frames.length > 0 && ( @@ -137,6 +168,30 @@ export default function TrimBar({ className="pointer-events-none absolute inset-y-0 border-y-2 border-zinc-100" style={{ left: `${leftPct}%`, width: `${rightPct - leftPct}%` }} /> + {/* Removed, restored and cut spans */} + {marks.map((mark) => ( +
{ + e.stopPropagation() + onMarkClick?.(mark) + }} + /> + ))} + {/* Selected piece and razor points */} + {selected && ( +
+ )} + {splits.filter((t) => t > start && t < end).map((t) => ( +
+ ))} {/* Playhead: white core with a dark halo so it reads on bright frames */} {time >= windowStart && time <= windowEnd && (
In {formatTimecode(start)} - {formatTimecode(end - start)} long + + {formatTimecode(end - start)} long + {playsFor !== undefined && Math.abs(playsFor - (end - start)) > 0.05 && ` · plays ${formatTimecode(playsFor)}`} + Out {formatTimecode(end)}
diff --git a/src/renderer/src/lib/previewBus.ts b/src/renderer/src/lib/previewBus.ts index 654a524..8914710 100644 --- a/src/renderer/src/lib/previewBus.ts +++ b/src/renderer/src/lib/previewBus.ts @@ -24,6 +24,10 @@ interface PreviewBus { scrubHandler: ((t: number) => void) | null setScrubHandler: (fn: ((t: number) => void) | null) => void scrub: (t: number) => void + /** Play/pause the mounted preview (keyboard shortcuts). */ + toggleHandler: (() => void) | null + setToggleHandler: (fn: (() => void) | null) => void + togglePlay: () => void } export const usePreviewBus = create((set, get) => ({ @@ -34,5 +38,8 @@ export const usePreviewBus = create((set, get) => ({ seek: (t) => get().seekHandler?.(t), scrubHandler: null, setScrubHandler: (fn) => set({ scrubHandler: fn }), - scrub: (t) => get().scrubHandler?.(t) + scrub: (t) => get().scrubHandler?.(t), + toggleHandler: null, + setToggleHandler: (fn) => set({ toggleHandler: fn }), + togglePlay: () => get().toggleHandler?.() })) diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 5604360..bfbc29f 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -17,12 +17,25 @@ import type { import { findWholeVideoClip, highlightClips, isWholeVideoClip } from '@shared/wholeVideo' import { mergeReframeResult, needsReframe } from '@shared/reframe' +import { clearHistory, recordSave, redo as redoEdit, trackClip, undo as undoEdit } from '@shared/editHistory' /** Font faces already registered with document.fonts (FontFace API). */ const loadedFontFaces = new Map() /** Saved trim changes that arrived while a reframe request was in flight. */ const queuedReframes = new Set() +/** Apply an undo/redo step and save it without recording a new step. */ +async function stepHistory(clipId: string, step: (clip: Clip) => Clip | null): Promise { + const { project } = useStore.getState() + const clip = project?.clips.find((c) => c.id === clipId) + const next = clip && step(clip) + if (!project || !next) return + useStore.getState().updateClipLocal(next) + useStore.setState({ historyVersion: useStore.getState().historyVersion + 1 }) + await window.cutawan.updateClip(project.id, next) + if (useStore.getState().selectedClipId === clipId) await useStore.getState().ensureReframe(clipId) +} + /** Register custom fonts with the renderer so previews match exports. */ async function registerFonts(fonts: CustomFont[]): Promise { for (const f of fonts) { @@ -113,6 +126,11 @@ interface AppState { openEditor: (clipId: string) => void closeEditor: () => void updateClip: (clip: Clip) => Promise + /** Step the clip's own edits back or forward (see lib/editHistory.ts). */ + undo: (clipId: string) => Promise + redo: (clipId: string) => Promise + /** Bumped on every undo-history change so undo/redo buttons re-render. */ + historyVersion: number updateClipLocal: (clip: Clip) => void generateCaption: (clipId: string) => Promise captionBusy: Record @@ -169,6 +187,7 @@ export const useStore = create((set, get) => ({ reframeBusy: {}, reframeError: {}, backgroundReframing: {}, + historyVersion: 0, init: async () => { const [settings, projects, customFonts] = await Promise.all([ @@ -247,6 +266,7 @@ export const useStore = create((set, get) => ({ }, openProject: async (id) => { + clearHistory() const project = await window.cutawan.loadProject(id) // A whole-video project reopens on its edit; a clip project on the grid. const wholeVideo = project.mode === 'whole-video' ? findWholeVideoClip(project) : null @@ -275,7 +295,10 @@ export const useStore = create((set, get) => ({ } }, - goHome: () => set({ screen: 'home', selectedClipId: null, pipelineError: null }), + goHome: () => { + clearHistory() + set({ screen: 'home', selectedClipId: null, pipelineError: null }) + }, // Deselect the current project and return to the import screen so a new // video can be added. Non-destructive: the project stays saved on disk and @@ -361,7 +384,11 @@ export const useStore = create((set, get) => ({ if (project) await window.cutawan.cancelAnalyze(project.id) }, - openEditor: (clipId) => set({ selectedClipId: clipId, screen: 'editor' }), + openEditor: (clipId) => { + const clip = get().project?.clips.find((c) => c.id === clipId) + if (clip) trackClip(clip) + set({ selectedClipId: clipId, screen: 'editor' }) + }, closeEditor: () => set({ selectedClipId: null, @@ -380,12 +407,16 @@ export const useStore = create((set, get) => ({ const project = get().project if (!project) return get().updateClipLocal(clip) + if (recordSave(clip)) set({ historyVersion: get().historyVersion + 1 }) await window.cutawan.updateClip(project.id, clip) if (get().project?.id === project.id && get().selectedClipId === clip.id) { await get().ensureReframe(clip.id) } }, + undo: async (clipId) => stepHistory(clipId, undoEdit), + redo: async (clipId) => stepHistory(clipId, redoEdit), + generateCaption: async (clipId) => { const project = get().project if (!project || get().captionBusy[clipId]) return diff --git a/src/shared/editHistory.ts b/src/shared/editHistory.ts new file mode 100644 index 0000000..7a39322 --- /dev/null +++ b/src/shared/editHistory.ts @@ -0,0 +1,76 @@ +import type { Clip } from './types' + +/** + * Per-clip undo/redo over what the user owns: the edit state, text, B-roll + * and manually set layouts. Analysis results (focus track, automatic layout) + * are never undone. Typing and drags update the clip locally many times + * before one save, so a step is recorded against the last *saved* state. + */ + +type Snapshot = Pick + +const LIMIT = 100 + +interface History { saved: Snapshot; past: Snapshot[]; future: Snapshot[] } +const histories = new Map() + +function snapshot(clip: Clip): Snapshot { + return structuredClone({ edit: clip.edit, title: clip.title, hook: clip.hook, caption: clip.caption, + broll: clip.broll, visualLayout: clip.visualLayout }) +} + +const same = (a: Snapshot, b: Snapshot): boolean => JSON.stringify(a) === JSON.stringify(b) + +/** Apply a snapshot, keeping analysis-owned layout unless the user had set one. */ +function restore(clip: Clip, s: Snapshot): Clip { + const manual = (s.visualLayout?.revision ?? 0) > 0 || (clip.visualLayout?.revision ?? 0) > 0 + return { ...clip, ...structuredClone(s), visualLayout: manual ? structuredClone(s.visualLayout) : clip.visualLayout } +} + +/** Start tracking a clip as saved (on opening the editor); keeps existing history. */ +export function trackClip(clip: Clip): void { + if (!histories.has(clip.id)) histories.set(clip.id, { saved: snapshot(clip), past: [], future: [] }) +} + +/** Record a save. Returns true when it created an undo step. */ +export function recordSave(clip: Clip): boolean { + const history = histories.get(clip.id) + const next = snapshot(clip) + if (!history) { histories.set(clip.id, { saved: next, past: [], future: [] }); return false } + if (same(history.saved, next)) return false + history.past.push(history.saved) + if (history.past.length > LIMIT) history.past.shift() + history.future = [] + history.saved = next + return true +} + +/** The clip as it was one step back, or null. */ +export function undo(clip: Clip): Clip | null { + const history = histories.get(clip.id) + const previous = history?.past.pop() + if (!history || !previous) return null + history.future.push(snapshot(clip)) + history.saved = previous + return restore(clip, previous) +} + +/** The clip one step forward again, or null. */ +export function redo(clip: Clip): Clip | null { + const history = histories.get(clip.id) + const next = history?.future.pop() + if (!history || !next) return null + history.past.push(snapshot(clip)) + history.saved = next + return restore(clip, next) +} + +export function historyState(clipId: string): { canUndo: boolean; canRedo: boolean } { + const history = histories.get(clipId) + return { canUndo: Boolean(history?.past.length), canRedo: Boolean(history?.future.length) } +} + +/** Forget history (project closed or regenerated). */ +export function clearHistory(): void { + histories.clear() +} diff --git a/src/shared/editOps.ts b/src/shared/editOps.ts new file mode 100644 index 0000000..387c4ea --- /dev/null +++ b/src/shared/editOps.ts @@ -0,0 +1,79 @@ +import type { ClipEditState, TimeRange, Transcript } from './types' +import { normalizeRanges } from './tighten' + +/** + * Pure timeline edits shared by keyboard shortcuts, the timeline and the + * transcript. Each returns a new edit state; the caller saves it (and records + * undo history). Times are source seconds. + */ + +/** Razor points closer than this to an existing point or a clip edge are ignored. */ +const SPLIT_TOLERANCE_SEC = 0.05 +/** The trim never shrinks below this. */ +export const MIN_CLIP_SEC = 1 + +/** Add a razor point at `t` (inside the trim). */ +export function splitAt(edit: ClipEditState, t: number): ClipEditState { + if (t <= edit.start + SPLIT_TOLERANCE_SEC || t >= edit.end - SPLIT_TOLERANCE_SEC) return edit + const splits = edit.splits ?? [] + if (splits.some(s => Math.abs(s - t) < SPLIT_TOLERANCE_SEC)) return edit + return { ...edit, splits: [...splits, t].sort((a, b) => a - b) } +} + +/** Pieces between the trim edges and razor points, in order. */ +export function timelinePieces(edit: ClipEditState): TimeRange[] { + const bounds = [edit.start, ...(edit.splits ?? []).filter(s => s > edit.start && s < edit.end).sort((a, b) => a - b), edit.end] + return bounds.slice(0, -1).map((start, i) => ({ start, end: bounds[i + 1] })) +} + +/** The piece containing `t`. */ +export function pieceAt(edit: ClipEditState, t: number): TimeRange | undefined { + return timelinePieces(edit).find(p => t >= p.start && t < p.end) ?? timelinePieces(edit).at(-1) +} + +/** Remove a source range from playback (ripple: later material closes the gap). */ +export function cutRange(edit: ClipEditState, range: TimeRange): ClipEditState { + return { ...edit, cuts: normalizeRanges([...(edit.cuts ?? []), range], edit.start, edit.end) } +} + +/** Put back the user cut containing `t`. */ +export function uncutAt(edit: ClipEditState, t: number): ClipEditState { + const cuts = (edit.cuts ?? []).filter(c => !(t >= c.start && t <= c.end)) + return cuts.length === (edit.cuts ?? []).length ? edit : { ...edit, cuts } +} + +/** Keep a pause that automatic removal took out, or remove it again. */ +export function toggleRestored(edit: ClipEditState, pause: TimeRange): ClipEditState { + const restored = edit.restored ?? [] + const existing = restored.find(r => r.start < pause.end && r.end > pause.start) + return { ...edit, restored: existing ? restored.filter(r => r !== existing) : [...restored, { ...pause }] } +} + +/** + * The source range covering transcript words [first, last] (indices into the + * clip's words), with each edge halfway into the neighbouring gap so the cut + * falls between words rather than on them. + */ +export function wordsRange( + words: Array<{ start: number; end: number }>, first: number, last: number +): TimeRange { + const a = words[first], b = words[last] + const before = words[first - 1], after = words[last + 1] + const start = before ? Math.max(before.end, (before.end + a.start) / 2) : a.start + const end = after ? Math.min(after.start, (b.end + after.start) / 2) : b.end + return { start, end } +} + +/** Words of `transcript` inside the trim, in order (same set the editor shows). */ +export function clipWords(transcript: Transcript, start: number, end: number): Array<{ start: number; end: number; text: string }> { + return transcript.segments.flatMap(s => s.words).filter(w => { + const mid = (w.start + w.end) / 2 + return mid >= start && mid <= end + }).sort((a, b) => a.start - b.start) +} + +/** Set the in or out point at `t`, keeping at least MIN_CLIP_SEC. */ +export function setTrimEdge(edit: ClipEditState, which: 'start' | 'end', t: number): ClipEditState { + if (which === 'start') return { ...edit, start: Math.max(0, Math.min(t, edit.end - MIN_CLIP_SEC)) } + return { ...edit, end: Math.max(t, edit.start + MIN_CLIP_SEC) } +} diff --git a/src/shared/tighten.ts b/src/shared/tighten.ts index 3020743..a1c469d 100644 --- a/src/shared/tighten.ts +++ b/src/shared/tighten.ts @@ -1,4 +1,4 @@ -import type { Clip, SpeechRegion, Transcript } from './types' +import type { Clip, SpeechRegion, TimeRange, Transcript } from './types' /** * "Tighten cuts": compute which sub-segments of a clip to keep so that long @@ -13,14 +13,68 @@ export interface KeptSegment { end: number } -/** Actual playback length, including protected visual footage and removed pauses. */ -export function editedClipDuration(clip: Clip, transcript: Transcript | null): number { - const kept = transcript && clip.edit.tightenCuts - ? computeKeptSegments(transcript, clip.edit.start, clip.edit.end, clip.visualStory?.protectedRanges) - : null +/** Actual playback length, after pause removal and the user's cuts. */ +export function editedClipDuration(clip: Pick, transcript: Transcript | null): number { + const kept = clipKeptSegments(clip, transcript) return kept ? kept.reduce((sum, range) => sum + range.end - range.start, 0) : clip.edit.end - clip.edit.start } +/** Pieces shorter than this after cutting are dropped rather than flashed. */ +const MIN_PIECE_SEC = 0.08 + +/** Sorted, merged ranges clipped to [from, to]. */ +export function normalizeRanges(ranges: TimeRange[] | undefined, from: number, to: number): TimeRange[] { + const clipped = (ranges ?? []) + .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) + .map(r => ({ start: Math.max(from, Math.min(r.start, r.end)), end: Math.min(to, Math.max(r.start, r.end)) })) + .filter(r => r.end > r.start) + .sort((a, b) => a.start - b.start) + const out: TimeRange[] = [] + for (const r of clipped) { + const last = out[out.length - 1] + if (last && r.start <= last.end) last.end = Math.max(last.end, r.end) + else out.push({ ...r }) + } + return out +} + +/** `ranges` minus `remove`, both normalized. */ +export function subtractRanges(ranges: TimeRange[], remove: TimeRange[]): TimeRange[] { + let out = ranges.map(r => ({ ...r })) + for (const cut of remove) { + out = out.flatMap(r => cut.end <= r.start || cut.start >= r.end ? [r] + : [{ start: r.start, end: cut.start }, { start: cut.end, end: r.end }].filter(p => p.end > p.start)) + } + return out +} + +/** + * The single source of truth for what plays: automatic pause removal (when + * enabled), plus pauses the user restored, minus the user's cuts. Null means + * the whole trim plays untouched. Export, preview, durations and the AI's + * view of the edit all go through here. + */ +export function clipKeptSegments(clip: Pick, transcript: Transcript | null): KeptSegment[] | null { + const { start, end } = clip.edit + const auto = clip.edit.tightenCuts && transcript + ? computeKeptSegments(transcript, start, end, clip.visualStory?.protectedRanges) + : null + const cuts = normalizeRanges(clip.edit.cuts, start, end) + const restored = auto ? normalizeRanges(clip.edit.restored, start, end) : [] + if (!cuts.length && !restored.length) return auto + const base = normalizeRanges([...(auto ?? [{ start, end }]), ...restored], start, end) + const kept = subtractRanges(base, cuts).filter(r => r.end - r.start >= MIN_PIECE_SEC) + if (kept.length === 1 && kept[0].start <= start + 1e-6 && kept[0].end >= end - 1e-6) return null + return kept +} + +/** What automatic pause removal takes out of the trim, for display and restore. */ +export function autoRemovedRanges(clip: Pick, transcript: Transcript | null): TimeRange[] { + if (!clip.edit.tightenCuts || !transcript) return [] + const auto = computeKeptSegments(transcript, clip.edit.start, clip.edit.end, clip.visualStory?.protectedRanges) + return auto ? subtractRanges([{ start: clip.edit.start, end: clip.edit.end }], auto) : [] +} + /** Pause longer than this (between words) gets cut down. */ const MAX_PAUSE_SEC = 0.7 /** Breathing room kept around speech when a pause is trimmed. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 8c515a7..0ab280d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,8 +30,11 @@ export interface TranscriptSegment { energy?: number } +/** A span of source time, in seconds. */ +export interface TimeRange { start: number; end: number } + /** A span of detected speech, in source seconds. */ -export interface SpeechRegion { start: number; end: number } +export type SpeechRegion = TimeRange export interface Transcript { language: string @@ -109,6 +112,12 @@ export interface ClipEditState { compositionPreference?: 'auto' | 'content-first' | 'stacked' | 'content-only' /** False turns detected two-person split-screen ranges back into speaker crops. */ speakerSplit?: boolean + /** Source ranges the user cut out (always removed, ripple-closed). */ + cuts?: TimeRange[] + /** Source ranges automatic pause removal took out that the user put back. */ + restored?: TimeRange[] + /** Razor points (source seconds) dividing the timeline into selectable pieces. */ + splits?: number[] /** Explicit framing choice, even when its values match generated defaults. */ layoutChosen?: boolean /** Remove long pauses and filler words ("um", "uh") from the clip. */ diff --git a/tests/editHistory.test.ts b/tests/editHistory.test.ts new file mode 100644 index 0000000..5f2751f --- /dev/null +++ b/tests/editHistory.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { clearHistory, historyState, recordSave, redo, trackClip, undo } from '@shared/editHistory' +import type { Clip } from '@shared/types' + +const base = { id: 'c', title: 'T', hook: '', caption: '', broll: [], focusTrack: [{ t: 0, x: 0.5 }], + edit: { start: 0, end: 20, cuts: [] } } as unknown as Clip + +beforeEach(() => clearHistory()) + +describe('edit history', () => { + it('undoes and redoes saved edits in order', () => { + trackClip(base) + const cut = { ...base, edit: { ...base.edit, cuts: [{ start: 2, end: 4 }] } } + const trimmed = { ...cut, edit: { ...cut.edit, end: 15 } } + expect(recordSave(cut)).toBe(true) + expect(recordSave(trimmed)).toBe(true) + const back = undo(trimmed)! + expect(back.edit.end).toBe(20) + expect(back.edit.cuts).toEqual([{ start: 2, end: 4 }]) + const first = undo(back)! + expect(first.edit.cuts).toEqual([]) + expect(undo(first)).toBeNull() + expect(redo(first)!.edit.cuts).toEqual([{ start: 2, end: 4 }]) + }) + + it('records one step per save, not per local keystroke, and ignores no-op saves', () => { + trackClip(base) + expect(recordSave(base)).toBe(false) + expect(recordSave({ ...base, title: 'Final title' })).toBe(true) + expect(historyState('c')).toEqual({ canUndo: true, canRedo: false }) + }) + + it('never undoes analysis results such as the focus track', () => { + trackClip(base) + recordSave({ ...base, title: 'New' }) + const analysed = { ...base, title: 'New', focusTrack: [{ t: 0, x: 0.2 }] } + expect(undo(analysed)!.focusTrack).toEqual([{ t: 0, x: 0.2 }]) + }) + + it('a new edit after undo clears redo', () => { + trackClip(base) + const a = { ...base, title: 'A' } + recordSave(a) + const back = undo(a)! + recordSave({ ...back, title: 'B' }) + expect(historyState('c').canRedo).toBe(false) + }) +}) diff --git a/tests/editOps.test.ts b/tests/editOps.test.ts new file mode 100644 index 0000000..6f5a9c5 --- /dev/null +++ b/tests/editOps.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { cutRange, pieceAt, setTrimEdge, splitAt, timelinePieces, toggleRestored, uncutAt, wordsRange } from '@shared/editOps' +import type { ClipEditState } from '@shared/types' + +const edit = { start: 10, end: 30 } as ClipEditState + +describe('razor and pieces', () => { + it('splits the timeline into selectable pieces', () => { + const split = splitAt(splitAt(edit, 20), 15) + expect(split.splits).toEqual([15, 20]) + expect(timelinePieces(split)).toEqual([{ start: 10, end: 15 }, { start: 15, end: 20 }, { start: 20, end: 30 }]) + expect(pieceAt(split, 17)).toEqual({ start: 15, end: 20 }) + }) + it('ignores splits at the edges or on top of another split', () => { + expect(splitAt(edit, 10.01)).toBe(edit) + const once = splitAt(edit, 20) + expect(splitAt(once, 20.02)).toBe(once) + }) +}) + +describe('cuts', () => { + it('cuts a piece and puts it back', () => { + const cut = cutRange(edit, { start: 15, end: 20 }) + expect(cut.cuts).toEqual([{ start: 15, end: 20 }]) + expect(uncutAt(cut, 17).cuts).toEqual([]) + expect(uncutAt(cut, 25)).toBe(cut) + }) + it('merges overlapping cuts', () => { + expect(cutRange(cutRange(edit, { start: 12, end: 15 }), { start: 14, end: 18 }).cuts).toEqual([{ start: 12, end: 18 }]) + }) + it('toggles a restored pause', () => { + const pause = { start: 12, end: 13 } + const kept = toggleRestored(edit, pause) + expect(kept.restored).toEqual([pause]) + expect(toggleRestored(kept, pause).restored).toEqual([]) + }) +}) + +describe('word ranges and trim edges', () => { + const words = [{ start: 1, end: 1.4 }, { start: 1.6, end: 2 }, { start: 3, end: 3.5 }] + it('cuts words from the middle of the gaps around them', () => { + expect(wordsRange(words, 1, 1)).toEqual({ start: 1.5, end: 2.5 }) + expect(wordsRange(words, 0, 0)).toEqual({ start: 1, end: 1.5 }) + }) + it('keeps a minimum clip length when setting in and out', () => { + expect(setTrimEdge(edit, 'start', 29.8).start).toBe(29) + expect(setTrimEdge(edit, 'end', 5).end).toBe(11) + expect(setTrimEdge(edit, 'start', -3).start).toBe(0) + }) +}) diff --git a/tests/manualCuts.test.ts b/tests/manualCuts.test.ts new file mode 100644 index 0000000..50c8248 --- /dev/null +++ b/tests/manualCuts.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { autoRemovedRanges, clipKeptSegments, editedClipDuration, normalizeRanges, subtractRanges } from '@shared/tighten' +import type { Clip, ClipEditState, Transcript } from '@shared/types' + +/** Words every 0.5 s except a 3 s pause between 5 and 8. */ +const transcript: Transcript = { + language: 'en', durationSec: 20, + segments: [{ id: 0, start: 0, end: 20, text: '', words: [ + ...Array.from({ length: 10 }, (_, i) => ({ text: `a${i}`, start: i * 0.5, end: i * 0.5 + 0.4 })), + ...Array.from({ length: 24 }, (_, i) => ({ text: `b${i}`, start: 8 + i * 0.5, end: 8 + i * 0.5 + 0.4 })) + ] }] +} +const clip = (edit: Partial): Pick => + ({ edit: { start: 0, end: 20, tightenCuts: false, ...edit } as ClipEditState }) + +describe('range helpers', () => { + it('normalizes, clips and merges ranges', () => { + expect(normalizeRanges([{ start: 5, end: 3 }, { start: 4, end: 6 }, { start: -1, end: 0.5 }, { start: 30, end: 40 }], 0, 20)) + .toEqual([{ start: 0, end: 0.5 }, { start: 3, end: 6 }]) + }) + it('subtracts cuts', () => { + expect(subtractRanges([{ start: 0, end: 10 }], [{ start: 2, end: 3 }, { start: 9, end: 12 }])) + .toEqual([{ start: 0, end: 2 }, { start: 3, end: 9 }]) + }) +}) + +describe('clipKeptSegments', () => { + it('plays the whole trim when there is nothing to remove', () => { + expect(clipKeptSegments(clip({}), transcript)).toBeNull() + expect(editedClipDuration(clip({}), transcript)).toBe(20) + }) + + it('removes a manual cut even without a transcript or pause removal', () => { + expect(clipKeptSegments(clip({ cuts: [{ start: 2, end: 4 }] }), null)).toEqual([{ start: 0, end: 2 }, { start: 4, end: 20 }]) + expect(editedClipDuration(clip({ cuts: [{ start: 2, end: 4 }] }), null)).toBe(18) + }) + + it('combines automatic pause removal with cuts and restored pauses', () => { + const auto = clipKeptSegments(clip({ tightenCuts: true }), transcript)! + const removed = autoRemovedRanges(clip({ tightenCuts: true }), transcript) + expect(removed.length).toBeGreaterThan(0) + expect(removed.some(r => r.start >= 4.5 && r.end <= 8.5)).toBe(true) + // Putting the pause back plays it again. + expect(clipKeptSegments(clip({ tightenCuts: true, restored: removed }), transcript)).toBeNull() + expect(editedClipDuration(clip({ tightenCuts: true, restored: removed }), transcript)).toBe(20) + expect(auto.reduce((s, r) => s + r.end - r.start, 0)).toBeLessThan(20) + // A cut always wins, including over a restored pause. + const cut = clipKeptSegments(clip({ tightenCuts: true, restored: removed, cuts: [{ start: 10, end: 12 }] }), transcript)! + expect(cut.some(r => r.start < 11 && r.end > 11)).toBe(false) + }) + + it('ignores cuts outside the trim and drops slivers left between cuts', () => { + expect(clipKeptSegments(clip({ cuts: [{ start: 30, end: 40 }] }), null)).toBeNull() + expect(clipKeptSegments(clip({ cuts: [{ start: 2, end: 4 }, { start: 4.05, end: 6 }] }), null)) + .toEqual([{ start: 0, end: 2 }, { start: 6, end: 20 }]) + }) +}) From 6e7f6464fe043da681a92a864d477803db7453cd Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:48:57 +0100 Subject: [PATCH 2/7] Fix review findings in timeline editing - Undo/redo restores framing fields only when the undone step changed them, so analysis that landed since is kept. - Start undo history from the saved clip on every save and when the editor mounts, so whole-video projects' first edit is undoable. - Refuse cuts that leave under a second of playback; export errors clearly if nothing is left. - Clicking outside the trim selects no piece; a selection that no longer matches a current piece is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/pipeline/render.ts | 3 ++ src/renderer/src/components/EditorScreen.tsx | 14 +++++++-- .../src/components/TimelineEditor.tsx | 14 ++++++--- src/renderer/src/store.ts | 5 ++++ src/shared/editHistory.ts | 30 ++++++++++++++----- src/shared/editOps.ts | 14 ++++++--- tests/editHistory.test.ts | 22 ++++++++++++++ tests/editOps.test.ts | 15 +++++++++- 8 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/main/pipeline/render.ts b/src/main/pipeline/render.ts index 729e06d..ea46e55 100644 --- a/src/main/pipeline/render.ts +++ b/src/main/pipeline/render.ts @@ -634,6 +634,9 @@ async function render(job: RenderJob): Promise { // timed against the source (captions, B-roll, face track) into the // compacted output timeline. const segments = clipKeptSegments(clip, transcript) + if (segments && segments.length === 0) { + throw new Error('Nothing is left to export: every part of this clip has been cut. Undo a cut or widen the trim.') + } const map = segments ? new TimeMap(segments) : null const outputDuration = map ? map.outputDuration : duration diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx index 0f459f2..e281ddc 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -25,7 +25,8 @@ import { useStore } from '../store' import PreviewPlayer from './PreviewPlayer' import CompositionControls from './CompositionControls' import TimelineEditor from './TimelineEditor' -import { cutRange } from '@shared/editOps' +import { cutRange, keepsPlayback } from '@shared/editOps' +import { trackClip } from '@shared/editHistory' import ScoreBadge from './ScoreBadge' import TranscriptEditor from './TranscriptEditor' import { ExportButton } from './ClipsScreen' @@ -96,6 +97,12 @@ export default function EditorScreen(): React.JSX.Element { // opening one is what triggers the analysis (see shared/reframe.ts). const clipId = clip?.id ?? null const reframePending = clip ? needsReframe(clip) : false + // Undo history starts from the clip as opened, whichever screen led here + // (whole-video projects open straight into the editor). + useEffect(() => { + const opened = useStore.getState().project?.clips.find((c) => c.id === clipId) + if (opened) trackClip(opened) + }, [clipId]) useEffect(() => { if (clipId && !sourceMissing) void ensureReframe(clipId) // Later trim changes trigger analysis after updateClip has saved them. @@ -527,7 +534,10 @@ export default function EditorScreen(): React.JSX.Element { clipStart={clip.edit.start} clipEnd={clip.edit.end} cuts={clip.edit.cuts} - onCut={(range) => void updateClip({ ...clip, edit: cutRange(clip.edit, range) })} + onCut={(range) => { + const next = { ...clip, edit: cutRange(clip.edit, range) } + if (keepsPlayback(next, project.transcript)) void updateClip(next) + }} onTrim={(start, end) => { void updateClip({ ...clip, diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx index 58d9001..61dd8e5 100644 --- a/src/renderer/src/components/TimelineEditor.tsx +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { Redo2, Scissors, SplitSquareHorizontal, Trash2, Undo2 } from 'lucide-react' import type { Clip, TimelineData, TimeRange } from '@shared/types' import { autoRemovedRanges, editedClipDuration, normalizeRanges } from '@shared/tighten' -import { cutRange, pieceAt, setTrimEdge, splitAt, timelinePieces, toggleRestored, uncutAt } from '@shared/editOps' +import { cutRange, keepsPlayback, pieceAt, setTrimEdge, splitAt, timelinePieces, toggleRestored, uncutAt } from '@shared/editOps' import { historyState } from '@shared/editHistory' import { usePreviewBus } from '../lib/previewBus' import { useStore } from '../store' @@ -51,12 +51,18 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time }, [clip, transcript, edit.restored, edit.cuts, edit.start, edit.end, edit.tightenCuts]) const pieces = timelinePieces(edit) - // A selection only means something once the timeline has been split. - const selection = selected && pieces.length > 1 ? selected : null + // A selection only means something once the timeline has been split, and + // only while it is still exactly one of the current pieces (a split, trim + // or undo since the click would otherwise leave a stale range to cut). + const selection = selected && pieces.length > 1 && + pieces.some((p) => Math.abs(p.start - selected.start) < 1e-6 && Math.abs(p.end - selected.end) < 1e-6) + ? selected : null const cutSelected = (): void => { if (!selection) return - onSave(cutRange(edit, selection)) + const next = cutRange(edit, selection) + // Never cut away everything that plays. + if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) setSelected(null) } const split = (): void => { diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index bfbc29f..9be3f02 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -406,6 +406,11 @@ export const useStore = create((set, get) => ({ updateClip: async (clip) => { const project = get().project if (!project) return + // The stored copy is the last saved state (local edits during typing and + // drags replace it only here), so start history from it whichever way + // the editor was opened. + const saved = project.clips.find((c) => c.id === clip.id) + if (saved) trackClip(saved) get().updateClipLocal(clip) if (recordSave(clip)) set({ historyVersion: get().historyVersion + 1 }) await window.cutawan.updateClip(project.id, clip) diff --git a/src/shared/editHistory.ts b/src/shared/editHistory.ts index 7a39322..8937c9d 100644 --- a/src/shared/editHistory.ts +++ b/src/shared/editHistory.ts @@ -21,13 +21,27 @@ function snapshot(clip: Clip): Snapshot { const same = (a: Snapshot, b: Snapshot): boolean => JSON.stringify(a) === JSON.stringify(b) -/** Apply a snapshot, keeping analysis-owned layout unless the user had set one. */ -function restore(clip: Clip, s: Snapshot): Clip { - const manual = (s.visualLayout?.revision ?? 0) > 0 || (clip.visualLayout?.revision ?? 0) > 0 - return { ...clip, ...structuredClone(s), visualLayout: manual ? structuredClone(s.visualLayout) : clip.visualLayout } +/** Framing fields the reframe analysis also sets (see shared/reframe.ts). */ +const FRAMING_FIELDS = ['reframeMode', 'framing', 'focusX', 'autoZoom', 'compositionPreference', 'layoutChosen'] as const + +/** + * Apply `target`, the other side of the step being undone or redone from + * `from`. Framing fields and automatic layouts change only when that step + * changed them: analysis may have updated them since, and undoing a rename + * must not put back the default crop. + */ +function restore(clip: Clip, target: Snapshot, from: Snapshot): Clip { + const edit = { ...structuredClone(target.edit) } + for (const field of FRAMING_FIELDS) { + if (JSON.stringify(target.edit[field]) === JSON.stringify(from.edit[field])) { + (edit as Record)[field] = clip.edit[field] + } + } + const manual = (target.visualLayout?.revision ?? 0) > 0 || (clip.visualLayout?.revision ?? 0) > 0 + return { ...clip, ...structuredClone(target), edit, visualLayout: manual ? structuredClone(target.visualLayout) : clip.visualLayout } } -/** Start tracking a clip as saved (on opening the editor); keeps existing history. */ +/** Start tracking a clip as last saved; keeps existing history. Call before each save. */ export function trackClip(clip: Clip): void { if (!histories.has(clip.id)) histories.set(clip.id, { saved: snapshot(clip), past: [], future: [] }) } @@ -50,9 +64,10 @@ export function undo(clip: Clip): Clip | null { const history = histories.get(clip.id) const previous = history?.past.pop() if (!history || !previous) return null + const from = history.saved history.future.push(snapshot(clip)) history.saved = previous - return restore(clip, previous) + return restore(clip, previous, from) } /** The clip one step forward again, or null. */ @@ -60,9 +75,10 @@ export function redo(clip: Clip): Clip | null { const history = histories.get(clip.id) const next = history?.future.pop() if (!history || !next) return null + const from = history.saved history.past.push(snapshot(clip)) history.saved = next - return restore(clip, next) + return restore(clip, next, from) } export function historyState(clipId: string): { canUndo: boolean; canRedo: boolean } { diff --git a/src/shared/editOps.ts b/src/shared/editOps.ts index 387c4ea..e87950e 100644 --- a/src/shared/editOps.ts +++ b/src/shared/editOps.ts @@ -1,5 +1,5 @@ -import type { ClipEditState, TimeRange, Transcript } from './types' -import { normalizeRanges } from './tighten' +import type { Clip, ClipEditState, TimeRange, Transcript } from './types' +import { editedClipDuration, normalizeRanges } from './tighten' /** * Pure timeline edits shared by keyboard shortcuts, the timeline and the @@ -26,9 +26,10 @@ export function timelinePieces(edit: ClipEditState): TimeRange[] { return bounds.slice(0, -1).map((start, i) => ({ start, end: bounds[i + 1] })) } -/** The piece containing `t`. */ +/** The piece containing `t`; none outside the trim. */ export function pieceAt(edit: ClipEditState, t: number): TimeRange | undefined { - return timelinePieces(edit).find(p => t >= p.start && t < p.end) ?? timelinePieces(edit).at(-1) + const pieces = timelinePieces(edit) + return pieces.find(p => t >= p.start && t < p.end) ?? (t === edit.end ? pieces.at(-1) : undefined) } /** Remove a source range from playback (ripple: later material closes the gap). */ @@ -77,3 +78,8 @@ export function setTrimEdge(edit: ClipEditState, which: 'start' | 'end', t: numb if (which === 'start') return { ...edit, start: Math.max(0, Math.min(t, edit.end - MIN_CLIP_SEC)) } return { ...edit, end: Math.max(t, edit.start + MIN_CLIP_SEC) } } + +/** A cut must leave something to play: never less than MIN_CLIP_SEC. */ +export function keepsPlayback(clip: Pick, transcript: Transcript | null): boolean { + return editedClipDuration(clip, transcript) >= MIN_CLIP_SEC +} diff --git a/tests/editHistory.test.ts b/tests/editHistory.test.ts index 5f2751f..f8c4eb8 100644 --- a/tests/editHistory.test.ts +++ b/tests/editHistory.test.ts @@ -46,3 +46,25 @@ describe('edit history', () => { expect(historyState('c').canRedo).toBe(false) }) }) + +describe('undo and automatic framing', () => { + it('keeps framing the analysis set after the clip was opened', () => { + const opened = { ...base, edit: { ...base.edit, framing: 'manual', focusX: 0.5, reframeMode: 'crop' } } as unknown as Clip + trackClip(opened) + recordSave({ ...opened, title: 'Renamed' }) + // Analysis lands, switching to automatic framing, then the user undoes the rename. + const analysed = { ...opened, title: 'Renamed', edit: { ...opened.edit, framing: 'auto', focusX: 0.31 } } as unknown as Clip + const back = undo(analysed)! + expect(back.title).toBe('T') + expect(back.edit.framing).toBe('auto') + expect(back.edit.focusX).toBe(0.31) + }) + + it('still undoes a framing change the user made', () => { + const opened = { ...base, edit: { ...base.edit, framing: 'auto', focusX: 0.5 } } as unknown as Clip + trackClip(opened) + const manual = { ...opened, edit: { ...opened.edit, framing: 'manual', focusX: 0.2 } } as unknown as Clip + recordSave(manual) + expect(undo(manual)!.edit.framing).toBe('auto') + }) +}) diff --git a/tests/editOps.test.ts b/tests/editOps.test.ts index 6f5a9c5..6ad48f2 100644 --- a/tests/editOps.test.ts +++ b/tests/editOps.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { cutRange, pieceAt, setTrimEdge, splitAt, timelinePieces, toggleRestored, uncutAt, wordsRange } from '@shared/editOps' +import { cutRange, keepsPlayback, pieceAt, setTrimEdge, splitAt, timelinePieces, toggleRestored, uncutAt, wordsRange } from '@shared/editOps' import type { ClipEditState } from '@shared/types' const edit = { start: 10, end: 30 } as ClipEditState @@ -11,6 +11,19 @@ describe('razor and pieces', () => { expect(timelinePieces(split)).toEqual([{ start: 10, end: 15 }, { start: 15, end: 20 }, { start: 20, end: 30 }]) expect(pieceAt(split, 17)).toEqual({ start: 15, end: 20 }) }) + it('selects nothing outside the trim', () => { + const split = splitAt(edit, 20) + expect(pieceAt(split, 5)).toBeUndefined() + expect(pieceAt(split, 31)).toBeUndefined() + expect(pieceAt(split, 30)).toEqual({ start: 20, end: 30 }) + }) + + it('refuses a cut that leaves nothing to play', () => { + expect(keepsPlayback({ edit: cutRange(edit, { start: 10, end: 30 }) }, null)).toBe(false) + expect(keepsPlayback({ edit: cutRange(edit, { start: 10, end: 29.5 }) }, null)).toBe(false) + expect(keepsPlayback({ edit: cutRange(edit, { start: 10, end: 20 }) }, null)).toBe(true) + }) + it('ignores splits at the edges or on top of another split', () => { expect(splitAt(edit, 10.01)).toBe(edit) const once = splitAt(edit, 20) From 0c22e2bdf9c72b06c2503eacb91aea1c562af5e3 Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:57:42 +0100 Subject: [PATCH 3/7] Fire one-shot editing shortcuts once per key press Held keys auto-repeat: only playhead stepping repeats now; play, split, cut, in/out and undo ignore repeats. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/components/TimelineEditor.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx index 61dd8e5..638f5da 100644 --- a/src/renderer/src/components/TimelineEditor.tsx +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -76,6 +76,12 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time const bus = usePreviewBus.getState() const mod = e.metaKey || e.ctrlKey const key = e.key.toLowerCase() + // Holding a key auto-repeats it: stepping should continue, but play, + // split, cut, in/out and undo must fire once per press. + if (e.repeat && !['j', 'l', 'arrowleft', 'arrowright'].includes(key)) { + if ([' ', 'k', 's', 'i', 'o', 'delete', 'backspace', 'z', 'y'].includes(key)) e.preventDefault() + return + } const step = (dt: number): void => bus.seek(Math.max(edit.start, Math.min(edit.end, bus.time + dt))) let handled = true if (mod && key === 'z') void (e.shiftKey ? redo(clip.id) : undo(clip.id)) From 2b7c434a619908985cda7781575b9c8dc522e184 Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:26:59 +0100 Subject: [PATCH 4/7] Refuse trims that would leave nothing to play In/out keys, trim-handle drags (snapping back) and trim-to-selection use the same guard as cuts. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/components/EditorScreen.tsx | 10 ++-------- src/renderer/src/components/TimelineEditor.tsx | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx index e281ddc..e1ba9e3 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -539,14 +539,8 @@ export default function EditorScreen(): React.JSX.Element { if (keepsPlayback(next, project.transcript)) void updateClip(next) }} onTrim={(start, end) => { - void updateClip({ - ...clip, - edit: { - ...clip.edit, - start: Math.max(windowStart, start), - end: Math.min(windowEnd, end) - } - }) + const next = { ...clip, edit: { ...clip.edit, start: Math.max(windowStart, start), end: Math.min(windowEnd, end) } } + if (keepsPlayback(next, project.transcript)) void updateClip(next) }} /> ) : ( diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx index 638f5da..057266e 100644 --- a/src/renderer/src/components/TimelineEditor.tsx +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -65,6 +65,11 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) setSelected(null) } + // Trims go through the same guard as cuts: narrowing the trim onto a + // stretch that is all cut would otherwise leave nothing to play. + const saveTrim = (next: Clip['edit']): void => { + if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) + } const split = (): void => { const next = splitAt(edit, usePreviewBus.getState().time) if (next !== edit) onSave(next) @@ -93,8 +98,8 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time else if (key === 'l') step(1) else if (key === 'arrowleft') step(e.shiftKey ? -1 : -1 / fps) else if (key === 'arrowright') step(e.shiftKey ? 1 : 1 / fps) - else if (key === 'i') onSave(setTrimEdge(edit, 'start', Math.max(windowStart, bus.time))) - else if (key === 'o') onSave(setTrimEdge(edit, 'end', Math.min(windowEnd, bus.time))) + else if (key === 'i') saveTrim(setTrimEdge(edit, 'start', Math.max(windowStart, bus.time))) + else if (key === 'o') saveTrim(setTrimEdge(edit, 'end', Math.min(windowEnd, bus.time))) else if (key === 's') split() else if (key === 'delete' || key === 'backspace') cutSelected() else if (key === 'escape') setSelected(null) @@ -116,9 +121,12 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time timeline={timeline} onChange={(start, end) => onLocal({ ...edit, start, end })} onCommit={() => { - // Read the live clip: the drag closure was created at drag start. + // Read the live clip: the drag closure was created at drag start, + // so `edit` here is the pre-drag trim to snap back to if refused. const current = useStore.getState().project?.clips.find((c) => c.id === clip.id) - if (current) onSave(current.edit) + if (!current) return + if (keepsPlayback(current, transcript)) onSave(current.edit) + else onLocal(edit) }} marks={marks} splits={edit.splits} From 0b4b89ada72a429959b30a0511438e4198130a64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 08:35:47 +0000 Subject: [PATCH 5/7] Fix undo framing/caption and empty-playback toggles - Only undo framing when the step was a user layout choice, so analysis crops grafted between saves are not reverted by rename/cut undo. - Gate tightenCuts and restored-pause toggles with keepsPlayback so they cannot leave the clip with nothing to play. - Record caption generation in edit history so undoing an earlier edit does not wipe a generated caption. --- src/renderer/src/components/EditorScreen.tsx | 5 ++++- src/renderer/src/components/TimelineEditor.tsx | 5 ++++- src/renderer/src/store.ts | 6 ++++++ src/shared/editHistory.ts | 14 +++++++++----- tests/editHistory.test.ts | 15 ++++++++++++++- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx index e1ba9e3..ce63d08 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -199,7 +199,10 @@ export default function EditorScreen(): React.JSX.Element { set({ tightenCuts: v })} + onChange={(v) => { + const next = userClipEdit(clip.edit, { tightenCuts: v }) + if (keepsPlayback({ ...clip, edit: next }, project.transcript)) set({ tightenCuts: v }) + }} /> {clip.edit.end - clip.edit.start > TIGHTEN_WARN_SEC && (

diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx index 057266e..8f7d700 100644 --- a/src/renderer/src/components/TimelineEditor.tsx +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -135,7 +135,10 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time playsFor={editedClipDuration(clip, transcript)} onMarkClick={(mark) => { if (mark.kind === 'cut') onSave(uncutAt(edit, (mark.range.start + mark.range.end) / 2)) - else onSave(toggleRestored(edit, mark.range)) + else { + const next = toggleRestored(edit, mark.range) + if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) + } }} />

diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 9be3f02..709739d 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -432,6 +432,9 @@ export const useStore = create((set, get) => ({ const current = get().project // Only graft the caption on: other clip edits may be in flight. if (fresh && current?.id === updated.id) { + const live = current.clips.find((c) => c.id === clipId) + if (live) trackClip(live) + const grafted = live ? { ...live, caption: fresh.caption } : null set({ project: { ...current, @@ -440,6 +443,9 @@ export const useStore = create((set, get) => ({ ) } }) + // Caption generation is otherwise outside history, so the next cut or + // rename would snapshot it and an undo of that edit would wipe it. + if (grafted && recordSave(grafted)) set({ historyVersion: get().historyVersion + 1 }) } } finally { const busy = { ...get().captionBusy } diff --git a/src/shared/editHistory.ts b/src/shared/editHistory.ts index 8937c9d..3f39639 100644 --- a/src/shared/editHistory.ts +++ b/src/shared/editHistory.ts @@ -26,14 +26,18 @@ const FRAMING_FIELDS = ['reframeMode', 'framing', 'focusX', 'autoZoom', 'composi /** * Apply `target`, the other side of the step being undone or redone from - * `from`. Framing fields and automatic layouts change only when that step - * changed them: analysis may have updated them since, and undoing a rename - * must not put back the default crop. + * `from`. Framing fields change only when that step was a user layout choice + * (`layoutChosen`): analysis may rewrite the crop between saves without a + * history step, and the next unrelated save must not make that crop undoable. */ function restore(clip: Clip, target: Snapshot, from: Snapshot): Clip { const edit = { ...structuredClone(target.edit) } - for (const field of FRAMING_FIELDS) { - if (JSON.stringify(target.edit[field]) === JSON.stringify(from.edit[field])) { + const framingStep = FRAMING_FIELDS.some( + (field) => JSON.stringify(target.edit[field]) !== JSON.stringify(from.edit[field]) + ) + const userFraming = Boolean(target.edit.layoutChosen || from.edit.layoutChosen) + if (!(framingStep && userFraming)) { + for (const field of FRAMING_FIELDS) { (edit as Record)[field] = clip.edit[field] } } diff --git a/tests/editHistory.test.ts b/tests/editHistory.test.ts index f8c4eb8..ddc43e7 100644 --- a/tests/editHistory.test.ts +++ b/tests/editHistory.test.ts @@ -63,8 +63,21 @@ describe('undo and automatic framing', () => { it('still undoes a framing change the user made', () => { const opened = { ...base, edit: { ...base.edit, framing: 'auto', focusX: 0.5 } } as unknown as Clip trackClip(opened) - const manual = { ...opened, edit: { ...opened.edit, framing: 'manual', focusX: 0.2 } } as unknown as Clip + const manual = { ...opened, edit: { ...opened.edit, framing: 'manual', focusX: 0.2, layoutChosen: true } } as unknown as Clip recordSave(manual) expect(undo(manual)!.edit.framing).toBe('auto') }) + + it('keeps analysis framing that landed between unrelated saves', () => { + const opened = { ...base, edit: { ...base.edit, framing: 'manual', focusX: 0.5, reframeMode: 'crop' } } as unknown as Clip + trackClip(opened) + // Analysis grafts without a history step; the next save snapshots it with a rename. + const analysed = { ...opened, edit: { ...opened.edit, framing: 'auto', focusX: 0.31 } } as unknown as Clip + const renamed = { ...analysed, title: 'Renamed' } as unknown as Clip + recordSave(renamed) + const back = undo(renamed)! + expect(back.title).toBe('T') + expect(back.edit.framing).toBe('auto') + expect(back.edit.focusX).toBe(0.31) + }) }) From 6d11be40605f666305d65be49c2c1d83141b3cb2 Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:45:32 +0100 Subject: [PATCH 6/7] Undo only what the user changed; guard toggles against empty playback - Undo steps record per-field before/after values of each save. Analysis and generated captions are absorbed into the baseline, so undo never restores an old crop or caption. - Turning on pause removal or re-removing a restored pause cannot remove the last playable span; the preview never loops on an empty edit. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/components/EditorScreen.tsx | 4 +- src/renderer/src/components/PreviewPlayer.tsx | 8 +- .../src/components/TimelineEditor.tsx | 9 +- src/renderer/src/store.ts | 17 ++- src/shared/editHistory.ts | 130 +++++++++++------- tests/editHistory.test.ts | 35 ++++- 6 files changed, 132 insertions(+), 71 deletions(-) diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx index ce63d08..b404398 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -200,8 +200,8 @@ export default function EditorScreen(): React.JSX.Element { label="Tighten cuts — remove pauses and filler words" checked={clip.edit.tightenCuts} onChange={(v) => { - const next = userClipEdit(clip.edit, { tightenCuts: v }) - if (keepsPlayback({ ...clip, edit: next }, project.transcript)) set({ tightenCuts: v }) + // Pause removal must not remove the last playable span. + if (keepsPlayback({ ...clip, edit: { ...clip.edit, tightenCuts: v } }, project.transcript)) set({ tightenCuts: v }) }} /> {clip.edit.end - clip.edit.start > TIGHTEN_WARN_SEC && ( diff --git a/src/renderer/src/components/PreviewPlayer.tsx b/src/renderer/src/components/PreviewPlayer.tsx index 9e289c3..32b4bb5 100644 --- a/src/renderer/src/components/PreviewPlayer.tsx +++ b/src/renderer/src/components/PreviewPlayer.tsx @@ -183,8 +183,12 @@ export default function PreviewPlayer({ }, [setScrubHandler, requestSeek]) // Mirrors the export by skipping removed pauses and the user's cuts. - const keptSegments = useMemo(() => clipKeptSegments(clip, project.transcript), - [clip, project.transcript]) + // An edit with nothing left plays the trim rather than seeking every frame; + // export reports it instead. + const keptSegments = useMemo(() => { + const kept = clipKeptSegments(clip, project.transcript) + return kept?.length ? kept : null + }, [clip, project.transcript]) const timeMap = useMemo(() => (keptSegments ? new TimeMap(keptSegments) : null), [keptSegments]) const outputDuration = timeMap?.outputDuration ?? duration const outputTime = timeMap ? timeMap.toOutput(time) : Math.max(0, time - start) diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx index 8f7d700..ad4ee9c 100644 --- a/src/renderer/src/components/TimelineEditor.tsx +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -65,8 +65,8 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) setSelected(null) } - // Trims go through the same guard as cuts: narrowing the trim onto a - // stretch that is all cut would otherwise leave nothing to play. + // Trims and re-removed pauses go through the same guard as cuts: they could + // otherwise leave nothing to play. const saveTrim = (next: Clip['edit']): void => { if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) } @@ -135,10 +135,7 @@ export default function TimelineEditor({ clip, windowStart, windowEnd, fps, time playsFor={editedClipDuration(clip, transcript)} onMarkClick={(mark) => { if (mark.kind === 'cut') onSave(uncutAt(edit, (mark.range.start + mark.range.end) / 2)) - else { - const next = toggleRestored(edit, mark.range) - if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) - } + else saveTrim(toggleRestored(edit, mark.range)) }} />
diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 709739d..a90d22b 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -17,13 +17,19 @@ import type { import { findWholeVideoClip, highlightClips, isWholeVideoClip } from '@shared/wholeVideo' import { mergeReframeResult, needsReframe } from '@shared/reframe' -import { clearHistory, recordSave, redo as redoEdit, trackClip, undo as undoEdit } from '@shared/editHistory' +import { clearHistory, noteExternal, recordSave, redo as redoEdit, trackClip, undo as undoEdit } from '@shared/editHistory' /** Font faces already registered with document.fonts (FontFace API). */ const loadedFontFaces = new Map() /** Saved trim changes that arrived while a reframe request was in flight. */ const queuedReframes = new Set() +/** Analysis and caption results are not user edits: keep them out of undo steps. */ +function absorbExternal(clipId: string): void { + const clip = useStore.getState().project?.clips.find((c) => c.id === clipId) + if (clip) noteExternal(clip) +} + /** Apply an undo/redo step and save it without recording a new step. */ async function stepHistory(clipId: string, step: (clip: Clip) => Clip | null): Promise { const { project } = useStore.getState() @@ -217,6 +223,7 @@ export const useStore = create((set, get) => ({ ) } }) + absorbExternal(event.clipId) } else { // A failed background run stays pending without an error, so opening // the clip retries it. @@ -432,9 +439,6 @@ export const useStore = create((set, get) => ({ const current = get().project // Only graft the caption on: other clip edits may be in flight. if (fresh && current?.id === updated.id) { - const live = current.clips.find((c) => c.id === clipId) - if (live) trackClip(live) - const grafted = live ? { ...live, caption: fresh.caption } : null set({ project: { ...current, @@ -443,9 +447,7 @@ export const useStore = create((set, get) => ({ ) } }) - // Caption generation is otherwise outside history, so the next cut or - // rename would snapshot it and an undo of that edit would wipe it. - if (grafted && recordSave(grafted)) set({ historyVersion: get().historyVersion + 1 }) + absorbExternal(clipId) } } finally { const busy = { ...get().captionBusy } @@ -482,6 +484,7 @@ export const useStore = create((set, get) => ({ ) } }) + absorbExternal(clipId) } } catch (err) { // The clip stays pending; the editor shows the failure with a retry and diff --git a/src/shared/editHistory.ts b/src/shared/editHistory.ts index 3f39639..9653f3a 100644 --- a/src/shared/editHistory.ts +++ b/src/shared/editHistory.ts @@ -1,88 +1,112 @@ import type { Clip } from './types' /** - * Per-clip undo/redo over what the user owns: the edit state, text, B-roll - * and manually set layouts. Analysis results (focus track, automatic layout) - * are never undone. Typing and drags update the clip locally many times - * before one save, so a step is recorded against the last *saved* state. + * Per-clip undo/redo of the user's own edits. + * + * Each step records only the fields that save actually changed, with their + * values before and after, so undo touches nothing else. Results merged in + * from elsewhere (reframe analysis, generated captions) are absorbed into the + * baseline with `noteExternal` instead of becoming steps, so undoing a cut can + * never put back an old crop or an old caption. + * + * Typing and drags update the clip locally many times before one save, so a + * step is measured against the last *saved* state, not the live copy. */ -type Snapshot = Pick - const LIMIT = 100 -interface History { saved: Snapshot; past: Snapshot[]; future: Snapshot[] } +/** Top-level clip fields the user edits; edit fields are tracked one by one. */ +const CLIP_KEYS = ['title', 'hook', 'caption', 'broll', 'visualLayout'] as const +/** What reframe analysis and caption generation write without a user save. */ +const EXTERNAL_KEYS = ['edit.reframeMode', 'edit.framing', 'edit.focusX', 'edit.autoZoom', + 'edit.compositionPreference', 'visualLayout', 'caption'] + +type Values = Map +interface Change { key: string; before: string | undefined; after: string | undefined } +interface History { saved: Values; past: Change[][]; future: Change[][] } const histories = new Map() -function snapshot(clip: Clip): Snapshot { - return structuredClone({ edit: clip.edit, title: clip.title, hook: clip.hook, caption: clip.caption, - broll: clip.broll, visualLayout: clip.visualLayout }) +function values(clip: Clip): Values { + const out: Values = new Map() + for (const key of CLIP_KEYS) out.set(key, JSON.stringify(clip[key] ?? null)) + for (const [key, value] of Object.entries(clip.edit)) out.set(`edit.${key}`, JSON.stringify(value ?? null)) + return out } -const same = (a: Snapshot, b: Snapshot): boolean => JSON.stringify(a) === JSON.stringify(b) - -/** Framing fields the reframe analysis also sets (see shared/reframe.ts). */ -const FRAMING_FIELDS = ['reframeMode', 'framing', 'focusX', 'autoZoom', 'compositionPreference', 'layoutChosen'] as const - -/** - * Apply `target`, the other side of the step being undone or redone from - * `from`. Framing fields change only when that step was a user layout choice - * (`layoutChosen`): analysis may rewrite the crop between saves without a - * history step, and the next unrelated save must not make that crop undoable. - */ -function restore(clip: Clip, target: Snapshot, from: Snapshot): Clip { - const edit = { ...structuredClone(target.edit) } - const framingStep = FRAMING_FIELDS.some( - (field) => JSON.stringify(target.edit[field]) !== JSON.stringify(from.edit[field]) - ) - const userFraming = Boolean(target.edit.layoutChosen || from.edit.layoutChosen) - if (!(framingStep && userFraming)) { - for (const field of FRAMING_FIELDS) { - (edit as Record)[field] = clip.edit[field] +function apply(clip: Clip, changes: Change[], side: 'before' | 'after'): Clip { + const next: Clip = { ...clip, edit: { ...clip.edit } } + for (const change of changes) { + const raw = change[side] + const value = raw === undefined ? undefined : JSON.parse(raw) + if (change.key.startsWith('edit.')) { + const field = change.key.slice(5) as keyof Clip['edit'] + if (value === null || value === undefined) delete next.edit[field] + else (next.edit as unknown as Record)[field] = value + } else { + (next as unknown as Record)[change.key] = value ?? undefined } } - const manual = (target.visualLayout?.revision ?? 0) > 0 || (clip.visualLayout?.revision ?? 0) > 0 - return { ...clip, ...structuredClone(target), edit, visualLayout: manual ? structuredClone(target.visualLayout) : clip.visualLayout } + return next } -/** Start tracking a clip as last saved; keeps existing history. Call before each save. */ +/** Start tracking a clip as last saved; keeps existing history. */ export function trackClip(clip: Clip): void { - if (!histories.has(clip.id)) histories.set(clip.id, { saved: snapshot(clip), past: [], future: [] }) + if (!histories.has(clip.id)) histories.set(clip.id, { saved: values(clip), past: [], future: [] }) +} + +/** Absorb results that arrived from analysis or caption generation. */ +export function noteExternal(clip: Clip): void { + const history = histories.get(clip.id) + if (!history) return + const current = values(clip) + for (const key of EXTERNAL_KEYS) { + if (current.has(key)) history.saved.set(key, current.get(key)!) + else history.saved.delete(key) + } } /** Record a save. Returns true when it created an undo step. */ export function recordSave(clip: Clip): boolean { const history = histories.get(clip.id) - const next = snapshot(clip) + const next = values(clip) if (!history) { histories.set(clip.id, { saved: next, past: [], future: [] }); return false } - if (same(history.saved, next)) return false - history.past.push(history.saved) + const keys = new Set([...history.saved.keys(), ...next.keys()]) + const changes: Change[] = [] + for (const key of keys) { + const before = history.saved.get(key), after = next.get(key) + if (before !== after) changes.push({ key, before, after }) + } + if (!changes.length) return false + history.past.push(changes) if (history.past.length > LIMIT) history.past.shift() history.future = [] history.saved = next return true } -/** The clip as it was one step back, or null. */ -export function undo(clip: Clip): Clip | null { +function step(clip: Clip, from: 'past' | 'future'): Clip | null { const history = histories.get(clip.id) - const previous = history?.past.pop() - if (!history || !previous) return null - const from = history.saved - history.future.push(snapshot(clip)) - history.saved = previous - return restore(clip, previous, from) + const changes = history?.[from].pop() + if (!history || !changes) return null + const side = from === 'past' ? 'before' : 'after' + const next = apply(clip, changes, side) + for (const change of changes) { + const value = change[side] + if (value === undefined) history.saved.delete(change.key) + else history.saved.set(change.key, value) + } + history[from === 'past' ? 'future' : 'past'].push(changes) + return next } -/** The clip one step forward again, or null. */ +/** The clip with the last recorded step undone, or null. */ +export function undo(clip: Clip): Clip | null { + return step(clip, 'past') +} + +/** The clip with the last undone step reapplied, or null. */ export function redo(clip: Clip): Clip | null { - const history = histories.get(clip.id) - const next = history?.future.pop() - if (!history || !next) return null - const from = history.saved - history.past.push(snapshot(clip)) - history.saved = next - return restore(clip, next, from) + return step(clip, 'future') } export function historyState(clipId: string): { canUndo: boolean; canRedo: boolean } { diff --git a/tests/editHistory.test.ts b/tests/editHistory.test.ts index ddc43e7..b0ffbfc 100644 --- a/tests/editHistory.test.ts +++ b/tests/editHistory.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { clearHistory, historyState, recordSave, redo, trackClip, undo } from '@shared/editHistory' +import { clearHistory, historyState, noteExternal, recordSave, redo, trackClip, undo } from '@shared/editHistory' import type { Clip } from '@shared/types' const base = { id: 'c', title: 'T', hook: '', caption: '', broll: [], focusTrack: [{ t: 0, x: 0.5 }], @@ -81,3 +81,36 @@ describe('undo and automatic framing', () => { expect(back.edit.focusX).toBe(0.31) }) }) + +describe('results that arrive between saves', () => { + it('does not treat analysis landing between two saves as a user step', () => { + const opened = { ...base, edit: { ...base.edit, framing: 'manual', focusX: 0.5 } } as unknown as Clip + trackClip(opened) + recordSave({ ...opened, edit: { ...opened.edit, cuts: [{ start: 2, end: 3 }] } }) + // Analysis lands (merged by the store, not saved by the user)… + const analysed = { ...opened, edit: { ...opened.edit, cuts: [{ start: 2, end: 3 }], framing: 'auto', focusX: 0.31 } } as unknown as Clip + noteExternal(analysed) + // …then the user renames and undoes the rename. + const renamed = { ...analysed, title: 'New' } + recordSave(renamed) + const back = undo(renamed)! + expect(back.title).toBe('T') + expect(back.edit.framing).toBe('auto') + expect(back.edit.focusX).toBe(0.31) + // Undoing the cut after that still leaves the analysis framing alone. + const beforeCut = undo(back)! + expect(beforeCut.edit.cuts).toEqual([]) + expect(beforeCut.edit.framing).toBe('auto') + }) + + it('keeps a generated caption when an earlier edit is undone', () => { + trackClip(base) + const trimmed = { ...base, edit: { ...base.edit, end: 15 } } + recordSave(trimmed) + const captioned = { ...trimmed, caption: 'Generated caption' } + noteExternal(captioned) + const back = undo(captioned)! + expect(back.edit.end).toBe(20) + expect(back.caption).toBe('Generated caption') + }) +}) From 6634fdff77613173ce81eb9e2d3d57827ea18737 Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:57:16 +0100 Subject: [PATCH 7/7] Count framing as a user undo step only after a layout choice Analysis rewrites framing until layoutChosen, so framing differences that reach a save unannounced are absorbed rather than made undoable. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/shared/editHistory.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/shared/editHistory.ts b/src/shared/editHistory.ts index 9653f3a..4b87fce 100644 --- a/src/shared/editHistory.ts +++ b/src/shared/editHistory.ts @@ -17,9 +17,10 @@ const LIMIT = 100 /** Top-level clip fields the user edits; edit fields are tracked one by one. */ const CLIP_KEYS = ['title', 'hook', 'caption', 'broll', 'visualLayout'] as const +/** Framing that reframe analysis rewrites until the user chooses a layout. */ +const FRAMING_KEYS = ['edit.reframeMode', 'edit.framing', 'edit.focusX', 'edit.autoZoom', 'edit.compositionPreference'] /** What reframe analysis and caption generation write without a user save. */ -const EXTERNAL_KEYS = ['edit.reframeMode', 'edit.framing', 'edit.focusX', 'edit.autoZoom', - 'edit.compositionPreference', 'visualLayout', 'caption'] +const EXTERNAL_KEYS = [...FRAMING_KEYS, 'visualLayout', 'caption'] type Values = Map interface Change { key: string; before: string | undefined; after: string | undefined } @@ -74,7 +75,9 @@ export function recordSave(clip: Clip): boolean { const changes: Change[] = [] for (const key of keys) { const before = history.saved.get(key), after = next.get(key) - if (before !== after) changes.push({ key, before, after }) + // Until the user picks a layout (layoutChosen), framing differences are + // analysis results, even if they reached this save unannounced. + if (before !== after && (clip.edit.layoutChosen || !FRAMING_KEYS.includes(key))) changes.push({ key, before, after }) } if (!changes.length) return false history.past.push(changes)