@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
|
@@ -10,28 +10,156 @@
|
|
|
10
10
|
* The caption layer is sized at the native render resolution (1080 × 1920) and
|
|
11
11
|
* scaled down to fit the player via ResizeObserver so pixel values are 1:1 with
|
|
12
12
|
* the render output.
|
|
13
|
+
*
|
|
14
|
+
* Per-segment positioning
|
|
15
|
+
* -----------------------
|
|
16
|
+
* The caption layer itself stays `pointer-events-none` at all times — the
|
|
17
|
+
* template DOM is arbitrary per style AND changes per frame (word-by-word swaps
|
|
18
|
+
* its content every few frames), so it is not something that can be hit-tested
|
|
19
|
+
* reliably. Instead the rendered caption's bounding box is *measured* and a
|
|
20
|
+
* separate interactive selection box is drawn over it (z 50, the same layer the
|
|
21
|
+
* overlay handles use). That box is the only interactive element: clicking it
|
|
22
|
+
* selects the segment active at the playhead, dragging it moves the segment
|
|
23
|
+
* (offsetX/offsetY), and dragging a corner scales it (scale).
|
|
13
24
|
*/
|
|
14
25
|
|
|
15
|
-
import { useEffect, useRef, useState } from 'react'
|
|
26
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
16
27
|
import type { Captions } from '../../schema'
|
|
17
28
|
import type { OverlayFactory } from '../../types'
|
|
18
29
|
import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
|
|
30
|
+
import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
|
|
31
|
+
import {
|
|
32
|
+
captionDragGeometry,
|
|
33
|
+
captionDragPatch,
|
|
34
|
+
hasEscapedClickSlop,
|
|
35
|
+
readCaptionGeometry,
|
|
36
|
+
type CaptionDragState,
|
|
37
|
+
type CaptionGeometry,
|
|
38
|
+
} from './captionDragState'
|
|
19
39
|
|
|
20
40
|
const RENDER_W = 1080
|
|
21
41
|
const RENDER_H = 1920
|
|
22
42
|
|
|
43
|
+
// Screen-px padding added around the measured text so the selection box is
|
|
44
|
+
// comfortably grabbable and its outline clears the templates' glyph shadows.
|
|
45
|
+
const BOX_PAD = 6
|
|
46
|
+
|
|
47
|
+
// Corner handles, as [corner, x fraction, y fraction] of the selection box.
|
|
48
|
+
const CORNERS = [['nw', 0, 0], ['ne', 1, 0], ['sw', 0, 1], ['se', 1, 1]] as const
|
|
49
|
+
|
|
50
|
+
// Screen px of the selection box that must stay inside the frame — see the
|
|
51
|
+
// clamp in the layout effect for why a fully-offscreen box is unrecoverable.
|
|
52
|
+
const BOX_KEEP_VISIBLE = 24
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Clamp one axis of the selection box so at least `BOX_KEEP_VISIBLE` px of it
|
|
56
|
+
* overlaps the frame. `pos` is the box's leading edge relative to the frame,
|
|
57
|
+
* `size` its extent on that axis, `extent` the frame's.
|
|
58
|
+
*
|
|
59
|
+
* A box entirely inside the frame is returned untouched: the clamp range
|
|
60
|
+
* [KEEP - size, extent - KEEP] contains every such position, since a fitting
|
|
61
|
+
* box has 0 >= KEEP - size and pos <= extent - size <= extent - KEEP.
|
|
62
|
+
*/
|
|
63
|
+
export function clampVisible(pos: number, size: number, extent: number): number {
|
|
64
|
+
const min = BOX_KEEP_VISIBLE - size
|
|
65
|
+
const max = extent - BOX_KEEP_VISIBLE
|
|
66
|
+
// A frame smaller than the keep-visible margin inverts the range; leave the
|
|
67
|
+
// position alone rather than snapping it to a meaningless bound.
|
|
68
|
+
if (max < min) return pos
|
|
69
|
+
return Math.max(min, Math.min(max, pos))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Rect { left: number; top: number; width: number; height: number }
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Union of the client rects of every painted text run (and replaced element)
|
|
76
|
+
* under `root`.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately NOT `root.getBoundingClientRect()`: the template's own outermost
|
|
79
|
+
* element is a frame-sized `position: fixed; inset: 0` box (captionOuterStyle),
|
|
80
|
+
* so its rect — and the rect of any wrapper we put around it — is the entire
|
|
81
|
+
* preview, which would put the selection box around the whole frame. Walking to
|
|
82
|
+
* the text nodes is the only markup-agnostic way to find where the caption
|
|
83
|
+
* actually paints, and it costs nothing on a subtree this small.
|
|
84
|
+
*
|
|
85
|
+
* Returns null when nothing is painted (e.g. `pop` renders no word between two
|
|
86
|
+
* word windows), which is the signal to hide the selection box entirely.
|
|
87
|
+
*/
|
|
88
|
+
export function measureCaptionContentRect(root: HTMLElement): Rect | null {
|
|
89
|
+
const doc = root.ownerDocument
|
|
90
|
+
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
|
|
91
|
+
const range = doc.createRange()
|
|
92
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
|
|
93
|
+
|
|
94
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
95
|
+
let rect: DOMRect | null = null
|
|
96
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
97
|
+
if (!node.nodeValue?.trim()) continue
|
|
98
|
+
range.selectNodeContents(node)
|
|
99
|
+
rect = range.getBoundingClientRect()
|
|
100
|
+
} else {
|
|
101
|
+
// Replaced elements paint without text nodes. Everything else is a
|
|
102
|
+
// container whose box is either the frame-sized outer wrapper or a
|
|
103
|
+
// full-width anchor box — including them would defeat the point.
|
|
104
|
+
const tag = (node as Element).tagName?.toUpperCase()
|
|
105
|
+
if (tag !== 'IMG' && tag !== 'SVG' && tag !== 'VIDEO' && tag !== 'CANVAS') continue
|
|
106
|
+
rect = (node as Element).getBoundingClientRect()
|
|
107
|
+
}
|
|
108
|
+
if (!rect || (!rect.width && !rect.height)) continue
|
|
109
|
+
left = Math.min(left, rect.left)
|
|
110
|
+
top = Math.min(top, rect.top)
|
|
111
|
+
right = Math.max(right, rect.right)
|
|
112
|
+
bottom = Math.max(bottom, rect.bottom)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!Number.isFinite(left)) return null
|
|
116
|
+
return { left, top, width: right - left, height: bottom - top }
|
|
117
|
+
}
|
|
118
|
+
|
|
23
119
|
interface CaptionPreviewProps {
|
|
24
120
|
track: Captions
|
|
25
121
|
currentTime: number
|
|
26
122
|
fps: number
|
|
27
123
|
compileOverlay: (src: string) => Promise<OverlayFactory>
|
|
28
124
|
resolveCaptionTemplate?: (style: string) => string
|
|
125
|
+
/** Currently-selected caption segment id (shared with the timeline row). */
|
|
126
|
+
selectedCaptionId?: string
|
|
127
|
+
/** Click on the selection box — selects the segment active at the playhead.
|
|
128
|
+
* Accepts null for symmetry with the timeline row's copy of this callback
|
|
129
|
+
* (VideoEditor supplies one handler to both); the preview only ever passes
|
|
130
|
+
* an id, since it has no deselect affordance of its own. */
|
|
131
|
+
onSelectCaption?: (id: string | null) => void
|
|
132
|
+
/** Commit a finished drag/resize onto one segment. */
|
|
133
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
29
134
|
}
|
|
30
135
|
|
|
31
|
-
export default function CaptionPreview({
|
|
136
|
+
export default function CaptionPreview({
|
|
137
|
+
track,
|
|
138
|
+
currentTime,
|
|
139
|
+
fps,
|
|
140
|
+
compileOverlay,
|
|
141
|
+
resolveCaptionTemplate,
|
|
142
|
+
selectedCaptionId,
|
|
143
|
+
onSelectCaption,
|
|
144
|
+
onCaptionSegmentChange,
|
|
145
|
+
}: CaptionPreviewProps) {
|
|
32
146
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
147
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
148
|
+
const boxRef = useRef<HTMLDivElement>(null)
|
|
33
149
|
const [scale, setScale] = useState<number | null>(null)
|
|
34
150
|
const [factory, setFactory] = useState<OverlayFactory | null>(null)
|
|
151
|
+
const [hovered, setHovered] = useState(false)
|
|
152
|
+
|
|
153
|
+
// In-flight gesture + its live geometry. `live` is applied to the segment we
|
|
154
|
+
// hand the template (not to the project) so the caption moves under the
|
|
155
|
+
// cursor; the project is written once, on mouseup.
|
|
156
|
+
const [drag, setDrag] = useState<CaptionDragState | null>(null)
|
|
157
|
+
const [live, setLive] = useState<({ id: string } & CaptionGeometry) | null>(null)
|
|
158
|
+
const liveRef = useRef<typeof live>(null)
|
|
159
|
+
// Read through a ref so a host that passes a fresh callback identity per
|
|
160
|
+
// render can't re-subscribe the document listeners mid-gesture.
|
|
161
|
+
const changeRef = useRef(onCaptionSegmentChange)
|
|
162
|
+
changeRef.current = onCaptionSegmentChange
|
|
35
163
|
|
|
36
164
|
// Scale the 1080×1920 render layer to fit the actual player size
|
|
37
165
|
useEffect(() => {
|
|
@@ -59,6 +187,25 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
59
187
|
const frame = Math.round(currentTime * fps)
|
|
60
188
|
const lastSeg = track.segments[track.segments.length - 1]
|
|
61
189
|
|
|
190
|
+
// The segment the template is showing. Computed from `frame / fps` (not from
|
|
191
|
+
// currentTime) with the templates' own `>= start && < end` predicate, so the
|
|
192
|
+
// selection box can never address a different segment than the one on screen.
|
|
193
|
+
const t = fps > 0 ? frame / fps : 0
|
|
194
|
+
const activeSeg = track.segments.find(s => t >= s.start && t < s.end) ?? null
|
|
195
|
+
|
|
196
|
+
// Interactivity requires a stable segment id (backfilled by VideoEditor) and a
|
|
197
|
+
// host that wired at least one caption callback. Without both, the layer stays
|
|
198
|
+
// exactly as it was: a passive, click-through caption overlay.
|
|
199
|
+
const activeId = activeSeg?.id
|
|
200
|
+
const canInteract = !!activeId && !!(onSelectCaption || onCaptionSegmentChange)
|
|
201
|
+
const isSelected = canInteract && selectedCaptionId === activeId
|
|
202
|
+
|
|
203
|
+
// The box unmounts when the playhead leaves a segment, so a pending
|
|
204
|
+
// mouseleave never arrives and the hover hairline would carry over onto the
|
|
205
|
+
// next segment's box. Drop it whenever the target segment changes; a pointer
|
|
206
|
+
// that really is over the new box gets its mouseenter back on the next move.
|
|
207
|
+
useEffect(() => { setHovered(false) }, [activeId])
|
|
208
|
+
|
|
62
209
|
// Theme props for the template: everything on the track except style/segments
|
|
63
210
|
// (handled separately) and googleFonts (a render-time font-loading hint, not
|
|
64
211
|
// a template prop). Normalize the legacy lowercase `fontsize` key to the
|
|
@@ -67,10 +214,114 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
67
214
|
const themeProps: Record<string, unknown> = { ...theme }
|
|
68
215
|
if (fontsize != null) themeProps.fontSize = fontsize
|
|
69
216
|
|
|
217
|
+
// Live drag preview: overlay the in-flight geometry onto the dragged segment
|
|
218
|
+
// only. The templates read offsetX/offsetY/scale straight off the segment, so
|
|
219
|
+
// this is all it takes for the caption to follow the cursor.
|
|
220
|
+
const segments = live
|
|
221
|
+
? track.segments.map(s => (s.id === live.id ? { ...s, offsetX: live.offsetX, offsetY: live.offsetY, scale: live.scale } : s))
|
|
222
|
+
: track.segments
|
|
223
|
+
|
|
70
224
|
const element = (factory && scale !== null)
|
|
71
|
-
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments
|
|
225
|
+
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments, ...themeProps })
|
|
72
226
|
: null
|
|
73
227
|
|
|
228
|
+
// ── Gesture lifecycle ──────────────────────────────────────────────────────
|
|
229
|
+
// Mirrors useDragOverlay's shape (document-level listeners for the life of the
|
|
230
|
+
// gesture, live state during, one commit on mouseup) — see captionDragState.ts
|
|
231
|
+
// for why the maths is duplicated rather than shared.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const d = drag
|
|
234
|
+
// `scale === null` is defensive only — a gesture can start only from the
|
|
235
|
+
// box, which renders only when `element` exists, which requires a measured
|
|
236
|
+
// scale. Kept so the metrics below can never be built from a null scale.
|
|
237
|
+
if (!d || scale === null) return
|
|
238
|
+
const metrics = { previewScale: scale, renderW: RENDER_W, renderH: RENDER_H }
|
|
239
|
+
|
|
240
|
+
function onMove(e: MouseEvent) {
|
|
241
|
+
// Inside the click slop the press is still just a selection click, so
|
|
242
|
+
// clear any live geometry — that also makes "drag out and back" cancel.
|
|
243
|
+
const next = hasEscapedClickSlop(d!, e.clientX, e.clientY)
|
|
244
|
+
? { id: d!.id, ...captionDragGeometry(d!, e.clientX, e.clientY, metrics) }
|
|
245
|
+
: null
|
|
246
|
+
setLive(next)
|
|
247
|
+
liveRef.current = next
|
|
248
|
+
}
|
|
249
|
+
function onUp() {
|
|
250
|
+
// Only commit if the pointer actually moved — a bare click selects and
|
|
251
|
+
// must not push an undo step or queue a save.
|
|
252
|
+
const l = liveRef.current
|
|
253
|
+
if (l) changeRef.current?.(l.id, captionDragPatch(d!, l))
|
|
254
|
+
setDrag(null)
|
|
255
|
+
setLive(null)
|
|
256
|
+
liveRef.current = null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
document.addEventListener('mousemove', onMove)
|
|
260
|
+
document.addEventListener('mouseup', onUp)
|
|
261
|
+
return () => {
|
|
262
|
+
document.removeEventListener('mousemove', onMove)
|
|
263
|
+
document.removeEventListener('mouseup', onUp)
|
|
264
|
+
}
|
|
265
|
+
}, [drag, scale])
|
|
266
|
+
|
|
267
|
+
function startGesture(type: CaptionDragState['type'], e: React.MouseEvent) {
|
|
268
|
+
if (!activeId) return
|
|
269
|
+
// Keep the mousedown from starting a text selection and from reaching the
|
|
270
|
+
// player's play/pause layer underneath.
|
|
271
|
+
e.preventDefault()
|
|
272
|
+
e.stopPropagation()
|
|
273
|
+
// Select and begin the gesture in the same press: unlike overlays (selected
|
|
274
|
+
// from the timeline) the preview box IS the caption's selection affordance,
|
|
275
|
+
// so requiring a separate click first would just cost a click.
|
|
276
|
+
if (selectedCaptionId !== activeId) onSelectCaption?.(activeId)
|
|
277
|
+
if (!onCaptionSegmentChange) return
|
|
278
|
+
const g = readCaptionGeometry(activeSeg)
|
|
279
|
+
setDrag({
|
|
280
|
+
id: activeId, type,
|
|
281
|
+
initX: e.clientX, initY: e.clientY,
|
|
282
|
+
initOffsetX: g.offsetX, initOffsetY: g.offsetY, initScale: g.scale,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Selection box placement ────────────────────────────────────────────────
|
|
287
|
+
// Positioned imperatively (style writes on a ref) rather than through state:
|
|
288
|
+
// the caption content is re-measured after every render — which for
|
|
289
|
+
// word-by-word means every frame of playback — and routing that through
|
|
290
|
+
// setState would cost an extra render per frame and risk a measure→render→
|
|
291
|
+
// measure loop. No dependency array on purpose: it must run after *every*
|
|
292
|
+
// commit, since any of them (new frame, resize, live drag) can move the text.
|
|
293
|
+
useLayoutEffect(() => {
|
|
294
|
+
const box = boxRef.current
|
|
295
|
+
const root = wrapRef.current
|
|
296
|
+
const content = contentRef.current
|
|
297
|
+
if (!box || !root || !content) return
|
|
298
|
+
const rect = measureCaptionContentRect(content)
|
|
299
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
|
300
|
+
box.style.display = 'none'
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
// Both rects are in client coords; the root is inset-0 of the player frame,
|
|
304
|
+
// so subtracting it gives offsets in the box's own positioning context.
|
|
305
|
+
const rootRect = root.getBoundingClientRect()
|
|
306
|
+
const w = rect.width + BOX_PAD * 2
|
|
307
|
+
const h = rect.height + BOX_PAD * 2
|
|
308
|
+
// Keep a grabbable sliver of the box inside the frame. offsetX/offsetY are
|
|
309
|
+
// unbounded and there is no numeric reset control for captions (unlike
|
|
310
|
+
// overlays, which have OverlayPropsModal), so a segment dragged fully past
|
|
311
|
+
// the frame edge would have BOTH its text and its selection box clipped
|
|
312
|
+
// away by this wrapper's `overflow-hidden` — leaving no way to drag it back
|
|
313
|
+
// short of hand-editing project.json. Dragging is delta-based (see
|
|
314
|
+
// captionDragState.ts), so a clamped box still moves the segment correctly.
|
|
315
|
+
// Captions inside the frame are never clamped, so nothing else changes.
|
|
316
|
+
const left = clampVisible(rect.left - rootRect.left - BOX_PAD, w, rootRect.width)
|
|
317
|
+
const top = clampVisible(rect.top - rootRect.top - BOX_PAD, h, rootRect.height)
|
|
318
|
+
box.style.display = 'block'
|
|
319
|
+
box.style.left = `${left}px`
|
|
320
|
+
box.style.top = `${top}px`
|
|
321
|
+
box.style.width = `${w}px`
|
|
322
|
+
box.style.height = `${h}px`
|
|
323
|
+
})
|
|
324
|
+
|
|
74
325
|
return (
|
|
75
326
|
// zIndex 45 keeps captions above the active <video> (z 1) and overlay items
|
|
76
327
|
// (z `trackIdx + 12`, ≈12–20) — mirroring the final render, where the caption
|
|
@@ -81,7 +332,7 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
81
332
|
<div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden" style={{ zIndex: 45 }}>
|
|
82
333
|
<OverlayErrorBoundary label={`caption: ${track.style}`} resetKey={track.style}>
|
|
83
334
|
{element && scale !== null && (
|
|
84
|
-
<div style={{
|
|
335
|
+
<div ref={contentRef} style={{
|
|
85
336
|
position: 'absolute',
|
|
86
337
|
top: 0, left: 0,
|
|
87
338
|
width: RENDER_W,
|
|
@@ -93,6 +344,49 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
93
344
|
</div>
|
|
94
345
|
)}
|
|
95
346
|
</OverlayErrorBoundary>
|
|
347
|
+
|
|
348
|
+
{/* Selection box — the only interactive part of the caption layer. z 50
|
|
349
|
+
matches the overlay selection handles (see the layering note above);
|
|
350
|
+
the wrapper stays click-through so only the box itself catches events. */}
|
|
351
|
+
{element && canInteract && (
|
|
352
|
+
<div className="absolute inset-0" style={{ zIndex: 50, pointerEvents: 'none' }}>
|
|
353
|
+
<div
|
|
354
|
+
ref={boxRef}
|
|
355
|
+
onMouseDown={(e) => startGesture('move', e)}
|
|
356
|
+
onMouseEnter={() => setHovered(true)}
|
|
357
|
+
onMouseLeave={() => setHovered(false)}
|
|
358
|
+
style={{
|
|
359
|
+
// left/top/width/height are written by the layout effect above.
|
|
360
|
+
position: 'absolute',
|
|
361
|
+
display: 'none',
|
|
362
|
+
pointerEvents: 'auto',
|
|
363
|
+
touchAction: 'none',
|
|
364
|
+
cursor: isSelected ? (drag?.type === 'move' ? 'grabbing' : 'grab') : 'pointer',
|
|
365
|
+
// Unselected captions show a hairline on hover only — a permanent
|
|
366
|
+
// outline around every caption would be noise during review.
|
|
367
|
+
outline: isSelected ? '2px solid var(--editor-selection)'
|
|
368
|
+
: hovered ? '1px dashed var(--editor-selection)'
|
|
369
|
+
: 'none',
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{isSelected && onCaptionSegmentChange && CORNERS.map(([corner, fx, fy]) => (
|
|
373
|
+
<div
|
|
374
|
+
key={corner}
|
|
375
|
+
onMouseDown={(e) => startGesture(`resize-${corner}`, e)}
|
|
376
|
+
style={{
|
|
377
|
+
position: 'absolute',
|
|
378
|
+
left: `calc(${fx * 100}% - 6px)`,
|
|
379
|
+
top: `calc(${fy * 100}% - 6px)`,
|
|
380
|
+
width: 12, height: 12, backgroundColor: '#fff',
|
|
381
|
+
border: '1.5px solid var(--editor-selection)', borderRadius: 2,
|
|
382
|
+
cursor: corner === 'nw' || corner === 'se' ? 'nwse-resize' : 'nesw-resize',
|
|
383
|
+
pointerEvents: 'auto', touchAction: 'none',
|
|
384
|
+
}}
|
|
385
|
+
/>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
96
390
|
</div>
|
|
97
391
|
)
|
|
98
392
|
}
|
|
@@ -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
|
}
|