Skip to content

Timeline cuts, transcript cuts, undo/redo and editing shortcuts - #73

Merged
JeremySNR merged 7 commits into
mainfrom
editing-cuts
Sep 23, 2026
Merged

JeremySNR merged 7 commits into
mainfrom
editing-cuts

Conversation

@JeremySNR

@JeremySNR JeremySNR commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #72 (uses its voice-aware pause removal). Retarget to main once #72 merges.

Summary

  • One source of truth for what plays: clipKeptSegments = automatic pause removal + pauses the user restored − user cuts. Export, preview, clip-card durations and the AI review all go through it, so what you see is what exports.
  • Timeline: razor split at the playhead, click a piece to select it, Delete ripple-cuts it. Cuts show red-hatched (click to put back). Automatically removed pauses show hatched (click to keep). The trim bar shows "24.0 s long · plays 21.0 s".
  • Transcript: drag across words, then Delete or "Cut selection". Cut edges sit halfway into the neighbouring gaps. Cut words are struck through.
  • Undo/redo per clip over user-owned fields (edit, text, B-roll, manual layouts), one step per save. Analysis results are never undone.
  • Shortcuts: Space/K play, J/L ±1 s, ←/→ one frame (Shift ±1 s), I/O in/out, S or ⌘K split, Delete cut, ⌘Z / ⇧⌘Z (Ctrl on Windows/Linux). Ignored while typing.

Not in this PR: zoomable multi-track timeline, reordering segments, caption/B-roll/zoom tracks.

Test plan

  • npm test (567), typecheck, lint, build
  • Real exports with cuts, with and without pause removal: output durations match the plan (33.00 s; 34.53 vs 34.52 s), audio present, full decode passes
  • Smoke walkthrough on macOS with real key events: split twice, select the middle piece, Delete → "1 cut"; ⌘Z → restored
  • Manual: J/L/arrow stepping feel, transcript cut + undo, restoring an auto-removed pause

🤖 Generated with Claude Code


Note

Medium Risk
Changes core edit/export timing and persisted ClipEditState fields; mistakes would desync preview from ffmpeg output or corrupt projects, though shared helpers and tests mitigate this.

Overview
Adds manual clip editing on top of automatic pause removal, with preview, export, durations, and AI review all driven by a single clipKeptSegments path (pause tightening ± restored spans − user cuts).

Timeline: TimelineEditor wraps the trim bar with razor splits, piece selection, ripple delete, clickable marks for auto-removed pauses (restore) and user cuts (uncut), and a plays duration when it differs from trim length. Transcript: word-range selection can ripple-cut; cut words show struck through. Undo/redo is per clip (one step per save); reframe/caption/analysis updates are absorbed so undo never reverts framing or generated captions. Shortcuts (Space/K, J/L, arrows, I/O, S/⌘K split, Delete, ⌘Z) go through a preview bus; edits are blocked when they would leave less than ~1s playable. Export fails clearly if everything is cut.

Smoke capture exercises split → delete → undo; new unit tests cover editOps, clipKeptSegments, and history behavior.

Reviewed by Cursor Bugbot for commit 6634fdf. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: Whole-video editor skips undo tracking
    • openProject and captionWholeVideo now call trackClip when entering the editor so the first edit creates a real undo step.
  • ✅ Fixed: Held keys repeat play and trim
    • Timeline shortcuts ignore e.repeat except for J/L and arrow scrubbing so Space/K and I/O fire once per press.
  • ✅ Fixed: Off-trim clicks select the last piece
    • pieceAt only falls back to the last piece at exactly edit.end; times before the in-point now return undefined.
  • ✅ Fixed: Empty kept segments break playback
    • cutRange refuses cuts that leave nothing playable, and clipKeptSegments returns null instead of an empty array.
  • ✅ Fixed: Caption generation is not undoable
    • generateCaption now trackClips and recordSaves the grafted caption so undo restores the previous caption.

Create PR

Or push these changes by commenting:

@cursor push 8af9c913d5
Preview (8af9c913d5)
diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx
--- a/src/renderer/src/components/TimelineEditor.tsx
+++ b/src/renderer/src/components/TimelineEditor.tsx
@@ -70,6 +70,8 @@
       const bus = usePreviewBus.getState()
       const mod = e.metaKey || e.ctrlKey
       const key = e.key.toLowerCase()
+      // J/L and arrows benefit from key-repeat; play, trim, split, cut and undo do not.
+      if (e.repeat && key !== 'j' && key !== 'l' && key !== 'arrowleft' && key !== 'arrowright') 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))

diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts
--- a/src/renderer/src/store.ts
+++ b/src/renderer/src/store.ts
@@ -270,6 +270,7 @@
     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
+    if (wholeVideo) trackClip(wholeVideo)
     set({
       project,
       screen: wholeVideo ? 'editor' : project.clips.length > 0 ? 'clips' : 'home',
@@ -358,6 +359,7 @@
       const clip = findWholeVideoClip(updated)
       // Straight into the editor: captions and framing are what this mode is
       // for, and there is no clip grid to choose from.
+      if (clip) trackClip(clip)
       set({
         project: updated,
         screen: clip ? 'editor' : 'home',
@@ -425,16 +427,18 @@
       const updated = await window.cutawan.generateCaption(project.id, clipId)
       const fresh = updated.clips.find((c) => c.id === clipId)
       const current = get().project
+      const local = current?.clips.find((c) => c.id === clipId)
       // Only graft the caption on: other clip edits may be in flight.
-      if (fresh && current?.id === updated.id) {
+      if (fresh && local && current?.id === updated.id) {
+        trackClip(local)
+        const next = { ...local, caption: fresh.caption }
         set({
           project: {
             ...current,
-            clips: current.clips.map((c) =>
-              c.id === clipId ? { ...c, caption: fresh.caption } : c
-            )
+            clips: current.clips.map((c) => (c.id === clipId ? next : c))
           }
         })
+        if (recordSave(next)) set({ historyVersion: get().historyVersion + 1 })
       }
     } finally {
       const busy = { ...get().captionBusy }

diff --git a/src/shared/editOps.ts b/src/shared/editOps.ts
--- 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 { normalizeRanges, subtractRanges } from './tighten'
 
 /**
  * Pure timeline edits shared by keyboard shortcuts, the timeline and the
@@ -26,14 +26,20 @@
   return bounds.slice(0, -1).map((start, i) => ({ start, end: bounds[i + 1] }))
 }
 
-/** The piece containing `t`. */
+/** The piece containing `t`. Half-open [start, end); only `edit.end` maps to the last piece. */
 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). */
 export function cutRange(edit: ClipEditState, range: TimeRange): ClipEditState {
-  return { ...edit, cuts: normalizeRanges([...(edit.cuts ?? []), range], edit.start, edit.end) }
+  const cuts = normalizeRanges([...(edit.cuts ?? []), range], edit.start, edit.end)
+  // Refuse a cut that would leave nothing playable (empty kept plan breaks preview/export).
+  const remaining = subtractRanges([{ start: edit.start, end: edit.end }], cuts)
+    .filter(r => r.end - r.start >= 0.08)
+  if (remaining.length === 0) return edit
+  return { ...edit, cuts }
 }
 
 /** Put back the user cut containing `t`. */

diff --git a/src/shared/tighten.ts b/src/shared/tighten.ts
--- a/src/shared/tighten.ts
+++ b/src/shared/tighten.ts
@@ -64,6 +64,8 @@
   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)
+  // Empty arrays are truthy; callers would build TimeMap([]) / concat=n=0 and thrash.
+  if (kept.length === 0) return null
   if (kept.length === 1 && kept[0].start <= start + 1e-6 && kept[0].end >= end - 1e-6) return null
   return kept
 }

diff --git a/tests/editHistory.test.ts b/tests/editHistory.test.ts
--- a/tests/editHistory.test.ts
+++ b/tests/editHistory.test.ts
@@ -45,4 +45,11 @@
     recordSave({ ...back, title: 'B' })
     expect(historyState('c').canRedo).toBe(false)
   })
+
+  it('records caption changes so generate-caption can be undone', () => {
+    trackClip(base)
+    const captioned = { ...base, caption: 'Hook line' }
+    expect(recordSave(captioned)).toBe(true)
+    expect(undo(captioned)!.caption).toBe('')
+  })
 })

diff --git a/tests/editOps.test.ts b/tests/editOps.test.ts
--- a/tests/editOps.test.ts
+++ b/tests/editOps.test.ts
@@ -10,6 +10,8 @@
     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 })
+    expect(pieceAt(split, 30)).toEqual({ start: 20, end: 30 })
+    expect(pieceAt(split, 9)).toBeUndefined()
   })
   it('ignores splits at the edges or on top of another split', () => {
     expect(splitAt(edit, 10.01)).toBe(edit)
@@ -28,6 +30,9 @@
   it('merges overlapping cuts', () => {
     expect(cutRange(cutRange(edit, { start: 12, end: 15 }), { start: 14, end: 18 }).cuts).toEqual([{ start: 12, end: 18 }])
   })
+  it('refuses a cut that would leave nothing to play', () => {
+    expect(cutRange(edit, { start: 10, end: 30 })).toBe(edit)
+  })
   it('toggles a restored pause', () => {
     const pause = { start: 12, end: 13 }
     const kept = toggleRestored(edit, pause)

diff --git a/tests/manualCuts.test.ts b/tests/manualCuts.test.ts
--- a/tests/manualCuts.test.ts
+++ b/tests/manualCuts.test.ts
@@ -54,4 +54,8 @@
     expect(clipKeptSegments(clip({ cuts: [{ start: 2, end: 4 }, { start: 4.05, end: 6 }] }), null))
       .toEqual([{ start: 0, end: 2 }, { start: 6, end: 20 }])
   })
+
+  it('returns null rather than an empty plan when cuts remove everything', () => {
+    expect(clipKeptSegments(clip({ cuts: [{ start: 0, end: 20 }] }), null)).toBeNull()
+  })
 })

You can send follow-ups to the cloud agent here.

Comment thread src/renderer/src/store.ts
Comment thread src/renderer/src/components/TimelineEditor.tsx
Comment thread src/shared/editOps.ts Outdated

/** 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Off-trim clicks select the last piece

Medium Severity

pieceAt falls back to the last piece for any time that is not inside a piece. Clicks in the dimmed pre-roll, or just left of the in-handle, select the end of the clip, so Delete can ripple-cut the wrong segment.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c4cc4f. Configure here.

Comment thread src/shared/tighten.ts
Comment thread src/renderer/src/store.ts

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Undo reverts analysis framing
    • Undo now preserves analysis framing unless layoutChosen marks a user change, and stores the restored clip as history.saved so later undos keep that crop.

Create PR

Or push these changes by commenting:

@cursor push c0ae6fd107
Preview (c0ae6fd107)
diff --git a/src/shared/editHistory.ts b/src/shared/editHistory.ts
--- a/src/shared/editHistory.ts
+++ b/src/shared/editHistory.ts
@@ -26,14 +26,16 @@
 
 /**
  * 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 and automatic layouts change only when the user
+ * owned that change (`layoutChosen` on either side). Analysis may update
+ * framing without a history step — including between track and the next
+ * save — 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) }
+  const userFraming = Boolean(target.edit.layoutChosen || from.edit.layoutChosen)
   for (const field of FRAMING_FIELDS) {
-    if (JSON.stringify(target.edit[field]) === JSON.stringify(from.edit[field])) {
+    if (!userFraming || JSON.stringify(target.edit[field]) === JSON.stringify(from.edit[field])) {
       (edit as Record<string, unknown>)[field] = clip.edit[field]
     }
   }
@@ -66,8 +68,11 @@
   if (!history || !previous) return null
   const from = history.saved
   history.future.push(snapshot(clip))
-  history.saved = previous
-  return restore(clip, previous, from)
+  const restored = restore(clip, previous, from)
+  // Persist what undo actually kept (e.g. analysis framing), not the raw past
+  // snapshot — otherwise the next save treats preserved framing as a new edit.
+  history.saved = snapshot(restored)
+  return restored
 }
 
 /** The clip one step forward again, or null. */
@@ -77,8 +82,9 @@
   if (!history || !next) return null
   const from = history.saved
   history.past.push(snapshot(clip))
-  history.saved = next
-  return restore(clip, next, from)
+  const restored = restore(clip, next, from)
+  history.saved = snapshot(restored)
+  return restored
 }
 
 export function historyState(clipId: string): { canUndo: boolean; canRedo: boolean } {

diff --git a/tests/editHistory.test.ts b/tests/editHistory.test.ts
--- a/tests/editHistory.test.ts
+++ b/tests/editHistory.test.ts
@@ -60,10 +60,28 @@
     expect(back.edit.focusX).toBe(0.31)
   })
 
+  it('keeps analysis framing when it landed before the first save', () => {
+    const opened = { ...base, edit: { ...base.edit, framing: 'manual', focusX: 0.5, reframeMode: 'crop' } } as unknown as Clip
+    trackClip(opened)
+    // Usual path: ensureReframe merges into the live clip without touching history.
+    const analysed = { ...opened, edit: { ...opened.edit, framing: 'auto', focusX: 0.31 } } as unknown as Clip
+    recordSave({ ...analysed, title: 'Renamed' })
+    const back = undo({ ...analysed, title: 'Renamed' })!
+    expect(back.title).toBe('T')
+    expect(back.edit.framing).toBe('auto')
+    expect(back.edit.focusX).toBe(0.31)
+    // A later edit must still not undo the framing that was just preserved.
+    recordSave({ ...back, edit: { ...back.edit, cuts: [{ start: 2, end: 4 }] } })
+    const again = undo({ ...back, edit: { ...back.edit, cuts: [{ start: 2, end: 4 }] } })!
+    expect(again.edit.cuts).toEqual([])
+    expect(again.edit.framing).toBe('auto')
+    expect(again.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
+    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')
   })

You can send follow-ups to the cloud agent here.

Comment thread src/shared/editHistory.ts Outdated

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Trim can empty the playback
    • I/O, trim-bar, and transcript trim now refuse edits that leave less than MIN_CLIP_SEC of playback after cuts.
  • ✅ Fixed: Preview captions show cut words
    • CaptionOverlay now drops words removed by clipKeptSegments, matching export's remapTranscript filter.

Create PR

Or push these changes by commenting:

@cursor push 53e7f7e8e3
Preview (53e7f7e8e3)
diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx
--- a/src/renderer/src/components/EditorScreen.tsx
+++ b/src/renderer/src/components/EditorScreen.tsx
@@ -539,14 +539,16 @@
                 if (keepsPlayback(next, project.transcript)) void updateClip(next)
               }}
               onTrim={(start, end) => {
-                void updateClip({
+                const next = {
                   ...clip,
                   edit: {
                     ...clip.edit,
                     start: Math.max(windowStart, start),
                     end: Math.min(windowEnd, end)
                   }
-                })
+                }
+                // Same guard as cuts: never leave an empty (or near-empty) playback.
+                if (keepsPlayback(next, project.transcript)) void updateClip(next)
               }}
             />
           ) : (

diff --git a/src/renderer/src/components/PreviewPlayer.tsx b/src/renderer/src/components/PreviewPlayer.tsx
--- a/src/renderer/src/components/PreviewPlayer.tsx
+++ b/src/renderer/src/components/PreviewPlayer.tsx
@@ -490,11 +490,15 @@
   )
 
   // Same grouping and line layout the ASS export uses, so what wraps here
-  // wraps identically in the file.
+  // wraps identically in the file. Drop cut/pause-removed words the way
+  // remapTranscript does at export, so live captions match the file.
   const groups = useMemo(() => {
+    const kept = clipKeptSegments(clip, transcript)
+    const map = kept ? new TimeMap(kept) : null
     const words = wordsInRange(transcript, clip.edit.start, clip.edit.end)
+      .filter((w) => !map || !map.isRemoved((w.start + w.end) / 2))
     return groupWords(words, captionLayoutBudget(style, aspectRatio))
-  }, [transcript, clip.edit.start, clip.edit.end, style, aspectRatio])
+  }, [transcript, clip, style, aspectRatio])
 
   const groupIndex = groups.findIndex(
     (g, i) => time >= g.start && time < groupDisplayEnd(groups, i, clip.edit.end)

diff --git a/src/renderer/src/components/TimelineEditor.tsx b/src/renderer/src/components/TimelineEditor.tsx
--- a/src/renderer/src/components/TimelineEditor.tsx
+++ b/src/renderer/src/components/TimelineEditor.tsx
@@ -93,8 +93,12 @@
       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' || key === 'o') {
+        const next = setTrimEdge(edit, key === 'i' ? 'start' : 'end',
+          key === 'i' ? Math.max(windowStart, bus.time) : Math.min(windowEnd, bus.time))
+        // Same guard as cuts: never leave an empty (or near-empty) playback.
+        if (keepsPlayback({ ...clip, edit: next }, transcript)) onSave(next)
+      }
       else if (key === 's') split()
       else if (key === 'delete' || key === 'backspace') cutSelected()
       else if (key === 'escape') setSelected(null)
@@ -114,11 +118,14 @@
         start={edit.start}
         end={edit.end}
         timeline={timeline}
-        onChange={(start, end) => onLocal({ ...edit, start, end })}
+        onChange={(start, end) => {
+          const next = { ...edit, start, end }
+          if (keepsPlayback({ ...clip, edit: next }, transcript)) onLocal(next)
+        }}
         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)
+          if (current && keepsPlayback(current, transcript)) onSave(current.edit)
         }}
         marks={marks}
         splits={edit.splits}

diff --git a/tests/editOps.test.ts b/tests/editOps.test.ts
--- a/tests/editOps.test.ts
+++ b/tests/editOps.test.ts
@@ -24,6 +24,12 @@
     expect(keepsPlayback({ edit: cutRange(edit, { start: 10, end: 20 }) }, null)).toBe(true)
   })
 
+  it('refuses a trim that sits entirely inside a cut', () => {
+    const cut = cutRange(edit, { start: 15, end: 25 })
+    expect(keepsPlayback({ edit: { ...cut, start: 16, end: 24 } }, null)).toBe(false)
+    expect(keepsPlayback({ edit: { ...cut, 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)

You can send follow-ups to the cloud agent here.

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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trim can empty the playback

Medium Severity

keepsPlayback blocks a cut that would leave nothing to play, but I/O and trim never run that check. Narrowing the in/out points onto a cut (or trimming the clip to already-cut words) makes clipKeptSegments return an empty list. Export then throws, and the preview scrubber divides by a zero outputDuration.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 46bb9d8. Configure here.

Comment thread src/renderer/src/components/PreviewPlayer.tsx
JeremySNR and others added 4 commits September 23, 2026 09:24
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@JeremySNR
JeremySNR changed the base branch from speed-and-framing-quality to main September 23, 2026 08:27

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Undo reverts analysis framing
    • Restore now keeps live framing unless the undone step was a user layout choice (layoutChosen), so analysis crops snapped into later saves are no longer reverted.
  • ✅ Fixed: Toggles can empty playback
    • tightenCuts and toggleRestored now refuse the change when keepsPlayback would fail, matching cut/trim guards.
  • ✅ Fixed: Undo drops generated captions
    • generateCaption now records a history step when grafting the caption, so later undo of cuts/renames no longer restores a null caption.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 2b7c434. Configure here.

Comment thread src/shared/editHistory.ts Outdated
Comment thread src/shared/editHistory.ts Outdated
Comment thread src/renderer/src/components/EditorScreen.tsx
cursoragent and others added 3 commits September 23, 2026 08:35
- 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.
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@JeremySNR
JeremySNR merged commit 042bd8a into main Sep 23, 2026
5 checks passed
@JeremySNR
JeremySNR deleted the editing-cuts branch September 23, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants