@bycrux/editor 0.8.9 → 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/carousel/CarouselEditor.tsx +34 -14
- package/src/carousel/SlidePropertyPanel.tsx +1 -35
- package/src/index.ts +4 -0
- package/src/schema.ts +11 -3
- package/src/ui/SwatchInput.tsx +57 -0
- package/src/ui/index.ts +2 -0
- package/src/video/VideoEditor.tsx +107 -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/TranscriptPanel.tsx +57 -0
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +85 -0
|
@@ -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
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { cn, inspectorInputClass, SwatchInput } from '../../ui'
|
|
4
|
+
import { inferOverlayPropFields, type PropField } from './overlay-prop-fields'
|
|
5
|
+
|
|
6
|
+
// Remember the dialog's dragged position for the browser session, so it re-opens
|
|
7
|
+
// where the operator left it instead of always at the default corner. Scoped to
|
|
8
|
+
// sessionStorage (per-tab, survives reloads, clears when the tab closes) and
|
|
9
|
+
// guarded so a non-browser/blocked-storage host degrades to the default.
|
|
10
|
+
const POS_KEY = 'montaj.overlayPropsModal.pos'
|
|
11
|
+
|
|
12
|
+
function loadSavedPos(): { x: number; y: number } | null {
|
|
13
|
+
try {
|
|
14
|
+
const raw = sessionStorage.getItem(POS_KEY)
|
|
15
|
+
if (!raw) return null
|
|
16
|
+
const p = JSON.parse(raw) as { x?: unknown; y?: unknown }
|
|
17
|
+
if (typeof p.x !== 'number' || typeof p.y !== 'number') return null
|
|
18
|
+
// Clamp into the current viewport so a saved spot never lands off-screen
|
|
19
|
+
// (e.g. after the window shrank), keeping the header grabbable.
|
|
20
|
+
return {
|
|
21
|
+
x: Math.min(Math.max(0, p.x), window.innerWidth - 120),
|
|
22
|
+
y: Math.min(Math.max(0, p.y), window.innerHeight - 60),
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveSavedPos(p: { x: number; y: number }) {
|
|
30
|
+
try {
|
|
31
|
+
sessionStorage.setItem(POS_KEY, JSON.stringify(p))
|
|
32
|
+
} catch {
|
|
33
|
+
/* storage blocked/unavailable — position just won't persist */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
itemProps: Record<string, unknown>
|
|
39
|
+
onSave: (next: Record<string, unknown>) => void
|
|
40
|
+
onClose: () => void
|
|
41
|
+
/** Live-preview every in-progress edit (no history/save) so the overlay
|
|
42
|
+
* updates as the operator tweaks. Commit happens on Save; Cancel reverts. */
|
|
43
|
+
onPreview?: (next: Record<string, unknown>) => void
|
|
44
|
+
/** Resolve a workspace path to a servable URL (for image thumbnails). */
|
|
45
|
+
fileUrl?: (path: string) => string
|
|
46
|
+
/** Upload a picked file, returning its new workspace path (for image "change"). */
|
|
47
|
+
uploadFile?: (file: File) => Promise<string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Image prop control: a thumbnail preview plus a file picker that uploads the
|
|
51
|
+
// chosen file (via the host adapter) and swaps the prop to the returned path.
|
|
52
|
+
// When no uploadFile is available (non-Montaj host), it degrades to an editable
|
|
53
|
+
// path text field so the value is still reachable.
|
|
54
|
+
function ImageField({
|
|
55
|
+
name,
|
|
56
|
+
value,
|
|
57
|
+
fileUrl,
|
|
58
|
+
uploadFile,
|
|
59
|
+
onChange,
|
|
60
|
+
}: {
|
|
61
|
+
name: string
|
|
62
|
+
value: string
|
|
63
|
+
fileUrl?: (path: string) => string
|
|
64
|
+
uploadFile?: (file: File) => Promise<string>
|
|
65
|
+
onChange: (v: string) => void
|
|
66
|
+
}) {
|
|
67
|
+
const [busy, setBusy] = useState(false)
|
|
68
|
+
const [err, setErr] = useState<string | null>(null)
|
|
69
|
+
const preview = value ? (fileUrl ? fileUrl(value) : value) : ''
|
|
70
|
+
|
|
71
|
+
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
|
72
|
+
const file = e.target.files?.[0]
|
|
73
|
+
e.target.value = '' // let the same file be re-picked later
|
|
74
|
+
if (!file || !uploadFile) return
|
|
75
|
+
setBusy(true)
|
|
76
|
+
setErr(null)
|
|
77
|
+
try {
|
|
78
|
+
onChange(await uploadFile(file))
|
|
79
|
+
} catch (x) {
|
|
80
|
+
setErr(String(x))
|
|
81
|
+
} finally {
|
|
82
|
+
setBusy(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex items-center gap-3">
|
|
88
|
+
{preview ? (
|
|
89
|
+
<img
|
|
90
|
+
src={preview}
|
|
91
|
+
alt={name}
|
|
92
|
+
className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border,#1f2937)] object-cover bg-black/20"
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border,#1f2937)] text-[10px] text-[var(--editor-text)]/40">
|
|
96
|
+
none
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
100
|
+
<span className="truncate font-mono text-xs text-[var(--editor-text)]/70" title={value}>
|
|
101
|
+
{value ? value.split('/').pop() : '—'}
|
|
102
|
+
</span>
|
|
103
|
+
{uploadFile ? (
|
|
104
|
+
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border,#1f2937)] px-2.5 py-1 text-xs text-[var(--editor-text)]/80 hover:bg-white/5">
|
|
105
|
+
{busy ? 'Uploading…' : 'Change…'}
|
|
106
|
+
<input
|
|
107
|
+
type="file"
|
|
108
|
+
accept="image/*"
|
|
109
|
+
aria-label={name}
|
|
110
|
+
className="hidden"
|
|
111
|
+
onChange={onPick}
|
|
112
|
+
disabled={busy}
|
|
113
|
+
/>
|
|
114
|
+
</label>
|
|
115
|
+
) : (
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
aria-label={name}
|
|
119
|
+
value={value}
|
|
120
|
+
onChange={e => onChange(e.target.value)}
|
|
121
|
+
className={cn(inspectorInputClass, 'font-mono')}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
{err && <span className="text-[11px] text-red-400">{err}</span>}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function OverlayPropsModal({ itemProps, onSave, onClose, onPreview, fileUrl, uploadFile }: Props) {
|
|
131
|
+
const [draft, setDraft] = useState<PropField[]>(() => inferOverlayPropFields(itemProps))
|
|
132
|
+
|
|
133
|
+
// Floating, draggable panel (no dimming backdrop) so the overlay stays visible
|
|
134
|
+
// and updates live while editing. `pos` is null until first drag, then a
|
|
135
|
+
// fixed viewport position; the default anchors it top-right, off the centered
|
|
136
|
+
// preview.
|
|
137
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
138
|
+
const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadSavedPos())
|
|
139
|
+
const [dragging, setDragging] = useState(false)
|
|
140
|
+
const grabRef = useRef({ dx: 0, dy: 0 })
|
|
141
|
+
const posRef = useRef<{ x: number; y: number } | null>(pos)
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const onKey = (e: KeyboardEvent) => {
|
|
145
|
+
if (e.key === 'Escape') onClose()
|
|
146
|
+
}
|
|
147
|
+
document.addEventListener('keydown', onKey)
|
|
148
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
149
|
+
}, [onClose])
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!dragging) return
|
|
153
|
+
function onMove(e: MouseEvent) {
|
|
154
|
+
const np = { x: e.clientX - grabRef.current.dx, y: e.clientY - grabRef.current.dy }
|
|
155
|
+
posRef.current = np
|
|
156
|
+
setPos(np)
|
|
157
|
+
}
|
|
158
|
+
function onUp() {
|
|
159
|
+
setDragging(false)
|
|
160
|
+
if (posRef.current) saveSavedPos(posRef.current) // persist once, on drop
|
|
161
|
+
}
|
|
162
|
+
window.addEventListener('mousemove', onMove)
|
|
163
|
+
window.addEventListener('mouseup', onUp)
|
|
164
|
+
return () => {
|
|
165
|
+
window.removeEventListener('mousemove', onMove)
|
|
166
|
+
window.removeEventListener('mouseup', onUp)
|
|
167
|
+
}
|
|
168
|
+
}, [dragging])
|
|
169
|
+
|
|
170
|
+
function startDrag(e: ReactMouseEvent) {
|
|
171
|
+
const rect = panelRef.current?.getBoundingClientRect()
|
|
172
|
+
if (!rect) return
|
|
173
|
+
grabRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }
|
|
174
|
+
setPos({ x: rect.left, y: rect.top })
|
|
175
|
+
setDragging(true)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mergeProps(fields: PropField[]): Record<string, unknown> {
|
|
179
|
+
const next: Record<string, unknown> = { ...itemProps }
|
|
180
|
+
for (const f of fields) next[f.name] = f.value
|
|
181
|
+
return next
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Update one field and immediately preview the merged props (live) so the
|
|
185
|
+
// overlay reflects the edit without waiting for Save.
|
|
186
|
+
function setField(name: string, value: string | number | boolean) {
|
|
187
|
+
const nd = draft.map(f => (f.name === name ? { ...f, value } : f))
|
|
188
|
+
setDraft(nd)
|
|
189
|
+
onPreview?.(mergeProps(nd))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const panelStyle: CSSProperties = {
|
|
193
|
+
position: 'fixed',
|
|
194
|
+
...(pos ? { left: pos.x, top: pos.y } : { top: 16, right: 16 }),
|
|
195
|
+
// The panel portals to <body>, outside the VideoEditor element that carries
|
|
196
|
+
// the --editor-* theme vars, so those are undefined here — give opaque
|
|
197
|
+
// fallbacks so the panel is solid, not see-through.
|
|
198
|
+
background: 'var(--editor-surface, #111827)',
|
|
199
|
+
borderColor: 'var(--editor-border, #1f2937)',
|
|
200
|
+
color: 'var(--editor-text, #f3f4f6)',
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return createPortal(
|
|
204
|
+
<div
|
|
205
|
+
ref={panelRef}
|
|
206
|
+
className="z-50 w-96 max-h-[85vh] border rounded-xl shadow-2xl flex flex-col overflow-hidden"
|
|
207
|
+
style={panelStyle}
|
|
208
|
+
>
|
|
209
|
+
{/* Header — drag handle */}
|
|
210
|
+
<div
|
|
211
|
+
onMouseDown={startDrag}
|
|
212
|
+
className="flex cursor-move select-none items-center justify-between px-5 py-4 border-b border-[var(--editor-border,#1f2937)]"
|
|
213
|
+
>
|
|
214
|
+
<h2 className="text-sm font-semibold">Edit overlay</h2>
|
|
215
|
+
<button
|
|
216
|
+
onMouseDown={e => e.stopPropagation()}
|
|
217
|
+
onClick={onClose}
|
|
218
|
+
aria-label="Close"
|
|
219
|
+
className="opacity-55 hover:opacity-100 transition-opacity text-lg leading-none"
|
|
220
|
+
>
|
|
221
|
+
×
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Fields */}
|
|
226
|
+
<div className="flex flex-col gap-3 px-5 py-4 overflow-y-auto">
|
|
227
|
+
{draft.map(f => (
|
|
228
|
+
<div key={f.name} className="flex flex-col gap-1">
|
|
229
|
+
<span className="text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55">
|
|
230
|
+
{f.name}
|
|
231
|
+
</span>
|
|
232
|
+
{f.kind === 'boolean' ? (
|
|
233
|
+
<input
|
|
234
|
+
type="checkbox"
|
|
235
|
+
aria-label={f.name}
|
|
236
|
+
checked={f.value as boolean}
|
|
237
|
+
onChange={e => setField(f.name, e.target.checked)}
|
|
238
|
+
className="h-4 w-4 accent-[var(--editor-accent)]"
|
|
239
|
+
/>
|
|
240
|
+
) : f.kind === 'number' ? (
|
|
241
|
+
<input
|
|
242
|
+
type="number"
|
|
243
|
+
aria-label={f.name}
|
|
244
|
+
value={f.value as number}
|
|
245
|
+
onChange={e => setField(f.name, e.target.value === '' ? 0 : Number(e.target.value))}
|
|
246
|
+
className={cn(inspectorInputClass, 'text-right')}
|
|
247
|
+
/>
|
|
248
|
+
) : f.kind === 'color' ? (
|
|
249
|
+
<SwatchInput value={String(f.value)} ariaLabel={f.name} onChange={v => setField(f.name, v)} />
|
|
250
|
+
) : f.kind === 'image' ? (
|
|
251
|
+
<ImageField
|
|
252
|
+
name={f.name}
|
|
253
|
+
value={String(f.value)}
|
|
254
|
+
fileUrl={fileUrl}
|
|
255
|
+
uploadFile={uploadFile}
|
|
256
|
+
onChange={v => setField(f.name, v)}
|
|
257
|
+
/>
|
|
258
|
+
) : (
|
|
259
|
+
<textarea
|
|
260
|
+
aria-label={f.name}
|
|
261
|
+
value={f.value as string}
|
|
262
|
+
rows={1}
|
|
263
|
+
onChange={e => setField(f.name, e.target.value)}
|
|
264
|
+
className={cn(inspectorInputClass, 'h-auto resize-y')}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
))}
|
|
269
|
+
{draft.length === 0 && (
|
|
270
|
+
<p className="text-sm text-[var(--editor-text)]/55">This overlay has no editable props.</p>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
{/* Footer */}
|
|
275
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border,#1f2937)]">
|
|
276
|
+
<button
|
|
277
|
+
onClick={onClose}
|
|
278
|
+
className="text-sm px-4 py-1.5 rounded-md border border-[var(--editor-border,#1f2937)] opacity-80 hover:opacity-100 hover:bg-white/5 transition-colors"
|
|
279
|
+
>
|
|
280
|
+
Cancel
|
|
281
|
+
</button>
|
|
282
|
+
<button
|
|
283
|
+
onClick={() => onSave(mergeProps(draft))}
|
|
284
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-accent,#6366f1)] text-[var(--editor-accent-foreground,#ffffff)] hover:opacity-90 transition-colors"
|
|
285
|
+
>
|
|
286
|
+
Save
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>,
|
|
290
|
+
document.body,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
@@ -5,8 +5,10 @@ import type { OverlayFactory } from '../../types'
|
|
|
5
5
|
import CaptionPreview from './CaptionPreview'
|
|
6
6
|
import { getOverlayDesignCanvas } from '../design-canvas'
|
|
7
7
|
import { useDragOverlay } from './useDragOverlay'
|
|
8
|
+
import type { OverlayChanges } from './useDragOverlay'
|
|
8
9
|
import OverlayItemsLayer from './OverlayItemsLayer'
|
|
9
10
|
import { useVideoPlayback } from './useVideoPlayback'
|
|
11
|
+
import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
|
|
10
12
|
import { sourceCropVideoStyle } from './sourceCropStyle'
|
|
11
13
|
import CarouselPreview from './CarouselPreview'
|
|
12
14
|
|
|
@@ -14,10 +16,10 @@ import CarouselPreview from './CarouselPreview'
|
|
|
14
16
|
|
|
15
17
|
interface PreviewPlayerProps {
|
|
16
18
|
project: Project
|
|
17
|
-
|
|
18
|
-
onTimeUpdate: (t: number) => void
|
|
19
|
+
clock: PlaybackClock
|
|
19
20
|
selectedOverlayId?: string
|
|
20
|
-
onOverlayChange?: (id: string, changes:
|
|
21
|
+
onOverlayChange?: (id: string, changes: OverlayChanges) => void
|
|
22
|
+
onEditOverlay?: (id: string) => void
|
|
21
23
|
// Adapter-injected capabilities
|
|
22
24
|
compileOverlay: (src: string) => Promise<OverlayFactory>
|
|
23
25
|
clearOverlayCache?: (src?: string) => void
|
|
@@ -28,10 +30,10 @@ interface PreviewPlayerProps {
|
|
|
28
30
|
|
|
29
31
|
export default function PreviewPlayer({
|
|
30
32
|
project,
|
|
31
|
-
|
|
32
|
-
onTimeUpdate,
|
|
33
|
+
clock,
|
|
33
34
|
selectedOverlayId,
|
|
34
35
|
onOverlayChange,
|
|
36
|
+
onEditOverlay,
|
|
35
37
|
compileOverlay,
|
|
36
38
|
clearOverlayCache,
|
|
37
39
|
watchFile,
|
|
@@ -40,6 +42,11 @@ export default function PreviewPlayer({
|
|
|
40
42
|
}: PreviewPlayerProps) {
|
|
41
43
|
if (project.projectType === 'carousel') return <CarouselPreview project={project} />
|
|
42
44
|
|
|
45
|
+
// Subscribe to the playhead store. PreviewPlayer legitimately re-renders per
|
|
46
|
+
// tick — activeClip/cropStyle memos and the video/overlay/caption children all
|
|
47
|
+
// depend on the current time.
|
|
48
|
+
const currentTime = usePlaybackTime(clock)
|
|
49
|
+
|
|
43
50
|
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
44
51
|
|
|
45
52
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
@@ -86,7 +93,7 @@ export default function PreviewPlayer({
|
|
|
86
93
|
clips,
|
|
87
94
|
tracks0NonVideo,
|
|
88
95
|
overlayTracks,
|
|
89
|
-
} = useVideoPlayback(project, currentTime,
|
|
96
|
+
} = useVideoPlayback(project, currentTime, clock.set, fileUrl)
|
|
90
97
|
|
|
91
98
|
const captionTrack = useMemo(() => project.captions, [project])
|
|
92
99
|
|
|
@@ -302,6 +309,7 @@ export default function PreviewPlayer({
|
|
|
302
309
|
renderScale={renderScale}
|
|
303
310
|
selectedOverlayId={selectedOverlayId}
|
|
304
311
|
onOverlayChange={onOverlayChange}
|
|
312
|
+
onEditOverlay={onEditOverlay}
|
|
305
313
|
containerRef={containerRef}
|
|
306
314
|
dragState={dragState}
|
|
307
315
|
setDragState={setDragState}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import type { EditorProject, VisualItem } from '../../../schema'
|
|
4
|
+
import type { OverlayFactory } from '../../../types'
|
|
5
|
+
import OverlayItemsLayer from '../OverlayItemsLayer'
|
|
6
|
+
|
|
7
|
+
// Mounts OverlayItemsLayer in isolation with the minimal prop surface it needs.
|
|
8
|
+
// Mirrors the fake-adapter capability shims from VideoEditor.test.tsx
|
|
9
|
+
// (compileOverlay resolves to a no-op factory), driving the preview layer
|
|
10
|
+
// directly so the double-click → onEditOverlay routing is exercisable. The
|
|
11
|
+
// props dialog itself now lives in VideoEditor, so the layer only reports the
|
|
12
|
+
// intent via onEditOverlay.
|
|
13
|
+
const emptySnap = { x: false, y: false, left: false, right: false, top: false, bottom: false }
|
|
14
|
+
|
|
15
|
+
function makeProject(): EditorProject {
|
|
16
|
+
return {
|
|
17
|
+
id: 'p',
|
|
18
|
+
status: 'draft',
|
|
19
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
20
|
+
tracks: [[]],
|
|
21
|
+
} as unknown as EditorProject
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderLayer(item: VisualItem, opts: { selected?: boolean } = {}) {
|
|
25
|
+
const onEditOverlay = vi.fn()
|
|
26
|
+
const onOverlayChange = vi.fn()
|
|
27
|
+
const containerRef = { current: document.createElement('div') }
|
|
28
|
+
const utils = render(
|
|
29
|
+
<OverlayItemsLayer
|
|
30
|
+
project={makeProject()}
|
|
31
|
+
currentTime={1}
|
|
32
|
+
isPlaying={false}
|
|
33
|
+
isCanvasProject={false}
|
|
34
|
+
overlayTracks={[[item]]}
|
|
35
|
+
tracks0NonVideo={[]}
|
|
36
|
+
renderScale={0.2}
|
|
37
|
+
selectedOverlayId={opts.selected === false ? undefined : item.id}
|
|
38
|
+
onOverlayChange={onOverlayChange}
|
|
39
|
+
onEditOverlay={onEditOverlay}
|
|
40
|
+
containerRef={containerRef}
|
|
41
|
+
dragState={null}
|
|
42
|
+
setDragState={vi.fn()}
|
|
43
|
+
liveOffset={null}
|
|
44
|
+
liveScale={null}
|
|
45
|
+
liveRotation={null}
|
|
46
|
+
snapGuides={emptySnap}
|
|
47
|
+
snapRotation={null}
|
|
48
|
+
compileOverlay={vi.fn(async (): Promise<OverlayFactory> => () => null)}
|
|
49
|
+
fileUrl={(pth: string) => pth}
|
|
50
|
+
/>,
|
|
51
|
+
)
|
|
52
|
+
return { ...utils, onEditOverlay, onOverlayChange }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The overlay's async compileOverlay resolves after each test's assertions,
|
|
56
|
+
// flushing a CustomOverlay state update outside act(). Harmless fake-compiler
|
|
57
|
+
// artifact; silence the console noise the way VideoEditor.test does.
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
60
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
61
|
+
})
|
|
62
|
+
afterEach(() => vi.restoreAllMocks())
|
|
63
|
+
|
|
64
|
+
const overlayItem = (over: Partial<VisualItem> = {}): VisualItem => ({
|
|
65
|
+
id: 'overlay-1',
|
|
66
|
+
type: 'overlay',
|
|
67
|
+
src: 'o.jsx',
|
|
68
|
+
start: 0,
|
|
69
|
+
end: 10,
|
|
70
|
+
props: { homeName: 'x' },
|
|
71
|
+
...over,
|
|
72
|
+
} as VisualItem)
|
|
73
|
+
|
|
74
|
+
describe('OverlayItemsLayer — double-click to edit overlay', () => {
|
|
75
|
+
it('double-clicking a selected JSX overlay requests the props dialog', () => {
|
|
76
|
+
const { container, onEditOverlay } = renderLayer(overlayItem())
|
|
77
|
+
|
|
78
|
+
// The overlay wrapper (outermost div) carries the onDoubleClick handler.
|
|
79
|
+
const wrapper = container.querySelector('div') as HTMLDivElement
|
|
80
|
+
fireEvent.dblClick(wrapper)
|
|
81
|
+
|
|
82
|
+
expect(onEditOverlay).toHaveBeenCalledWith('overlay-1')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not request editing when the overlay is not selected', () => {
|
|
86
|
+
const { container, onEditOverlay } = renderLayer(overlayItem(), { selected: false })
|
|
87
|
+
|
|
88
|
+
const wrapper = container.querySelector('div') as HTMLDivElement
|
|
89
|
+
fireEvent.dblClick(wrapper)
|
|
90
|
+
|
|
91
|
+
expect(onEditOverlay).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('no longer renders an inline text editor or an in-layer pencil / modal', () => {
|
|
95
|
+
const { container } = renderLayer(overlayItem({ props: { text: 'Hi' } }))
|
|
96
|
+
|
|
97
|
+
const wrapper = container.querySelector('div') as HTMLDivElement
|
|
98
|
+
fireEvent.dblClick(wrapper)
|
|
99
|
+
|
|
100
|
+
// The dialog is owned by VideoEditor now: the layer neither inline-edits nor
|
|
101
|
+
// renders the modal or a floating pencil itself.
|
|
102
|
+
expect(screen.queryByRole('textbox')).toBeNull()
|
|
103
|
+
expect(screen.queryByRole('heading', { name: 'Edit overlay' })).toBeNull()
|
|
104
|
+
expect(screen.queryByRole('button', { name: 'Edit overlay props' })).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import OverlayPropsModal from '../OverlayPropsModal'
|
|
4
|
+
|
|
5
|
+
const props = { homeName: 'Colombia', homeScore: 2, accent: '#FCD116', players: [{ n: 1 }] }
|
|
6
|
+
|
|
7
|
+
test('renders a field per primitive prop and commits merged props', () => {
|
|
8
|
+
const onSave = vi.fn()
|
|
9
|
+
render(<OverlayPropsModal itemProps={props} onSave={onSave} onClose={() => {}} />)
|
|
10
|
+
|
|
11
|
+
const name = screen.getByLabelText('homeName')
|
|
12
|
+
fireEvent.change(name, { target: { value: 'Argentina' } })
|
|
13
|
+
const score = screen.getByLabelText('homeScore')
|
|
14
|
+
fireEvent.change(score, { target: { value: '3' } })
|
|
15
|
+
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
|
16
|
+
|
|
17
|
+
expect(onSave).toHaveBeenCalledWith({
|
|
18
|
+
homeName: 'Argentina',
|
|
19
|
+
homeScore: 3, // number kind round-trips as number
|
|
20
|
+
accent: '#FCD116',
|
|
21
|
+
players: [{ n: 1 }], // non-primitive prop preserved untouched
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('escape closes without saving', () => {
|
|
26
|
+
const onSave = vi.fn()
|
|
27
|
+
const onClose = vi.fn()
|
|
28
|
+
render(<OverlayPropsModal itemProps={props} onSave={onSave} onClose={onClose} />)
|
|
29
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
30
|
+
expect(onClose).toHaveBeenCalled()
|
|
31
|
+
expect(onSave).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { inferOverlayPropFields } from '../overlay-prop-fields'
|
|
3
|
+
|
|
4
|
+
test('infers kinds from values', () => {
|
|
5
|
+
const fields = inferOverlayPropFields({
|
|
6
|
+
homeName: 'Colombia',
|
|
7
|
+
homeScore: 2,
|
|
8
|
+
accent: '#FCD116',
|
|
9
|
+
muted: false,
|
|
10
|
+
players: [{ name: 'x' }], // non-primitive: skipped
|
|
11
|
+
onClick: () => {}, // non-primitive: skipped
|
|
12
|
+
homeSrc: '/path/to/crest.png', // image path → image kind
|
|
13
|
+
})
|
|
14
|
+
expect(fields).toEqual([
|
|
15
|
+
{ name: 'homeName', kind: 'text', value: 'Colombia' },
|
|
16
|
+
{ name: 'homeScore', kind: 'number', value: 2 },
|
|
17
|
+
{ name: 'accent', kind: 'color', value: '#FCD116' },
|
|
18
|
+
{ name: 'muted', kind: 'boolean', value: false },
|
|
19
|
+
{ name: 'homeSrc', kind: 'image', value: '/path/to/crest.png' },
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('detects image paths (extensions, query/hash, data URLs) vs plain text', () => {
|
|
24
|
+
const f = inferOverlayPropFields({
|
|
25
|
+
a: '/assets/logo.PNG',
|
|
26
|
+
b: 'https://cdn.x/y.jpg?v=2',
|
|
27
|
+
c: 'photo.webp#frag',
|
|
28
|
+
d: 'inline.svg',
|
|
29
|
+
e: 'data:image/png;base64,AAAA',
|
|
30
|
+
f: '/notes/readme.txt', // not an image
|
|
31
|
+
g: 'just some words', // not an image
|
|
32
|
+
})
|
|
33
|
+
expect(f.map(x => x.kind)).toEqual(['image', 'image', 'image', 'image', 'image', 'text', 'text'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('preserves insertion order and handles empty/absent props', () => {
|
|
37
|
+
expect(inferOverlayPropFields({})).toEqual([])
|
|
38
|
+
expect(inferOverlayPropFields(undefined)).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('color detection is strict hex only', () => {
|
|
42
|
+
const f = inferOverlayPropFields({ a: '#ff0', b: '#FFAA00CC', c: '#xyz', d: 'red' })
|
|
43
|
+
expect(f.map(x => x.kind)).toEqual(['color', 'color', 'text', 'text'])
|
|
44
|
+
})
|