@bycrux/editor 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +260 -124
- package/src/video/__tests__/VideoEditor.test.tsx +245 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/__tests__/captionSnap.test.ts +87 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/preview/CaptionPreview.tsx +310 -4
- package/src/video/preview/PreviewPlayer.tsx +13 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +168 -0
- package/src/video/preview/captionDragState.ts +201 -0
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/Timeline.tsx +31 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- package/src/video/timeline/makeCaptionEdit.ts +33 -10
|
@@ -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,61 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
93
344
|
</div>
|
|
94
345
|
)}
|
|
95
346
|
</OverlayErrorBoundary>
|
|
347
|
+
|
|
348
|
+
{/* Centre/middle snap guides. Same amber hairlines, same z 50, and the
|
|
349
|
+
same `drag.type === 'move'` gate the overlay layer uses, so a snapped
|
|
350
|
+
caption reads identically to a snapped overlay. Only the centre pair
|
|
351
|
+
exists here — caption edge snap is deliberately not implemented (its
|
|
352
|
+
geometry does not transfer; see captionDragState.ts). */}
|
|
353
|
+
{drag?.type === 'move' && live?.snapX && (
|
|
354
|
+
<div className="absolute top-0 bottom-0 left-1/2 w-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
|
|
355
|
+
)}
|
|
356
|
+
{drag?.type === 'move' && live?.snapY && (
|
|
357
|
+
<div className="absolute left-0 right-0 top-1/2 h-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
|
|
358
|
+
)}
|
|
359
|
+
|
|
360
|
+
{/* Selection box — the only interactive part of the caption layer. z 50
|
|
361
|
+
matches the overlay selection handles (see the layering note above);
|
|
362
|
+
the wrapper stays click-through so only the box itself catches events. */}
|
|
363
|
+
{element && canInteract && (
|
|
364
|
+
<div className="absolute inset-0" style={{ zIndex: 50, pointerEvents: 'none' }}>
|
|
365
|
+
<div
|
|
366
|
+
ref={boxRef}
|
|
367
|
+
onMouseDown={(e) => startGesture('move', e)}
|
|
368
|
+
onMouseEnter={() => setHovered(true)}
|
|
369
|
+
onMouseLeave={() => setHovered(false)}
|
|
370
|
+
style={{
|
|
371
|
+
// left/top/width/height are written by the layout effect above.
|
|
372
|
+
position: 'absolute',
|
|
373
|
+
display: 'none',
|
|
374
|
+
pointerEvents: 'auto',
|
|
375
|
+
touchAction: 'none',
|
|
376
|
+
cursor: isSelected ? (drag?.type === 'move' ? 'grabbing' : 'grab') : 'pointer',
|
|
377
|
+
// Unselected captions show a hairline on hover only — a permanent
|
|
378
|
+
// outline around every caption would be noise during review.
|
|
379
|
+
outline: isSelected ? '2px solid var(--editor-selection)'
|
|
380
|
+
: hovered ? '1px dashed var(--editor-selection)'
|
|
381
|
+
: 'none',
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
{isSelected && onCaptionSegmentChange && CORNERS.map(([corner, fx, fy]) => (
|
|
385
|
+
<div
|
|
386
|
+
key={corner}
|
|
387
|
+
onMouseDown={(e) => startGesture(`resize-${corner}`, e)}
|
|
388
|
+
style={{
|
|
389
|
+
position: 'absolute',
|
|
390
|
+
left: `calc(${fx * 100}% - 6px)`,
|
|
391
|
+
top: `calc(${fy * 100}% - 6px)`,
|
|
392
|
+
width: 12, height: 12, backgroundColor: '#fff',
|
|
393
|
+
border: '1.5px solid var(--editor-selection)', borderRadius: 2,
|
|
394
|
+
cursor: corner === 'nw' || corner === 'se' ? 'nwse-resize' : 'nesw-resize',
|
|
395
|
+
pointerEvents: 'auto', touchAction: 'none',
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
))}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
96
402
|
</div>
|
|
97
403
|
)
|
|
98
404
|
}
|
|
@@ -6,6 +6,7 @@ import CaptionPreview from './CaptionPreview'
|
|
|
6
6
|
import { getOverlayDesignCanvas } from '../design-canvas'
|
|
7
7
|
import { useDragOverlay } from './useDragOverlay'
|
|
8
8
|
import type { OverlayChanges } from './useDragOverlay'
|
|
9
|
+
import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
|
|
9
10
|
import OverlayItemsLayer from './OverlayItemsLayer'
|
|
10
11
|
import { useVideoPlayback } from './useVideoPlayback'
|
|
11
12
|
import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
|
|
@@ -26,6 +27,12 @@ interface PreviewPlayerProps {
|
|
|
26
27
|
watchFile?: (path: string, onChange: () => void) => () => void
|
|
27
28
|
fileUrl: (path: string) => string
|
|
28
29
|
resolveCaptionTemplate?: (style: string) => string
|
|
30
|
+
// Caption selection/positioning — pass-through to CaptionPreview. All optional:
|
|
31
|
+
// a host that omits them (PendingSurface) gets the historical passive,
|
|
32
|
+
// click-through caption layer.
|
|
33
|
+
selectedCaptionId?: string
|
|
34
|
+
onSelectCaption?: (id: string | null) => void
|
|
35
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export default function PreviewPlayer({
|
|
@@ -39,6 +46,9 @@ export default function PreviewPlayer({
|
|
|
39
46
|
watchFile,
|
|
40
47
|
fileUrl,
|
|
41
48
|
resolveCaptionTemplate,
|
|
49
|
+
selectedCaptionId,
|
|
50
|
+
onSelectCaption,
|
|
51
|
+
onCaptionSegmentChange,
|
|
42
52
|
}: PreviewPlayerProps) {
|
|
43
53
|
if (project.projectType === 'carousel') return <CarouselPreview project={project} />
|
|
44
54
|
|
|
@@ -334,6 +344,9 @@ export default function PreviewPlayer({
|
|
|
334
344
|
fps={project.settings?.fps ?? 30}
|
|
335
345
|
compileOverlay={compileOverlay}
|
|
336
346
|
resolveCaptionTemplate={resolveCaptionTemplate}
|
|
347
|
+
selectedCaptionId={selectedCaptionId}
|
|
348
|
+
onSelectCaption={onSelectCaption}
|
|
349
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
337
350
|
/>
|
|
338
351
|
)}
|
|
339
352
|
</div>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
CAPTION_DRAG_SLOP_PX,
|
|
4
|
+
CAPTION_MAX_SCALE,
|
|
5
|
+
CAPTION_MIN_SCALE,
|
|
6
|
+
captionDragGeometry,
|
|
7
|
+
captionDragPatch,
|
|
8
|
+
hasEscapedClickSlop,
|
|
9
|
+
readCaptionGeometry,
|
|
10
|
+
screenDeltaToFramePercent,
|
|
11
|
+
type CaptionDragState,
|
|
12
|
+
type CaptionFrameMetrics,
|
|
13
|
+
} from '../captionDragState'
|
|
14
|
+
|
|
15
|
+
const RENDER_W = 1080
|
|
16
|
+
const RENDER_H = 1920
|
|
17
|
+
|
|
18
|
+
/** Preview scale for a player rendered `px` wide (the ResizeObserver value). */
|
|
19
|
+
function metrics(onScreenWidth: number): CaptionFrameMetrics {
|
|
20
|
+
return { previewScale: onScreenWidth / RENDER_W, renderW: RENDER_W, renderH: RENDER_H }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function drag(over: Partial<CaptionDragState> = {}): CaptionDragState {
|
|
24
|
+
return {
|
|
25
|
+
id: 'cap-0', type: 'move',
|
|
26
|
+
initX: 100, initY: 200,
|
|
27
|
+
initOffsetX: 0, initOffsetY: 0, initScale: 1,
|
|
28
|
+
...over,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('readCaptionGeometry', () => {
|
|
33
|
+
it('applies the schema defaults', () => {
|
|
34
|
+
expect(readCaptionGeometry(undefined)).toEqual({ offsetX: 0, offsetY: 0, scale: 1 })
|
|
35
|
+
expect(readCaptionGeometry({})).toEqual({ offsetX: 0, offsetY: 0, scale: 1 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('preserves explicit zeros rather than falling back to the default', () => {
|
|
39
|
+
expect(readCaptionGeometry({ offsetX: 0, offsetY: -12, scale: 0.5 }))
|
|
40
|
+
.toEqual({ offsetX: 0, offsetY: -12, scale: 0.5 })
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('screenDeltaToFramePercent', () => {
|
|
45
|
+
it('divides by the preview scale then converts to percent of frame', () => {
|
|
46
|
+
// Half-size preview: 54 screen px = 108 design px = 10% of 1080.
|
|
47
|
+
const { dx, dy } = screenDeltaToFramePercent(54, 96, metrics(540))
|
|
48
|
+
expect(dx).toBeCloseTo(10, 10)
|
|
49
|
+
expect(dy).toBeCloseTo(10, 10)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('is independent of window size — the same fraction of the player is the same percent', () => {
|
|
53
|
+
// A drag of 25% of the player width must read 25% at any preview size.
|
|
54
|
+
for (const w of [270, 540, 1080, 1600]) {
|
|
55
|
+
const { dx } = screenDeltaToFramePercent(w * 0.25, 0, metrics(w))
|
|
56
|
+
expect(dx).toBeCloseTo(25, 10)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses the frame HEIGHT for the vertical axis (non-square frames)', () => {
|
|
61
|
+
// 192 screen px at 1:1 = 192 design px = 10% of 1920, not 17.8% of 1080.
|
|
62
|
+
const { dy } = screenDeltaToFramePercent(0, 192, metrics(RENDER_W))
|
|
63
|
+
expect(dy).toBeCloseTo(10, 10)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns a zero delta for degenerate metrics instead of NaN/Infinity', () => {
|
|
67
|
+
expect(screenDeltaToFramePercent(50, 50, { previewScale: 0, renderW: RENDER_W, renderH: RENDER_H })).toEqual({ dx: 0, dy: 0 })
|
|
68
|
+
expect(screenDeltaToFramePercent(50, 50, { previewScale: 1, renderW: 0, renderH: RENDER_H })).toEqual({ dx: 0, dy: 0 })
|
|
69
|
+
expect(screenDeltaToFramePercent(50, 50, { previewScale: NaN, renderW: RENDER_W, renderH: RENDER_H })).toEqual({ dx: 0, dy: 0 })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('captionDragGeometry — move', () => {
|
|
74
|
+
it('adds the total delta to the offset captured at drag start', () => {
|
|
75
|
+
const d = drag({ initOffsetX: 5, initOffsetY: -10 })
|
|
76
|
+
// 270 screen px right at half scale = 540 design px = 50% of 1080.
|
|
77
|
+
const g = captionDragGeometry(d, d.initX + 270, d.initY + 192, metrics(540))
|
|
78
|
+
expect(g.offsetX).toBeCloseTo(5 + 50, 10)
|
|
79
|
+
expect(g.offsetY).toBeCloseTo(-10 + 20, 10)
|
|
80
|
+
expect(g.scale).toBe(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('accumulates from the gesture start, not from the previous sample (no drift)', () => {
|
|
84
|
+
const d = drag({ initOffsetX: 5 })
|
|
85
|
+
const m = metrics(540)
|
|
86
|
+
// Sampling the same pointer position twice, and sampling it after a detour,
|
|
87
|
+
// must both give the same answer — the function is a pure map from pointer
|
|
88
|
+
// position to geometry.
|
|
89
|
+
const a = captionDragGeometry(d, 400, 200, m)
|
|
90
|
+
captionDragGeometry(d, 999, 999, m)
|
|
91
|
+
const b = captionDragGeometry(d, 400, 200, m)
|
|
92
|
+
expect(b).toEqual(a)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is a no-op at the starting pointer position', () => {
|
|
96
|
+
// 12/34 are both well outside the centre-snap radius, so the geometry is
|
|
97
|
+
// the untouched starting offsets. The move branch also reports whether each
|
|
98
|
+
// axis is currently snapped (see captionSnap.test.ts) — asserted here as
|
|
99
|
+
// false so this stays a genuine no-op check rather than an assertion about
|
|
100
|
+
// the return shape.
|
|
101
|
+
const d = drag({ initOffsetX: 12, initOffsetY: 34, initScale: 1.5 })
|
|
102
|
+
expect(captionDragGeometry(d, d.initX, d.initY, metrics(540)))
|
|
103
|
+
.toEqual({ offsetX: 12, offsetY: 34, scale: 1.5, snapX: false, snapY: false })
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('captionDragGeometry — corner resize', () => {
|
|
108
|
+
const m = metrics(RENDER_W) // 1:1, so screen px == design px
|
|
109
|
+
|
|
110
|
+
it('grows when dragging a corner outward and shrinks when dragging inward', () => {
|
|
111
|
+
const se = drag({ type: 'resize-se' })
|
|
112
|
+
expect(captionDragGeometry(se, se.initX + 108, se.initY, m).scale).toBeCloseTo(1.1, 10)
|
|
113
|
+
expect(captionDragGeometry(se, se.initX - 108, se.initY, m).scale).toBeCloseTo(0.9, 10)
|
|
114
|
+
|
|
115
|
+
// The NW corner's outward direction is up-left, so the signs invert.
|
|
116
|
+
const nw = drag({ type: 'resize-nw' })
|
|
117
|
+
expect(captionDragGeometry(nw, nw.initX - 108, nw.initY, m).scale).toBeCloseTo(1.1, 10)
|
|
118
|
+
expect(captionDragGeometry(nw, nw.initX + 108, nw.initY, m).scale).toBeCloseTo(0.9, 10)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('sums both axes and scales proportionally from the starting scale', () => {
|
|
122
|
+
const d = drag({ type: 'resize-se', initScale: 2 })
|
|
123
|
+
// +10% of width and +10% of height → delta 0.2 → 2 * 1.2.
|
|
124
|
+
const g = captionDragGeometry(d, d.initX + 108, d.initY + 192, m)
|
|
125
|
+
expect(g.scale).toBeCloseTo(2.4, 10)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('leaves the offsets untouched', () => {
|
|
129
|
+
const d = drag({ type: 'resize-ne', initOffsetX: 7, initOffsetY: -3 })
|
|
130
|
+
const g = captionDragGeometry(d, d.initX + 500, d.initY - 500, m)
|
|
131
|
+
expect(g.offsetX).toBe(7)
|
|
132
|
+
expect(g.offsetY).toBe(-3)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('clamps to the scale bounds', () => {
|
|
136
|
+
const shrink = drag({ type: 'resize-se', initScale: 1 })
|
|
137
|
+
expect(captionDragGeometry(shrink, shrink.initX - 100_000, shrink.initY, m).scale).toBe(CAPTION_MIN_SCALE)
|
|
138
|
+
const grow = drag({ type: 'resize-se', initScale: 1 })
|
|
139
|
+
expect(captionDragGeometry(grow, grow.initX + 100_000, grow.initY, m).scale).toBe(CAPTION_MAX_SCALE)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('hasEscapedClickSlop', () => {
|
|
144
|
+
it('treats a small wobble as a click, not a drag', () => {
|
|
145
|
+
const d = drag()
|
|
146
|
+
expect(hasEscapedClickSlop(d, d.initX, d.initY)).toBe(false)
|
|
147
|
+
expect(hasEscapedClickSlop(d, d.initX + 2, d.initY + 2)).toBe(false) // 2.83px
|
|
148
|
+
expect(hasEscapedClickSlop(d, d.initX + 3, d.initY + 3)).toBe(true) // 4.24px
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('measures radially, not per axis', () => {
|
|
152
|
+
const d = drag()
|
|
153
|
+
expect(hasEscapedClickSlop(d, d.initX, d.initY + CAPTION_DRAG_SLOP_PX + 1)).toBe(true)
|
|
154
|
+
expect(hasEscapedClickSlop(d, d.initX - CAPTION_DRAG_SLOP_PX - 1, d.initY)).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('captionDragPatch', () => {
|
|
159
|
+
it('a move writes only the offsets', () => {
|
|
160
|
+
expect(captionDragPatch(drag(), { offsetX: 3, offsetY: 4, scale: 2 }))
|
|
161
|
+
.toEqual({ offsetX: 3, offsetY: 4 })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('a resize writes only the scale', () => {
|
|
165
|
+
expect(captionDragPatch(drag({ type: 'resize-sw' }), { offsetX: 3, offsetY: 4, scale: 2 }))
|
|
166
|
+
.toEqual({ scale: 2 })
|
|
167
|
+
})
|
|
168
|
+
})
|