@devbycrux/editor 0.1.0 → 0.3.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/carousel/CarouselEditor.tsx +28 -16
  4. package/src/carousel/SlideCanvas.tsx +12 -0
  5. package/src/carousel/SlidePropertyPanel.tsx +40 -2
  6. package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
  7. package/src/index.ts +23 -1
  8. package/src/types.ts +161 -0
  9. package/src/video/RenderModal.tsx +252 -0
  10. package/src/video/VersionPanel.tsx +83 -0
  11. package/src/video/VideoEditor.tsx +508 -0
  12. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  13. package/src/video/__tests__/captionRepair.test.ts +134 -0
  14. package/src/video/__tests__/cuts.test.ts +198 -0
  15. package/src/video/captionRepair.ts +41 -0
  16. package/src/video/cuts.ts +369 -0
  17. package/src/video/design-canvas.ts +11 -0
  18. package/src/video/preview/CaptionPreview.tsx +83 -0
  19. package/src/video/preview/CarouselPreview.tsx +35 -0
  20. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  21. package/src/video/preview/PreviewPlayer.tsx +178 -0
  22. package/src/video/preview/useDragOverlay.ts +167 -0
  23. package/src/video/preview/useVideoPlayback.ts +761 -0
  24. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  25. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  26. package/src/video/timeline/EditableSegment.tsx +30 -0
  27. package/src/video/timeline/Scrubber.tsx +184 -0
  28. package/src/video/timeline/Timeline.tsx +375 -0
  29. package/src/video/timeline/TimelineContext.ts +25 -0
  30. package/src/video/timeline/TranscriptModal.tsx +63 -0
  31. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  32. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  33. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  34. package/src/video/timeline/multiSelectOps.ts +157 -0
  35. package/src/video/timeline/useItemDragDrop.ts +190 -0
  36. package/src/video/timeline/useTimelineZoom.ts +48 -0
  37. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, waitFor } from '@testing-library/react'
3
+ import type {
4
+ EditorAdapter,
5
+ ImageElement,
6
+ Project,
7
+ RenderEvent,
8
+ VersionEntry,
9
+ WaveformChunk,
10
+ } from '../../types'
11
+ import VideoEditor from '../VideoEditor'
12
+
13
+ // ── Fake adapter ──────────────────────────────────────────────────────────────
14
+ // Full EditorAdapter with the video-editor capabilities VideoEditor threads:
15
+ // listVersionHistory / restoreVersion / getWaveformChunks / compileOverlay /
16
+ // fileUrl / resolveCaptionTemplate. No host (`@/`) modules are mocked — the
17
+ // package owns the assembled editor.
18
+
19
+ function makeVideoProject(overrides: Partial<Project> = {}): Project {
20
+ return {
21
+ id: 'vid-1',
22
+ name: 'Test Video',
23
+ status: 'draft',
24
+ editingPrompt: '',
25
+ projectType: 'video',
26
+ settings: { resolution: [1080, 1920], fps: 30 },
27
+ tracks: [
28
+ [
29
+ {
30
+ id: 'clip-0',
31
+ type: 'video',
32
+ src: 'a.mp4',
33
+ start: 0,
34
+ end: 4,
35
+ inPoint: 0,
36
+ outPoint: 4,
37
+ },
38
+ ],
39
+ ],
40
+ audio: { tracks: [] },
41
+ assets: [],
42
+ ...overrides,
43
+ } as Project
44
+ }
45
+
46
+ interface FakeAdapter extends EditorAdapter<Project> {
47
+ saveCalls: Array<{ id: string; project: Project }>
48
+ }
49
+
50
+ function makeFakeAdapter(): FakeAdapter {
51
+ const saveCalls: Array<{ id: string; project: Project }> = []
52
+ return {
53
+ loadProject: vi.fn(async () => makeVideoProject()),
54
+ saveProject: vi.fn(async (id: string, project: Project) => { saveCalls.push({ id, project }) }),
55
+ subscribe: () => () => {},
56
+ render: async function* (): AsyncIterable<RenderEvent> {
57
+ yield { type: 'done', outputPath: '/out.mp4' }
58
+ },
59
+ resolveImageSrc: (el: ImageElement) => el.src,
60
+ compileOverlay: vi.fn(async () => () => null),
61
+ listGlobalOverlays: vi.fn(async () => []),
62
+ listSystemOverlays: vi.fn(async () => []),
63
+ uploadFile: vi.fn(async () => '/path'),
64
+ fileUrl: (path: string) => path,
65
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
66
+ restoreVersion: vi.fn(async (_id: string, _hash: string) => makeVideoProject()),
67
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
68
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
69
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
70
+ saveCalls,
71
+ }
72
+ }
73
+
74
+ beforeEach(() => {
75
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
76
+ vi.spyOn(console, 'error').mockImplementation(() => {})
77
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
78
+ observe() {}
79
+ unobserve() {}
80
+ disconnect() {}
81
+ }
82
+ // jsdom doesn't implement media element playback.
83
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
84
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
85
+ // jsdom has no Web Audio API; the video player wires per-clip gain through it.
86
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
87
+ state = 'running'
88
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
89
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
90
+ get destination() { return {} }
91
+ close() {}
92
+ }
93
+ })
94
+ afterEach(() => vi.restoreAllMocks())
95
+
96
+ describe('VideoEditor — editor-package integration', () => {
97
+ it('renders the timeline and preview for a draft project', async () => {
98
+ const adapter = makeFakeAdapter()
99
+ const initial = makeVideoProject()
100
+ const { container } = render(
101
+ <VideoEditor
102
+ project={initial}
103
+ adapter={adapter}
104
+ onProjectChange={vi.fn()}
105
+ slots={{ pendingStatus: <div data-testid="pending" />, exportActions: <div data-testid="export" /> }}
106
+ />,
107
+ )
108
+
109
+ // Timeline: the zoom control reads "1×" once totalDuration > 0.
110
+ await waitFor(() => {
111
+ expect(container.textContent).toContain('×')
112
+ })
113
+ // Preview: the video player mounts <video> elements for the clips.
114
+ await waitFor(() => {
115
+ expect(container.querySelector('video')).not.toBeNull()
116
+ })
117
+ })
118
+
119
+ it('shows the host pendingStatus slot for a pending project', async () => {
120
+ const adapter = makeFakeAdapter()
121
+ const initial = makeVideoProject({ status: 'pending', tracks: [[]] })
122
+ const { getByTestId } = render(
123
+ <VideoEditor
124
+ project={initial}
125
+ adapter={adapter}
126
+ onProjectChange={vi.fn()}
127
+ slots={{ pendingStatus: <div data-testid="pending" />, exportActions: <div data-testid="export" /> }}
128
+ />,
129
+ )
130
+ await waitFor(() => getByTestId('pending'))
131
+ })
132
+
133
+ it('queries version history via adapter.listVersionHistory', async () => {
134
+ const adapter = makeFakeAdapter()
135
+ const initial = makeVideoProject()
136
+ render(
137
+ <VideoEditor
138
+ project={initial}
139
+ adapter={adapter}
140
+ onProjectChange={vi.fn()}
141
+ slots={{ pendingStatus: <div data-testid="pending" />, exportActions: <div data-testid="export" /> }}
142
+ />,
143
+ )
144
+ await waitFor(() => {
145
+ expect(adapter.listVersionHistory).toHaveBeenCalledWith('vid-1')
146
+ })
147
+ })
148
+
149
+ it('invokes onBackToSetup affordance only when the host supplies it', async () => {
150
+ const adapter = makeFakeAdapter()
151
+ const initial = makeVideoProject({ status: 'pending', tracks: [[]] })
152
+ const onBackToSetup = vi.fn()
153
+ const { findByText } = render(
154
+ <VideoEditor
155
+ project={initial}
156
+ adapter={adapter}
157
+ onProjectChange={vi.fn()}
158
+ onBackToSetup={onBackToSetup}
159
+ />,
160
+ )
161
+ const back = await findByText(/Back to setup/i)
162
+ back.click()
163
+ expect(onBackToSetup).toHaveBeenCalledTimes(1)
164
+ })
165
+
166
+ it('Render button flips project status to final and persists before opening modal', async () => {
167
+ const adapter = makeFakeAdapter()
168
+ const initial = makeVideoProject({ status: 'draft' })
169
+ const onProjectChange = vi.fn()
170
+ const { findByText } = render(
171
+ <VideoEditor
172
+ project={initial}
173
+ adapter={adapter}
174
+ onProjectChange={onProjectChange}
175
+ slots={{ exportActions: <div /> }}
176
+ />,
177
+ )
178
+
179
+ const renderBtn = await findByText('Render →')
180
+ renderBtn.click()
181
+
182
+ // onProjectChange should have been called with status: 'final'
183
+ expect(onProjectChange).toHaveBeenCalledWith(
184
+ expect.objectContaining({ status: 'final' }),
185
+ )
186
+ // saveProject should have been called with status: 'final'
187
+ await waitFor(() => {
188
+ expect(adapter.saveProject).toHaveBeenCalledWith(
189
+ 'vid-1',
190
+ expect.objectContaining({ status: 'final' }),
191
+ )
192
+ })
193
+ })
194
+
195
+ it('shows the skill-path card on the pending surface when getInfo returns a path', async () => {
196
+ const adapter = makeFakeAdapter()
197
+ adapter.getInfo = vi.fn(async () => ({ root_skill_path: 'skills/video-skill.md' }))
198
+ const initial = makeVideoProject({ status: 'pending', tracks: [[]] })
199
+ const { findByText } = render(
200
+ <VideoEditor
201
+ project={initial}
202
+ adapter={adapter}
203
+ onProjectChange={vi.fn()}
204
+ // no pendingStatus slot → should show default card
205
+ />,
206
+ )
207
+
208
+ // The card header should appear
209
+ await findByText(/Send this to your agent/i)
210
+ // The copy-able prompt text should include the skill path
211
+ await findByText(/skills\/video-skill\.md/i)
212
+ })
213
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { repairCaptionWords } from '../captionRepair'
3
+ import type { Captions, CaptionSegment } from '../../schema'
4
+
5
+ function makeCaptions(segments: CaptionSegment[]): Captions {
6
+ return { style: 'word-by-word', segments }
7
+ }
8
+
9
+ describe('repairCaptionWords', () => {
10
+ it('returns null when all segments are already consistent', () => {
11
+ const captions = makeCaptions([
12
+ {
13
+ id: 's1',
14
+ text: 'hello world',
15
+ start: 0,
16
+ end: 2,
17
+ words: [
18
+ { word: 'hello', start: 0, end: 1 },
19
+ { word: 'world', start: 1, end: 2 },
20
+ ],
21
+ },
22
+ ])
23
+ expect(repairCaptionWords(captions)).toBeNull()
24
+ })
25
+
26
+ it('returns null on case-insensitive match', () => {
27
+ const captions = makeCaptions([
28
+ {
29
+ id: 's1',
30
+ text: 'Hello World',
31
+ start: 0,
32
+ end: 2,
33
+ words: [
34
+ { word: 'hello', start: 0, end: 1 },
35
+ { word: 'world', start: 1, end: 2 },
36
+ ],
37
+ },
38
+ ])
39
+ // words text "hello world" vs seg.text "Hello World" — case-insensitive match → no repair
40
+ expect(repairCaptionWords(captions)).toBeNull()
41
+ })
42
+
43
+ it('repairs a segment whose words text diverged from edited text', () => {
44
+ const captions = makeCaptions([
45
+ {
46
+ id: 's1',
47
+ text: 'new edited text', // edited inline
48
+ start: 0,
49
+ end: 3,
50
+ words: [
51
+ { word: 'old', start: 0, end: 1 },
52
+ { word: 'stale', start: 1, end: 2 },
53
+ { word: 'words', start: 2, end: 3 },
54
+ ],
55
+ },
56
+ ])
57
+ const result = repairCaptionWords(captions)
58
+ expect(result).not.toBeNull()
59
+ const seg = result!.segments[0]
60
+ expect(seg.words).toHaveLength(3)
61
+ expect(seg.words![0].word).toBe('new')
62
+ expect(seg.words![1].word).toBe('edited')
63
+ expect(seg.words![2].word).toBe('text')
64
+ // Uniform timing across [0, 3] — each word gets 1s
65
+ expect(seg.words![0].start).toBeCloseTo(0)
66
+ expect(seg.words![0].end).toBeCloseTo(1)
67
+ expect(seg.words![1].start).toBeCloseTo(1)
68
+ expect(seg.words![2].end).toBeCloseTo(3)
69
+ })
70
+
71
+ it('handles a segment with no words array (missing)', () => {
72
+ const captions = makeCaptions([
73
+ {
74
+ id: 's2',
75
+ text: 'only text',
76
+ start: 1,
77
+ end: 3,
78
+ // words absent
79
+ },
80
+ ])
81
+ const result = repairCaptionWords(captions)
82
+ expect(result).not.toBeNull()
83
+ const seg = result!.segments[0]
84
+ expect(seg.words).toHaveLength(2)
85
+ expect(seg.words![0].word).toBe('only')
86
+ expect(seg.words![1].word).toBe('text')
87
+ })
88
+
89
+ it('only repairs diverged segments, preserving consistent ones', () => {
90
+ const captions = makeCaptions([
91
+ {
92
+ id: 's1',
93
+ text: 'unchanged',
94
+ start: 0,
95
+ end: 1,
96
+ words: [{ word: 'unchanged', start: 0, end: 1 }],
97
+ },
98
+ {
99
+ id: 's2',
100
+ text: 'new words here',
101
+ start: 1,
102
+ end: 4,
103
+ words: [{ word: 'old', start: 1, end: 2 }],
104
+ },
105
+ ])
106
+ const result = repairCaptionWords(captions)
107
+ expect(result).not.toBeNull()
108
+ // s1 preserved by reference
109
+ expect(result!.segments[0]).toBe(captions.segments[0])
110
+ // s2 repaired
111
+ expect(result!.segments[1].words).toHaveLength(3)
112
+ })
113
+
114
+ it('returns null for empty segments array', () => {
115
+ const captions = makeCaptions([])
116
+ // no segments → nothing changed
117
+ expect(repairCaptionWords(captions)).toBeNull()
118
+ })
119
+
120
+ it('handles a single-word segment with correct timing', () => {
121
+ const captions = makeCaptions([
122
+ {
123
+ id: 's3',
124
+ text: 'hello',
125
+ start: 5,
126
+ end: 7,
127
+ words: [{ word: 'goodbye', start: 5, end: 7 }],
128
+ },
129
+ ])
130
+ const result = repairCaptionWords(captions)
131
+ expect(result).not.toBeNull()
132
+ expect(result!.segments[0].words![0]).toEqual({ word: 'hello', start: 5, end: 7 })
133
+ })
134
+ })
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ applyCutToTracks,
4
+ applyCutToItem,
5
+ collapseGaps,
6
+ splitAtTime,
7
+ } from '../cuts'
8
+ import type { EditorProject as Project } from '../../schema'
9
+
10
+ // Minimal project factory — only the fields the cut engine touches.
11
+ function makeProject(over: Partial<Project> = {}): Project {
12
+ return {
13
+ id: 'p1',
14
+ status: 'draft',
15
+ settings: { resolution: [1080, 1920] },
16
+ tracks: [[]],
17
+ ...over,
18
+ }
19
+ }
20
+
21
+ describe('applyCutToTracks (lift)', () => {
22
+ it('returns the project unchanged for a zero/negative-length cut', () => {
23
+ const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10 }]] })
24
+ expect(applyCutToTracks(p, { start: 5, end: 5 })).toBe(p)
25
+ expect(applyCutToTracks(p, { start: 6, end: 3 })).toBe(p)
26
+ })
27
+
28
+ it('deletes a clip fully inside the cut and leaves later clips in place (no shift)', () => {
29
+ const p = makeProject({
30
+ tracks: [[
31
+ { id: 'a', type: 'video', start: 0, end: 5 },
32
+ { id: 'b', type: 'video', start: 5, end: 10 },
33
+ ]],
34
+ })
35
+ const out = applyCutToTracks(p, { start: 5, end: 10 })
36
+ const primary = out.tracks![0]
37
+ expect(primary.map(c => c.id)).toEqual(['a'])
38
+ expect(primary[0]).toMatchObject({ start: 0, end: 5 })
39
+ })
40
+
41
+ it('trims a clip overlapping the left edge of the cut', () => {
42
+ const p = makeProject({
43
+ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
44
+ })
45
+ const out = applyCutToTracks(p, { start: 6, end: 12 })
46
+ expect(out.tracks![0][0]).toMatchObject({ id: 'a', end: 6, outPoint: 6 })
47
+ })
48
+
49
+ it('splits a clip the cut spans into two fragments (lift positions)', () => {
50
+ const p = makeProject({
51
+ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
52
+ })
53
+ const out = applyCutToTracks(p, { start: 3, end: 7 })
54
+ const primary = out.tracks![0]
55
+ expect(primary).toHaveLength(2)
56
+ expect(primary[0]).toMatchObject({ id: 'a', start: 0, end: 3 })
57
+ expect(primary[1].start).toBe(7) // right fragment stays at original timeline pos
58
+ expect(primary[1].inPoint).toBe(7)
59
+ })
60
+
61
+ it('shifts captions after the cut by the cut duration', () => {
62
+ const p = makeProject({
63
+ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10 }]],
64
+ captions: {
65
+ style: 'subtitle',
66
+ segments: [
67
+ { text: 'before', start: 0, end: 2 },
68
+ { text: 'after', start: 8, end: 10 },
69
+ ],
70
+ },
71
+ })
72
+ const out = applyCutToTracks(p, { start: 3, end: 5 })
73
+ const segs = out.captions!.segments
74
+ expect(segs.find(s => s.text === 'before')).toMatchObject({ start: 0, end: 2 })
75
+ expect(segs.find(s => s.text === 'after')).toMatchObject({ start: 6, end: 8 })
76
+ })
77
+
78
+ it('leaves overlay tracks (tracks[1+]) untouched', () => {
79
+ const overlay = { id: 'o', type: 'overlay' as const, start: 4, end: 6 }
80
+ const p = makeProject({
81
+ tracks: [
82
+ [{ id: 'a', type: 'video', start: 0, end: 10 }],
83
+ [overlay],
84
+ ],
85
+ })
86
+ const out = applyCutToTracks(p, { start: 3, end: 7 })
87
+ expect(out.tracks![1][0]).toEqual(overlay)
88
+ })
89
+ })
90
+
91
+ describe('collapseGaps', () => {
92
+ it('returns the same reference when there are fewer than 2 clips', () => {
93
+ const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 5, end: 10 }]] })
94
+ expect(collapseGaps(p)).toBe(p)
95
+ })
96
+
97
+ it('returns the same reference when there are no gaps', () => {
98
+ const p = makeProject({
99
+ tracks: [[
100
+ { id: 'a', type: 'video', start: 0, end: 5 },
101
+ { id: 'b', type: 'video', start: 5, end: 10 },
102
+ ]],
103
+ })
104
+ expect(collapseGaps(p)).toBe(p)
105
+ })
106
+
107
+ it('shifts clips left to close gaps and remaps captions', () => {
108
+ const p = makeProject({
109
+ tracks: [[
110
+ { id: 'a', type: 'video', start: 0, end: 5 },
111
+ { id: 'b', type: 'video', start: 8, end: 12 },
112
+ ]],
113
+ captions: {
114
+ style: 'subtitle',
115
+ segments: [{ text: 'b-cap', start: 9, end: 11, words: [{ word: 'x', start: 9, end: 11 }] }],
116
+ },
117
+ })
118
+ const out = collapseGaps(p)
119
+ const primary = out.tracks![0]
120
+ expect(primary[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
121
+ expect(primary[1]).toMatchObject({ id: 'b', start: 5, end: 9 }) // shifted left by 3
122
+ const cap = out.captions!.segments[0]
123
+ expect(cap).toMatchObject({ start: 6, end: 8 })
124
+ expect(cap.words![0]).toMatchObject({ start: 6, end: 8 })
125
+ })
126
+ })
127
+
128
+ describe('applyCutToItem (collapse, single item)', () => {
129
+ it('returns the project unchanged when itemId is not found', () => {
130
+ const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10 }]] })
131
+ expect(applyCutToItem(p, 'nope', { start: 2, end: 4 })).toBe(p)
132
+ })
133
+
134
+ it('collapses a middle cut within a single primary clip (right fragment butts left)', () => {
135
+ const p = makeProject({
136
+ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
137
+ })
138
+ const out = applyCutToItem(p, 'a', { start: 3, end: 7 })
139
+ const primary = out.tracks![0]
140
+ expect(primary).toHaveLength(2)
141
+ expect(primary[0]).toMatchObject({ start: 0, end: 3, outPoint: 3 })
142
+ // right fragment collapses: starts at cut.start (3), duration = remaining 3s
143
+ expect(primary[1]).toMatchObject({ start: 3, end: 6, inPoint: 7 })
144
+ })
145
+
146
+ it('clamps the cut to the item bounds', () => {
147
+ const p = makeProject({
148
+ tracks: [[{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 0, outPoint: 6 }]],
149
+ })
150
+ const out = applyCutToItem(p, 'a', { start: 0, end: 4 })
151
+ const primary = out.tracks![0]
152
+ // cut clamped to [2,4]; left fragment removed (nothing before 2), right collapses to start 2
153
+ expect(primary[0]).toMatchObject({ start: 2 })
154
+ })
155
+ })
156
+
157
+ describe('splitAtTime', () => {
158
+ it('returns the same reference when nothing contains the playhead', () => {
159
+ const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 0, end: 5 }]] })
160
+ expect(splitAtTime(p, 8, null)).toBe(p)
161
+ })
162
+
163
+ it('splits every clip containing `at` when itemId is null', () => {
164
+ const p = makeProject({
165
+ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
166
+ })
167
+ const out = splitAtTime(p, 4, null)
168
+ const primary = out.tracks![0]
169
+ expect(primary).toHaveLength(2)
170
+ expect(primary[0]).toMatchObject({ start: 0, end: 4 })
171
+ expect(primary[1]).toMatchObject({ start: 4 })
172
+ })
173
+
174
+ it('splits only the named item when itemId is given', () => {
175
+ const p = makeProject({
176
+ tracks: [[
177
+ { id: 'a', type: 'video', start: 0, end: 10 },
178
+ { id: 'b', type: 'video', start: 0, end: 10 },
179
+ ]],
180
+ })
181
+ const out = splitAtTime(p, 5, 'a')
182
+ // 'a' split into 2 fragments (original id + split id); 'b' untouched.
183
+ expect(out.tracks![0].filter(c => c.id.startsWith('a'))).toHaveLength(2)
184
+ expect(out.tracks![0].filter(c => c.id === 'b')).toHaveLength(1)
185
+ expect(out.tracks![0]).toHaveLength(3)
186
+ })
187
+
188
+ it('splits audio tracks containing `at`', () => {
189
+ const p = makeProject({
190
+ tracks: [[]],
191
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10, inPoint: 0 }] },
192
+ })
193
+ const out = splitAtTime(p, 4, null)
194
+ expect(out.audio!.tracks).toHaveLength(2)
195
+ expect(out.audio!.tracks[0]).toMatchObject({ end: 4, outPoint: 4 })
196
+ expect(out.audio!.tracks[1]).toMatchObject({ start: 4, inPoint: 4 })
197
+ })
198
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * repairCaptionWords — pure caption-data normalizer.
3
+ *
4
+ * When a caption segment's text has been edited inline, `seg.words[]` may lag
5
+ * behind and hold stale word text (or be missing entirely). This function walks
6
+ * every segment and, whenever the words[] text diverges from `seg.text`,
7
+ * regenerates words with uniform timing across [seg.start, seg.end].
8
+ *
9
+ * Returns the repaired `Captions` object when at least one segment was changed,
10
+ * or `null` when the data was already consistent (so callers can skip the
11
+ * downstream save/notify).
12
+ */
13
+ import type { Captions, CaptionSegment } from '../schema'
14
+
15
+ function repairSegment(seg: CaptionSegment): CaptionSegment {
16
+ const wordsText = (seg.words ?? []).map(w => w.word).join(' ')
17
+ if (wordsText.trim().toLowerCase() === seg.text.trim().toLowerCase()) return seg
18
+
19
+ const newWords = seg.text.split(/\s+/).filter(Boolean)
20
+ const segDur = seg.end - seg.start
21
+ const wordDur = segDur / (newWords.length || 1)
22
+ return {
23
+ ...seg,
24
+ words: newWords.map((w, i) => ({
25
+ word: w,
26
+ start: seg.start + i * wordDur,
27
+ end: seg.start + (i + 1) * wordDur,
28
+ })),
29
+ }
30
+ }
31
+
32
+ export function repairCaptionWords(captions: Captions): Captions | null {
33
+ let changed = false
34
+ const repairedSegments = captions.segments.map(seg => {
35
+ const repaired = repairSegment(seg)
36
+ if (repaired !== seg) changed = true
37
+ return repaired
38
+ })
39
+ if (!changed) return null
40
+ return { ...captions, segments: repairedSegments }
41
+ }