eprec 0.0.1 → 1.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -29
  3. package/app/assets/styles.css +129 -0
  4. package/app/client/app.tsx +37 -0
  5. package/app/client/counter.tsx +22 -0
  6. package/app/client/entry.tsx +8 -0
  7. package/app/components/layout.tsx +37 -0
  8. package/app/config/env.ts +31 -0
  9. package/app/config/import-map.ts +9 -0
  10. package/app/config/init-env.ts +3 -0
  11. package/app/config/routes.ts +5 -0
  12. package/app/helpers/render.ts +6 -0
  13. package/app/router.tsx +102 -0
  14. package/app/routes/index.tsx +50 -0
  15. package/app-server.ts +60 -0
  16. package/cli.ts +173 -0
  17. package/package.json +46 -7
  18. package/process-course/chapter-processor.ts +1037 -0
  19. package/process-course/cli.ts +236 -0
  20. package/process-course/config.ts +50 -0
  21. package/process-course/edits/cli.ts +167 -0
  22. package/process-course/edits/combined-video-editor.ts +316 -0
  23. package/process-course/edits/edit-workspace.ts +90 -0
  24. package/process-course/edits/index.ts +20 -0
  25. package/process-course/edits/regenerate-transcript.ts +84 -0
  26. package/process-course/edits/remove-ranges.test.ts +36 -0
  27. package/process-course/edits/remove-ranges.ts +287 -0
  28. package/process-course/edits/timestamp-refinement.test.ts +25 -0
  29. package/process-course/edits/timestamp-refinement.ts +172 -0
  30. package/process-course/edits/transcript-diff.test.ts +105 -0
  31. package/process-course/edits/transcript-diff.ts +214 -0
  32. package/process-course/edits/transcript-output.test.ts +50 -0
  33. package/process-course/edits/transcript-output.ts +36 -0
  34. package/process-course/edits/types.ts +26 -0
  35. package/process-course/edits/video-editor.ts +246 -0
  36. package/process-course/errors.test.ts +63 -0
  37. package/process-course/errors.ts +82 -0
  38. package/process-course/ffmpeg.ts +449 -0
  39. package/process-course/jarvis-commands/handlers.ts +71 -0
  40. package/process-course/jarvis-commands/index.ts +14 -0
  41. package/process-course/jarvis-commands/parser.test.ts +348 -0
  42. package/process-course/jarvis-commands/parser.ts +257 -0
  43. package/process-course/jarvis-commands/types.ts +46 -0
  44. package/process-course/jarvis-commands/windows.ts +254 -0
  45. package/process-course/logging.ts +24 -0
  46. package/process-course/paths.test.ts +59 -0
  47. package/process-course/paths.ts +53 -0
  48. package/process-course/summary.test.ts +209 -0
  49. package/process-course/summary.ts +210 -0
  50. package/process-course/types.ts +85 -0
  51. package/process-course/utils/audio-analysis.test.ts +348 -0
  52. package/process-course/utils/audio-analysis.ts +463 -0
  53. package/process-course/utils/chapter-selection.test.ts +307 -0
  54. package/process-course/utils/chapter-selection.ts +136 -0
  55. package/process-course/utils/file-utils.test.ts +83 -0
  56. package/process-course/utils/file-utils.ts +57 -0
  57. package/process-course/utils/filename.test.ts +27 -0
  58. package/process-course/utils/filename.ts +12 -0
  59. package/process-course/utils/time-ranges.test.ts +221 -0
  60. package/process-course/utils/time-ranges.ts +86 -0
  61. package/process-course/utils/transcript.test.ts +257 -0
  62. package/process-course/utils/transcript.ts +86 -0
  63. package/process-course/utils/video-editing.ts +44 -0
  64. package/process-course-video.ts +389 -0
  65. package/public/robots.txt +2 -0
  66. package/server/bundling.ts +210 -0
  67. package/speech-detection.ts +355 -0
  68. package/utils.ts +138 -0
  69. package/whispercpp-transcribe.ts +343 -0
@@ -0,0 +1,316 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import { copyFile, mkdir, mkdtemp, rename, rm } from 'node:fs/promises'
4
+ import {
5
+ detectSpeechBounds,
6
+ checkSegmentHasSpeech,
7
+ } from '../../speech-detection'
8
+ import { extractChapterSegmentAccurate, concatSegments } from '../ffmpeg'
9
+ import { clamp, getMediaDurationSeconds } from '../../utils'
10
+ import { EDIT_CONFIG } from '../config'
11
+ import { editVideo } from './video-editor'
12
+ import {
13
+ findSpeechEndWithRmsFallback,
14
+ findSpeechStartWithRmsFallback,
15
+ } from '../utils/audio-analysis'
16
+ import { allocateJoinPadding } from '../utils/video-editing'
17
+
18
+ export interface CombineVideosOptions {
19
+ video1Path: string
20
+ video1TranscriptJsonPath?: string
21
+ video1EditedTextPath?: string
22
+ video1Duration?: number
23
+ video2Path: string
24
+ video2TranscriptJsonPath?: string
25
+ video2EditedTextPath?: string
26
+ video2Duration?: number
27
+ outputPath: string
28
+ overlapPaddingMs?: number
29
+ }
30
+
31
+ export interface CombineVideosResult {
32
+ success: boolean
33
+ error?: string
34
+ outputPath?: string
35
+ video1TrimEnd: number
36
+ video2TrimStart: number
37
+ }
38
+
39
+ export async function combineVideos(
40
+ options: CombineVideosOptions,
41
+ ): Promise<CombineVideosResult> {
42
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-combine-'))
43
+ try {
44
+ const { video1Path, video2Path } = await applyOptionalEdits(
45
+ options,
46
+ tempDir,
47
+ )
48
+ const editsApplied =
49
+ options.video1EditedTextPath || options.video2EditedTextPath
50
+ const video1Duration = editsApplied
51
+ ? await getMediaDurationSeconds(video1Path)
52
+ : (options.video1Duration ?? (await getMediaDurationSeconds(video1Path)))
53
+ const video2Duration = editsApplied
54
+ ? await getMediaDurationSeconds(video2Path)
55
+ : (options.video2Duration ?? (await getMediaDurationSeconds(video2Path)))
56
+
57
+ const video1HasSpeech = await checkSegmentHasSpeech(
58
+ video1Path,
59
+ video1Duration,
60
+ )
61
+ if (!video1HasSpeech) {
62
+ return {
63
+ success: false,
64
+ error: 'First video has no speech; cannot combine.',
65
+ video1TrimEnd: 0,
66
+ video2TrimStart: 0,
67
+ }
68
+ }
69
+
70
+ const paddingSeconds =
71
+ (options.overlapPaddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs) / 1000
72
+
73
+ const video1SpeechEnd = await findVideo1SpeechEnd({
74
+ inputPath: video1Path,
75
+ duration: video1Duration,
76
+ })
77
+ const { speechStart: video2SpeechStart, speechEnd: video2SpeechEnd } =
78
+ await findVideo2SpeechBounds({
79
+ inputPath: video2Path,
80
+ duration: video2Duration,
81
+ })
82
+ const video1AvailableSilence = Math.max(0, video1Duration - video1SpeechEnd)
83
+ const video2AvailableSilence = Math.max(0, video2SpeechStart)
84
+ const { previousPaddingSeconds, currentPaddingSeconds } =
85
+ allocateJoinPadding({
86
+ paddingSeconds,
87
+ previousAvailableSeconds: video1AvailableSilence,
88
+ currentAvailableSeconds: video2AvailableSilence,
89
+ })
90
+ const video1TrimEnd = clamp(
91
+ video1SpeechEnd + previousPaddingSeconds,
92
+ 0,
93
+ video1Duration,
94
+ )
95
+ const video2TrimStart = clamp(
96
+ video2SpeechStart - currentPaddingSeconds,
97
+ 0,
98
+ video2Duration,
99
+ )
100
+ const video2TrimEnd = clamp(
101
+ video2SpeechEnd + paddingSeconds,
102
+ 0,
103
+ video2Duration,
104
+ )
105
+
106
+ const segment1Path = path.join(tempDir, 'segment-1.mp4')
107
+ const segment2Path = path.join(tempDir, 'segment-2.mp4')
108
+ await extractChapterSegmentAccurate({
109
+ inputPath: video1Path,
110
+ outputPath: segment1Path,
111
+ start: 0,
112
+ end: video1TrimEnd,
113
+ })
114
+ if (video2TrimEnd <= video2TrimStart + 0.005) {
115
+ return {
116
+ success: false,
117
+ error: `Invalid trim bounds for second video: start (${video2TrimStart.toFixed(3)}s) >= end (${video2TrimEnd.toFixed(3)}s)`,
118
+ video1TrimEnd,
119
+ video2TrimStart,
120
+ }
121
+ }
122
+ await extractChapterSegmentAccurate({
123
+ inputPath: video2Path,
124
+ outputPath: segment2Path,
125
+ start: video2TrimStart,
126
+ end: video2TrimEnd,
127
+ })
128
+
129
+ const segment2HasSpeech = await checkSegmentHasSpeech(
130
+ segment2Path,
131
+ video2TrimEnd - video2TrimStart,
132
+ )
133
+ if (!segment2HasSpeech) {
134
+ return {
135
+ success: false,
136
+ error: 'Second video has no speech after trimming.',
137
+ video1TrimEnd,
138
+ video2TrimStart,
139
+ }
140
+ }
141
+
142
+ const resolvedOutputPath = await resolveOutputPath(
143
+ options.outputPath,
144
+ video1Path,
145
+ video2Path,
146
+ tempDir,
147
+ )
148
+ await mkdir(path.dirname(resolvedOutputPath), { recursive: true })
149
+ await concatSegments({
150
+ segmentPaths: [segment1Path, segment2Path],
151
+ outputPath: resolvedOutputPath,
152
+ })
153
+ await finalizeOutput(resolvedOutputPath, options.outputPath)
154
+
155
+ return {
156
+ success: true,
157
+ outputPath: options.outputPath,
158
+ video1TrimEnd,
159
+ video2TrimStart,
160
+ }
161
+ } catch (error) {
162
+ return {
163
+ success: false,
164
+ error: error instanceof Error ? error.message : String(error),
165
+ video1TrimEnd: 0,
166
+ video2TrimStart: 0,
167
+ }
168
+ } finally {
169
+ await rm(tempDir, { recursive: true, force: true })
170
+ }
171
+ }
172
+
173
+ async function applyOptionalEdits(
174
+ options: CombineVideosOptions,
175
+ tempDir: string,
176
+ ): Promise<{ video1Path: string; video2Path: string }> {
177
+ let video1Path = options.video1Path
178
+ let video2Path = options.video2Path
179
+
180
+ if (options.video1EditedTextPath) {
181
+ if (!options.video1TranscriptJsonPath) {
182
+ throw new Error('Missing transcript JSON for first video edits.')
183
+ }
184
+ const editedPath = path.join(tempDir, 'video1-edited.mp4')
185
+ const result = await editVideo({
186
+ inputPath: options.video1Path,
187
+ transcriptJsonPath: options.video1TranscriptJsonPath,
188
+ editedTextPath: options.video1EditedTextPath,
189
+ outputPath: editedPath,
190
+ paddingMs: options.overlapPaddingMs,
191
+ })
192
+ if (!result.success) {
193
+ throw new Error(result.error ?? 'Failed to edit first video.')
194
+ }
195
+ video1Path = editedPath
196
+ }
197
+
198
+ if (options.video2EditedTextPath) {
199
+ if (!options.video2TranscriptJsonPath) {
200
+ throw new Error('Missing transcript JSON for second video edits.')
201
+ }
202
+ const editedPath = path.join(tempDir, 'video2-edited.mp4')
203
+ const result = await editVideo({
204
+ inputPath: options.video2Path,
205
+ transcriptJsonPath: options.video2TranscriptJsonPath,
206
+ editedTextPath: options.video2EditedTextPath,
207
+ outputPath: editedPath,
208
+ paddingMs: options.overlapPaddingMs,
209
+ })
210
+ if (!result.success) {
211
+ throw new Error(result.error ?? 'Failed to edit second video.')
212
+ }
213
+ video2Path = editedPath
214
+ }
215
+
216
+ return { video1Path, video2Path }
217
+ }
218
+
219
+ async function findVideo1SpeechEnd(options: {
220
+ inputPath: string
221
+ duration: number
222
+ }): Promise<number> {
223
+ const endSearchWindow = Math.min(
224
+ options.duration * 0.3,
225
+ EDIT_CONFIG.speechSearchWindowSeconds * 2,
226
+ )
227
+ const endSearchStart = Math.max(0, options.duration - endSearchWindow)
228
+ const speechBounds = await detectSpeechBounds(
229
+ options.inputPath,
230
+ endSearchStart,
231
+ options.duration,
232
+ options.duration,
233
+ )
234
+ const speechEnd = speechBounds.note
235
+ ? speechBounds.end
236
+ : endSearchStart + speechBounds.end
237
+ let effectiveSpeechEnd = speechEnd
238
+ if (speechBounds.note || options.duration - speechEnd < 0.05) {
239
+ const rmsSpeechEnd = await findSpeechEndWithRmsFallback({
240
+ inputPath: options.inputPath,
241
+ start: endSearchStart,
242
+ duration: options.duration - endSearchStart,
243
+ })
244
+ if (rmsSpeechEnd !== null) {
245
+ effectiveSpeechEnd = endSearchStart + rmsSpeechEnd
246
+ }
247
+ }
248
+ return effectiveSpeechEnd
249
+ }
250
+
251
+ async function findVideo2SpeechBounds(options: {
252
+ inputPath: string
253
+ duration: number
254
+ }): Promise<{ speechStart: number; speechEnd: number }> {
255
+ const speechBounds = await detectSpeechBounds(
256
+ options.inputPath,
257
+ 0,
258
+ options.duration,
259
+ options.duration,
260
+ )
261
+ let speechStart = speechBounds.start
262
+ if (speechBounds.note || speechBounds.start <= 0.05) {
263
+ const rmsSpeechStart = await findSpeechStartWithRmsFallback({
264
+ inputPath: options.inputPath,
265
+ start: 0,
266
+ duration: options.duration,
267
+ })
268
+ if (rmsSpeechStart !== null) {
269
+ speechStart = rmsSpeechStart
270
+ }
271
+ }
272
+ let speechEnd = speechBounds.end
273
+ if (speechBounds.note || options.duration - speechBounds.end < 0.05) {
274
+ const rmsSpeechEnd = await findSpeechEndWithRmsFallback({
275
+ inputPath: options.inputPath,
276
+ start: 0,
277
+ duration: options.duration,
278
+ })
279
+ if (rmsSpeechEnd !== null) {
280
+ speechEnd = rmsSpeechEnd
281
+ }
282
+ }
283
+ return {
284
+ speechStart,
285
+ speechEnd,
286
+ }
287
+ }
288
+
289
+ async function resolveOutputPath(
290
+ outputPath: string,
291
+ video1Path: string,
292
+ video2Path: string,
293
+ tempDir: string,
294
+ ): Promise<string> {
295
+ const resolvedOutput = path.resolve(outputPath)
296
+ const resolvedVideo1 = path.resolve(video1Path)
297
+ const resolvedVideo2 = path.resolve(video2Path)
298
+ if (resolvedOutput === resolvedVideo1 || resolvedOutput === resolvedVideo2) {
299
+ return path.join(tempDir, `combined-output${path.extname(outputPath)}`)
300
+ }
301
+ return outputPath
302
+ }
303
+
304
+ async function finalizeOutput(tempOutputPath: string, outputPath: string) {
305
+ const resolvedTemp = path.resolve(tempOutputPath)
306
+ const resolvedOutput = path.resolve(outputPath)
307
+ if (resolvedTemp === resolvedOutput) {
308
+ return
309
+ }
310
+ await rm(outputPath, { force: true })
311
+ try {
312
+ await rename(tempOutputPath, outputPath)
313
+ } catch {
314
+ await copyFile(tempOutputPath, outputPath)
315
+ }
316
+ }
@@ -0,0 +1,90 @@
1
+ import path from 'node:path'
2
+ import { copyFile, mkdir } from 'node:fs/promises'
3
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
4
+ import {
5
+ buildTranscriptWordsWithIndices,
6
+ generateTranscriptJson,
7
+ generateTranscriptText,
8
+ } from './transcript-output'
9
+
10
+ export type EditWorkspace = {
11
+ editsDirectory: string
12
+ transcriptTextPath: string
13
+ transcriptJsonPath: string
14
+ originalVideoPath: string
15
+ instructionsPath: string
16
+ }
17
+
18
+ export async function createEditWorkspace(options: {
19
+ outputDir: string
20
+ sourceVideoPath: string
21
+ sourceDuration: number
22
+ segments: TranscriptSegment[]
23
+ }): Promise<EditWorkspace> {
24
+ const editsRoot = path.join(options.outputDir, 'edits')
25
+ const parsed = path.parse(options.sourceVideoPath)
26
+ const editsDirectory = path.join(editsRoot, parsed.name)
27
+ await mkdir(editsDirectory, { recursive: true })
28
+
29
+ const originalVideoPath = path.join(editsDirectory, `original${parsed.ext}`)
30
+ await copyFile(options.sourceVideoPath, originalVideoPath)
31
+
32
+ const words = buildTranscriptWordsWithIndices(options.segments)
33
+ const transcriptTextPath = path.join(editsDirectory, 'transcript.txt')
34
+ const transcriptJsonPath = path.join(editsDirectory, 'transcript.json')
35
+ await Bun.write(transcriptTextPath, generateTranscriptText(words))
36
+ await Bun.write(
37
+ transcriptJsonPath,
38
+ generateTranscriptJson({
39
+ sourceVideo: path.basename(options.sourceVideoPath),
40
+ sourceDuration: options.sourceDuration,
41
+ words,
42
+ }),
43
+ )
44
+
45
+ const instructionsPath = path.join(editsDirectory, 'edit-instructions.md')
46
+ await Bun.write(
47
+ instructionsPath,
48
+ buildInstructions({
49
+ editsDirectory,
50
+ originalVideoPath,
51
+ transcriptJsonPath,
52
+ transcriptTextPath,
53
+ outputBasename: `${parsed.name}.edited${parsed.ext}`,
54
+ }),
55
+ )
56
+
57
+ return {
58
+ editsDirectory,
59
+ transcriptTextPath,
60
+ transcriptJsonPath,
61
+ originalVideoPath,
62
+ instructionsPath,
63
+ }
64
+ }
65
+
66
+ function buildInstructions(options: {
67
+ editsDirectory: string
68
+ originalVideoPath: string
69
+ transcriptJsonPath: string
70
+ transcriptTextPath: string
71
+ outputBasename: string
72
+ }): string {
73
+ return [
74
+ '# Manual edit workflow',
75
+ '',
76
+ '1) Edit `transcript.txt` and delete whole words only.',
77
+ '2) Run:',
78
+ '',
79
+ ` bun process-course/edits/cli.ts edit-video \\`,
80
+ ` --input "${options.originalVideoPath}" \\`,
81
+ ` --transcript "${options.transcriptJsonPath}" \\`,
82
+ ` --edited "${options.transcriptTextPath}" \\`,
83
+ ` --output "${path.join(options.editsDirectory, options.outputBasename)}"`,
84
+ '',
85
+ 'If the transcript no longer matches, regenerate it with:',
86
+ '',
87
+ ` bun process-course/edits/regenerate-transcript.ts --dir "${options.editsDirectory}"`,
88
+ '',
89
+ ].join('\n')
90
+ }
@@ -0,0 +1,20 @@
1
+ export type {
2
+ TranscriptJson,
3
+ TranscriptWordWithIndex,
4
+ TranscriptMismatchError,
5
+ } from './types'
6
+ export {
7
+ buildTranscriptWordsWithIndices,
8
+ generateTranscriptJson,
9
+ generateTranscriptText,
10
+ } from './transcript-output'
11
+ export { diffTranscripts, validateEditedTranscript } from './transcript-diff'
12
+ export {
13
+ wordsToTimeRanges,
14
+ refineRemovalRange,
15
+ refineAllRemovalRanges,
16
+ } from './timestamp-refinement'
17
+ export { editVideo, buildEditedOutputPath } from './video-editor'
18
+ export { combineVideos } from './combined-video-editor'
19
+ export type { EditWorkspace } from './edit-workspace'
20
+ export { createEditWorkspace } from './edit-workspace'
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bun
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { mkdtemp, readdir, rm } from 'node:fs/promises'
5
+ import yargs from 'yargs/yargs'
6
+ import { hideBin } from 'yargs/helpers'
7
+ import { extractTranscriptionAudio } from '../ffmpeg'
8
+ import { transcribeAudio } from '../../whispercpp-transcribe'
9
+ import { scaleTranscriptSegments } from '../jarvis-commands/parser'
10
+ import { EDIT_CONFIG } from '../config'
11
+ import {
12
+ buildTranscriptWordsWithIndices,
13
+ generateTranscriptJson,
14
+ generateTranscriptText,
15
+ } from './transcript-output'
16
+ import { getMediaDurationSeconds } from '../../utils'
17
+
18
+ async function main() {
19
+ const argv = yargs(hideBin(process.argv))
20
+ .scriptName('regenerate-transcript')
21
+ .option('dir', {
22
+ type: 'string',
23
+ demandOption: true,
24
+ describe: 'Edits directory containing transcript files',
25
+ })
26
+ .help()
27
+ .parseSync()
28
+
29
+ const editsDir = path.resolve(String(argv.dir))
30
+ const originalPath = await findOriginalVideo(editsDir)
31
+ const duration = await getMediaDurationSeconds(originalPath)
32
+
33
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'edit-transcript-'))
34
+ const audioPath = path.join(tempDir, 'transcribe.wav')
35
+ const outputBasePath = path.join(tempDir, 'transcript')
36
+
37
+ try {
38
+ await extractTranscriptionAudio({
39
+ inputPath: originalPath,
40
+ outputPath: audioPath,
41
+ start: 0,
42
+ end: duration,
43
+ })
44
+
45
+ const transcription = await transcribeAudio(audioPath, {
46
+ outputBasePath,
47
+ })
48
+ const segments =
49
+ transcription.segmentsSource === 'tokens'
50
+ ? transcription.segments
51
+ : scaleTranscriptSegments(transcription.segments, duration)
52
+ const words = buildTranscriptWordsWithIndices(segments)
53
+
54
+ const transcriptText = generateTranscriptText(words)
55
+ const transcriptJson = generateTranscriptJson({
56
+ sourceVideo: path.basename(originalPath),
57
+ sourceDuration: duration,
58
+ words,
59
+ })
60
+
61
+ await Bun.write(path.join(editsDir, 'transcript.txt'), transcriptText)
62
+ await Bun.write(path.join(editsDir, 'transcript.json'), transcriptJson)
63
+ } finally {
64
+ if (!EDIT_CONFIG.keepEditIntermediates) {
65
+ await rm(tempDir, { recursive: true, force: true })
66
+ }
67
+ }
68
+ }
69
+
70
+ async function findOriginalVideo(editsDir: string): Promise<string> {
71
+ const entries = await readdir(editsDir)
72
+ const originalFile = entries.find((entry) => entry.startsWith('original.'))
73
+ if (!originalFile) {
74
+ throw new Error(`No original video found in ${editsDir}.`)
75
+ }
76
+ return path.join(editsDir, originalFile)
77
+ }
78
+
79
+ main().catch((error) => {
80
+ console.error(
81
+ `[error] ${error instanceof Error ? error.message : String(error)}`,
82
+ )
83
+ process.exit(1)
84
+ })
@@ -0,0 +1,36 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { normalizeRemovalRanges, parseTimeRanges } from './remove-ranges'
3
+ import type { TimeRange } from '../types'
4
+
5
+ function createRange(start: number, end: number): TimeRange {
6
+ return { start, end }
7
+ }
8
+
9
+ function createRanges(...pairs: [number, number][]): TimeRange[] {
10
+ return pairs.map(([start, end]) => createRange(start, end))
11
+ }
12
+
13
+ test('parseTimeRanges parses comma and whitespace ranges', () => {
14
+ const ranges = parseTimeRanges('0-1, 2 - 3 4-5')
15
+ expect(ranges).toEqual(createRanges([0, 1], [2, 3], [4, 5]))
16
+ })
17
+
18
+ test('parseTimeRanges parses hh:mm:ss style timestamps', () => {
19
+ const ranges = parseTimeRanges('1:02-1:04.5')
20
+ expect(ranges).toHaveLength(1)
21
+ expect(ranges[0]?.start).toBeCloseTo(62, 6)
22
+ expect(ranges[0]?.end).toBeCloseTo(64.5, 6)
23
+ })
24
+
25
+ test('parseTimeRanges throws when end is missing', () => {
26
+ expect(() => parseTimeRanges('5-')).toThrow('Start and end required')
27
+ })
28
+
29
+ test('normalizeRemovalRanges clamps and merges ranges', () => {
30
+ const { ranges, warnings } = normalizeRemovalRanges({
31
+ ranges: createRanges([-1, 1], [0.8, 1.2], [4, 10]),
32
+ duration: 5,
33
+ })
34
+ expect(ranges).toEqual(createRanges([0, 1.2], [4, 5]))
35
+ expect(warnings.length).toBeGreaterThan(0)
36
+ })