@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.
- package/package.json +1 -1
- package/src/index.ts +4 -0
- 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 +331 -111
- package/src/video/__tests__/VideoEditor.test.tsx +279 -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/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +27 -6
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/preview/overlay-prop-fields.ts +39 -0
- package/src/video/preview/useDragOverlay.ts +21 -1
- package/src/video/preview/useVideoPlayback.ts +12 -3
- package/src/video/timeline/AudioTrackRow.tsx +6 -8
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +94 -30
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -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, 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
|
|
28
|
-
* edits via `onProjectChange`; persistence flows through
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
onTimeUpdate={setCurrentTime}
|
|
337
|
+
clock={clock}
|
|
243
338
|
getWaveformChunks={getWaveformChunks}
|
|
244
339
|
resolveFilePath={resolveFilePath}
|
|
245
|
-
onSaveProject={(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
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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 (
|
|
303
|
-
// places Render in its own header (onProvideRenderTrigger) can store the
|
|
304
|
-
// callback once without it going stale.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
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
|
|
308
414
|
const openRender = useCallback(() => {
|
|
309
|
-
const final = { ...
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
543
|
-
onTimeUpdate={setCurrentTime}
|
|
744
|
+
clock={clock}
|
|
544
745
|
onProjectChange={handleProjectChange}
|
|
545
|
-
onCaptionEdit={(p) =>
|
|
546
|
-
onOverlayEdit={(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) =>
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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,
|