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,214 @@
1
+ import { normalizeWords } from '../utils/transcript'
2
+ import type { TranscriptMismatchError, TranscriptWordWithIndex } from './types'
3
+
4
+ export type DiffResult = {
5
+ success: boolean
6
+ removedWords: TranscriptWordWithIndex[]
7
+ error?: string
8
+ mismatch?: TranscriptMismatchError
9
+ }
10
+
11
+ export type ValidationResult = {
12
+ valid: boolean
13
+ error?: string
14
+ mismatch?: TranscriptMismatchError
15
+ details?: {
16
+ unexpectedWord: string
17
+ position: number
18
+ }
19
+ }
20
+
21
+ export function diffTranscripts(options: {
22
+ originalWords: TranscriptWordWithIndex[]
23
+ editedText: string
24
+ }): DiffResult {
25
+ const validation = validateEditedTranscript(options)
26
+ if (!validation.valid) {
27
+ return {
28
+ success: false,
29
+ removedWords: [],
30
+ error: validation.error,
31
+ mismatch: validation.mismatch,
32
+ }
33
+ }
34
+
35
+ const editedWords = tokenizeEditedText(options.editedText)
36
+ const removedWords: TranscriptWordWithIndex[] = []
37
+ let originalIndex = 0
38
+ let editedIndex = 0
39
+
40
+ while (originalIndex < options.originalWords.length) {
41
+ const originalWord = options.originalWords[originalIndex]
42
+ if (!originalWord) {
43
+ originalIndex += 1
44
+ continue
45
+ }
46
+ const editedWord = editedWords[editedIndex]
47
+ if (editedWord && originalWord.word === editedWord) {
48
+ originalIndex += 1
49
+ editedIndex += 1
50
+ continue
51
+ }
52
+ removedWords.push(originalWord)
53
+ originalIndex += 1
54
+ }
55
+
56
+ if (editedIndex < editedWords.length) {
57
+ const mismatch = buildMismatchError({
58
+ type: 'word_added',
59
+ position: editedIndex,
60
+ editedWord: editedWords[editedIndex],
61
+ originalWord: null,
62
+ })
63
+ return {
64
+ success: false,
65
+ removedWords: [],
66
+ error: mismatch.message,
67
+ mismatch,
68
+ }
69
+ }
70
+
71
+ return { success: true, removedWords }
72
+ }
73
+
74
+ export function validateEditedTranscript(options: {
75
+ originalWords: TranscriptWordWithIndex[]
76
+ editedText: string
77
+ }): ValidationResult {
78
+ const editedWords = tokenizeEditedText(options.editedText)
79
+ if (options.originalWords.length === 0) {
80
+ return {
81
+ valid: false,
82
+ error: 'Original transcript has no words. Regenerate the transcript.',
83
+ }
84
+ }
85
+ if (editedWords.length === 0) {
86
+ return {
87
+ valid: false,
88
+ error:
89
+ 'Edited transcript is empty. Regenerate the transcript if this was unintentional.',
90
+ }
91
+ }
92
+
93
+ let originalIndex = 0
94
+ let editedIndex = 0
95
+
96
+ while (
97
+ originalIndex < options.originalWords.length &&
98
+ editedIndex < editedWords.length
99
+ ) {
100
+ const originalWord = options.originalWords[originalIndex]
101
+ const editedWord = editedWords[editedIndex]
102
+ if (!originalWord || !editedWord) {
103
+ break
104
+ }
105
+ if (originalWord.word === editedWord) {
106
+ originalIndex += 1
107
+ editedIndex += 1
108
+ continue
109
+ }
110
+
111
+ const nextMatchIndex = findNextMatchIndex(
112
+ options.originalWords,
113
+ editedWord,
114
+ originalIndex + 1,
115
+ )
116
+ if (nextMatchIndex === -1) {
117
+ const mismatchType = resolveMismatchType(
118
+ options.originalWords,
119
+ editedWord,
120
+ originalIndex,
121
+ )
122
+ const mismatch = buildMismatchError({
123
+ type: mismatchType,
124
+ position: editedIndex,
125
+ editedWord,
126
+ originalWord: originalWord.word,
127
+ })
128
+ return {
129
+ valid: false,
130
+ error: mismatch.message,
131
+ mismatch,
132
+ details: {
133
+ unexpectedWord: editedWord,
134
+ position: editedIndex,
135
+ },
136
+ }
137
+ }
138
+ originalIndex += 1
139
+ }
140
+
141
+ if (editedIndex < editedWords.length) {
142
+ const mismatch = buildMismatchError({
143
+ type: 'word_added',
144
+ position: editedIndex,
145
+ editedWord: editedWords[editedIndex],
146
+ originalWord: null,
147
+ })
148
+ return {
149
+ valid: false,
150
+ error: mismatch.message,
151
+ mismatch,
152
+ details: {
153
+ unexpectedWord: editedWords[editedIndex] ?? '',
154
+ position: editedIndex,
155
+ },
156
+ }
157
+ }
158
+
159
+ return { valid: true }
160
+ }
161
+
162
+ function tokenizeEditedText(text: string): string[] {
163
+ return normalizeWords(text)
164
+ }
165
+
166
+ function findNextMatchIndex(
167
+ words: TranscriptWordWithIndex[],
168
+ target: string,
169
+ startIndex: number,
170
+ ): number {
171
+ for (let index = startIndex; index < words.length; index += 1) {
172
+ if (words[index]?.word === target) {
173
+ return index
174
+ }
175
+ }
176
+ return -1
177
+ }
178
+
179
+ function resolveMismatchType(
180
+ words: TranscriptWordWithIndex[],
181
+ editedWord: string,
182
+ originalIndex: number,
183
+ ): TranscriptMismatchError['type'] {
184
+ const anyIndex = words.findIndex((word) => word.word === editedWord)
185
+ if (anyIndex >= 0 && anyIndex < originalIndex) {
186
+ return 'word_out_of_order'
187
+ }
188
+ return anyIndex >= 0 ? 'word_out_of_order' : 'word_modified'
189
+ }
190
+
191
+ function buildMismatchError(options: {
192
+ type: TranscriptMismatchError['type']
193
+ position: number
194
+ editedWord: string | undefined
195
+ originalWord: string | null
196
+ }): TranscriptMismatchError {
197
+ const expected =
198
+ options.originalWord === null ? 'end of transcript' : options.originalWord
199
+ const found = options.editedWord ?? 'end of transcript'
200
+ const typeLabel =
201
+ options.type === 'word_added'
202
+ ? 'Unexpected word'
203
+ : options.type === 'word_out_of_order'
204
+ ? 'Word out of order'
205
+ : 'Word modified'
206
+ const message = `Error: Transcript mismatch at word position ${options.position}.\nExpected: "${expected}"\nFound: "${found}"\n\nThe edited transcript contains changes that don't match the original.\nPlease regenerate the transcript and try again.`
207
+ return {
208
+ type: options.type,
209
+ position: options.position,
210
+ originalWord: options.originalWord ?? undefined,
211
+ editedWord: options.editedWord ?? undefined,
212
+ message: `${typeLabel}. ${message}`,
213
+ }
214
+ }
@@ -0,0 +1,50 @@
1
+ import { test, expect } from 'bun:test'
2
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
3
+ import {
4
+ buildTranscriptWordsWithIndices,
5
+ generateTranscriptJson,
6
+ generateTranscriptText,
7
+ } from './transcript-output'
8
+
9
+ function createSegment(
10
+ start: number,
11
+ end: number,
12
+ text: string,
13
+ ): TranscriptSegment {
14
+ return { start, end, text }
15
+ }
16
+
17
+ test('buildTranscriptWordsWithIndices assigns indices in order', () => {
18
+ const segments = [createSegment(0, 2, 'Hello world')]
19
+ const words = buildTranscriptWordsWithIndices(segments)
20
+ expect(words).toHaveLength(2)
21
+ expect(words[0]).toMatchObject({ word: 'hello', index: 0, start: 0, end: 1 })
22
+ expect(words[1]).toMatchObject({ word: 'world', index: 1, start: 1, end: 2 })
23
+ })
24
+
25
+ test('generateTranscriptText returns readable prose', () => {
26
+ const segments = [createSegment(0, 2, 'Hello world')]
27
+ const words = buildTranscriptWordsWithIndices(segments)
28
+ expect(generateTranscriptText(words)).toBe('hello world\n')
29
+ })
30
+
31
+ test('generateTranscriptJson outputs valid metadata', () => {
32
+ const segments = [createSegment(0, 2, 'Hello world')]
33
+ const words = buildTranscriptWordsWithIndices(segments)
34
+ const json = generateTranscriptJson({
35
+ sourceVideo: 'chapter-01.mp4',
36
+ sourceDuration: 2,
37
+ words,
38
+ })
39
+ const parsed = JSON.parse(json) as {
40
+ version: number
41
+ source_video: string
42
+ source_duration: number
43
+ words: Array<{ word: string; start: number; end: number; index: number }>
44
+ }
45
+ expect(parsed.version).toBe(1)
46
+ expect(parsed.source_video).toBe('chapter-01.mp4')
47
+ expect(parsed.source_duration).toBe(2)
48
+ expect(parsed.words).toHaveLength(2)
49
+ expect(parsed.words[0]).toHaveProperty('word', 'hello')
50
+ })
@@ -0,0 +1,36 @@
1
+ import type { TranscriptSegment } from '../../whispercpp-transcribe'
2
+ import { buildTranscriptWords } from '../jarvis-commands/parser'
3
+ import type { TranscriptJson, TranscriptWordWithIndex } from './types'
4
+
5
+ export function buildTranscriptWordsWithIndices(
6
+ segments: TranscriptSegment[],
7
+ ): TranscriptWordWithIndex[] {
8
+ const words = buildTranscriptWords(segments)
9
+ return words.map((word, index) => ({
10
+ ...word,
11
+ index,
12
+ }))
13
+ }
14
+
15
+ export function generateTranscriptText(
16
+ words: TranscriptWordWithIndex[],
17
+ ): string {
18
+ if (words.length === 0) {
19
+ return ''
20
+ }
21
+ return `${words.map((word) => word.word).join(' ')}\n`
22
+ }
23
+
24
+ export function generateTranscriptJson(options: {
25
+ sourceVideo: string
26
+ sourceDuration: number
27
+ words: TranscriptWordWithIndex[]
28
+ }): string {
29
+ const payload: TranscriptJson = {
30
+ version: 1,
31
+ source_video: options.sourceVideo,
32
+ source_duration: options.sourceDuration,
33
+ words: options.words,
34
+ }
35
+ return `${JSON.stringify(payload, null, 2)}\n`
36
+ }
@@ -0,0 +1,26 @@
1
+ export type TranscriptWordWithIndex = {
2
+ word: string
3
+ start: number
4
+ end: number
5
+ index: number
6
+ }
7
+
8
+ export type TranscriptJson = {
9
+ version: 1
10
+ source_video: string
11
+ source_duration: number
12
+ words: TranscriptWordWithIndex[]
13
+ }
14
+
15
+ export type TranscriptMismatchType =
16
+ | 'word_added'
17
+ | 'word_modified'
18
+ | 'word_out_of_order'
19
+
20
+ export type TranscriptMismatchError = {
21
+ type: TranscriptMismatchType
22
+ position: number
23
+ originalWord?: string
24
+ editedWord?: string
25
+ message: string
26
+ }
@@ -0,0 +1,246 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
4
+ import { extractChapterSegmentAccurate, concatSegments } from '../ffmpeg'
5
+ import { buildKeepRanges, mergeTimeRanges } from '../utils/time-ranges'
6
+ import { EDIT_CONFIG } from '../config'
7
+ import { diffTranscripts, validateEditedTranscript } from './transcript-diff'
8
+ import {
9
+ refineAllRemovalRanges,
10
+ wordsToTimeRanges,
11
+ } from './timestamp-refinement'
12
+ import type { TimeRange } from '../types'
13
+ import type { TranscriptJson, TranscriptWordWithIndex } from './types'
14
+
15
+ export interface EditVideoOptions {
16
+ inputPath: string
17
+ transcriptJsonPath: string
18
+ editedTextPath: string
19
+ outputPath: string
20
+ paddingMs?: number
21
+ }
22
+
23
+ export interface EditVideoResult {
24
+ success: boolean
25
+ error?: string
26
+ outputPath?: string
27
+ removedWords: TranscriptWordWithIndex[]
28
+ removedRanges: TimeRange[]
29
+ }
30
+
31
+ export function buildEditedOutputPath(inputPath: string): string {
32
+ const parsed = path.parse(inputPath)
33
+ return path.join(parsed.dir, `${parsed.name}.edited${parsed.ext}`)
34
+ }
35
+
36
+ export async function editVideo(
37
+ options: EditVideoOptions,
38
+ ): Promise<EditVideoResult> {
39
+ try {
40
+ const transcript = await readTranscriptJson(options.transcriptJsonPath)
41
+ const editedText = await Bun.file(options.editedTextPath).text()
42
+ const validation = validateEditedTranscript({
43
+ originalWords: transcript.words,
44
+ editedText,
45
+ })
46
+ if (!validation.valid) {
47
+ return {
48
+ success: false,
49
+ error: validation.error,
50
+ removedWords: [],
51
+ removedRanges: [],
52
+ }
53
+ }
54
+ const diffResult = diffTranscripts({
55
+ originalWords: transcript.words,
56
+ editedText,
57
+ })
58
+ if (!diffResult.success) {
59
+ return {
60
+ success: false,
61
+ error: diffResult.error,
62
+ removedWords: [],
63
+ removedRanges: [],
64
+ }
65
+ }
66
+
67
+ const removedWords = diffResult.removedWords
68
+ if (removedWords.length === 0) {
69
+ await ensureOutputCopy(options.inputPath, options.outputPath)
70
+ return {
71
+ success: true,
72
+ outputPath: options.outputPath,
73
+ removedWords,
74
+ removedRanges: [],
75
+ }
76
+ }
77
+
78
+ const removalRanges = wordsToTimeRanges(removedWords)
79
+ if (removalRanges.length === 0) {
80
+ await ensureOutputCopy(options.inputPath, options.outputPath)
81
+ return {
82
+ success: true,
83
+ outputPath: options.outputPath,
84
+ removedWords,
85
+ removedRanges: [],
86
+ }
87
+ }
88
+
89
+ const refinedRanges = await refineAllRemovalRanges({
90
+ inputPath: options.inputPath,
91
+ duration: transcript.source_duration,
92
+ ranges: removalRanges,
93
+ paddingMs: options.paddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs,
94
+ })
95
+ const mergedRanges = mergeTimeRanges(
96
+ refinedRanges.map((range) => range.refined),
97
+ )
98
+ const keepRanges = buildKeepRanges(
99
+ 0,
100
+ transcript.source_duration,
101
+ mergedRanges,
102
+ )
103
+
104
+ if (keepRanges.length === 0) {
105
+ return {
106
+ success: false,
107
+ error:
108
+ 'Edits remove the entire video. Regenerate the transcript and retry.',
109
+ removedWords,
110
+ removedRanges: mergedRanges,
111
+ }
112
+ }
113
+
114
+ await mkdir(path.dirname(options.outputPath), { recursive: true })
115
+
116
+ const isFullRange =
117
+ keepRanges.length === 1 &&
118
+ keepRanges[0] &&
119
+ keepRanges[0].start <= 0.001 &&
120
+ keepRanges[0].end >= transcript.source_duration - 0.001
121
+ if (isFullRange) {
122
+ await ensureOutputCopy(options.inputPath, options.outputPath)
123
+ return {
124
+ success: true,
125
+ outputPath: options.outputPath,
126
+ removedWords,
127
+ removedRanges: mergedRanges,
128
+ }
129
+ }
130
+
131
+ if (keepRanges.length === 1 && keepRanges[0]) {
132
+ await extractChapterSegmentAccurate({
133
+ inputPath: options.inputPath,
134
+ outputPath: options.outputPath,
135
+ start: keepRanges[0].start,
136
+ end: keepRanges[0].end,
137
+ })
138
+ return {
139
+ success: true,
140
+ outputPath: options.outputPath,
141
+ removedWords,
142
+ removedRanges: mergedRanges,
143
+ }
144
+ }
145
+
146
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-edit-'))
147
+ try {
148
+ const segmentPaths: string[] = []
149
+ for (const [index, range] of keepRanges.entries()) {
150
+ const segmentPath = path.join(tempDir, `segment-${index + 1}.mp4`)
151
+ await extractChapterSegmentAccurate({
152
+ inputPath: options.inputPath,
153
+ outputPath: segmentPath,
154
+ start: range.start,
155
+ end: range.end,
156
+ })
157
+ segmentPaths.push(segmentPath)
158
+ }
159
+ await concatSegments({
160
+ segmentPaths,
161
+ outputPath: options.outputPath,
162
+ })
163
+ return {
164
+ success: true,
165
+ outputPath: options.outputPath,
166
+ removedWords,
167
+ removedRanges: mergedRanges,
168
+ }
169
+ } finally {
170
+ await rm(tempDir, { recursive: true, force: true })
171
+ }
172
+ } catch (error) {
173
+ return {
174
+ success: false,
175
+ error: error instanceof Error ? error.message : String(error),
176
+ removedWords: [],
177
+ removedRanges: [],
178
+ }
179
+ }
180
+ }
181
+
182
+ async function ensureOutputCopy(inputPath: string, outputPath: string) {
183
+ const resolvedInput = path.resolve(inputPath)
184
+ const resolvedOutput = path.resolve(outputPath)
185
+ if (resolvedInput === resolvedOutput) {
186
+ return
187
+ }
188
+ await mkdir(path.dirname(outputPath), { recursive: true })
189
+ await copyFile(inputPath, outputPath)
190
+ }
191
+
192
+ async function readTranscriptJson(
193
+ transcriptJsonPath: string,
194
+ ): Promise<TranscriptJson> {
195
+ const raw = await Bun.file(transcriptJsonPath).text()
196
+ let parsed: unknown
197
+ try {
198
+ parsed = JSON.parse(raw)
199
+ } catch (error) {
200
+ throw new Error(
201
+ `Transcript JSON parse error: ${error instanceof Error ? error.message : String(error)}`,
202
+ )
203
+ }
204
+ if (!parsed || typeof parsed !== 'object') {
205
+ throw new Error('Transcript JSON is not an object.')
206
+ }
207
+ const payload = parsed as TranscriptJson
208
+ if (payload.version !== 1) {
209
+ throw new Error('Unsupported transcript JSON version.')
210
+ }
211
+ if (!payload.source_video || typeof payload.source_video !== 'string') {
212
+ throw new Error('Transcript JSON missing source_video.')
213
+ }
214
+ if (
215
+ typeof payload.source_duration !== 'number' ||
216
+ !Number.isFinite(payload.source_duration) ||
217
+ payload.source_duration <= 0
218
+ ) {
219
+ throw new Error('Transcript JSON has invalid source_duration.')
220
+ }
221
+ if (!Array.isArray(payload.words)) {
222
+ throw new Error('Transcript JSON missing words array.')
223
+ }
224
+ const words: TranscriptWordWithIndex[] = payload.words.map((word, index) => {
225
+ if (!word || typeof word !== 'object') {
226
+ throw new Error(`Transcript JSON word ${index} is invalid.`)
227
+ }
228
+ const entry = word as TranscriptWordWithIndex
229
+ if (typeof entry.word !== 'string') {
230
+ throw new Error(`Transcript JSON word ${index} missing word.`)
231
+ }
232
+ if (typeof entry.start !== 'number' || typeof entry.end !== 'number') {
233
+ throw new Error(`Transcript JSON word ${index} missing timing.`)
234
+ }
235
+ if (typeof entry.index !== 'number') {
236
+ throw new Error(`Transcript JSON word ${index} missing index.`)
237
+ }
238
+ return entry
239
+ })
240
+ return {
241
+ version: 1,
242
+ source_video: payload.source_video,
243
+ source_duration: payload.source_duration,
244
+ words,
245
+ }
246
+ }
@@ -0,0 +1,63 @@
1
+ import { test, expect } from 'bun:test'
2
+ import {
3
+ BadTakeError,
4
+ ChapterProcessingError,
5
+ ChapterTooShortError,
6
+ CommandParseError,
7
+ SpliceError,
8
+ TranscriptTooShortError,
9
+ TrimWindowError,
10
+ } from './errors'
11
+
12
+ test('ChapterProcessingError exposes metadata', () => {
13
+ const error = new ChapterProcessingError('Failed', 2, 'Intro')
14
+ expect(error.name).toBe('ChapterProcessingError')
15
+ expect(error.message).toBe('Failed')
16
+ expect(error.chapterIndex).toBe(2)
17
+ expect(error.chapterTitle).toBe('Intro')
18
+ })
19
+
20
+ test('ChapterTooShortError formats message with duration', () => {
21
+ const error = new ChapterTooShortError(1, 'Basics', 4.1234, 5)
22
+ expect(error.name).toBe('ChapterTooShortError')
23
+ expect(error.message).toBe('Chapter "Basics" is too short (4.12s < 5s)')
24
+ expect(error.duration).toBe(4.1234)
25
+ expect(error.minDuration).toBe(5)
26
+ })
27
+
28
+ test('CommandParseError stores transcript context', () => {
29
+ const error = new CommandParseError('Bad command', 'Jarvis bad take')
30
+ expect(error.name).toBe('CommandParseError')
31
+ expect(error.message).toBe('Bad command')
32
+ expect(error.transcript).toBe('Jarvis bad take')
33
+ })
34
+
35
+ test('TranscriptTooShortError formats message with word count', () => {
36
+ const error = new TranscriptTooShortError(0, 'Intro', 5, 10)
37
+ expect(error.name).toBe('TranscriptTooShortError')
38
+ expect(error.message).toBe(
39
+ 'Chapter "Intro" transcript too short (5 words < 10)',
40
+ )
41
+ expect(error.wordCount).toBe(5)
42
+ expect(error.minWordCount).toBe(10)
43
+ })
44
+
45
+ test('BadTakeError sets name and message', () => {
46
+ const error = new BadTakeError(3, 'Outro')
47
+ expect(error.name).toBe('BadTakeError')
48
+ expect(error.message).toBe('Chapter "Outro" marked as bad take')
49
+ })
50
+
51
+ test('SpliceError uses custom name', () => {
52
+ const error = new SpliceError('Failed to splice')
53
+ expect(error.name).toBe('SpliceError')
54
+ expect(error.message).toBe('Failed to splice')
55
+ })
56
+
57
+ test('TrimWindowError formats message with precision', () => {
58
+ const error = new TrimWindowError(1.23456, 1.23999)
59
+ expect(error.name).toBe('TrimWindowError')
60
+ expect(error.message).toBe('Trim window too small (1.235s -> 1.240s)')
61
+ expect(error.start).toBe(1.23456)
62
+ expect(error.end).toBe(1.23999)
63
+ })