@bycrux/editor 0.10.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/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/captionRepair.ts +13 -1
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/PreviewPlayer.tsx +13 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/captionDragState.ts +175 -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { render, waitFor } from '@testing-library/react'
|
|
2
|
+
import { render, waitFor, act, fireEvent } from '@testing-library/react'
|
|
3
3
|
import type {
|
|
4
|
+
CaptionEvent,
|
|
4
5
|
EditorAdapter,
|
|
5
6
|
ImageElement,
|
|
6
7
|
Project,
|
|
@@ -47,14 +48,30 @@ function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
|
47
48
|
|
|
48
49
|
interface FakeAdapter extends EditorAdapter<Project> {
|
|
49
50
|
saveCalls: Array<{ id: string; project: Project }>
|
|
51
|
+
/** Push a server-authored SSE frame to every active subscriber. */
|
|
52
|
+
emit: (project: Project) => void
|
|
53
|
+
/** When true, saveProject() blocks until flushSaves() so a save stays pending. */
|
|
54
|
+
setHoldSaves: (hold: boolean) => void
|
|
55
|
+
/** Resolve every held saveProject() promise. */
|
|
56
|
+
flushSaves: () => void
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
function makeFakeAdapter(): FakeAdapter {
|
|
53
60
|
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
61
|
+
let subscribers: Array<(project: Project) => void> = []
|
|
62
|
+
let holdSaves = false
|
|
63
|
+
let saveResolvers: Array<() => void> = []
|
|
54
64
|
return {
|
|
55
65
|
loadProject: vi.fn(async () => makeVideoProject()),
|
|
56
|
-
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
57
|
-
|
|
66
|
+
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
67
|
+
saveCalls.push({ id, project })
|
|
68
|
+
if (holdSaves) await new Promise<void>((resolve) => saveResolvers.push(resolve))
|
|
69
|
+
}),
|
|
70
|
+
// Capture the sync core's frame callback so a test can drive SSE frames.
|
|
71
|
+
subscribe: (_id: string, onFrame: (project: Project) => void) => {
|
|
72
|
+
subscribers.push(onFrame)
|
|
73
|
+
return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
|
|
74
|
+
},
|
|
58
75
|
render: async function* (): AsyncIterable<RenderEvent> {
|
|
59
76
|
yield { type: 'done', outputPath: '/out.mp4' }
|
|
60
77
|
},
|
|
@@ -70,6 +87,9 @@ function makeFakeAdapter(): FakeAdapter {
|
|
|
70
87
|
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
71
88
|
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
72
89
|
saveCalls,
|
|
90
|
+
emit: (project: Project) => { for (const s of [...subscribers]) s(project) },
|
|
91
|
+
setHoldSaves: (hold: boolean) => { holdSaves = hold },
|
|
92
|
+
flushSaves: () => { const r = saveResolvers; saveResolvers = []; r.forEach((res) => res()) },
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
95
|
|
|
@@ -244,4 +264,226 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
244
264
|
expect(merged.offsetX).toBe(5)
|
|
245
265
|
expect(merged.offsetY).toBe(10)
|
|
246
266
|
})
|
|
267
|
+
|
|
268
|
+
// ── Sync core adoption: undo/redo + SSE echo protection ──────────────────────
|
|
269
|
+
// These drive the one reliable DOM-triggerable mutation (the Render button,
|
|
270
|
+
// which flips status draft→final through sync.mutate) and then exercise the
|
|
271
|
+
// shared save/undo core the editor now routes through.
|
|
272
|
+
|
|
273
|
+
it('undo restores the pre-mutation project and re-persists it', async () => {
|
|
274
|
+
const adapter = makeFakeAdapter()
|
|
275
|
+
const onProjectChange = vi.fn()
|
|
276
|
+
const { findByText } = render(
|
|
277
|
+
<VideoEditor
|
|
278
|
+
project={makeVideoProject({ status: 'draft' })}
|
|
279
|
+
adapter={adapter}
|
|
280
|
+
onProjectChange={onProjectChange}
|
|
281
|
+
slots={{ exportActions: <div /> }}
|
|
282
|
+
/>,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
// Mutation: Render flips status draft → final and persists via the queue.
|
|
286
|
+
const renderBtn = await findByText('Render →')
|
|
287
|
+
await act(async () => { renderBtn.click() })
|
|
288
|
+
await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('final'))
|
|
289
|
+
|
|
290
|
+
// Undo (Cmd+Z): restores 'draft' AND enqueues a save of the restored state.
|
|
291
|
+
await act(async () => {
|
|
292
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
293
|
+
})
|
|
294
|
+
await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('draft'))
|
|
295
|
+
// Host is notified of the restored (draft) authoritative state.
|
|
296
|
+
expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'draft' }))
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('redo re-applies an undone mutation and re-persists it', async () => {
|
|
300
|
+
const adapter = makeFakeAdapter()
|
|
301
|
+
const { findByText } = render(
|
|
302
|
+
<VideoEditor
|
|
303
|
+
project={makeVideoProject({ status: 'draft' })}
|
|
304
|
+
adapter={adapter}
|
|
305
|
+
onProjectChange={vi.fn()}
|
|
306
|
+
slots={{ exportActions: <div /> }}
|
|
307
|
+
/>,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
const renderBtn = await findByText('Render →')
|
|
311
|
+
await act(async () => { renderBtn.click() })
|
|
312
|
+
await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('final'))
|
|
313
|
+
|
|
314
|
+
// Undo back to draft…
|
|
315
|
+
await act(async () => {
|
|
316
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
317
|
+
})
|
|
318
|
+
await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('draft'))
|
|
319
|
+
|
|
320
|
+
// …then Redo (Cmd+Shift+Z) re-applies 'final'.
|
|
321
|
+
await act(async () => {
|
|
322
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true, shiftKey: true }))
|
|
323
|
+
})
|
|
324
|
+
await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('final'))
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('does not clobber an optimistic edit with an SSE frame that arrives mid-save', async () => {
|
|
328
|
+
const adapter = makeFakeAdapter()
|
|
329
|
+
adapter.setHoldSaves(true) // saves hang so the mutation queue stays pending
|
|
330
|
+
const onProjectChange = vi.fn()
|
|
331
|
+
const { findByText } = render(
|
|
332
|
+
<VideoEditor
|
|
333
|
+
project={makeVideoProject({ status: 'draft', name: 'Original' })}
|
|
334
|
+
adapter={adapter}
|
|
335
|
+
onProjectChange={onProjectChange}
|
|
336
|
+
slots={{ exportActions: <div /> }}
|
|
337
|
+
/>,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
// Optimistic mutation: status → final. Its save is now in-flight (held).
|
|
341
|
+
const renderBtn = await findByText('Render →')
|
|
342
|
+
await act(async () => { renderBtn.click() })
|
|
343
|
+
await waitFor(() =>
|
|
344
|
+
expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'final' })),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
// A stale server frame arrives WHILE the save is pending. It must be deferred,
|
|
348
|
+
// not applied — otherwise it would regress the optimistic 'final' edit.
|
|
349
|
+
await act(async () => {
|
|
350
|
+
adapter.emit(makeVideoProject({ status: 'draft', name: 'StaleServerFrame' }))
|
|
351
|
+
})
|
|
352
|
+
// Optimistic edit intact: still 'final', and the stale frame never reached the host.
|
|
353
|
+
expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'final' }))
|
|
354
|
+
expect(onProjectChange).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'StaleServerFrame' }))
|
|
355
|
+
|
|
356
|
+
// Once the save drains, the deferred frame is applied (last-write-wins).
|
|
357
|
+
await act(async () => { adapter.flushSaves() })
|
|
358
|
+
await waitFor(() =>
|
|
359
|
+
expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'StaleServerFrame' })),
|
|
360
|
+
)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Regression: cancelOverlayEdit routes through sync.applyExternal (see
|
|
364
|
+
// use-project-sync.ts) to revert the live preview. applyExternal used to leave
|
|
365
|
+
// the sync core's transient-gesture baseline pointing at the pre-edit snapshot;
|
|
366
|
+
// if a real external frame then arrived before the *next* gesture, that next
|
|
367
|
+
// gesture would see a non-null baseline and skip re-baselining, so its commit
|
|
368
|
+
// pushed the STALE pre-first-gesture snapshot as the undo target — a later
|
|
369
|
+
// Undo would silently discard the external change. Drives the actual DOM path
|
|
370
|
+
// (select overlay → open dialog → live preview → Cancel) rather than the core
|
|
371
|
+
// directly, to prove the fix holds through VideoEditor's wiring too.
|
|
372
|
+
it('Cancel after previewing an overlay-props edit reverts the project, and a later gesture is not corrupted by the stale pre-edit baseline', async () => {
|
|
373
|
+
const adapter = makeFakeAdapter()
|
|
374
|
+
const onProjectChange = vi.fn()
|
|
375
|
+
const initial = makeVideoProject({
|
|
376
|
+
name: 'Original',
|
|
377
|
+
tracks: [
|
|
378
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
379
|
+
[{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Old text' } }],
|
|
380
|
+
],
|
|
381
|
+
})
|
|
382
|
+
const { findByText, findByTitle, findByLabelText, getByText } = render(
|
|
383
|
+
<VideoEditor
|
|
384
|
+
project={initial}
|
|
385
|
+
adapter={adapter}
|
|
386
|
+
onProjectChange={onProjectChange}
|
|
387
|
+
slots={{ exportActions: <div /> }}
|
|
388
|
+
/>,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
// Select the overlay item — additive (metaKey) click sidesteps the plain-
|
|
392
|
+
// click playhead-seek branch, which needs real layout metrics jsdom doesn't
|
|
393
|
+
// provide — then open its props dialog via the timeline's Pencil button.
|
|
394
|
+
const overlayBlock = await findByText('▪ overlay')
|
|
395
|
+
fireEvent.click(overlayBlock, { metaKey: true })
|
|
396
|
+
const editBtn = await findByTitle('Edit overlay')
|
|
397
|
+
fireEvent.click(editBtn)
|
|
398
|
+
|
|
399
|
+
// Preview an edit — mutateTransient baselines against the pre-gesture state.
|
|
400
|
+
const textField = await findByLabelText('text')
|
|
401
|
+
fireEvent.change(textField, { target: { value: 'Live preview' } })
|
|
402
|
+
await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(
|
|
403
|
+
expect.objectContaining({
|
|
404
|
+
tracks: expect.arrayContaining([
|
|
405
|
+
expect.arrayContaining([expect.objectContaining({ id: 'overlay-1', props: { text: 'Live preview' } })]),
|
|
406
|
+
]),
|
|
407
|
+
}),
|
|
408
|
+
))
|
|
409
|
+
|
|
410
|
+
// Cancel — routes through applyExternal, reverting to the pre-edit snapshot.
|
|
411
|
+
fireEvent.click(getByText('Cancel'))
|
|
412
|
+
await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(
|
|
413
|
+
expect.objectContaining({
|
|
414
|
+
name: 'Original',
|
|
415
|
+
tracks: expect.arrayContaining([
|
|
416
|
+
expect.arrayContaining([expect.objectContaining({ id: 'overlay-1', props: { text: 'Old text' } })]),
|
|
417
|
+
]),
|
|
418
|
+
}),
|
|
419
|
+
))
|
|
420
|
+
|
|
421
|
+
// A real external frame now arrives (SSE echo / caption regen / restoreVersion)
|
|
422
|
+
// — an authoritative change that must survive whatever the cancelled gesture
|
|
423
|
+
// left behind in the sync core.
|
|
424
|
+
await act(async () => { adapter.emit({ ...initial, name: 'FromServer' }) })
|
|
425
|
+
await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'FromServer' })))
|
|
426
|
+
|
|
427
|
+
// A second overlay-props gesture must baseline against THIS state, not a
|
|
428
|
+
// stale pre-first-gesture snapshot left behind if Cancel failed to clear it.
|
|
429
|
+
const editBtn2 = await findByTitle('Edit overlay')
|
|
430
|
+
fireEvent.click(editBtn2)
|
|
431
|
+
const textField2 = await findByLabelText('text')
|
|
432
|
+
fireEvent.change(textField2, { target: { value: 'Second edit' } })
|
|
433
|
+
fireEvent.click(getByText('Save'))
|
|
434
|
+
|
|
435
|
+
// Undo should remove only the second gesture, landing back on the external
|
|
436
|
+
// ('FromServer') state — not the stale first-gesture baseline ('Original').
|
|
437
|
+
await act(async () => {
|
|
438
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
439
|
+
})
|
|
440
|
+
await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'FromServer' })))
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// Regression: the caption-repair effect (VideoEditor.tsx, near backfillCaptionIds)
|
|
444
|
+
// used to be keyed on project.id ONLY, so it ran once per project load and never
|
|
445
|
+
// again. CaptionRegenModal's onDone replaces project.captions via applyExternal
|
|
446
|
+
// WITHOUT changing project.id, so a mid-session regeneration used to skip word
|
|
447
|
+
// repair entirely until a remount. The effect is now also keyed on
|
|
448
|
+
// project.captions.
|
|
449
|
+
it('regenerating captions mid-session (no project.id change) re-runs word repair, and the effect settles instead of looping', async () => {
|
|
450
|
+
const adapter = makeFakeAdapter()
|
|
451
|
+
// Text has a double space between words and no words[] at all — this
|
|
452
|
+
// exercises both "needs a repair pass" AND (via captionRepair.ts's
|
|
453
|
+
// whitespace-normalized comparison) "the repair reaches a fixed point on
|
|
454
|
+
// the very next pass", which is what stops the widened effect from
|
|
455
|
+
// applyExternal-ing forever.
|
|
456
|
+
adapter.generateCaptions = async function* (): AsyncIterable<CaptionEvent> {
|
|
457
|
+
yield {
|
|
458
|
+
type: 'done',
|
|
459
|
+
captions: { style: 'clean', segments: [{ id: 's1', text: 'brand new text', start: 0, end: 3 }] },
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const onProjectChange = vi.fn()
|
|
463
|
+
const { findByText } = render(
|
|
464
|
+
<VideoEditor
|
|
465
|
+
project={makeVideoProject({ status: 'draft' })}
|
|
466
|
+
adapter={adapter}
|
|
467
|
+
onProjectChange={onProjectChange}
|
|
468
|
+
slots={{ exportActions: <div /> }}
|
|
469
|
+
/>,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
const regenBtn = await findByText('Regenerate')
|
|
473
|
+
await act(async () => { regenBtn.click() })
|
|
474
|
+
|
|
475
|
+
// Repair fired for a captions-only replacement — words[] derived from the
|
|
476
|
+
// (whitespace-collapsed) text, not left stale/absent.
|
|
477
|
+
await waitFor(() => {
|
|
478
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
479
|
+
expect(last.captions?.segments[0]?.words?.map((w) => w.word)).toEqual(['brand', 'new', 'text'])
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// Settles: once repaired, flushing further ticks must produce no additional
|
|
483
|
+
// onProjectChange calls — proves the effect reached its fixed point instead
|
|
484
|
+
// of looping.
|
|
485
|
+
const settledCallCount = onProjectChange.mock.calls.length
|
|
486
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
487
|
+
expect(onProjectChange.mock.calls.length).toBe(settledCallCount)
|
|
488
|
+
})
|
|
247
489
|
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { Project } from '../../types'
|
|
3
|
+
import type { CaptionSegment } from '../../schema'
|
|
4
|
+
import { backfillCaptionIds } from '../VideoEditor'
|
|
5
|
+
|
|
6
|
+
function project(segments: CaptionSegment[] | null): Project {
|
|
7
|
+
return (segments
|
|
8
|
+
? { id: 'p1', captions: { style: 'word-by-word', segments } }
|
|
9
|
+
: { id: 'p1' }) as unknown as Project
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function seg(over: Partial<CaptionSegment> = {}): CaptionSegment {
|
|
13
|
+
return { text: 'hello', start: 0, end: 1, ...over }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ids = (p: Project) => p.captions!.segments.map((s) => s.id)
|
|
17
|
+
|
|
18
|
+
describe('backfillCaptionIds', () => {
|
|
19
|
+
it('returns the SAME reference when there is nothing to do', () => {
|
|
20
|
+
// This is the property the backfill effect's loop-safety rests on: after our
|
|
21
|
+
// own applyExternal re-fires the effect, the second pass must be a no-op.
|
|
22
|
+
const noCaptions = project(null)
|
|
23
|
+
expect(backfillCaptionIds(noCaptions)).toBe(noCaptions)
|
|
24
|
+
|
|
25
|
+
const empty = project([])
|
|
26
|
+
expect(backfillCaptionIds(empty)).toBe(empty)
|
|
27
|
+
|
|
28
|
+
const done = project([seg({ id: 'cap-0' }), seg({ id: 'cap-1' })])
|
|
29
|
+
expect(backfillCaptionIds(done)).toBe(done)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is idempotent — backfilling the result changes nothing further', () => {
|
|
33
|
+
const once = backfillCaptionIds(project([seg(), seg(), seg()]))
|
|
34
|
+
expect(backfillCaptionIds(once)).toBe(once)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('mints cap-<index> for an all-id-less track (regenerated captions)', () => {
|
|
38
|
+
const out = backfillCaptionIds(project([seg(), seg(), seg()]))
|
|
39
|
+
expect(ids(out)).toEqual(['cap-0', 'cap-1', 'cap-2'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('never overwrites an existing id', () => {
|
|
43
|
+
const out = backfillCaptionIds(project([seg({ id: 'kept' }), seg()]))
|
|
44
|
+
expect(ids(out)[0]).toBe('kept')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not mint a duplicate of an id already in use', () => {
|
|
48
|
+
// `cap-1` is already taken at index 2; index 1 must not be handed the same id.
|
|
49
|
+
const out = backfillCaptionIds(project([seg({ id: 'cap-0' }), seg(), seg({ id: 'cap-1' })]))
|
|
50
|
+
const got = ids(out)
|
|
51
|
+
expect(got).toEqual(['cap-0', 'cap-2', 'cap-1'])
|
|
52
|
+
expect(new Set(got).size).toBe(got.length)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('stays collision-free across a mixed track (backfilled + freshly regenerated)', () => {
|
|
56
|
+
const out = backfillCaptionIds(project([
|
|
57
|
+
seg(), seg({ id: 'cap-0' }), seg(), seg({ id: 'cap-3' }), seg(), seg({ id: 'cap-1' }), seg(),
|
|
58
|
+
]))
|
|
59
|
+
const got = ids(out)
|
|
60
|
+
expect(new Set(got).size).toBe(got.length)
|
|
61
|
+
expect(got.every((id) => !!id)).toBe(true)
|
|
62
|
+
// Deterministic: the counter only moves forward, skipping ids already taken.
|
|
63
|
+
expect(got).toEqual(['cap-2', 'cap-0', 'cap-4', 'cap-3', 'cap-5', 'cap-1', 'cap-6'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('preserves every other field on the segments it touches', () => {
|
|
67
|
+
const out = backfillCaptionIds(project([seg({ text: 'x', start: 2, end: 4, offsetX: 10, scale: 1.5 })]))
|
|
68
|
+
expect(out.captions!.segments[0]).toMatchObject({ text: 'x', start: 2, end: 4, offsetX: 10, scale: 1.5 })
|
|
69
|
+
})
|
|
70
|
+
})
|