@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.
- package/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/CarouselEditor.tsx +28 -16
- package/src/carousel/SlideCanvas.tsx +12 -0
- package/src/carousel/SlidePropertyPanel.tsx +40 -2
- package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
- package/src/index.ts +23 -1
- package/src/types.ts +161 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +603 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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
|
+
}
|