@bycrux/editor 0.9.0 → 0.10.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/video/VideoEditor.tsx +98 -14
- package/src/video/__tests__/VideoEditor.test.tsx +34 -0
- package/src/video/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +14 -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__/overlay-prop-fields.test.ts +44 -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/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +64 -29
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -112,6 +112,10 @@ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
|
|
|
112
112
|
export { OverlayPreview } from './preview/OverlayPreview'
|
|
113
113
|
export type { OverlayPreviewProps } from './preview/OverlayPreview'
|
|
114
114
|
|
|
115
|
+
// ── Playback clock (external playhead store) ──────────────────────────────────
|
|
116
|
+
export { createPlaybackClock, usePlaybackTime } from './video/playback-clock'
|
|
117
|
+
export type { PlaybackClock } from './video/playback-clock'
|
|
118
|
+
|
|
115
119
|
// ── Video preview ─────────────────────────────────────────────────────────────
|
|
116
120
|
export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
|
|
117
121
|
export { default as CarouselPreview } from './video/preview/CarouselPreview'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Crop, Info, Magnet, Undo2 } from 'lucide-react'
|
|
2
|
+
import { Crop, Info, Magnet, Pencil, Undo2 } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
4
|
import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
|
|
5
5
|
import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
|
|
@@ -9,9 +9,12 @@ import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './c
|
|
|
9
9
|
import { repairCaptionWords } from './captionRepair'
|
|
10
10
|
import Timeline from './timeline/Timeline'
|
|
11
11
|
import PreviewPlayer from './preview/PreviewPlayer'
|
|
12
|
+
import { createPlaybackClock, type PlaybackClock } from './playback-clock'
|
|
13
|
+
import type { OverlayChanges } from './preview/useDragOverlay'
|
|
12
14
|
import VersionPanel from './VersionPanel'
|
|
13
15
|
import RenderModal from './RenderModal'
|
|
14
16
|
import CaptionRegenModal from './CaptionRegenModal'
|
|
17
|
+
import OverlayPropsModal from './preview/OverlayPropsModal'
|
|
15
18
|
|
|
16
19
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
17
20
|
// own `Project`). Montaj passes its richer Project; the index signature on
|
|
@@ -140,7 +143,11 @@ function PendingSurface<P extends Project>({
|
|
|
140
143
|
getWaveformChunks,
|
|
141
144
|
resolveFilePath,
|
|
142
145
|
}: SurfaceProps<P> & { onBackToSetup?: () => void }) {
|
|
143
|
-
|
|
146
|
+
// The playhead lives in an external store (not useState) so ~60Hz ticks only
|
|
147
|
+
// re-render the leaves that display time — not this whole surface.
|
|
148
|
+
const clockRef = useRef<PlaybackClock | null>(null)
|
|
149
|
+
if (!clockRef.current) clockRef.current = createPlaybackClock()
|
|
150
|
+
const clock = clockRef.current
|
|
144
151
|
const [skillPath, setSkillPath] = useState<string | null>(null)
|
|
145
152
|
const [copied, setCopied] = useState(false)
|
|
146
153
|
const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
|
|
@@ -177,8 +184,7 @@ function PendingSurface<P extends Project>({
|
|
|
177
184
|
{hasTrimmedClips ? (
|
|
178
185
|
<PreviewPlayer
|
|
179
186
|
project={project}
|
|
180
|
-
|
|
181
|
-
onTimeUpdate={setCurrentTime}
|
|
187
|
+
clock={clock}
|
|
182
188
|
compileOverlay={adapter.compileOverlay}
|
|
183
189
|
clearOverlayCache={adapter.clearOverlayCache}
|
|
184
190
|
watchFile={adapter.watchFile}
|
|
@@ -238,8 +244,7 @@ function PendingSurface<P extends Project>({
|
|
|
238
244
|
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
239
245
|
<Timeline
|
|
240
246
|
project={project}
|
|
241
|
-
|
|
242
|
-
onTimeUpdate={setCurrentTime}
|
|
247
|
+
clock={clock}
|
|
243
248
|
getWaveformChunks={getWaveformChunks}
|
|
244
249
|
resolveFilePath={resolveFilePath}
|
|
245
250
|
onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
|
|
@@ -280,7 +285,12 @@ function ReviewSurface<P extends Project>({
|
|
|
280
285
|
regenEnabled?: boolean
|
|
281
286
|
isClipQueued?: (itemId: string) => boolean
|
|
282
287
|
}) {
|
|
283
|
-
|
|
288
|
+
// Playhead in an external store, not useState — ~60Hz ticks re-render only the
|
|
289
|
+
// leaves that display time (preview, scrubber, transcript) instead of the whole
|
|
290
|
+
// review surface (toolbar + timeline + every context consumer).
|
|
291
|
+
const clockRef = useRef<PlaybackClock | null>(null)
|
|
292
|
+
if (!clockRef.current) clockRef.current = createPlaybackClock()
|
|
293
|
+
const clock = clockRef.current
|
|
284
294
|
const [canUndo, setCanUndo] = useState(false)
|
|
285
295
|
const historyRef = useRef<P[]>([])
|
|
286
296
|
// Multi-select: all currently-selected timeline item ids. Single-select
|
|
@@ -343,6 +353,55 @@ function ReviewSurface<P extends Project>({
|
|
|
343
353
|
if (!cropTarget && cropMode) setCropMode(false)
|
|
344
354
|
}, [cropTarget, cropMode])
|
|
345
355
|
|
|
356
|
+
// Overlay props dialog — opened from the preview (double-click), the controls
|
|
357
|
+
// bar, or the timeline block. VideoEditor owns the state so all three surfaces
|
|
358
|
+
// share one modal. Edits ride handleOverlayChange (history + save for free).
|
|
359
|
+
const [editingOverlayId, setEditingOverlayId] = useState<string | null>(null)
|
|
360
|
+
// Project snapshot taken when the dialog opens, so history/undo and Cancel
|
|
361
|
+
// revert to the pre-edit state even though edits preview live in between.
|
|
362
|
+
const editOriginalRef = useRef<P | null>(null)
|
|
363
|
+
const requestEditOverlay = useCallback((id: string) => {
|
|
364
|
+
editOriginalRef.current = projectRef.current
|
|
365
|
+
setEditingOverlayId(id)
|
|
366
|
+
}, [])
|
|
367
|
+
const allVisualItems = (project.tracks ?? []).flat()
|
|
368
|
+
const editingOverlayItem = editingOverlayId
|
|
369
|
+
? allVisualItems.find(i => i.id === editingOverlayId) ?? null
|
|
370
|
+
: null
|
|
371
|
+
|
|
372
|
+
function withItemProps(id: string, nextProps: Record<string, unknown>): P {
|
|
373
|
+
return {
|
|
374
|
+
...project,
|
|
375
|
+
tracks: (project.tracks ?? []).map(track =>
|
|
376
|
+
track.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
|
|
377
|
+
),
|
|
378
|
+
} as P
|
|
379
|
+
}
|
|
380
|
+
// Live preview: reflect the in-progress edit via onProjectChange only — no
|
|
381
|
+
// history push, no save — so the overlay re-renders as the operator tweaks.
|
|
382
|
+
function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
|
|
383
|
+
onProjectChange(withItemProps(id, nextProps))
|
|
384
|
+
}
|
|
385
|
+
// Commit on Save: snapshot the pre-edit project for undo, then persist.
|
|
386
|
+
function commitOverlayEdit(id: string, nextProps: Record<string, unknown>) {
|
|
387
|
+
if (editOriginalRef.current) pushHistory(editOriginalRef.current)
|
|
388
|
+
const updated = withItemProps(id, nextProps)
|
|
389
|
+
onProjectChange(updated)
|
|
390
|
+
save(updated)
|
|
391
|
+
editOriginalRef.current = null
|
|
392
|
+
setEditingOverlayId(null)
|
|
393
|
+
}
|
|
394
|
+
// Cancel/Esc/close: discard the live preview by restoring the snapshot.
|
|
395
|
+
function cancelOverlayEdit() {
|
|
396
|
+
if (editOriginalRef.current) onProjectChange(editOriginalRef.current)
|
|
397
|
+
editOriginalRef.current = null
|
|
398
|
+
setEditingOverlayId(null)
|
|
399
|
+
}
|
|
400
|
+
// The primary-selected JSX overlay, if any — drives the controls-bar edit button.
|
|
401
|
+
const selectedOverlayItem = primarySelectedId
|
|
402
|
+
? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
|
|
403
|
+
: null
|
|
404
|
+
|
|
346
405
|
function pushHistory(prev: P) {
|
|
347
406
|
historyRef.current = [...historyRef.current.slice(-49), prev]
|
|
348
407
|
setCanUndo(true)
|
|
@@ -377,7 +436,7 @@ function ReviewSurface<P extends Project>({
|
|
|
377
436
|
setSelectedIds([])
|
|
378
437
|
}
|
|
379
438
|
|
|
380
|
-
function handleOverlayChange(id: string, changes:
|
|
439
|
+
function handleOverlayChange(id: string, changes: OverlayChanges) {
|
|
381
440
|
pushHistory(project)
|
|
382
441
|
const updated = {
|
|
383
442
|
...project,
|
|
@@ -390,7 +449,7 @@ function ReviewSurface<P extends Project>({
|
|
|
390
449
|
}
|
|
391
450
|
|
|
392
451
|
function handleSplit(at?: number) {
|
|
393
|
-
const updated = splitAtTime(project, at ??
|
|
452
|
+
const updated = splitAtTime(project, at ?? clock.get(), primarySelectedId ?? null)
|
|
394
453
|
if (updated === project) return
|
|
395
454
|
pushHistory(project)
|
|
396
455
|
onProjectChange(updated as P)
|
|
@@ -420,7 +479,7 @@ function ReviewSurface<P extends Project>({
|
|
|
420
479
|
}
|
|
421
480
|
document.addEventListener('keydown', onKey)
|
|
422
481
|
return () => document.removeEventListener('keydown', onKey)
|
|
423
|
-
}, [project,
|
|
482
|
+
}, [project, primarySelectedId, canUndo])
|
|
424
483
|
|
|
425
484
|
async function handleRestoreVersion(hash: string) {
|
|
426
485
|
if (!adapter.restoreVersion) return
|
|
@@ -449,10 +508,10 @@ function ReviewSurface<P extends Project>({
|
|
|
449
508
|
>
|
|
450
509
|
<PreviewPlayer
|
|
451
510
|
project={project}
|
|
452
|
-
|
|
453
|
-
onTimeUpdate={setCurrentTime}
|
|
511
|
+
clock={clock}
|
|
454
512
|
selectedOverlayId={primarySelectedId ?? undefined}
|
|
455
513
|
onOverlayChange={handleOverlayChange}
|
|
514
|
+
onEditOverlay={requestEditOverlay}
|
|
456
515
|
compileOverlay={adapter.compileOverlay}
|
|
457
516
|
clearOverlayCache={adapter.clearOverlayCache}
|
|
458
517
|
watchFile={adapter.watchFile}
|
|
@@ -524,6 +583,16 @@ function ReviewSurface<P extends Project>({
|
|
|
524
583
|
>
|
|
525
584
|
<Crop size={12} />
|
|
526
585
|
</button>
|
|
586
|
+
{selectedOverlayItem && (
|
|
587
|
+
<button
|
|
588
|
+
onClick={() => requestEditOverlay(selectedOverlayItem.id)}
|
|
589
|
+
title="Edit overlay — text, colors, and other properties"
|
|
590
|
+
aria-label="Edit overlay"
|
|
591
|
+
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)]"
|
|
592
|
+
>
|
|
593
|
+
<Pencil size={12} />
|
|
594
|
+
</button>
|
|
595
|
+
)}
|
|
527
596
|
{/* Default placement. A host that sets onProvideRenderTrigger renders
|
|
528
597
|
Render in its own chrome instead, so the toolbar button is hidden. */}
|
|
529
598
|
{!onProvideRenderTrigger && (
|
|
@@ -539,11 +608,11 @@ function ReviewSurface<P extends Project>({
|
|
|
539
608
|
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
540
609
|
<Timeline
|
|
541
610
|
project={project}
|
|
542
|
-
|
|
543
|
-
onTimeUpdate={setCurrentTime}
|
|
611
|
+
clock={clock}
|
|
544
612
|
onProjectChange={handleProjectChange}
|
|
545
613
|
onCaptionEdit={(p) => { onProjectChange(p as P); save(p as P) }}
|
|
546
614
|
onOverlayEdit={(p) => { onProjectChange(p as P); save(p as P) }}
|
|
615
|
+
onEditOverlay={requestEditOverlay}
|
|
547
616
|
selectedIds={selectedIds}
|
|
548
617
|
onSelectIds={setSelectedIds}
|
|
549
618
|
onSplit={handleSplit}
|
|
@@ -670,6 +739,21 @@ function ReviewSurface<P extends Project>({
|
|
|
670
739
|
/>
|
|
671
740
|
)}
|
|
672
741
|
|
|
742
|
+
{/* Overlay props dialog — edits the selected overlay's primitive props
|
|
743
|
+
(text, colors, numbers, toggles). Opened from the preview double-click,
|
|
744
|
+
the controls bar, or a timeline block. Rides handleOverlayChange, so
|
|
745
|
+
edits preview live and undo like any other change. */}
|
|
746
|
+
{editingOverlayItem && (
|
|
747
|
+
<OverlayPropsModal
|
|
748
|
+
itemProps={editingOverlayItem.props ?? {}}
|
|
749
|
+
fileUrl={adapter.fileUrl}
|
|
750
|
+
uploadFile={(file) => adapter.uploadFile(file, project.id)}
|
|
751
|
+
onPreview={(next) => previewOverlayProps(editingOverlayItem.id, next)}
|
|
752
|
+
onSave={(next) => commitOverlayEdit(editingOverlayItem.id, next)}
|
|
753
|
+
onClose={cancelOverlayEdit}
|
|
754
|
+
/>
|
|
755
|
+
)}
|
|
756
|
+
|
|
673
757
|
{/* Clip / audio inspector — host-rendered via render-prop seam. */}
|
|
674
758
|
{inspecting && renderClipInspector?.({
|
|
675
759
|
item: inspecting,
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
VersionEntry,
|
|
9
9
|
WaveformChunk,
|
|
10
10
|
} from '../../types'
|
|
11
|
+
import type { VisualItem } from '../../schema'
|
|
12
|
+
import type { OverlayChanges } from '../preview/useDragOverlay'
|
|
11
13
|
import VideoEditor from '../VideoEditor'
|
|
12
14
|
|
|
13
15
|
// ── Fake adapter ──────────────────────────────────────────────────────────────
|
|
@@ -210,4 +212,36 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
210
212
|
// The copy-able prompt text should include the skill path
|
|
211
213
|
await findByText(/skills\/video-skill\.md/i)
|
|
212
214
|
})
|
|
215
|
+
|
|
216
|
+
// `handleOverlayChange` (VideoEditor.tsx) is a private closure whose `changes`
|
|
217
|
+
// param is typed as `OverlayChanges` (useDragOverlay.ts) and whose body is
|
|
218
|
+
// exactly `{ ...item, ...changes }`. No control in the preview layer currently
|
|
219
|
+
// drives a `props` payload through it — the crop modal only ever sends
|
|
220
|
+
// sourceCrop/sourceWidth/sourceHeight, and drag/resize/rotate only ever send
|
|
221
|
+
// offsetX/offsetY/scale/rotation — so there is no DOM path in this harness
|
|
222
|
+
// that reaches a `props` change via a mounted <VideoEditor>. This test instead
|
|
223
|
+
// exercises the real merge contract directly: `changes` is typed against the
|
|
224
|
+
// actual `OverlayChanges` export (so `props` only compiles once useDragOverlay
|
|
225
|
+
// declares it), and the assertion applies the identical spread
|
|
226
|
+
// `handleOverlayChange` performs.
|
|
227
|
+
it('handleOverlayChange merges a props payload into the matching item without touching other fields', () => {
|
|
228
|
+
const item: VisualItem = {
|
|
229
|
+
id: 'overlay-1',
|
|
230
|
+
type: 'overlay',
|
|
231
|
+
src: 'overlay.jsx',
|
|
232
|
+
start: 0,
|
|
233
|
+
end: 4,
|
|
234
|
+
offsetX: 5,
|
|
235
|
+
offsetY: 10,
|
|
236
|
+
props: { text: 'Old text' },
|
|
237
|
+
}
|
|
238
|
+
const changes: OverlayChanges = { props: { text: 'New text' } }
|
|
239
|
+
|
|
240
|
+
// Mirrors VideoEditor.tsx handleOverlayChange's item-update line exactly.
|
|
241
|
+
const merged = { ...item, ...changes }
|
|
242
|
+
|
|
243
|
+
expect(merged.props).toEqual({ text: 'New text' })
|
|
244
|
+
expect(merged.offsetX).toBe(5)
|
|
245
|
+
expect(merged.offsetY).toBe(10)
|
|
246
|
+
})
|
|
213
247
|
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { render, act, screen } from '@testing-library/react'
|
|
2
|
+
import { createPlaybackClock, usePlaybackTime } from '../playback-clock'
|
|
3
|
+
|
|
4
|
+
test('set notifies subscribers and get returns latest', () => {
|
|
5
|
+
const clock = createPlaybackClock()
|
|
6
|
+
let seen = -1
|
|
7
|
+
const unsub = clock.subscribe(() => { seen = clock.get() })
|
|
8
|
+
clock.set(1.5)
|
|
9
|
+
expect(seen).toBe(1.5)
|
|
10
|
+
expect(clock.get()).toBe(1.5)
|
|
11
|
+
unsub()
|
|
12
|
+
clock.set(2)
|
|
13
|
+
expect(seen).toBe(1.5)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('set with an unchanged value does not notify', () => {
|
|
17
|
+
const clock = createPlaybackClock()
|
|
18
|
+
let calls = 0
|
|
19
|
+
clock.subscribe(() => calls++)
|
|
20
|
+
clock.set(1); clock.set(1)
|
|
21
|
+
expect(calls).toBe(1)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('only subscribing components re-render on tick', () => {
|
|
25
|
+
const clock = createPlaybackClock()
|
|
26
|
+
let siblingRenders = 0
|
|
27
|
+
|
|
28
|
+
function TimeDisplay() {
|
|
29
|
+
const t = usePlaybackTime(clock)
|
|
30
|
+
return <span data-testid="t">{t}</span>
|
|
31
|
+
}
|
|
32
|
+
function Sibling() {
|
|
33
|
+
siblingRenders++
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
function Root() {
|
|
37
|
+
return (<><TimeDisplay /><Sibling /></>)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
render(<Root />)
|
|
41
|
+
const before = siblingRenders
|
|
42
|
+
act(() => { clock.set(3.25) })
|
|
43
|
+
expect(screen.getByTestId('t').textContent).toBe('3.25')
|
|
44
|
+
expect(siblingRenders).toBe(before)
|
|
45
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface PlaybackClock {
|
|
4
|
+
get(): number
|
|
5
|
+
set(t: number): void
|
|
6
|
+
subscribe(cb: () => void): () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** External store for the playhead. Written ~60Hz by the playback engine;
|
|
10
|
+
* only components that render the time subscribe (usePlaybackTime). Event
|
|
11
|
+
* handlers that just need "time right now" call clock.get() — no re-renders. */
|
|
12
|
+
export function createPlaybackClock(initial = 0): PlaybackClock {
|
|
13
|
+
let time = initial
|
|
14
|
+
const subs = new Set<() => void>()
|
|
15
|
+
return {
|
|
16
|
+
get: () => time,
|
|
17
|
+
set(t) {
|
|
18
|
+
if (t === time) return
|
|
19
|
+
time = t
|
|
20
|
+
subs.forEach((cb) => cb())
|
|
21
|
+
},
|
|
22
|
+
subscribe(cb) {
|
|
23
|
+
subs.add(cb)
|
|
24
|
+
return () => { subs.delete(cb) }
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function usePlaybackTime(clock: PlaybackClock): number {
|
|
30
|
+
return useSyncExternalStore(clock.subscribe, clock.get, clock.get)
|
|
31
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
3
3
|
import type { OverlayFactory } from '../../types'
|
|
4
4
|
import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
|
|
5
5
|
import { getOverlayDesignCanvas } from '../design-canvas'
|
|
6
6
|
import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
|
|
7
|
-
import type { Corner } from './useDragOverlay'
|
|
7
|
+
import type { Corner, OverlayChanges } from './useDragOverlay'
|
|
8
8
|
import type { useDragOverlay } from './useDragOverlay'
|
|
9
9
|
|
|
10
10
|
const VIDEO_PRELOAD_S = 0.4 // mount this many seconds before item.start so the frame is ready
|
|
@@ -171,6 +171,14 @@ function CustomOverlay({
|
|
|
171
171
|
return () => unwatch()
|
|
172
172
|
}, [src, watchFile, compile])
|
|
173
173
|
|
|
174
|
+
// Deep-clone/rewrite the props once per props change instead of every frame.
|
|
175
|
+
// Live prop edits (OverlayPropsModal → VideoEditor.withItemProps) always
|
|
176
|
+
// produce a new `props` object reference, so this recomputes on every edit.
|
|
177
|
+
const resolvedProps = useMemo(
|
|
178
|
+
() => resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>,
|
|
179
|
+
[props, fileUrl],
|
|
180
|
+
)
|
|
181
|
+
|
|
174
182
|
if (error) {
|
|
175
183
|
return (
|
|
176
184
|
<div className="absolute bottom-4 left-4 right-4 pointer-events-none">
|
|
@@ -183,8 +191,6 @@ function CustomOverlay({
|
|
|
183
191
|
|
|
184
192
|
if (!factory) return null
|
|
185
193
|
|
|
186
|
-
const resolvedProps = resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>
|
|
187
|
-
|
|
188
194
|
const element = factory(frame, fps, durationFrames, resolvedProps)
|
|
189
195
|
if (!element) return null
|
|
190
196
|
|
|
@@ -288,7 +294,9 @@ interface OverlayItemsLayerProps {
|
|
|
288
294
|
tracks0NonVideo: VisualItem[]
|
|
289
295
|
renderScale: number
|
|
290
296
|
selectedOverlayId?: string
|
|
291
|
-
onOverlayChange?: (id: string, changes:
|
|
297
|
+
onOverlayChange?: (id: string, changes: OverlayChanges) => void
|
|
298
|
+
/** Open the props dialog for an overlay (owned by VideoEditor). */
|
|
299
|
+
onEditOverlay?: (id: string) => void
|
|
292
300
|
containerRef: React.RefObject<HTMLDivElement | null>
|
|
293
301
|
// from useDragOverlay
|
|
294
302
|
dragState: ReturnType<typeof useDragOverlay>['dragState']
|
|
@@ -315,6 +323,7 @@ export default function OverlayItemsLayer({
|
|
|
315
323
|
renderScale,
|
|
316
324
|
selectedOverlayId,
|
|
317
325
|
onOverlayChange,
|
|
326
|
+
onEditOverlay,
|
|
318
327
|
containerRef,
|
|
319
328
|
dragState,
|
|
320
329
|
setDragState,
|
|
@@ -330,6 +339,9 @@ export default function OverlayItemsLayer({
|
|
|
330
339
|
}: OverlayItemsLayerProps) {
|
|
331
340
|
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
332
341
|
|
|
342
|
+
// Interactive tracks — in canvas mode this includes track 0; otherwise overlays only.
|
|
343
|
+
const interactiveTracks = isCanvasProject ? project.tracks ?? [] : overlayTracks
|
|
344
|
+
|
|
333
345
|
return (
|
|
334
346
|
<>
|
|
335
347
|
{/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
|
|
@@ -395,7 +407,7 @@ export default function OverlayItemsLayer({
|
|
|
395
407
|
})}
|
|
396
408
|
|
|
397
409
|
{/* All interactive tracks — in canvas mode this includes track 0; otherwise overlays only */}
|
|
398
|
-
{
|
|
410
|
+
{interactiveTracks.map((trackItems, trackIdx) =>
|
|
399
411
|
trackItems.map((item) => {
|
|
400
412
|
const visible = currentTime >= item.start && currentTime < item.end
|
|
401
413
|
// Pre-mount video items slightly before their start so the frame is ready (no flash)
|
|
@@ -433,6 +445,14 @@ export default function OverlayItemsLayer({
|
|
|
433
445
|
setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
|
|
434
446
|
}
|
|
435
447
|
|
|
448
|
+
// Double-click a selected JSX overlay opens the props dialog (owned by
|
|
449
|
+
// VideoEditor). stopPropagation keeps it from re-triggering startMove.
|
|
450
|
+
function handleDoubleClick(e: React.MouseEvent) {
|
|
451
|
+
if (!isSel) return
|
|
452
|
+
e.stopPropagation()
|
|
453
|
+
if (item.type === 'overlay' && item.src) onEditOverlay?.(item.id)
|
|
454
|
+
}
|
|
455
|
+
|
|
436
456
|
// zIndex: canvas mode track 0 sits just above the play-toggle div (10), others stack above
|
|
437
457
|
const zIndex = isCanvasProject ? trackIdx + 11 : trackIdx + 12
|
|
438
458
|
|
|
@@ -501,14 +521,16 @@ export default function OverlayItemsLayer({
|
|
|
501
521
|
const frame = Math.round((currentTime - item.start) * fps)
|
|
502
522
|
const durationFrames = Math.round((item.end - item.start) * fps)
|
|
503
523
|
return (
|
|
504
|
-
<div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
|
|
524
|
+
<div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove} onDoubleClick={handleDoubleClick}>
|
|
505
525
|
{/* Render at native 1080×1920 then scale down to match container */}
|
|
506
|
-
<div
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
526
|
+
<div
|
|
527
|
+
style={{
|
|
528
|
+
position: 'absolute', top: 0, left: 0,
|
|
529
|
+
width: RENDER_W, height: RENDER_H,
|
|
530
|
+
transform: `scale(${renderScale})`, transformOrigin: 'top left',
|
|
531
|
+
pointerEvents: 'none',
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
512
534
|
<OverlayErrorBoundary
|
|
513
535
|
label={item.src.split('/').pop() ?? item.src}
|
|
514
536
|
watchPath={item.src}
|
|
@@ -600,6 +622,7 @@ export default function OverlayItemsLayer({
|
|
|
600
622
|
</svg>
|
|
601
623
|
</div>
|
|
602
624
|
)}
|
|
625
|
+
|
|
603
626
|
</>
|
|
604
627
|
)
|
|
605
628
|
}
|