@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.
- package/package.json +1 -1
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +260 -124
- package/src/video/__tests__/VideoEditor.test.tsx +245 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/PreviewPlayer.tsx +13 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/Timeline.tsx +31 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- 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, 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
|
|
31
|
-
* edits via `onProjectChange`; persistence flows through
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 (
|
|
313
|
-
// places Render in its own header (onProvideRenderTrigger) can store the
|
|
314
|
-
// callback once without it going stale.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
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 = { ...
|
|
320
|
-
|
|
321
|
-
|
|
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.
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
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
|
|
361
|
-
//
|
|
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 =
|
|
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
|
-
...
|
|
375
|
-
tracks: (
|
|
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
|
|
381
|
-
//
|
|
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
|
-
|
|
500
|
+
sync.mutateTransient(p => withItemProps(p, id, nextProps))
|
|
384
501
|
}
|
|
385
|
-
// Commit on Save:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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)
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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={
|
|
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) =>
|
|
614
|
-
onOverlayEdit={(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) =>
|
|
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
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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.
|
|
745
|
-
|
|
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={(
|
|
888
|
+
onSave={() => commitOverlayEdit()}
|
|
753
889
|
onClose={cancelOverlayEdit}
|
|
754
890
|
/>
|
|
755
891
|
)}
|