@bycrux/editor 0.9.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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +4 -0
  3. package/src/schema.ts +6 -0
  4. package/src/state/__tests__/use-project-sync.test.tsx +315 -0
  5. package/src/state/use-project-state.ts +47 -222
  6. package/src/state/use-project-sync.ts +310 -0
  7. package/src/video/VideoEditor.tsx +331 -111
  8. package/src/video/__tests__/VideoEditor.test.tsx +279 -3
  9. package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
  10. package/src/video/__tests__/captionPositioning.test.tsx +435 -0
  11. package/src/video/__tests__/captionRepair.test.ts +26 -0
  12. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  13. package/src/video/captionRepair.ts +13 -1
  14. package/src/video/playback-clock.ts +31 -0
  15. package/src/video/preview/CaptionPreview.tsx +298 -4
  16. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  17. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  18. package/src/video/preview/PreviewPlayer.tsx +27 -6
  19. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  20. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  21. package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
  22. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  23. package/src/video/preview/captionDragState.ts +175 -0
  24. package/src/video/preview/overlay-prop-fields.ts +39 -0
  25. package/src/video/preview/useDragOverlay.ts +21 -1
  26. package/src/video/preview/useVideoPlayback.ts +12 -3
  27. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  28. package/src/video/timeline/CaptionTrackRow.tsx +235 -0
  29. package/src/video/timeline/PlayheadLine.tsx +18 -0
  30. package/src/video/timeline/Scrubber.tsx +7 -5
  31. package/src/video/timeline/Timeline.tsx +94 -30
  32. package/src/video/timeline/TimelineContext.ts +2 -2
  33. package/src/video/timeline/TranscriptModal.tsx +10 -3
  34. package/src/video/timeline/TranscriptPanel.tsx +7 -1
  35. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  36. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
  37. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  38. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
  39. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
  40. package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
  41. package/src/video/timeline/makeCaptionEdit.ts +33 -10
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet, 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,10 +9,14 @@ 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'
14
+ import { createPlaybackClock, type PlaybackClock } from './playback-clock'
15
+ import type { OverlayChanges } from './preview/useDragOverlay'
12
16
  import VersionPanel from './VersionPanel'
13
17
  import RenderModal from './RenderModal'
14
18
  import CaptionRegenModal from './CaptionRegenModal'
19
+ import OverlayPropsModal from './preview/OverlayPropsModal'
15
20
 
16
21
  // Generic over the host's concrete project type `P` (default = the package's
17
22
  // own `Project`). Montaj passes its richer Project; the index signature on
@@ -19,16 +24,59 @@ import CaptionRegenModal from './CaptionRegenModal'
19
24
  // round-trips through edit→save (and `onProjectChange`) without casts.
20
25
  type Props<P extends Project = Project> = VideoEditorProps<P>
21
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
+
22
66
  /**
23
67
  * `<VideoEditor>` — the assembled, host-agnostic video editor.
24
68
  *
25
69
  * Absorbs Montaj's former LiveView (pending/processing surface) and ReviewView
26
70
  * (draft/final surface) into one component driven by the `EditorAdapter`.
27
- * Controlled like `<CarouselEditor>`: the host owns `project` and is notified of
28
- * edits via `onProjectChange`; persistence flows through `adapter.saveProject`.
29
- * It does NOT own a `useProjectState` reducer — it preserves the original
30
- * Live/Review save model exactly (mutate → onProjectChange → adapter.saveProject
31
- * 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.
32
80
  *
33
81
  * ProjectHeader is lifted out (the host renders it in its shell). This component
34
82
  * renders: timeline + preview + version panel + render modal + the host-supplied
@@ -51,31 +99,78 @@ export default function VideoEditor<P extends Project = Project>({
51
99
  }: Props<P>) {
52
100
  const emit = onProjectChange ?? (() => {})
53
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
+
54
152
  // ── Theme: apply tokens onto the editor container. ──
55
153
  const containerRef = useRef<HTMLDivElement>(null)
56
154
  useEffect(() => {
57
155
  if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
58
156
  }, [theme])
59
157
 
60
- const isPending = project.status === 'pending'
158
+ const isPending = sync.project.status === 'pending'
61
159
 
62
160
  // ── Shared injected adapter fns, threaded to Timeline + PreviewPlayer. ──
63
161
  const getWaveformChunks = adapter.getWaveformChunks
64
162
  const resolveFilePath = adapter.fileUrl
65
- const save = (p: P) => { void adapter.saveProject(p.id, p) }
66
163
 
67
164
  if (isPending) {
68
165
  return (
69
166
  <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
70
167
  <PendingSurface
71
- project={project}
168
+ sync={sync}
72
169
  adapter={adapter}
73
- onProjectChange={emit}
74
170
  slots={slots}
75
171
  onBackToSetup={onBackToSetup}
76
172
  getWaveformChunks={getWaveformChunks}
77
173
  resolveFilePath={resolveFilePath}
78
- save={save}
79
174
  />
80
175
  </div>
81
176
  )
@@ -84,15 +179,14 @@ export default function VideoEditor<P extends Project = Project>({
84
179
  return (
85
180
  <div ref={containerRef} className="flex flex-col h-full">
86
181
  <ReviewSurface
87
- project={project}
182
+ sync={sync}
183
+ emit={emit}
88
184
  adapter={adapter}
89
- onProjectChange={emit}
90
185
  slots={slots}
91
186
  assetsPlacement={assetsPlacement}
92
187
  renderProgressView={renderProgressView}
93
188
  getWaveformChunks={getWaveformChunks}
94
189
  resolveFilePath={resolveFilePath}
95
- save={save}
96
190
  renderClipInspector={renderClipInspector}
97
191
  renderSubcutRegen={renderSubcutRegen}
98
192
  regenEnabled={regenEnabled}
@@ -119,28 +213,30 @@ function useVersionHistory<P extends Project>(adapter: VideoEditorProps<P>['adap
119
213
  // ── Pending / processing surface (former LiveView) ───────────────────────────
120
214
 
121
215
  interface SurfaceProps<P extends Project> {
122
- project: P
216
+ sync: UseProjectSync<P>
123
217
  adapter: VideoEditorProps<P>['adapter']
124
- onProjectChange: (p: P) => void
125
218
  slots?: VideoEditorProps<P>['slots']
126
219
  assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
127
220
  renderProgressView?: VideoEditorProps<P>['renderProgressView']
128
221
  getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
129
222
  resolveFilePath: (path: string) => string
130
- save: (p: P) => void
131
223
  onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
132
224
  }
133
225
 
134
226
  function PendingSurface<P extends Project>({
135
- project,
227
+ sync,
136
228
  adapter,
137
- onProjectChange,
138
229
  slots,
139
230
  onBackToSetup,
140
231
  getWaveformChunks,
141
232
  resolveFilePath,
142
233
  }: SurfaceProps<P> & { onBackToSetup?: () => void }) {
143
- const [currentTime, setCurrentTime] = useState(0)
234
+ const project = sync.project
235
+ // The playhead lives in an external store (not useState) so ~60Hz ticks only
236
+ // re-render the leaves that display time — not this whole surface.
237
+ const clockRef = useRef<PlaybackClock | null>(null)
238
+ if (!clockRef.current) clockRef.current = createPlaybackClock()
239
+ const clock = clockRef.current
144
240
  const [skillPath, setSkillPath] = useState<string | null>(null)
145
241
  const [copied, setCopied] = useState(false)
146
242
  const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
@@ -161,7 +257,8 @@ function PendingSurface<P extends Project>({
161
257
  setRestoring(hash)
162
258
  try {
163
259
  const restored = await adapter.restoreVersion(project.id, hash)
164
- onProjectChange(restored)
260
+ // Server-authored, already persisted — apply without a save or undo push.
261
+ sync.applyExternal(restored)
165
262
  } catch (e) {
166
263
  console.error(e)
167
264
  } finally {
@@ -177,8 +274,7 @@ function PendingSurface<P extends Project>({
177
274
  {hasTrimmedClips ? (
178
275
  <PreviewPlayer
179
276
  project={project}
180
- currentTime={currentTime}
181
- onTimeUpdate={setCurrentTime}
277
+ clock={clock}
182
278
  compileOverlay={adapter.compileOverlay}
183
279
  clearOverlayCache={adapter.clearOverlayCache}
184
280
  watchFile={adapter.watchFile}
@@ -238,11 +334,10 @@ function PendingSurface<P extends Project>({
238
334
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
239
335
  <Timeline
240
336
  project={project}
241
- currentTime={currentTime}
242
- onTimeUpdate={setCurrentTime}
337
+ clock={clock}
243
338
  getWaveformChunks={getWaveformChunks}
244
339
  resolveFilePath={resolveFilePath}
245
- onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
340
+ onSaveProject={(p) => sync.mutate(() => p as P)}
246
341
  />
247
342
  </div>
248
343
  </div>
@@ -260,33 +355,43 @@ function PendingSurface<P extends Project>({
260
355
  // ── Draft / final surface (former ReviewView) ────────────────────────────────
261
356
 
262
357
  function ReviewSurface<P extends Project>({
263
- project,
358
+ sync,
359
+ emit,
264
360
  adapter,
265
- onProjectChange,
266
361
  slots,
267
362
  assetsPlacement = 'right',
268
363
  renderProgressView = 'phases',
269
364
  getWaveformChunks,
270
365
  resolveFilePath,
271
- save,
272
366
  renderClipInspector,
273
367
  renderSubcutRegen,
274
368
  regenEnabled,
275
369
  isClipQueued,
276
370
  onProvideRenderTrigger,
277
371
  }: SurfaceProps<P> & {
372
+ emit: (p: P) => void
278
373
  renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
279
374
  renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
280
375
  regenEnabled?: boolean
281
376
  isClipQueued?: (itemId: string) => boolean
282
377
  }) {
283
- const [currentTime, setCurrentTime] = useState(0)
284
- const [canUndo, setCanUndo] = useState(false)
285
- const historyRef = useRef<P[]>([])
378
+ const project = sync.project
379
+ // Playhead in an external store, not useState — ~60Hz ticks re-render only the
380
+ // leaves that display time (preview, scrubber, transcript) instead of the whole
381
+ // review surface (toolbar + timeline + every context consumer).
382
+ const clockRef = useRef<PlaybackClock | null>(null)
383
+ if (!clockRef.current) clockRef.current = createPlaybackClock()
384
+ const clock = clockRef.current
286
385
  // Multi-select: all currently-selected timeline item ids. Single-select
287
386
  // consumers (canvas preview, cut/split) use selectedIds[0] as the primary.
288
387
  const [selectedIds, setSelectedIds] = useState<string[]>([])
289
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)
290
395
  const [rippleMode, setRippleMode] = useState(false)
291
396
  const [showControls, setShowControls] = useState(false)
292
397
  // Source-crop mode: when on, the VideoSourceCropModal opens for the selected
@@ -299,34 +404,54 @@ function ReviewSurface<P extends Project>({
299
404
  const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
300
405
 
301
406
  // Render trigger — marks the project final, saves, and opens the RenderModal.
302
- // Kept stable (latest project/onChange/save read via refs) so a host that
303
- // places Render in its own header (onProvideRenderTrigger) can store the
304
- // callback once without it going stale.
305
- const projectRef = useRef(project); projectRef.current = project
306
- const onProjectChangeRef = useRef(onProjectChange); onProjectChangeRef.current = onProjectChange
307
- 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
308
414
  const openRender = useCallback(() => {
309
- const final = { ...projectRef.current, status: 'final' } as P
310
- onProjectChangeRef.current(final)
311
- saveRef.current(final)
415
+ const final = { ...syncProjectRef.current, status: 'final' } as P
416
+ emitRef.current(final)
417
+ void syncMutate(() => final)
312
418
  setRenderOpen(true)
313
- }, [])
419
+ }, [syncMutate, syncProjectRef])
314
420
  useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
315
421
 
316
422
  const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
317
423
 
318
424
  // Repair caption segments whose words[] text has diverged from edited seg.text.
319
425
  // Inline caption edits update seg.text but not seg.words; this normalizes the
320
- // 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.
321
448
  useEffect(() => {
322
449
  const captions = project.captions
323
450
  if (!captions?.segments?.length) return
324
451
  const repaired = repairCaptionWords(captions)
325
452
  if (!repaired) return
326
- const next = { ...project, captions: repaired } as P
327
- onProjectChange(next)
328
- void adapter.saveProject(next.id, next)
329
- }, [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])
330
455
 
331
456
  const clips = project.tracks?.[0] ?? []
332
457
  const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
@@ -343,91 +468,146 @@ function ReviewSurface<P extends Project>({
343
468
  if (!cropTarget && cropMode) setCropMode(false)
344
469
  }, [cropTarget, cropMode])
345
470
 
346
- function pushHistory(prev: P) {
347
- historyRef.current = [...historyRef.current.slice(-49), prev]
348
- setCanUndo(true)
349
- }
471
+ // Overlay props dialog — opened from the preview (double-click), the controls
472
+ // bar, or the timeline block. VideoEditor owns the state so all three surfaces
473
+ // share one modal. Edits ride the sync core's transient/commit gesture path
474
+ // (live preview + one undo step on Save).
475
+ const [editingOverlayId, setEditingOverlayId] = useState<string | null>(null)
476
+ // Project snapshot taken when the dialog opens, so Cancel reverts to the
477
+ // pre-edit state even though edits preview live in between.
478
+ const editOriginalRef = useRef<P | null>(null)
479
+ const requestEditOverlay = useCallback((id: string) => {
480
+ editOriginalRef.current = syncProjectRef.current
481
+ setEditingOverlayId(id)
482
+ }, [syncProjectRef])
483
+ const allVisualItems = (project.tracks ?? []).flat()
484
+ const editingOverlayItem = editingOverlayId
485
+ ? allVisualItems.find(i => i.id === editingOverlayId) ?? null
486
+ : null
350
487
 
351
- // Edits coming from the timeline (drag/move/track changes): snapshot for undo,
352
- // notify host, persist.
353
- function handleProjectChange(p: Project) {
354
- pushHistory(project)
355
- onProjectChange(p as P)
356
- save(p as P)
488
+ function withItemProps(base: P, id: string, nextProps: Record<string, unknown>): P {
489
+ return {
490
+ ...base,
491
+ tracks: (base.tracks ?? []).map(track =>
492
+ track.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
493
+ ),
494
+ } as P
357
495
  }
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.
499
+ function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
500
+ sync.mutateTransient(p => withItemProps(p, id, nextProps))
501
+ }
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()
506
+ editOriginalRef.current = null
507
+ setEditingOverlayId(null)
508
+ }
509
+ // Cancel/Esc/close: discard the live preview by restoring the pre-edit snapshot
510
+ // (no save, no undo push).
511
+ function cancelOverlayEdit() {
512
+ if (editOriginalRef.current) sync.applyExternal(editOriginalRef.current)
513
+ editOriginalRef.current = null
514
+ setEditingOverlayId(null)
515
+ }
516
+ // The primary-selected JSX overlay, if any — drives the controls-bar edit button.
517
+ const selectedOverlayItem = primarySelectedId
518
+ ? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
519
+ : null
358
520
 
359
- function handleUndo() {
360
- const hist = historyRef.current
361
- if (!hist.length) return
362
- const prev = hist[hist.length - 1]
363
- historyRef.current = hist.slice(0, -1)
364
- setCanUndo(hist.length > 1)
365
- onProjectChange(prev)
366
- save(prev)
521
+ // Edits coming from the timeline (drag/move/track changes): route through the
522
+ // sync core — one undo step + queued save + rollback-on-failure.
523
+ function handleProjectChange(p: Project) {
524
+ void sync.mutate(() => p as P)
367
525
  }
368
526
 
369
527
  function handleCut(cut: { start: number; end: number }) {
370
- pushHistory(project)
371
- let updated = primarySelectedId
372
- ? applyCutToItem(project, primarySelectedId, cut)
373
- : applyCutToTracks(project, cut)
374
- if (rippleMode) updated = collapseGaps(updated)
375
- onProjectChange(updated as P)
376
- 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
+ })
377
535
  setSelectedIds([])
378
536
  }
379
537
 
380
- function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill'; sourceCrop?: { x: number; y: number; w: number; h: number }; sourceWidth?: number; sourceHeight?: number }) {
381
- pushHistory(project)
382
- const updated = {
383
- ...project,
384
- tracks: (project.tracks ?? []).map(track =>
538
+ function handleOverlayChange(id: string, changes: OverlayChanges) {
539
+ void sync.mutate(p => ({
540
+ ...p,
541
+ tracks: (p.tracks ?? []).map(track =>
385
542
  track.map(item => item.id !== id ? item : { ...item, ...changes })
386
543
  ),
387
- } as P
388
- onProjectChange(updated)
389
- save(updated)
544
+ } as P))
390
545
  }
391
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
+
392
570
  function handleSplit(at?: number) {
393
- const updated = splitAtTime(project, at ?? currentTime, primarySelectedId ?? null)
394
- if (updated === project) return
395
- pushHistory(project)
396
- onProjectChange(updated as P)
397
- 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)
398
575
  }
399
576
 
400
577
  function handleRippleToggle() {
401
578
  const next = !rippleMode
402
579
  setRippleMode(next)
403
580
  if (next) {
404
- const collapsed = collapseGaps(project)
405
- if (collapsed !== project) {
406
- pushHistory(project)
407
- onProjectChange(collapsed as P)
408
- save(collapsed as P)
409
- }
581
+ const base = syncProjectRef.current
582
+ const collapsed = collapseGaps(base)
583
+ if (collapsed !== base) void sync.mutate(() => collapsed as P)
410
584
  }
411
585
  }
412
586
 
413
- // 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.
414
589
  useEffect(() => {
415
590
  const onKey = (e: KeyboardEvent) => {
416
591
  const el = e.target as HTMLElement
417
592
  if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
418
- if (e.key === 's' || e.key === 'S') { e.preventDefault(); handleSplit() }
419
- 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() }
420
599
  }
421
600
  document.addEventListener('keydown', onKey)
422
601
  return () => document.removeEventListener('keydown', onKey)
423
- }, [project, currentTime, primarySelectedId, canUndo])
602
+ }, [project, primarySelectedId, sync])
424
603
 
425
604
  async function handleRestoreVersion(hash: string) {
426
605
  if (!adapter.restoreVersion) return
427
606
  setRestoring(hash)
428
607
  try {
429
608
  const restored = await adapter.restoreVersion(project.id, hash)
430
- onProjectChange(restored)
609
+ // Server-authored, already persisted — apply without a save or undo push.
610
+ sync.applyExternal(restored)
431
611
  } catch (e) {
432
612
  console.error(e)
433
613
  } finally {
@@ -449,15 +629,18 @@ function ReviewSurface<P extends Project>({
449
629
  >
450
630
  <PreviewPlayer
451
631
  project={project}
452
- currentTime={currentTime}
453
- onTimeUpdate={setCurrentTime}
632
+ clock={clock}
454
633
  selectedOverlayId={primarySelectedId ?? undefined}
455
634
  onOverlayChange={handleOverlayChange}
635
+ onEditOverlay={requestEditOverlay}
456
636
  compileOverlay={adapter.compileOverlay}
457
637
  clearOverlayCache={adapter.clearOverlayCache}
458
638
  watchFile={adapter.watchFile}
459
639
  fileUrl={adapter.fileUrl}
460
640
  resolveCaptionTemplate={adapter.resolveCaptionTemplate}
641
+ selectedCaptionId={selectedCaptionId ?? undefined}
642
+ onSelectCaption={handleSelectCaption}
643
+ onCaptionSegmentChange={handleCaptionSegmentChange}
461
644
  />
462
645
  </div>
463
646
  ) : (
@@ -465,7 +648,7 @@ function ReviewSurface<P extends Project>({
465
648
  )}
466
649
  </div>
467
650
 
468
- {/* Track controls bar — info + split + ripple + render */}
651
+ {/* Track controls bar — info + undo/redo + split + ripple + render */}
469
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)]">
470
653
  <button
471
654
  onClick={() => setShowControls(true)}
@@ -476,14 +659,23 @@ function ReviewSurface<P extends Project>({
476
659
  <Info size={12} />
477
660
  </button>
478
661
  <button
479
- onClick={handleUndo}
480
- disabled={!canUndo}
662
+ onClick={sync.undo}
663
+ disabled={!sync.canUndo}
481
664
  title="Undo (Cmd/Ctrl+Z)"
482
665
  aria-label="Undo"
483
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"
484
667
  >
485
668
  <Undo2 size={12} />
486
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>
487
679
  <button
488
680
  onClick={() => handleSplit()}
489
681
  title="Split at playhead (S) — selected item or all clips"
@@ -524,6 +716,16 @@ function ReviewSurface<P extends Project>({
524
716
  >
525
717
  <Crop size={12} />
526
718
  </button>
719
+ {selectedOverlayItem && (
720
+ <button
721
+ onClick={() => requestEditOverlay(selectedOverlayItem.id)}
722
+ title="Edit overlay — text, colors, and other properties"
723
+ aria-label="Edit overlay"
724
+ 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)]"
725
+ >
726
+ <Pencil size={12} />
727
+ </button>
728
+ )}
527
729
  {/* Default placement. A host that sets onProvideRenderTrigger renders
528
730
  Render in its own chrome instead, so the toolbar button is hidden. */}
529
731
  {!onProvideRenderTrigger && (
@@ -539,18 +741,21 @@ function ReviewSurface<P extends Project>({
539
741
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
540
742
  <Timeline
541
743
  project={project}
542
- currentTime={currentTime}
543
- onTimeUpdate={setCurrentTime}
744
+ clock={clock}
544
745
  onProjectChange={handleProjectChange}
545
- onCaptionEdit={(p) => { onProjectChange(p as P); save(p as P) }}
546
- 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)}
748
+ onEditOverlay={requestEditOverlay}
547
749
  selectedIds={selectedIds}
548
750
  onSelectIds={setSelectedIds}
751
+ selectedCaptionId={selectedCaptionId}
752
+ onSelectCaption={handleSelectCaption}
753
+ onCaptionSegmentChange={handleCaptionSegmentChange}
549
754
  onSplit={handleSplit}
550
755
  onCut={handleCut}
551
756
  onInspectClip={(id) => setInspecting({ kind: 'clip', id })}
552
757
  onInspectAudio={(id) => setInspecting({ kind: 'audio', id })}
553
- onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
758
+ onSaveProject={(p) => sync.mutate(() => p as P)}
554
759
  rippleMode={rippleMode}
555
760
  getWaveformChunks={getWaveformChunks}
556
761
  resolveFilePath={resolveFilePath}
@@ -654,22 +859,37 @@ function ReviewSurface<P extends Project>({
654
859
  )}
655
860
 
656
861
  {/* Caption regen modal — adapter.generateCaptions stream. On done we patch
657
- project.captions via onProjectChange only. We deliberately do NOT call
658
- save(): montaj persists the regenerated captions server-side and the
659
- 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). */}
660
866
  {regenCaptionsOpen && adapter.generateCaptions && (
661
867
  <CaptionRegenModal
662
868
  adapter={adapter}
663
869
  projectId={project.id}
664
870
  onClose={() => setRegenCaptionsOpen(false)}
665
871
  onDone={(captions) => {
666
- const next = { ...project, captions } as P
667
- onProjectChange(next)
872
+ sync.applyExternal({ ...syncProjectRef.current, captions } as P)
668
873
  setRegenCaptionsOpen(false)
669
874
  }}
670
875
  />
671
876
  )}
672
877
 
878
+ {/* Overlay props dialog — edits the selected overlay's primitive props
879
+ (text, colors, numbers, toggles). Opened from the preview double-click,
880
+ the controls bar, or a timeline block. Edits preview live (transient) and
881
+ undo as one step on Save. */}
882
+ {editingOverlayItem && (
883
+ <OverlayPropsModal
884
+ itemProps={editingOverlayItem.props ?? {}}
885
+ fileUrl={adapter.fileUrl}
886
+ uploadFile={(file) => adapter.uploadFile(file, project.id)}
887
+ onPreview={(next) => previewOverlayProps(editingOverlayItem.id, next)}
888
+ onSave={() => commitOverlayEdit()}
889
+ onClose={cancelOverlayEdit}
890
+ />
891
+ )}
892
+
673
893
  {/* Clip / audio inspector — host-rendered via render-prop seam. */}
674
894
  {inspecting && renderClipInspector?.({
675
895
  item: inspecting,