@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.
@@ -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.trim().toLowerCase() === seg.text.trim().toLowerCase()) return seg
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