@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
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// captionPositioning.test.tsx
|
|
2
|
+
//
|
|
3
|
+
// Integration coverage for per-segment caption positioning (offsetX/offsetY/
|
|
4
|
+
// scale) at the CaptionPreview / VideoEditor layer. The pure drag maths
|
|
5
|
+
// already have 17 tests in captionDragState.test.ts — this file does NOT
|
|
6
|
+
// repeat that coverage. It covers the four integration behaviours the plan
|
|
7
|
+
// calls out:
|
|
8
|
+
// 1. dragging segment N updates only segment N
|
|
9
|
+
// 2. offsets survive a save/reload round-trip
|
|
10
|
+
// 3. a project with no offsets renders unchanged
|
|
11
|
+
// 4. clicking empty preview space does not select a caption
|
|
12
|
+
//
|
|
13
|
+
// jsdom does no layout, so two things need explicit stubbing for
|
|
14
|
+
// CaptionPreview to become interactive at all:
|
|
15
|
+
// - ResizeObserver never fires on its own → `scale` would stay null forever
|
|
16
|
+
// and the caption would never render. Unlike VideoEditor.test.tsx's
|
|
17
|
+
// no-op stub (fine there — it doesn't touch captions), this suite's
|
|
18
|
+
// ResizeObserver calls back synchronously with a fixed size.
|
|
19
|
+
// - Range.prototype.getBoundingClientRect does not exist in jsdom at all
|
|
20
|
+
// (throws "is not a function"), and Element.prototype.getBoundingClientRect
|
|
21
|
+
// always returns zeros. CaptionPreview's measureCaptionContentRect walks
|
|
22
|
+
// real text nodes via Range on every commit, so without a stub any test
|
|
23
|
+
// that renders actual caption content crashes.
|
|
24
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
import { render, waitFor, fireEvent } from '@testing-library/react'
|
|
26
|
+
import type { ReactElement } from 'react'
|
|
27
|
+
import CaptionPreview, { clampVisible } from '../preview/CaptionPreview'
|
|
28
|
+
import VideoEditor from '../VideoEditor'
|
|
29
|
+
import type { CaptionSegment, Captions } from '../../schema'
|
|
30
|
+
import type {
|
|
31
|
+
EditorAdapter,
|
|
32
|
+
ImageElement,
|
|
33
|
+
OverlayFactory,
|
|
34
|
+
Project,
|
|
35
|
+
RenderEvent,
|
|
36
|
+
VersionEntry,
|
|
37
|
+
WaveformChunk,
|
|
38
|
+
} from '../../types'
|
|
39
|
+
|
|
40
|
+
const RENDER_W = 1080
|
|
41
|
+
const RENDER_H = 1920
|
|
42
|
+
|
|
43
|
+
function fixedRect(left: number, top: number, right: number, bottom: number): DOMRect {
|
|
44
|
+
return {
|
|
45
|
+
left, top, right, bottom,
|
|
46
|
+
width: right - left, height: bottom - top,
|
|
47
|
+
x: left, y: top,
|
|
48
|
+
toJSON: () => ({}),
|
|
49
|
+
} as DOMRect
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
54
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
55
|
+
|
|
56
|
+
// Fires synchronously with a fixed 1080×1920 size — CaptionPreview's own
|
|
57
|
+
// `RENDER_W`/`RENDER_H` (hardcoded in CaptionPreview.tsx, independent of
|
|
58
|
+
// project.settings.resolution), so `scale` resolves to exactly 1 and every
|
|
59
|
+
// screen-px delta below maps 1:1 to design px.
|
|
60
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
61
|
+
cb: (entries: unknown[]) => void
|
|
62
|
+
constructor(cb: (entries: unknown[]) => void) { this.cb = cb }
|
|
63
|
+
observe() { this.cb([{ contentRect: { width: RENDER_W, height: RENDER_H } }]) }
|
|
64
|
+
unobserve() {}
|
|
65
|
+
disconnect() {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// jsdom: Range.prototype.getBoundingClientRect doesn't exist — assign it
|
|
69
|
+
// directly (vi.spyOn requires a pre-existing method).
|
|
70
|
+
Range.prototype.getBoundingClientRect = vi.fn(() => fixedRect(100, 1700, 300, 1750))
|
|
71
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => fixedRect(0, 0, RENDER_W, RENDER_H))
|
|
72
|
+
|
|
73
|
+
// jsdom doesn't implement media playback or Web Audio — VideoEditor mounts
|
|
74
|
+
// real <video> elements even when the test only cares about captions.
|
|
75
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
76
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
77
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
78
|
+
state = 'running'
|
|
79
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
80
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
81
|
+
get destination() { return {} }
|
|
82
|
+
close() {}
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.restoreAllMocks()
|
|
88
|
+
delete (Range.prototype as { getBoundingClientRect?: unknown }).getBoundingClientRect
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ── Shared test fixtures ──────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const seg = (over: Partial<CaptionSegment> = {}): CaptionSegment => ({
|
|
94
|
+
id: 'cap-0', text: 'hello', start: 0, end: 2, ...over,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A minimal but realistic caption template: picks the active segment the same
|
|
99
|
+
* way the real templates do (`t = frame/fps`, `segments.find`) and renders its
|
|
100
|
+
* text as a genuine text node, so measureCaptionContentRect has something to
|
|
101
|
+
* find. `onProps`, if given, is called with every factory invocation's props —
|
|
102
|
+
* used to inspect exactly what CaptionPreview threads through.
|
|
103
|
+
*/
|
|
104
|
+
function makeCompileOverlay(onProps?: (props: Record<string, unknown>) => void) {
|
|
105
|
+
return vi.fn(async (): Promise<OverlayFactory> => {
|
|
106
|
+
return (frame: number, fps: number, _duration: number, props: Record<string, unknown>): ReactElement | null => {
|
|
107
|
+
onProps?.(props)
|
|
108
|
+
const segments = (props.segments ?? []) as CaptionSegment[]
|
|
109
|
+
const t = frame / fps
|
|
110
|
+
const active = segments.find((s) => t >= s.start && t < s.end)
|
|
111
|
+
if (!active) return null
|
|
112
|
+
return <div><span>{active.text}</span></div>
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The selection box — CaptionPreview's only interactive element (z-index 50, pointer-events: auto). */
|
|
118
|
+
function getSelectionBox(container: HTMLElement): HTMLElement {
|
|
119
|
+
const layer = container.querySelector('[style*="z-index: 50"]')
|
|
120
|
+
if (!layer) throw new Error('selection box layer not found in the DOM — is the caption interactive yet?')
|
|
121
|
+
return layer.firstElementChild as HTMLElement
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── 1. Dragging segment N updates only segment N ───────────────────────────
|
|
125
|
+
|
|
126
|
+
describe('CaptionPreview — dragging updates only the dragged segment', () => {
|
|
127
|
+
const track: Captions = {
|
|
128
|
+
style: 'clean',
|
|
129
|
+
segments: [
|
|
130
|
+
seg({ id: 'cap-0', text: 'first', start: 0, end: 2 }),
|
|
131
|
+
seg({ id: 'cap-1', text: 'second', start: 2, end: 4 }),
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it('drags the segment active at the playhead and patches only its id', async () => {
|
|
136
|
+
const onCaptionSegmentChange = vi.fn()
|
|
137
|
+
const { container } = render(
|
|
138
|
+
<CaptionPreview
|
|
139
|
+
track={track}
|
|
140
|
+
currentTime={2.5} // active segment = cap-1
|
|
141
|
+
fps={30}
|
|
142
|
+
compileOverlay={makeCompileOverlay()}
|
|
143
|
+
resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
|
|
144
|
+
onSelectCaption={vi.fn()}
|
|
145
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
146
|
+
/>,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await waitFor(() => expect(container.querySelector('[style*="z-index: 50"]')).not.toBeNull())
|
|
150
|
+
const box = getSelectionBox(container)
|
|
151
|
+
|
|
152
|
+
fireEvent.mouseDown(box, { clientX: 500, clientY: 500 })
|
|
153
|
+
fireEvent.mouseMove(document, { clientX: 500 + 108, clientY: 500 + 192 }) // +10% x, +10% y at scale 1
|
|
154
|
+
fireEvent.mouseUp(document)
|
|
155
|
+
|
|
156
|
+
expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
|
|
157
|
+
const [id, patch] = onCaptionSegmentChange.mock.calls[0]
|
|
158
|
+
expect(id).toBe('cap-1')
|
|
159
|
+
expect(patch).toEqual({ offsetX: 10, offsetY: 10 })
|
|
160
|
+
expect(onCaptionSegmentChange).not.toHaveBeenCalledWith('cap-0', expect.anything())
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('a different playhead position drags the OTHER segment — selection follows the active id, not a fixed index', async () => {
|
|
164
|
+
const onCaptionSegmentChange = vi.fn()
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<CaptionPreview
|
|
167
|
+
track={track}
|
|
168
|
+
currentTime={0.5} // active segment = cap-0
|
|
169
|
+
fps={30}
|
|
170
|
+
compileOverlay={makeCompileOverlay()}
|
|
171
|
+
resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
|
|
172
|
+
onSelectCaption={vi.fn()}
|
|
173
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
174
|
+
/>,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(container.querySelector('[style*="z-index: 50"]')).not.toBeNull())
|
|
178
|
+
const box = getSelectionBox(container)
|
|
179
|
+
|
|
180
|
+
fireEvent.mouseDown(box, { clientX: 200, clientY: 200 })
|
|
181
|
+
fireEvent.mouseMove(document, { clientX: 200 - 54, clientY: 200 - 96 }) // -5% x, -5% y
|
|
182
|
+
fireEvent.mouseUp(document)
|
|
183
|
+
|
|
184
|
+
expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
|
|
185
|
+
const [id, patch] = onCaptionSegmentChange.mock.calls[0]
|
|
186
|
+
expect(id).toBe('cap-0')
|
|
187
|
+
expect(patch).toEqual({ offsetX: -5, offsetY: -5 })
|
|
188
|
+
expect(onCaptionSegmentChange).not.toHaveBeenCalledWith('cap-1', expect.anything())
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ── 2. Offsets survive a save/reload round-trip ─────────────────────────────
|
|
193
|
+
|
|
194
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
195
|
+
return {
|
|
196
|
+
id: 'vid-1',
|
|
197
|
+
name: 'Test Video',
|
|
198
|
+
status: 'draft',
|
|
199
|
+
editingPrompt: '',
|
|
200
|
+
projectType: 'video',
|
|
201
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
202
|
+
tracks: [
|
|
203
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
204
|
+
],
|
|
205
|
+
audio: { tracks: [] },
|
|
206
|
+
assets: [],
|
|
207
|
+
captions: { style: 'clean', segments: [seg({ start: 0, end: 4 })] },
|
|
208
|
+
...overrides,
|
|
209
|
+
} as Project
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface FakeAdapter extends EditorAdapter<Project> {
|
|
213
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function makeFakeAdapter(): FakeAdapter {
|
|
217
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
218
|
+
return {
|
|
219
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
220
|
+
saveProject: vi.fn(async (id: string, project: Project) => { saveCalls.push({ id, project }) }),
|
|
221
|
+
subscribe: () => () => {},
|
|
222
|
+
render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
|
|
223
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
224
|
+
compileOverlay: makeCompileOverlay(),
|
|
225
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
226
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
227
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
228
|
+
fileUrl: (path: string) => path,
|
|
229
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
230
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
231
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
232
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
233
|
+
saveCalls,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
describe('VideoEditor — caption offsets survive a save/reload round trip', () => {
|
|
238
|
+
// Exercises the actual persistence path the editor uses (preview drag →
|
|
239
|
+
// handleCaptionSegmentChange → makeCaptionEdit → sync.mutate →
|
|
240
|
+
// adapter.saveProject), not a hand-rolled JSON.stringify/parse round trip.
|
|
241
|
+
// "Reload" is a fresh <VideoEditor> mount seeded with exactly what
|
|
242
|
+
// saveProject received — the same `project` prop path a real page refresh
|
|
243
|
+
// takes (useProjectSync only consults it once, on mount).
|
|
244
|
+
it('a drag committed through the preview reaches adapter.saveProject, and a fresh mount from that saved project keeps the offsets', async () => {
|
|
245
|
+
const adapter = makeFakeAdapter()
|
|
246
|
+
const initial = makeVideoProject()
|
|
247
|
+
const { container, unmount } = render(
|
|
248
|
+
<VideoEditor
|
|
249
|
+
project={initial}
|
|
250
|
+
adapter={adapter}
|
|
251
|
+
onProjectChange={vi.fn()}
|
|
252
|
+
slots={{ exportActions: <div /> }}
|
|
253
|
+
/>,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
await waitFor(() => expect(container.querySelector('[style*="z-index: 50"]')).not.toBeNull())
|
|
257
|
+
const box = getSelectionBox(container)
|
|
258
|
+
fireEvent.mouseDown(box, { clientX: 500, clientY: 500 })
|
|
259
|
+
fireEvent.mouseMove(document, { clientX: 500 + 108, clientY: 500 + 192 }) // +10% x, +10% y
|
|
260
|
+
fireEvent.mouseUp(document)
|
|
261
|
+
|
|
262
|
+
await waitFor(() => {
|
|
263
|
+
const last = adapter.saveCalls[adapter.saveCalls.length - 1]
|
|
264
|
+
expect(last?.project.captions?.segments[0]).toMatchObject({ id: 'cap-0', offsetX: 10, offsetY: 10 })
|
|
265
|
+
})
|
|
266
|
+
const saved = adapter.saveCalls[adapter.saveCalls.length - 1].project
|
|
267
|
+
|
|
268
|
+
// Reload: unmount the live editor and mount a fresh one from exactly what
|
|
269
|
+
// was persisted.
|
|
270
|
+
unmount()
|
|
271
|
+
const onProjectChangeAfterReload = vi.fn()
|
|
272
|
+
render(
|
|
273
|
+
<VideoEditor
|
|
274
|
+
project={saved}
|
|
275
|
+
adapter={adapter}
|
|
276
|
+
onProjectChange={onProjectChangeAfterReload}
|
|
277
|
+
slots={{ exportActions: <div /> }}
|
|
278
|
+
/>,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(onProjectChangeAfterReload).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({
|
|
284
|
+
captions: expect.objectContaining({
|
|
285
|
+
segments: expect.arrayContaining([
|
|
286
|
+
expect.objectContaining({ id: 'cap-0', offsetX: 10, offsetY: 10 }),
|
|
287
|
+
]),
|
|
288
|
+
}),
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ── 3. A project with no offsets renders unchanged ──────────────────────────
|
|
296
|
+
|
|
297
|
+
describe('CaptionPreview — a segment with no offsets passes through unchanged', () => {
|
|
298
|
+
it('the template factory receives the exact, untouched segments array — no live-drag override, no injected defaults', async () => {
|
|
299
|
+
const track: Captions = {
|
|
300
|
+
style: 'clean',
|
|
301
|
+
segments: [seg({ offsetX: undefined, offsetY: undefined, scale: undefined })],
|
|
302
|
+
}
|
|
303
|
+
const received: unknown[] = []
|
|
304
|
+
const { container } = render(
|
|
305
|
+
<CaptionPreview
|
|
306
|
+
track={track}
|
|
307
|
+
currentTime={1}
|
|
308
|
+
fps={30}
|
|
309
|
+
compileOverlay={makeCompileOverlay((props) => received.push(props.segments))}
|
|
310
|
+
resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
|
|
311
|
+
/>,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
await waitFor(() => expect(container.querySelector('span')?.textContent).toBe('hello'))
|
|
315
|
+
expect(received.length).toBeGreaterThan(0)
|
|
316
|
+
const passed = received[received.length - 1] as CaptionSegment[]
|
|
317
|
+
|
|
318
|
+
// No `live` drag is in progress, so CaptionPreview's
|
|
319
|
+
// `segments = live ? track.segments.map(...) : track.segments` ternary must
|
|
320
|
+
// hand the template the literal same array — not a copy, and with no
|
|
321
|
+
// fields coerced (offsetX/offsetY/scale defaulting to 0/0/1 is
|
|
322
|
+
// captionInnerStyle's/captionOuterStyle's job, not CaptionPreview's).
|
|
323
|
+
expect(passed).toBe(track.segments)
|
|
324
|
+
expect(passed[0].offsetX).toBeUndefined()
|
|
325
|
+
expect(passed[0].offsetY).toBeUndefined()
|
|
326
|
+
expect(passed[0].scale).toBeUndefined()
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// ── 3b. An off-frame caption stays recoverable ──────────────────────────────
|
|
331
|
+
|
|
332
|
+
describe('clampVisible — the selection box never leaves the frame entirely', () => {
|
|
333
|
+
// offsetX/offsetY are unbounded and captions have no numeric reset control,
|
|
334
|
+
// so a segment dragged fully past the frame edge would have both its text and
|
|
335
|
+
// its selection box clipped away by the preview wrapper's `overflow-hidden` —
|
|
336
|
+
// no way back short of hand-editing project.json. The box is clamped to keep
|
|
337
|
+
// a grabbable sliver on screen; dragging is delta-based, so a clamped box
|
|
338
|
+
// still moves the segment correctly.
|
|
339
|
+
const FRAME = 400
|
|
340
|
+
const BOX = 100
|
|
341
|
+
|
|
342
|
+
it('leaves a box fully inside the frame untouched', () => {
|
|
343
|
+
for (const pos of [0, 30, 150, FRAME - BOX]) {
|
|
344
|
+
expect(clampVisible(pos, BOX, FRAME)).toBe(pos)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('pulls a box dragged off the leading edge back to a grabbable sliver', () => {
|
|
349
|
+
const clamped = clampVisible(-5000, BOX, FRAME)
|
|
350
|
+
expect(clamped + BOX).toBeGreaterThan(0) // right edge is on screen
|
|
351
|
+
expect(clamped).toBeLessThan(0) // still visibly off to the left
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('pulls a box dragged off the trailing edge back to a grabbable sliver', () => {
|
|
355
|
+
const clamped = clampVisible(5000, BOX, FRAME)
|
|
356
|
+
expect(clamped).toBeLessThan(FRAME) // left edge is on screen
|
|
357
|
+
expect(clamped).toBeGreaterThan(FRAME - BOX) // still visibly off to the right
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('is idempotent — clamping an already-clamped position changes nothing', () => {
|
|
361
|
+
const once = clampVisible(5000, BOX, FRAME)
|
|
362
|
+
expect(clampVisible(once, BOX, FRAME)).toBe(once)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// Degenerate frame: a collapsed/unmeasured player (extent 0) with a small box
|
|
366
|
+
// inverts the clamp range, so the position is passed through rather than
|
|
367
|
+
// snapped to a meaningless bound.
|
|
368
|
+
it('leaves the position alone when the clamp range inverts', () => {
|
|
369
|
+
expect(clampVisible(-999, 20, 0)).toBe(-999)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// ── 4. Clicking empty preview space does not select a caption ──────────────
|
|
374
|
+
|
|
375
|
+
describe('CaptionPreview — pointer-events design: only the measured selection box is clickable', () => {
|
|
376
|
+
const track: Captions = { style: 'clean', segments: [seg()] }
|
|
377
|
+
|
|
378
|
+
it('clicking the rendered caption text (or its wrapper) does not select it', async () => {
|
|
379
|
+
const onSelectCaption = vi.fn()
|
|
380
|
+
const { container, getByText } = render(
|
|
381
|
+
<CaptionPreview
|
|
382
|
+
track={track}
|
|
383
|
+
currentTime={1}
|
|
384
|
+
fps={30}
|
|
385
|
+
compileOverlay={makeCompileOverlay()}
|
|
386
|
+
resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
|
|
387
|
+
onSelectCaption={onSelectCaption}
|
|
388
|
+
/>,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
await waitFor(() => expect(getByText('hello')).toBeTruthy())
|
|
392
|
+
fireEvent.click(getByText('hello'))
|
|
393
|
+
fireEvent.click(getByText('hello').parentElement as HTMLElement)
|
|
394
|
+
// The whole caption layer stays click-through by design.
|
|
395
|
+
expect(container.firstElementChild).toHaveClass('pointer-events-none')
|
|
396
|
+
expect(onSelectCaption).not.toHaveBeenCalled()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('clicking empty space elsewhere in the preview (the outer wrap itself) does not select', async () => {
|
|
400
|
+
const onSelectCaption = vi.fn()
|
|
401
|
+
const { container } = render(
|
|
402
|
+
<CaptionPreview
|
|
403
|
+
track={track}
|
|
404
|
+
currentTime={1}
|
|
405
|
+
fps={30}
|
|
406
|
+
compileOverlay={makeCompileOverlay()}
|
|
407
|
+
resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
|
|
408
|
+
onSelectCaption={onSelectCaption}
|
|
409
|
+
/>,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
await waitFor(() => expect(container.querySelector('[style*="z-index: 50"]')).not.toBeNull())
|
|
413
|
+
fireEvent.click(container.firstElementChild as HTMLElement)
|
|
414
|
+
expect(onSelectCaption).not.toHaveBeenCalled()
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('contrast case: pressing the actual selection box DOES select — proves the negative results above are load-bearing, not just "nothing works"', async () => {
|
|
418
|
+
const onSelectCaption = vi.fn()
|
|
419
|
+
const { container } = render(
|
|
420
|
+
<CaptionPreview
|
|
421
|
+
track={track}
|
|
422
|
+
currentTime={1}
|
|
423
|
+
fps={30}
|
|
424
|
+
compileOverlay={makeCompileOverlay()}
|
|
425
|
+
resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
|
|
426
|
+
onSelectCaption={onSelectCaption}
|
|
427
|
+
/>,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
await waitFor(() => expect(container.querySelector('[style*="z-index: 50"]')).not.toBeNull())
|
|
431
|
+
const box = getSelectionBox(container)
|
|
432
|
+
fireEvent.mouseDown(box, { clientX: 10, clientY: 10 })
|
|
433
|
+
expect(onSelectCaption).toHaveBeenCalledWith('cap-0')
|
|
434
|
+
})
|
|
435
|
+
})
|
|
@@ -117,6 +117,32 @@ describe('repairCaptionWords', () => {
|
|
|
117
117
|
expect(repairCaptionWords(captions)).toBeNull()
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
+
// Regression: VideoEditor's caption-repair effect re-invokes repairCaptionWords
|
|
121
|
+
// on every applyExternal it triggers (so it also catches mid-session caption
|
|
122
|
+
// regeneration, not just the initial project load). That only terminates if
|
|
123
|
+
// repairing a segment once produces something that repairs to "nothing to do"
|
|
124
|
+
// (null) on the very next pass — otherwise the effect applyExternals forever.
|
|
125
|
+
// A naive comparison of the rejoined (single-spaced) words against a
|
|
126
|
+
// non-whitespace-collapsed seg.text never reaches that fixed point whenever
|
|
127
|
+
// seg.text has a double space, tab, etc. between words.
|
|
128
|
+
it('reaches a fixed point on the pass after repairing text with irregular internal whitespace', () => {
|
|
129
|
+
const captions = makeCaptions([
|
|
130
|
+
{
|
|
131
|
+
id: 's1',
|
|
132
|
+
text: 'hello world', // double space between words; words[] absent
|
|
133
|
+
start: 0,
|
|
134
|
+
end: 2,
|
|
135
|
+
},
|
|
136
|
+
])
|
|
137
|
+
const once = repairCaptionWords(captions)
|
|
138
|
+
expect(once).not.toBeNull()
|
|
139
|
+
expect(once!.segments[0].words!.map(w => w.word)).toEqual(['hello', 'world'])
|
|
140
|
+
|
|
141
|
+
// Re-running on the repaired result must be a true no-op.
|
|
142
|
+
const twice = repairCaptionWords(once!)
|
|
143
|
+
expect(twice).toBeNull()
|
|
144
|
+
})
|
|
145
|
+
|
|
120
146
|
it('handles a single-word segment with correct timing', () => {
|
|
121
147
|
const captions = makeCaptions([
|
|
122
148
|
{
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centre/middle snap for the caption move gesture.
|
|
3
|
+
*
|
|
4
|
+
* Regression: the first implementation of `captionDragGeometry` translated the
|
|
5
|
+
* segment with no snapping at all, so captions were the only draggable thing in
|
|
6
|
+
* the preview that would not settle on the frame's centre line — overlays did,
|
|
7
|
+
* captions did not. Edge snap remains deliberately absent (its geometry does
|
|
8
|
+
* not transfer to a content-sized anchor box); only centre/middle is expected.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
captionDragGeometry,
|
|
13
|
+
captionDragPatch,
|
|
14
|
+
CAPTION_SNAP_THRESHOLD,
|
|
15
|
+
type CaptionDragState,
|
|
16
|
+
type CaptionFrameMetrics,
|
|
17
|
+
} from '../preview/captionDragState'
|
|
18
|
+
|
|
19
|
+
// previewScale 1 with a 1080x1920 design frame: 1 screen px == 1 design px, so
|
|
20
|
+
// a dx of 10.8px is exactly 1% of frame width.
|
|
21
|
+
const METRICS: CaptionFrameMetrics = { previewScale: 1, renderW: 1080, renderH: 1920 }
|
|
22
|
+
const PX_PER_PCT_X = 1080 / 100
|
|
23
|
+
const PX_PER_PCT_Y = 1920 / 100
|
|
24
|
+
|
|
25
|
+
function move(initOffsetX: number, initOffsetY: number): CaptionDragState {
|
|
26
|
+
return {
|
|
27
|
+
id: 'cap-1', type: 'move',
|
|
28
|
+
initX: 0, initY: 0,
|
|
29
|
+
initOffsetX, initOffsetY, initScale: 1,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('captionDragGeometry — centre/middle snap', () => {
|
|
34
|
+
it('snaps offsetX to exactly 0 inside the threshold', () => {
|
|
35
|
+
// Start 5% left of centre, drag right to land 1% past centre (inside 2.5%).
|
|
36
|
+
const g = captionDragGeometry(move(-5, 40), 6 * PX_PER_PCT_X, 0, METRICS)
|
|
37
|
+
expect(g.offsetX).toBe(0)
|
|
38
|
+
expect(g.snapX).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('snaps offsetY to exactly 0 inside the threshold', () => {
|
|
42
|
+
const g = captionDragGeometry(move(40, -5), 0, 6 * PX_PER_PCT_Y, METRICS)
|
|
43
|
+
expect(g.offsetY).toBe(0)
|
|
44
|
+
expect(g.snapY).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('snaps both axes at once when dragged through the centre point', () => {
|
|
48
|
+
const g = captionDragGeometry(move(-1, -1), PX_PER_PCT_X, PX_PER_PCT_Y, METRICS)
|
|
49
|
+
expect(g).toMatchObject({ offsetX: 0, offsetY: 0, snapX: true, snapY: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does NOT snap outside the threshold — the raw offset is preserved', () => {
|
|
53
|
+
// Land 4% right of centre: outside 2.5%, so no snap and no rounding.
|
|
54
|
+
const g = captionDragGeometry(move(0, 40), 4 * PX_PER_PCT_X, 0, METRICS)
|
|
55
|
+
expect(g.offsetX).toBeCloseTo(4, 6)
|
|
56
|
+
expect(g.snapX).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('uses the same threshold as the overlay hook', () => {
|
|
60
|
+
// Just inside snaps, just outside does not — pinning the boundary so the
|
|
61
|
+
// two gestures cannot drift apart.
|
|
62
|
+
const inside = captionDragGeometry(move(0, 40), (CAPTION_SNAP_THRESHOLD - 0.1) * PX_PER_PCT_X, 0, METRICS)
|
|
63
|
+
const outside = captionDragGeometry(move(0, 40), (CAPTION_SNAP_THRESHOLD + 0.1) * PX_PER_PCT_X, 0, METRICS)
|
|
64
|
+
expect(inside.snapX).toBe(true)
|
|
65
|
+
expect(outside.snapX).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('commits the snapped value, not the raw pointer position', () => {
|
|
69
|
+
const d = move(-5, 40)
|
|
70
|
+
const g = captionDragGeometry(d, 6 * PX_PER_PCT_X, 0, METRICS)
|
|
71
|
+
expect(captionDragPatch(d, g)).toEqual({ offsetX: 0, offsetY: g.offsetY })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('leaves resize gestures unsnapped and untouched', () => {
|
|
75
|
+
const d: CaptionDragState = {
|
|
76
|
+
id: 'cap-1', type: 'resize-se',
|
|
77
|
+
initX: 0, initY: 0, initOffsetX: 1, initOffsetY: 1, initScale: 1,
|
|
78
|
+
}
|
|
79
|
+
const g = captionDragGeometry(d, 20, 20, METRICS)
|
|
80
|
+
// A resize must never move the segment, and must not report a snap.
|
|
81
|
+
expect(g.offsetX).toBe(1)
|
|
82
|
+
expect(g.offsetY).toBe(1)
|
|
83
|
+
expect(g.snapX).toBeUndefined()
|
|
84
|
+
expect(g.snapY).toBeUndefined()
|
|
85
|
+
expect(captionDragPatch(d, g)).toEqual({ scale: g.scale })
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -12,9 +12,21 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { Captions, CaptionSegment } from '../schema'
|
|
14
14
|
|
|
15
|
+
// Collapses any run of whitespace to a single space, in addition to trim +
|
|
16
|
+
// case-fold, before the two sides are compared. `newWords` below is always
|
|
17
|
+
// rejoined with single spaces, so comparing against a non-collapsed `seg.text`
|
|
18
|
+
// would forever "diverge" whenever the segment's text contains a double space,
|
|
19
|
+
// tab, or newline between words: repairSegment would re-repair on every call,
|
|
20
|
+
// producing a same-content but referentially-new `words` array each time. A
|
|
21
|
+
// caller that re-invokes this after every repair (VideoEditor's caption-repair
|
|
22
|
+
// effect does, to catch mid-session caption regeneration) depends on repairing
|
|
23
|
+
// converging to a true no-op on the very next pass — without this
|
|
24
|
+
// normalization it never would, and that caller would applyExternal forever.
|
|
25
|
+
const normalizeForCompare = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
26
|
+
|
|
15
27
|
function repairSegment(seg: CaptionSegment): CaptionSegment {
|
|
16
28
|
const wordsText = (seg.words ?? []).map(w => w.word).join(' ')
|
|
17
|
-
if (wordsText
|
|
29
|
+
if (normalizeForCompare(wordsText) === normalizeForCompare(seg.text)) return seg
|
|
18
30
|
|
|
19
31
|
const newWords = seg.text.split(/\s+/).filter(Boolean)
|
|
20
32
|
const segDur = seg.end - seg.start
|