@bycrux/editor 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet, Pencil, Undo2 } from 'lucide-react'
2
+ import { Crop, Info, Magnet, Pencil, Redo2, Undo2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
+ import { useProjectSync, type UseProjectSync } from '../state/use-project-sync'
4
5
  import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
5
6
  import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
6
7
  import { getOverlayDesignCanvas } from './design-canvas'
@@ -8,6 +9,7 @@ import { applyTheme, defaultMontajTheme } from '../theme'
8
9
  import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
9
10
  import { repairCaptionWords } from './captionRepair'
10
11
  import Timeline from './timeline/Timeline'
12
+ import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
11
13
  import PreviewPlayer from './preview/PreviewPlayer'
12
14
  import { createPlaybackClock, type PlaybackClock } from './playback-clock'
13
15
  import type { OverlayChanges } from './preview/useDragOverlay'
@@ -22,16 +24,59 @@ import OverlayPropsModal from './preview/OverlayPropsModal'
22
24
  // round-trips through edit→save (and `onProjectChange`) without casts.
23
25
  type Props<P extends Project = Project> = VideoEditorProps<P>
24
26
 
27
+ // Fills in a stable `cap-<n>` id for any caption segment that doesn't already
28
+ // have one (id was added to the schema after captions already existed on saved
29
+ // projects, and `steps/lyrics/caption.py` still writes segments without one).
30
+ // Never overwrites an existing id.
31
+ //
32
+ // Ids are minted against the ids already in use, NOT from the array index. A
33
+ // track can hold a mix of already-backfilled segments and fresh id-less ones —
34
+ // caption regeneration produces exactly that — so a literal `cap-1` sitting at
35
+ // index 4 would make an index-derived mint hand out `cap-1` a second time. Ids
36
+ // are the selection key for the preview drag and the timeline caption row, so a
37
+ // duplicate means clicking one segment highlights and moves a different one.
38
+ // The counter only ever moves forward, so the result is deterministic (a pure
39
+ // function of the input segments) and, for the common all-id-less track, is
40
+ // still exactly `cap-<index>`.
41
+ //
42
+ // Returns the same project reference when every segment already has an id, so
43
+ // callers can skip applying a no-op update — the property the backfill effect's
44
+ // loop-safety rests on.
45
+ export function backfillCaptionIds<P extends Project>(project: P): P {
46
+ const captions = project.captions
47
+ if (!captions) return project
48
+ const { segments } = captions
49
+ if (!segments.length || segments.every((seg) => seg.id)) return project
50
+
51
+ const used = new Set(segments.map((seg) => seg.id).filter((id): id is string => !!id))
52
+ let counter = 0
53
+ const mint = () => {
54
+ let id = `cap-${counter++}`
55
+ while (used.has(id)) id = `cap-${counter++}`
56
+ used.add(id)
57
+ return id
58
+ }
59
+
60
+ return {
61
+ ...project,
62
+ captions: { ...captions, segments: segments.map((seg) => (seg.id ? seg : { ...seg, id: mint() })) },
63
+ }
64
+ }
65
+
25
66
  /**
26
67
  * `<VideoEditor>` — the assembled, host-agnostic video editor.
27
68
  *
28
69
  * Absorbs Montaj's former LiveView (pending/processing surface) and ReviewView
29
70
  * (draft/final surface) into one component driven by the `EditorAdapter`.
30
- * Controlled like `<CarouselEditor>`: the host owns `project` and is notified of
31
- * edits via `onProjectChange`; persistence flows through `adapter.saveProject`.
32
- * It does NOT own a `useProjectState` reducer — it preserves the original
33
- * Live/Review save model exactly (mutate → onProjectChange → adapter.saveProject
34
- * fire-and-forget), so the host's pipeline fields survive untouched.
71
+ * Controlled like `<CarouselEditor>`: the host owns the initial `project` and is
72
+ * notified of edits via `onProjectChange`; persistence flows through the shared
73
+ * `useProjectSync` core (queued saves, SSE echo protection, undo/redo).
74
+ *
75
+ * The sync core is created ONCE here (not per-surface) so it owns a single SSE
76
+ * subscription and so `sync.project.status` drives the Pending↔Review switch —
77
+ * the editor now subscribes to live frames itself instead of receiving them as a
78
+ * prop, so a status transition (agent finishes → 'draft') must come from the
79
+ * core's own stream, not the host re-rendering with a new prop.
35
80
  *
36
81
  * ProjectHeader is lifted out (the host renders it in its shell). This component
37
82
  * renders: timeline + preview + version panel + render modal + the host-supplied
@@ -54,31 +99,78 @@ export default function VideoEditor<P extends Project = Project>({
54
99
  }: Props<P>) {
55
100
  const emit = onProjectChange ?? (() => {})
56
101
 
102
+ // Shared save/undo/SSE core. Created once at the top so there is exactly one
103
+ // subscription per editor and `sync.project` is the single source of truth for
104
+ // both the surface switch and the surface contents. `project` (the prop) is
105
+ // only the initial value — after mount the core owns state and reconciles live
106
+ // frames itself (video-shaped → default plain-replace reconcile).
107
+ const sync = useProjectSync<P>(adapter, project.id, project)
108
+
109
+ // Every caption segment needs a stable `id` for selection (preview drag,
110
+ // clickable timeline row). Segments saved before `id` existed on the schema
111
+ // are missing it, and `steps/lyrics/caption.py` still writes segments without
112
+ // one, so backfill `cap-<index>` whenever the caption track changes identity.
113
+ //
114
+ // Keyed on `sync.project.id` AND `sync.project.captions` so it covers every
115
+ // entry point a caption track reaches state through:
116
+ // - the `initial` value seeded into useProjectSync's reducer above (only
117
+ // consulted on this component's first mount — React ignores later changes
118
+ // to a useReducer initial arg);
119
+ // - a same-mounted-instance swap to a different project id, which arrives
120
+ // via the SSE subscription's `applyExternal` (e.g. client-side navigation
121
+ // between two projects without VideoEditor unmounting);
122
+ // - a caption REGENERATION inside a live session (CaptionRegenModal →
123
+ // applyExternal with a whole new, id-less `captions` object). The project
124
+ // id does not change there, so an id-keyed effect would not re-fire and
125
+ // every segment would silently become unselectable.
126
+ //
127
+ // Loop-proof: `backfillCaptionIds` returns the *same* project reference when
128
+ // every segment already has an id, so the pass that follows our own
129
+ // `applyExternal` (which necessarily produces a new `captions` reference, and
130
+ // therefore re-fires this effect exactly once) finds nothing to do and stops.
131
+ // Every other re-fire — one per caption edit — is a cheap `.every()` no-op.
132
+ //
133
+ // `applyExternal` — no save, no undo push: this is normalization of loaded
134
+ // data, not a user edit, so it must not dirty the project or contend with the
135
+ // undo stack; the ids persist naturally the next time the operator makes a
136
+ // real edit.
137
+ useEffect(() => {
138
+ const backfilled = backfillCaptionIds(sync.project)
139
+ if (backfilled !== sync.project) sync.applyExternal(backfilled)
140
+ }, [sync.project.id, sync.project.captions])
141
+
142
+ // Notify the host of every authoritative change — edits, undo/redo, and SSE
143
+ // frames — so its non-editor chrome (title, status pill) stays in sync. Mirrors
144
+ // CarouselEditor. `emit` is read via a ref so the effect only fires on state
145
+ // change, not when the host passes a new `onProjectChange` identity.
146
+ const emitRef = useRef(emit)
147
+ emitRef.current = emit
148
+ useEffect(() => {
149
+ emitRef.current(sync.project)
150
+ }, [sync.project])
151
+
57
152
  // ── Theme: apply tokens onto the editor container. ──
58
153
  const containerRef = useRef<HTMLDivElement>(null)
59
154
  useEffect(() => {
60
155
  if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
61
156
  }, [theme])
62
157
 
63
- const isPending = project.status === 'pending'
158
+ const isPending = sync.project.status === 'pending'
64
159
 
65
160
  // ── Shared injected adapter fns, threaded to Timeline + PreviewPlayer. ──
66
161
  const getWaveformChunks = adapter.getWaveformChunks
67
162
  const resolveFilePath = adapter.fileUrl
68
- const save = (p: P) => { void adapter.saveProject(p.id, p) }
69
163
 
70
164
  if (isPending) {
71
165
  return (
72
166
  <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
73
167
  <PendingSurface
74
- project={project}
168
+ sync={sync}
75
169
  adapter={adapter}
76
- onProjectChange={emit}
77
170
  slots={slots}
78
171
  onBackToSetup={onBackToSetup}
79
172
  getWaveformChunks={getWaveformChunks}
80
173
  resolveFilePath={resolveFilePath}
81
- save={save}
82
174
  />
83
175
  </div>
84
176
  )
@@ -87,15 +179,14 @@ export default function VideoEditor<P extends Project = Project>({
87
179
  return (
88
180
  <div ref={containerRef} className="flex flex-col h-full">
89
181
  <ReviewSurface
90
- project={project}
182
+ sync={sync}
183
+ emit={emit}
91
184
  adapter={adapter}
92
- onProjectChange={emit}
93
185
  slots={slots}
94
186
  assetsPlacement={assetsPlacement}
95
187
  renderProgressView={renderProgressView}
96
188
  getWaveformChunks={getWaveformChunks}
97
189
  resolveFilePath={resolveFilePath}
98
- save={save}
99
190
  renderClipInspector={renderClipInspector}
100
191
  renderSubcutRegen={renderSubcutRegen}
101
192
  regenEnabled={regenEnabled}
@@ -122,27 +213,25 @@ function useVersionHistory<P extends Project>(adapter: VideoEditorProps<P>['adap
122
213
  // ── Pending / processing surface (former LiveView) ───────────────────────────
123
214
 
124
215
  interface SurfaceProps<P extends Project> {
125
- project: P
216
+ sync: UseProjectSync<P>
126
217
  adapter: VideoEditorProps<P>['adapter']
127
- onProjectChange: (p: P) => void
128
218
  slots?: VideoEditorProps<P>['slots']
129
219
  assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
130
220
  renderProgressView?: VideoEditorProps<P>['renderProgressView']
131
221
  getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
132
222
  resolveFilePath: (path: string) => string
133
- save: (p: P) => void
134
223
  onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
135
224
  }
136
225
 
137
226
  function PendingSurface<P extends Project>({
138
- project,
227
+ sync,
139
228
  adapter,
140
- onProjectChange,
141
229
  slots,
142
230
  onBackToSetup,
143
231
  getWaveformChunks,
144
232
  resolveFilePath,
145
233
  }: SurfaceProps<P> & { onBackToSetup?: () => void }) {
234
+ const project = sync.project
146
235
  // The playhead lives in an external store (not useState) so ~60Hz ticks only
147
236
  // re-render the leaves that display time — not this whole surface.
148
237
  const clockRef = useRef<PlaybackClock | null>(null)
@@ -168,7 +257,8 @@ function PendingSurface<P extends Project>({
168
257
  setRestoring(hash)
169
258
  try {
170
259
  const restored = await adapter.restoreVersion(project.id, hash)
171
- onProjectChange(restored)
260
+ // Server-authored, already persisted — apply without a save or undo push.
261
+ sync.applyExternal(restored)
172
262
  } catch (e) {
173
263
  console.error(e)
174
264
  } finally {
@@ -247,7 +337,7 @@ function PendingSurface<P extends Project>({
247
337
  clock={clock}
248
338
  getWaveformChunks={getWaveformChunks}
249
339
  resolveFilePath={resolveFilePath}
250
- onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
340
+ onSaveProject={(p) => sync.mutate(() => p as P)}
251
341
  />
252
342
  </div>
253
343
  </div>
@@ -265,38 +355,43 @@ function PendingSurface<P extends Project>({
265
355
  // ── Draft / final surface (former ReviewView) ────────────────────────────────
266
356
 
267
357
  function ReviewSurface<P extends Project>({
268
- project,
358
+ sync,
359
+ emit,
269
360
  adapter,
270
- onProjectChange,
271
361
  slots,
272
362
  assetsPlacement = 'right',
273
363
  renderProgressView = 'phases',
274
364
  getWaveformChunks,
275
365
  resolveFilePath,
276
- save,
277
366
  renderClipInspector,
278
367
  renderSubcutRegen,
279
368
  regenEnabled,
280
369
  isClipQueued,
281
370
  onProvideRenderTrigger,
282
371
  }: SurfaceProps<P> & {
372
+ emit: (p: P) => void
283
373
  renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
284
374
  renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
285
375
  regenEnabled?: boolean
286
376
  isClipQueued?: (itemId: string) => boolean
287
377
  }) {
378
+ const project = sync.project
288
379
  // Playhead in an external store, not useState — ~60Hz ticks re-render only the
289
380
  // leaves that display time (preview, scrubber, transcript) instead of the whole
290
381
  // review surface (toolbar + timeline + every context consumer).
291
382
  const clockRef = useRef<PlaybackClock | null>(null)
292
383
  if (!clockRef.current) clockRef.current = createPlaybackClock()
293
384
  const clock = clockRef.current
294
- const [canUndo, setCanUndo] = useState(false)
295
- const historyRef = useRef<P[]>([])
296
385
  // Multi-select: all currently-selected timeline item ids. Single-select
297
386
  // consumers (canvas preview, cut/split) use selectedIds[0] as the primary.
298
387
  const [selectedIds, setSelectedIds] = useState<string[]>([])
299
388
  const primarySelectedId = selectedIds[0] ?? null
389
+ // Selected caption segment id. Deliberately owned here rather than inside the
390
+ // preview: it is shared selection state. The preview draws the selection box /
391
+ // drag handles for it, and the timeline's caption row (later task) highlights
392
+ // and seeks to the same segment — that sibling only needs `selectedCaptionId`
393
+ // and `setSelectedCaptionId` passed down, no lifting required.
394
+ const [selectedCaptionId, setSelectedCaptionId] = useState<string | null>(null)
300
395
  const [rippleMode, setRippleMode] = useState(false)
301
396
  const [showControls, setShowControls] = useState(false)
302
397
  // Source-crop mode: when on, the VideoSourceCropModal opens for the selected
@@ -309,34 +404,54 @@ function ReviewSurface<P extends Project>({
309
404
  const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
310
405
 
311
406
  // Render trigger — marks the project final, saves, and opens the RenderModal.
312
- // Kept stable (latest project/onChange/save read via refs) so a host that
313
- // places Render in its own header (onProvideRenderTrigger) can store the
314
- // callback once without it going stale.
315
- const projectRef = useRef(project); projectRef.current = project
316
- const onProjectChangeRef = useRef(onProjectChange); onProjectChangeRef.current = onProjectChange
317
- const saveRef = useRef(save); saveRef.current = save
407
+ // Kept stable (the sync mutators/ref are stable; `emit` read via ref) so a host
408
+ // that places Render in its own header (onProvideRenderTrigger) can store the
409
+ // callback once without it going stale. `emit(final)` fires synchronously so
410
+ // the host's chrome flips to "final" immediately; the queued `mutate` makes it
411
+ // canonical and persists it.
412
+ const { mutate: syncMutate, projectRef: syncProjectRef } = sync
413
+ const emitRef = useRef(emit); emitRef.current = emit
318
414
  const openRender = useCallback(() => {
319
- const final = { ...projectRef.current, status: 'final' } as P
320
- onProjectChangeRef.current(final)
321
- saveRef.current(final)
415
+ const final = { ...syncProjectRef.current, status: 'final' } as P
416
+ emitRef.current(final)
417
+ void syncMutate(() => final)
322
418
  setRenderOpen(true)
323
- }, [])
419
+ }, [syncMutate, syncProjectRef])
324
420
  useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
325
421
 
326
422
  const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
327
423
 
328
424
  // Repair caption segments whose words[] text has diverged from edited seg.text.
329
425
  // Inline caption edits update seg.text but not seg.words; this normalizes the
330
- // data so PreviewPlayer's word-level timing is correct. Runs once per project.id.
426
+ // data so PreviewPlayer's word-level timing is correct.
427
+ //
428
+ // Keyed on BOTH project.id and project.captions — mirrors the id-backfill
429
+ // effect above for the identical reason: `CaptionRegenModal`'s `onDone`
430
+ // replaces project.captions via applyExternal WITHOUT changing project.id, so
431
+ // an id-keyed-only effect would miss mid-session caption regeneration and
432
+ // freshly regenerated captions would skip repair until a remount.
433
+ //
434
+ // Applied via `applyExternal` (no save, no undo push): it's a local
435
+ // reconciliation, not a user edit — pushing an undo entry on load would make the
436
+ // operator's first Cmd-Z undo the repair, and the normalized captions persist on
437
+ // the next real save anyway.
438
+ //
439
+ // Loop-proof: `repairCaptionWords` returns `null` (a true no-op) once every
440
+ // segment's words[] already matches its text — see captionRepair.ts, which
441
+ // whitespace-normalizes the comparison specifically so this holds even when
442
+ // the edited/regenerated text itself contains irregular internal spacing
443
+ // (without that normalization, repairing never reaches a fixed point and this
444
+ // effect would applyExternal forever). The pass that follows our own
445
+ // applyExternal (which necessarily produces a new `captions` reference, and
446
+ // therefore re-fires this effect exactly once) finds nothing left to repair
447
+ // and stops. Every other re-fire — one per caption edit — is a cheap no-op scan.
331
448
  useEffect(() => {
332
449
  const captions = project.captions
333
450
  if (!captions?.segments?.length) return
334
451
  const repaired = repairCaptionWords(captions)
335
452
  if (!repaired) return
336
- const next = { ...project, captions: repaired } as P
337
- onProjectChange(next)
338
- void adapter.saveProject(next.id, next)
339
- }, [project.id]) // intentionally keyed on project.id only — runs once per project load
453
+ sync.applyExternal({ ...project, captions: repaired } as P)
454
+ }, [project.id, project.captions])
340
455
 
341
456
  const clips = project.tracks?.[0] ?? []
342
457
  const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
@@ -355,45 +470,46 @@ function ReviewSurface<P extends Project>({
355
470
 
356
471
  // Overlay props dialog — opened from the preview (double-click), the controls
357
472
  // bar, or the timeline block. VideoEditor owns the state so all three surfaces
358
- // share one modal. Edits ride handleOverlayChange (history + save for free).
473
+ // share one modal. Edits ride the sync core's transient/commit gesture path
474
+ // (live preview + one undo step on Save).
359
475
  const [editingOverlayId, setEditingOverlayId] = useState<string | null>(null)
360
- // Project snapshot taken when the dialog opens, so history/undo and Cancel
361
- // revert to the pre-edit state even though edits preview live in between.
476
+ // Project snapshot taken when the dialog opens, so Cancel reverts to the
477
+ // pre-edit state even though edits preview live in between.
362
478
  const editOriginalRef = useRef<P | null>(null)
363
479
  const requestEditOverlay = useCallback((id: string) => {
364
- editOriginalRef.current = projectRef.current
480
+ editOriginalRef.current = syncProjectRef.current
365
481
  setEditingOverlayId(id)
366
- }, [])
482
+ }, [syncProjectRef])
367
483
  const allVisualItems = (project.tracks ?? []).flat()
368
484
  const editingOverlayItem = editingOverlayId
369
485
  ? allVisualItems.find(i => i.id === editingOverlayId) ?? null
370
486
  : null
371
487
 
372
- function withItemProps(id: string, nextProps: Record<string, unknown>): P {
488
+ function withItemProps(base: P, id: string, nextProps: Record<string, unknown>): P {
373
489
  return {
374
- ...project,
375
- tracks: (project.tracks ?? []).map(track =>
490
+ ...base,
491
+ tracks: (base.tracks ?? []).map(track =>
376
492
  track.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
377
493
  ),
378
494
  } as P
379
495
  }
380
- // Live preview: reflect the in-progress edit via onProjectChange only — no
381
- // history push, no save — so the overlay re-renders as the operator tweaks.
496
+ // Live preview: reflect the in-progress edit locally (transient — no save, no
497
+ // undo push) so the overlay re-renders as the operator tweaks. `commit()` on
498
+ // Save persists the accumulated transient state as one undo step.
382
499
  function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
383
- onProjectChange(withItemProps(id, nextProps))
500
+ sync.mutateTransient(p => withItemProps(p, id, nextProps))
384
501
  }
385
- // Commit on Save: snapshot the pre-edit project for undo, then persist.
386
- function commitOverlayEdit(id: string, nextProps: Record<string, unknown>) {
387
- if (editOriginalRef.current) pushHistory(editOriginalRef.current)
388
- const updated = withItemProps(id, nextProps)
389
- onProjectChange(updated)
390
- save(updated)
502
+ // Commit on Save: the last preview already applied the final props transiently,
503
+ // so committing persists them and records one undo step (the pre-edit baseline).
504
+ function commitOverlayEdit() {
505
+ void sync.commit()
391
506
  editOriginalRef.current = null
392
507
  setEditingOverlayId(null)
393
508
  }
394
- // Cancel/Esc/close: discard the live preview by restoring the snapshot.
509
+ // Cancel/Esc/close: discard the live preview by restoring the pre-edit snapshot
510
+ // (no save, no undo push).
395
511
  function cancelOverlayEdit() {
396
- if (editOriginalRef.current) onProjectChange(editOriginalRef.current)
512
+ if (editOriginalRef.current) sync.applyExternal(editOriginalRef.current)
397
513
  editOriginalRef.current = null
398
514
  setEditingOverlayId(null)
399
515
  }
@@ -402,91 +518,96 @@ function ReviewSurface<P extends Project>({
402
518
  ? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
403
519
  : null
404
520
 
405
- function pushHistory(prev: P) {
406
- historyRef.current = [...historyRef.current.slice(-49), prev]
407
- setCanUndo(true)
408
- }
409
-
410
- // Edits coming from the timeline (drag/move/track changes): snapshot for undo,
411
- // notify host, persist.
521
+ // Edits coming from the timeline (drag/move/track changes): route through the
522
+ // sync core — one undo step + queued save + rollback-on-failure.
412
523
  function handleProjectChange(p: Project) {
413
- pushHistory(project)
414
- onProjectChange(p as P)
415
- save(p as P)
416
- }
417
-
418
- function handleUndo() {
419
- const hist = historyRef.current
420
- if (!hist.length) return
421
- const prev = hist[hist.length - 1]
422
- historyRef.current = hist.slice(0, -1)
423
- setCanUndo(hist.length > 1)
424
- onProjectChange(prev)
425
- save(prev)
524
+ void sync.mutate(() => p as P)
426
525
  }
427
526
 
428
527
  function handleCut(cut: { start: number; end: number }) {
429
- pushHistory(project)
430
- let updated = primarySelectedId
431
- ? applyCutToItem(project, primarySelectedId, cut)
432
- : applyCutToTracks(project, cut)
433
- if (rippleMode) updated = collapseGaps(updated)
434
- onProjectChange(updated as P)
435
- save(updated as P)
528
+ void sync.mutate(p => {
529
+ let updated = primarySelectedId
530
+ ? applyCutToItem(p, primarySelectedId, cut)
531
+ : applyCutToTracks(p, cut)
532
+ if (rippleMode) updated = collapseGaps(updated)
533
+ return updated as P
534
+ })
436
535
  setSelectedIds([])
437
536
  }
438
537
 
439
538
  function handleOverlayChange(id: string, changes: OverlayChanges) {
440
- pushHistory(project)
441
- const updated = {
442
- ...project,
443
- tracks: (project.tracks ?? []).map(track =>
539
+ void sync.mutate(p => ({
540
+ ...p,
541
+ tracks: (p.tracks ?? []).map(track =>
444
542
  track.map(item => item.id !== id ? item : { ...item, ...changes })
445
543
  ),
446
- } as P
447
- onProjectChange(updated)
448
- save(updated)
544
+ } as P))
449
545
  }
450
546
 
547
+ // Commit a per-segment caption change (preview drag → offsetX/offsetY/scale).
548
+ // Routed through `makeCaptionEdit` so there is exactly one project-mutation
549
+ // path for caption edits — it addresses the segment by id and leaves the
550
+ // fields the patch omits alone — and through `sync.mutate` so a finished drag
551
+ // lands as one undo step plus a queued save, same as a timeline caption edit.
552
+ // Only ONE of makeCaptionEdit's two callbacks is supplied: both are invoked
553
+ // with the same updated project, so passing both would mutate twice.
554
+ const handleCaptionSegmentChange = useCallback((segmentId: string, patch: CaptionEditPatch) => {
555
+ makeCaptionEdit(segmentId, syncProjectRef.current, (p) => void syncMutate(() => p as P))(patch)
556
+ }, [syncProjectRef, syncMutate])
557
+
558
+ // Selecting a caption segment and selecting a normal timeline item are
559
+ // mutually exclusive selection models — never show both sets of handles at
560
+ // once (see CaptionTrackRow's file header). A caption can be selected from
561
+ // either the preview (click the selection box) or the timeline's caption
562
+ // row, so this wrapper — not Timeline — is the one place that must clear
563
+ // `selectedIds` on every caption selection; Timeline's own
564
+ // `handleSelectItem` handles the reverse (selecting an item clears this).
565
+ const handleSelectCaption = useCallback((id: string | null) => {
566
+ setSelectedCaptionId(id)
567
+ if (id !== null) setSelectedIds([])
568
+ }, [])
569
+
451
570
  function handleSplit(at?: number) {
452
- const updated = splitAtTime(project, at ?? clock.get(), primarySelectedId ?? null)
453
- if (updated === project) return
454
- pushHistory(project)
455
- onProjectChange(updated as P)
456
- save(updated as P)
571
+ const base = syncProjectRef.current
572
+ const updated = splitAtTime(base, at ?? clock.get(), primarySelectedId ?? null)
573
+ if (updated === base) return
574
+ void sync.mutate(() => updated as P)
457
575
  }
458
576
 
459
577
  function handleRippleToggle() {
460
578
  const next = !rippleMode
461
579
  setRippleMode(next)
462
580
  if (next) {
463
- const collapsed = collapseGaps(project)
464
- if (collapsed !== project) {
465
- pushHistory(project)
466
- onProjectChange(collapsed as P)
467
- save(collapsed as P)
468
- }
581
+ const base = syncProjectRef.current
582
+ const collapsed = collapseGaps(base)
583
+ if (collapsed !== base) void sync.mutate(() => collapsed as P)
469
584
  }
470
585
  }
471
586
 
472
- // Keyboard: split (S) and undo (cmd/ctrl-Z). Guarded against text inputs.
587
+ // Keyboard: split (S), undo (cmd/ctrl-Z), redo (cmd/ctrl-shift-Z or cmd/ctrl-Y).
588
+ // Guarded against text inputs.
473
589
  useEffect(() => {
474
590
  const onKey = (e: KeyboardEvent) => {
475
591
  const el = e.target as HTMLElement
476
592
  if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
477
- if (e.key === 's' || e.key === 'S') { e.preventDefault(); handleSplit() }
478
- if ((e.metaKey || e.ctrlKey) && e.key === 'z') { e.preventDefault(); handleUndo() }
593
+ if (e.key === 's' || e.key === 'S') { e.preventDefault(); handleSplit(); return }
594
+ const mod = e.metaKey || e.ctrlKey
595
+ if (!mod) return
596
+ const key = e.key.toLowerCase()
597
+ if (key === 'z' && !e.shiftKey) { e.preventDefault(); sync.undo() }
598
+ else if ((key === 'z' && e.shiftKey) || key === 'y') { e.preventDefault(); sync.redo() }
479
599
  }
480
600
  document.addEventListener('keydown', onKey)
481
601
  return () => document.removeEventListener('keydown', onKey)
482
- }, [project, primarySelectedId, canUndo])
602
+ }, [project, primarySelectedId, sync])
483
603
 
484
604
  async function handleRestoreVersion(hash: string) {
485
605
  if (!adapter.restoreVersion) return
486
606
  setRestoring(hash)
487
607
  try {
488
608
  const restored = await adapter.restoreVersion(project.id, hash)
489
- onProjectChange(restored)
609
+ // Server-authored, already persisted — apply without a save or undo push.
610
+ sync.applyExternal(restored)
490
611
  } catch (e) {
491
612
  console.error(e)
492
613
  } finally {
@@ -517,6 +638,9 @@ function ReviewSurface<P extends Project>({
517
638
  watchFile={adapter.watchFile}
518
639
  fileUrl={adapter.fileUrl}
519
640
  resolveCaptionTemplate={adapter.resolveCaptionTemplate}
641
+ selectedCaptionId={selectedCaptionId ?? undefined}
642
+ onSelectCaption={handleSelectCaption}
643
+ onCaptionSegmentChange={handleCaptionSegmentChange}
520
644
  />
521
645
  </div>
522
646
  ) : (
@@ -524,7 +648,7 @@ function ReviewSurface<P extends Project>({
524
648
  )}
525
649
  </div>
526
650
 
527
- {/* Track controls bar — info + split + ripple + render */}
651
+ {/* Track controls bar — info + undo/redo + split + ripple + render */}
528
652
  <div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
529
653
  <button
530
654
  onClick={() => setShowControls(true)}
@@ -535,14 +659,23 @@ function ReviewSurface<P extends Project>({
535
659
  <Info size={12} />
536
660
  </button>
537
661
  <button
538
- onClick={handleUndo}
539
- disabled={!canUndo}
662
+ onClick={sync.undo}
663
+ disabled={!sync.canUndo}
540
664
  title="Undo (Cmd/Ctrl+Z)"
541
665
  aria-label="Undo"
542
666
  className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
543
667
  >
544
668
  <Undo2 size={12} />
545
669
  </button>
670
+ <button
671
+ onClick={sync.redo}
672
+ disabled={!sync.canRedo}
673
+ title="Redo (Cmd/Ctrl+Shift+Z)"
674
+ aria-label="Redo"
675
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
676
+ >
677
+ <Redo2 size={12} />
678
+ </button>
546
679
  <button
547
680
  onClick={() => handleSplit()}
548
681
  title="Split at playhead (S) — selected item or all clips"
@@ -610,16 +743,19 @@ function ReviewSurface<P extends Project>({
610
743
  project={project}
611
744
  clock={clock}
612
745
  onProjectChange={handleProjectChange}
613
- onCaptionEdit={(p) => { onProjectChange(p as P); save(p as P) }}
614
- onOverlayEdit={(p) => { onProjectChange(p as P); save(p as P) }}
746
+ onCaptionEdit={(p) => void sync.mutate(() => p as P)}
747
+ onOverlayEdit={(p) => void sync.mutate(() => p as P)}
615
748
  onEditOverlay={requestEditOverlay}
616
749
  selectedIds={selectedIds}
617
750
  onSelectIds={setSelectedIds}
751
+ selectedCaptionId={selectedCaptionId}
752
+ onSelectCaption={handleSelectCaption}
753
+ onCaptionSegmentChange={handleCaptionSegmentChange}
618
754
  onSplit={handleSplit}
619
755
  onCut={handleCut}
620
756
  onInspectClip={(id) => setInspecting({ kind: 'clip', id })}
621
757
  onInspectAudio={(id) => setInspecting({ kind: 'audio', id })}
622
- onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
758
+ onSaveProject={(p) => sync.mutate(() => p as P)}
623
759
  rippleMode={rippleMode}
624
760
  getWaveformChunks={getWaveformChunks}
625
761
  resolveFilePath={resolveFilePath}
@@ -723,17 +859,17 @@ function ReviewSurface<P extends Project>({
723
859
  )}
724
860
 
725
861
  {/* Caption regen modal — adapter.generateCaptions stream. On done we patch
726
- project.captions via onProjectChange only. We deliberately do NOT call
727
- save(): montaj persists the regenerated captions server-side and the
728
- SSE subscribe frame reconciles, so a saveProject here would double-write. */}
862
+ project.captions via applyExternal only. We deliberately do NOT save:
863
+ montaj persists the regenerated captions server-side and the SSE frame
864
+ reconciles, so a saveProject here would double-write. applyExternal keeps
865
+ it out of the undo stack (server-authored, not a user edit). */}
729
866
  {regenCaptionsOpen && adapter.generateCaptions && (
730
867
  <CaptionRegenModal
731
868
  adapter={adapter}
732
869
  projectId={project.id}
733
870
  onClose={() => setRegenCaptionsOpen(false)}
734
871
  onDone={(captions) => {
735
- const next = { ...project, captions } as P
736
- onProjectChange(next)
872
+ sync.applyExternal({ ...syncProjectRef.current, captions } as P)
737
873
  setRegenCaptionsOpen(false)
738
874
  }}
739
875
  />
@@ -741,15 +877,15 @@ function ReviewSurface<P extends Project>({
741
877
 
742
878
  {/* Overlay props dialog — edits the selected overlay's primitive props
743
879
  (text, colors, numbers, toggles). Opened from the preview double-click,
744
- the controls bar, or a timeline block. Rides handleOverlayChange, so
745
- edits preview live and undo like any other change. */}
880
+ the controls bar, or a timeline block. Edits preview live (transient) and
881
+ undo as one step on Save. */}
746
882
  {editingOverlayItem && (
747
883
  <OverlayPropsModal
748
884
  itemProps={editingOverlayItem.props ?? {}}
749
885
  fileUrl={adapter.fileUrl}
750
886
  uploadFile={(file) => adapter.uploadFile(file, project.id)}
751
887
  onPreview={(next) => previewOverlayProps(editingOverlayItem.id, next)}
752
- onSave={(next) => commitOverlayEdit(editingOverlayItem.id, next)}
888
+ onSave={() => commitOverlayEdit()}
753
889
  onClose={cancelOverlayEdit}
754
890
  />
755
891
  )}