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..ea46e55 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,10 @@ 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) + 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/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..b404398 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -24,7 +24,9 @@ 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, keepsPlayback } from '@shared/editOps' +import { trackClip } from '@shared/editHistory' import ScoreBadge from './ScoreBadge' import TranscriptEditor from './TranscriptEditor' import { ExportButton } from './ClipsScreen' @@ -95,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. @@ -173,27 +181,28 @@ 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 }) }} />
set({ tightenCuts: v })} + onChange={(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 && (

@@ -527,15 +536,14 @@ export default function EditorScreen(): React.JSX.Element { transcript={project.transcript} clipStart={clip.edit.start} clipEnd={clip.edit.end} + cuts={clip.edit.cuts} + onCut={(range) => { + const next = { ...clip, edit: cutRange(clip.edit, range) } + 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/PreviewPlayer.tsx b/src/renderer/src/components/PreviewPlayer.tsx index e3ced1f..32b4bb5 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,13 @@ export default function PreviewPlayer({ return () => setScrubHandler(null) }, [setScrubHandler, requestSeek]) - // Mirrors the export's tighten-cuts behaviour by skipping removed spans. + // Mirrors the export by skipping removed pauses and the user's cuts. + // An edit with nothing left plays the trim rather than seeking every frame; + // export reports it instead. 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]) + 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) @@ -275,6 +277,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..ad4ee9c --- /dev/null +++ b/src/renderer/src/components/TimelineEditor.tsx @@ -0,0 +1,163 @@ +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, keepsPlayback, 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, 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 + const next = cutRange(edit, selection) + // Never cut away everything that plays. + if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next) + setSelected(null) + } + // 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) + } + 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() + // 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)) + 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') 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) + 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, + // 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) return + if (keepsPlayback(current, transcript)) onSave(current.edit) + else onLocal(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 saveTrim(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..a90d22b 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -17,12 +17,31 @@ import type { import { findWholeVideoClip, highlightClips, isWholeVideoClip } from '@shared/wholeVideo' import { mergeReframeResult, needsReframe } from '@shared/reframe' +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() + 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 +132,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 +193,7 @@ export const useStore = create((set, get) => ({ reframeBusy: {}, reframeError: {}, backgroundReframing: {}, + historyVersion: 0, init: async () => { const [settings, projects, customFonts] = await Promise.all([ @@ -198,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. @@ -247,6 +273,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 +302,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 +391,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, @@ -379,13 +413,22 @@ 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) 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 @@ -404,6 +447,7 @@ export const useStore = create((set, get) => ({ ) } }) + absorbExternal(clipId) } } finally { const busy = { ...get().captionBusy } @@ -440,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 new file mode 100644 index 0000000..4b87fce --- /dev/null +++ b/src/shared/editHistory.ts @@ -0,0 +1,123 @@ +import type { Clip } from './types' + +/** + * 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. + */ + +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 = [...FRAMING_KEYS, '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 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 +} + +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 + } + } + return next +} + +/** 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: 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 = values(clip) + if (!history) { histories.set(clip.id, { saved: next, past: [], future: [] }); return false } + 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) + // 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) + if (history.past.length > LIMIT) history.past.shift() + history.future = [] + history.saved = next + return true +} + +function step(clip: Clip, from: 'past' | 'future'): Clip | null { + const history = histories.get(clip.id) + 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 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 { + return step(clip, 'future') +} + +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..e87950e --- /dev/null +++ b/src/shared/editOps.ts @@ -0,0 +1,85 @@ +import type { Clip, ClipEditState, TimeRange, Transcript } from './types' +import { editedClipDuration, 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`; none outside the trim. */ +export function pieceAt(edit: ClipEditState, t: number): TimeRange | undefined { + 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). */ +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) } +} + +/** 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/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..b0ffbfc --- /dev/null +++ b/tests/editHistory.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it } from 'vitest' +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 }], + 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) + }) +}) + +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, 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) + }) +}) + +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') + }) +}) diff --git a/tests/editOps.test.ts b/tests/editOps.test.ts new file mode 100644 index 0000000..6ad48f2 --- /dev/null +++ b/tests/editOps.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +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 + +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('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) + 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 }]) + }) +})