eprec 0.0.1 → 1.0.1

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -29
  3. package/cli.ts +150 -0
  4. package/package.json +39 -7
  5. package/process-course/chapter-processor.ts +1037 -0
  6. package/process-course/cli.ts +236 -0
  7. package/process-course/config.ts +50 -0
  8. package/process-course/edits/cli.ts +167 -0
  9. package/process-course/edits/combined-video-editor.ts +316 -0
  10. package/process-course/edits/edit-workspace.ts +90 -0
  11. package/process-course/edits/index.ts +20 -0
  12. package/process-course/edits/regenerate-transcript.ts +84 -0
  13. package/process-course/edits/remove-ranges.test.ts +36 -0
  14. package/process-course/edits/remove-ranges.ts +287 -0
  15. package/process-course/edits/timestamp-refinement.test.ts +25 -0
  16. package/process-course/edits/timestamp-refinement.ts +172 -0
  17. package/process-course/edits/transcript-diff.test.ts +105 -0
  18. package/process-course/edits/transcript-diff.ts +214 -0
  19. package/process-course/edits/transcript-output.test.ts +50 -0
  20. package/process-course/edits/transcript-output.ts +36 -0
  21. package/process-course/edits/types.ts +26 -0
  22. package/process-course/edits/video-editor.ts +246 -0
  23. package/process-course/errors.test.ts +63 -0
  24. package/process-course/errors.ts +82 -0
  25. package/process-course/ffmpeg.ts +449 -0
  26. package/process-course/jarvis-commands/handlers.ts +71 -0
  27. package/process-course/jarvis-commands/index.ts +14 -0
  28. package/process-course/jarvis-commands/parser.test.ts +348 -0
  29. package/process-course/jarvis-commands/parser.ts +257 -0
  30. package/process-course/jarvis-commands/types.ts +46 -0
  31. package/process-course/jarvis-commands/windows.ts +254 -0
  32. package/process-course/logging.ts +24 -0
  33. package/process-course/paths.test.ts +59 -0
  34. package/process-course/paths.ts +53 -0
  35. package/process-course/summary.test.ts +209 -0
  36. package/process-course/summary.ts +210 -0
  37. package/process-course/types.ts +85 -0
  38. package/process-course/utils/audio-analysis.test.ts +348 -0
  39. package/process-course/utils/audio-analysis.ts +463 -0
  40. package/process-course/utils/chapter-selection.test.ts +307 -0
  41. package/process-course/utils/chapter-selection.ts +136 -0
  42. package/process-course/utils/file-utils.test.ts +83 -0
  43. package/process-course/utils/file-utils.ts +57 -0
  44. package/process-course/utils/filename.test.ts +27 -0
  45. package/process-course/utils/filename.ts +12 -0
  46. package/process-course/utils/time-ranges.test.ts +221 -0
  47. package/process-course/utils/time-ranges.ts +86 -0
  48. package/process-course/utils/transcript.test.ts +257 -0
  49. package/process-course/utils/transcript.ts +86 -0
  50. package/process-course/utils/video-editing.ts +44 -0
  51. package/process-course-video.ts +389 -0
  52. package/speech-detection.ts +355 -0
  53. package/utils.ts +138 -0
  54. package/whispercpp-transcribe.ts +345 -0
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env bun
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
5
+ import yargs from 'yargs/yargs'
6
+ import { hideBin } from 'yargs/helpers'
7
+ import { extractChapterSegmentAccurate, concatSegments } from '../ffmpeg'
8
+ import { buildKeepRanges, mergeTimeRanges } from '../utils/time-ranges'
9
+ import { clamp, getMediaDurationSeconds } from '../../utils'
10
+ import type { TimeRange } from '../types'
11
+
12
+ export type RemoveRangesOptions = {
13
+ inputPath: string
14
+ outputPath: string
15
+ ranges: TimeRange[]
16
+ duration?: number
17
+ }
18
+
19
+ export type RemoveRangesResult = {
20
+ success: boolean
21
+ error?: string
22
+ outputPath?: string
23
+ removedRanges: TimeRange[]
24
+ keepRanges: TimeRange[]
25
+ }
26
+
27
+ export function buildRangesRemovedOutputPath(inputPath: string): string {
28
+ const parsed = path.parse(inputPath)
29
+ return path.join(parsed.dir, `${parsed.name}.ranges-removed${parsed.ext}`)
30
+ }
31
+
32
+ export function parseTimeRanges(value: string): TimeRange[] {
33
+ const trimmed = value.trim()
34
+ if (!trimmed) {
35
+ throw new Error('No time ranges provided.')
36
+ }
37
+ const normalized = trimmed
38
+ .replace(/\s*-\s*/g, '-')
39
+ .replace(/\s*\.\.\s*/g, '..')
40
+ const tokens = normalized.split(/[\s,;]+/).filter(Boolean)
41
+ if (tokens.length === 0) {
42
+ throw new Error('No time ranges provided.')
43
+ }
44
+ return tokens.map(parseRangeToken)
45
+ }
46
+
47
+ export function normalizeRemovalRanges(options: {
48
+ ranges: TimeRange[]
49
+ duration: number
50
+ }): { ranges: TimeRange[]; warnings: string[] } {
51
+ const normalized: TimeRange[] = []
52
+ const warnings: string[] = []
53
+ for (const range of options.ranges) {
54
+ const clampedStart = clamp(range.start, 0, options.duration)
55
+ const clampedEnd = clamp(range.end, 0, options.duration)
56
+ if (clampedStart !== range.start || clampedEnd !== range.end) {
57
+ warnings.push(
58
+ `Clamped range ${range.start.toFixed(3)}-${range.end.toFixed(3)} to ${clampedStart.toFixed(3)}-${clampedEnd.toFixed(3)}.`,
59
+ )
60
+ }
61
+ if (clampedEnd <= clampedStart + 0.005) {
62
+ warnings.push(
63
+ `Skipping empty range ${clampedStart.toFixed(3)}-${clampedEnd.toFixed(3)}.`,
64
+ )
65
+ continue
66
+ }
67
+ normalized.push({ start: clampedStart, end: clampedEnd })
68
+ }
69
+ return { ranges: mergeTimeRanges(normalized), warnings }
70
+ }
71
+
72
+ export async function removeRangesFromMedia(
73
+ options: RemoveRangesOptions,
74
+ ): Promise<RemoveRangesResult> {
75
+ const removedRanges: TimeRange[] = []
76
+ const keepRanges: TimeRange[] = []
77
+ try {
78
+ const duration =
79
+ typeof options.duration === 'number'
80
+ ? options.duration
81
+ : await getMediaDurationSeconds(options.inputPath)
82
+ const normalized = normalizeRemovalRanges({
83
+ ranges: options.ranges,
84
+ duration,
85
+ })
86
+ normalized.warnings.forEach((warning) => console.warn(`[warn] ${warning}`))
87
+ removedRanges.push(...normalized.ranges)
88
+ if (removedRanges.length === 0) {
89
+ return {
90
+ success: false,
91
+ error: 'No valid ranges to remove after normalization.',
92
+ removedRanges,
93
+ keepRanges,
94
+ }
95
+ }
96
+ keepRanges.push(...buildKeepRanges(0, duration, removedRanges))
97
+ if (keepRanges.length === 0) {
98
+ return {
99
+ success: false,
100
+ error: 'Requested removals delete the entire file.',
101
+ removedRanges,
102
+ keepRanges,
103
+ }
104
+ }
105
+
106
+ const resolvedInput = path.resolve(options.inputPath)
107
+ const resolvedOutput = path.resolve(options.outputPath)
108
+ if (resolvedInput === resolvedOutput) {
109
+ return {
110
+ success: false,
111
+ error: 'Output path must be different from input path.',
112
+ removedRanges,
113
+ keepRanges,
114
+ }
115
+ }
116
+
117
+ await mkdir(path.dirname(options.outputPath), { recursive: true })
118
+
119
+ const isFullRange =
120
+ keepRanges.length === 1 &&
121
+ keepRanges[0] &&
122
+ keepRanges[0].start <= 0.001 &&
123
+ keepRanges[0].end >= duration - 0.001
124
+ if (isFullRange) {
125
+ await copyFile(options.inputPath, options.outputPath)
126
+ return {
127
+ success: true,
128
+ outputPath: options.outputPath,
129
+ removedRanges,
130
+ keepRanges,
131
+ }
132
+ }
133
+
134
+ if (keepRanges.length === 1 && keepRanges[0]) {
135
+ await extractChapterSegmentAccurate({
136
+ inputPath: options.inputPath,
137
+ outputPath: options.outputPath,
138
+ start: keepRanges[0].start,
139
+ end: keepRanges[0].end,
140
+ })
141
+ return {
142
+ success: true,
143
+ outputPath: options.outputPath,
144
+ removedRanges,
145
+ keepRanges,
146
+ }
147
+ }
148
+
149
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'remove-ranges-'))
150
+ try {
151
+ const segmentPaths: string[] = []
152
+ for (const [index, range] of keepRanges.entries()) {
153
+ const segmentPath = path.join(tempDir, `segment-${index + 1}.mp4`)
154
+ await extractChapterSegmentAccurate({
155
+ inputPath: options.inputPath,
156
+ outputPath: segmentPath,
157
+ start: range.start,
158
+ end: range.end,
159
+ })
160
+ segmentPaths.push(segmentPath)
161
+ }
162
+ if (segmentPaths.length < 2) {
163
+ throw new Error('Expected at least two segments to concat.')
164
+ }
165
+ await concatSegments({
166
+ segmentPaths,
167
+ outputPath: options.outputPath,
168
+ })
169
+ } finally {
170
+ await rm(tempDir, { recursive: true, force: true })
171
+ }
172
+
173
+ return {
174
+ success: true,
175
+ outputPath: options.outputPath,
176
+ removedRanges,
177
+ keepRanges,
178
+ }
179
+ } catch (error) {
180
+ return {
181
+ success: false,
182
+ error: error instanceof Error ? error.message : String(error),
183
+ removedRanges,
184
+ keepRanges,
185
+ }
186
+ }
187
+ }
188
+
189
+ async function main() {
190
+ const argv = yargs(hideBin(process.argv))
191
+ .scriptName('remove-ranges')
192
+ .option('input', {
193
+ type: 'string',
194
+ demandOption: true,
195
+ describe: 'Input media file',
196
+ })
197
+ .option('ranges', {
198
+ type: 'string',
199
+ demandOption: true,
200
+ describe: 'Comma or space-separated ranges (e.g. 12-15, 1:02-1:05)',
201
+ })
202
+ .option('output', {
203
+ type: 'string',
204
+ describe: 'Output path (defaults to .ranges-removed)',
205
+ })
206
+ .help()
207
+ .parseSync()
208
+
209
+ const inputPath = path.resolve(String(argv.input))
210
+ const outputPath =
211
+ typeof argv.output === 'string' && argv.output.trim().length > 0
212
+ ? path.resolve(argv.output)
213
+ : buildRangesRemovedOutputPath(inputPath)
214
+ const ranges = parseTimeRanges(String(argv.ranges))
215
+ const result = await removeRangesFromMedia({
216
+ inputPath,
217
+ outputPath,
218
+ ranges,
219
+ })
220
+ if (!result.success) {
221
+ console.error(result.error ?? 'Range removal failed.')
222
+ process.exit(1)
223
+ }
224
+ console.log(`Updated file written to ${outputPath}`)
225
+ }
226
+
227
+ function parseRangeToken(token: string): TimeRange {
228
+ const separator = token.includes('..') ? '..' : '-'
229
+ const parts = token.split(separator)
230
+ if (parts.length !== 2) {
231
+ throw new Error(`Invalid range "${token}". Use start-end format.`)
232
+ }
233
+ const startText = parts[0]?.trim() ?? ''
234
+ const endText = parts[1]?.trim() ?? ''
235
+ if (!startText || !endText) {
236
+ throw new Error(`Invalid range "${token}". Start and end required.`)
237
+ }
238
+ const start = parseTimestamp(startText)
239
+ const end = parseTimestamp(endText)
240
+ if (end <= start) {
241
+ throw new Error(`Invalid range "${token}". End must be after start.`)
242
+ }
243
+ return { start, end }
244
+ }
245
+
246
+ function parseTimestamp(value: string): number {
247
+ const trimmed = value.trim()
248
+ if (!trimmed) {
249
+ throw new Error('Invalid time value.')
250
+ }
251
+ if (!trimmed.includes(':')) {
252
+ const seconds = Number.parseFloat(trimmed)
253
+ if (!Number.isFinite(seconds) || seconds < 0) {
254
+ throw new Error(`Invalid time value "${value}".`)
255
+ }
256
+ return seconds
257
+ }
258
+ const parts = trimmed.split(':')
259
+ if (parts.length < 2 || parts.length > 3) {
260
+ throw new Error(`Invalid time value "${value}".`)
261
+ }
262
+ let totalSeconds = 0
263
+ for (const [index, part] of parts.entries()) {
264
+ const segment = part.trim()
265
+ if (!segment) {
266
+ throw new Error(`Invalid time value "${value}".`)
267
+ }
268
+ if (segment.includes('.') && index < parts.length - 1) {
269
+ throw new Error(`Invalid time value "${value}".`)
270
+ }
271
+ const numberValue = Number.parseFloat(segment)
272
+ if (!Number.isFinite(numberValue) || numberValue < 0) {
273
+ throw new Error(`Invalid time value "${value}".`)
274
+ }
275
+ totalSeconds = totalSeconds * 60 + numberValue
276
+ }
277
+ return totalSeconds
278
+ }
279
+
280
+ if (import.meta.main) {
281
+ main().catch((error) => {
282
+ console.error(
283
+ `[error] ${error instanceof Error ? error.message : String(error)}`,
284
+ )
285
+ process.exit(1)
286
+ })
287
+ }
@@ -0,0 +1,25 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { wordsToTimeRanges } from './timestamp-refinement'
3
+ import type { TranscriptWordWithIndex } from './types'
4
+
5
+ function createWord(
6
+ word: string,
7
+ start: number,
8
+ end: number,
9
+ index = 0,
10
+ ): TranscriptWordWithIndex {
11
+ return { word, start, end, index }
12
+ }
13
+
14
+ test('wordsToTimeRanges merges overlapping ranges', () => {
15
+ const words = [
16
+ createWord('hello', 0, 0.5, 0),
17
+ createWord('world', 0.45, 1, 1),
18
+ ]
19
+ const ranges = wordsToTimeRanges(words)
20
+ expect(ranges).toEqual([{ start: 0, end: 1 }])
21
+ })
22
+
23
+ test('wordsToTimeRanges returns empty for no words', () => {
24
+ expect(wordsToTimeRanges([])).toEqual([])
25
+ })
@@ -0,0 +1,172 @@
1
+ import { readAudioSamples } from '../ffmpeg'
2
+ import { CONFIG, EDIT_CONFIG } from '../config'
3
+ import { clamp } from '../../utils'
4
+ import { mergeTimeRanges } from '../utils/time-ranges'
5
+ import { findLowestAmplitudeBoundaryProgressive } from '../utils/audio-analysis'
6
+ import type { TimeRange } from '../types'
7
+ import type { TranscriptWordWithIndex } from './types'
8
+
9
+ export type RefinedRange = {
10
+ original: TimeRange
11
+ refined: TimeRange
12
+ }
13
+
14
+ export function wordsToTimeRanges(
15
+ words: TranscriptWordWithIndex[],
16
+ ): TimeRange[] {
17
+ const ranges = words.map((word) => ({ start: word.start, end: word.end }))
18
+ return mergeTimeRanges(ranges)
19
+ }
20
+
21
+ export async function refineRemovalRange(options: {
22
+ inputPath: string
23
+ duration: number
24
+ range: TimeRange
25
+ paddingMs?: number
26
+ }): Promise<RefinedRange> {
27
+ const paddingSeconds =
28
+ (options.paddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs) / 1000
29
+ const silenceStart = await findSilenceBoundary({
30
+ inputPath: options.inputPath,
31
+ duration: options.duration,
32
+ targetTime: options.range.start,
33
+ direction: 'before',
34
+ })
35
+ if (silenceStart === null) {
36
+ throw new Error(
37
+ buildSilenceError({
38
+ direction: 'before',
39
+ targetTime: options.range.start,
40
+ maxWindowSeconds: getMaxSilenceSearchSeconds({
41
+ duration: options.duration,
42
+ targetTime: options.range.start,
43
+ direction: 'before',
44
+ }),
45
+ }),
46
+ )
47
+ }
48
+ const silenceEnd = await findSilenceBoundary({
49
+ inputPath: options.inputPath,
50
+ duration: options.duration,
51
+ targetTime: options.range.end,
52
+ direction: 'after',
53
+ })
54
+ if (silenceEnd === null) {
55
+ throw new Error(
56
+ buildSilenceError({
57
+ direction: 'after',
58
+ targetTime: options.range.end,
59
+ maxWindowSeconds: getMaxSilenceSearchSeconds({
60
+ duration: options.duration,
61
+ targetTime: options.range.end,
62
+ direction: 'after',
63
+ }),
64
+ }),
65
+ )
66
+ }
67
+
68
+ const paddedStart = clamp(silenceStart + paddingSeconds, 0, options.duration)
69
+ const paddedEnd = clamp(silenceEnd - paddingSeconds, 0, options.duration)
70
+ const refinedStart =
71
+ paddedStart <= options.range.start ? paddedStart : silenceStart
72
+ const refinedEnd = paddedEnd >= options.range.end ? paddedEnd : silenceEnd
73
+
74
+ if (refinedEnd <= refinedStart + 0.005) {
75
+ throw new Error(
76
+ `Unable to create a non-empty cut around ${options.range.start.toFixed(3)}s-${options.range.end.toFixed(3)}s.`,
77
+ )
78
+ }
79
+
80
+ return {
81
+ original: options.range,
82
+ refined: { start: refinedStart, end: refinedEnd },
83
+ }
84
+ }
85
+
86
+ export async function refineAllRemovalRanges(options: {
87
+ inputPath: string
88
+ duration: number
89
+ ranges: TimeRange[]
90
+ paddingMs?: number
91
+ }): Promise<RefinedRange[]> {
92
+ const refined: RefinedRange[] = []
93
+ for (const range of options.ranges) {
94
+ refined.push(
95
+ await refineRemovalRange({
96
+ inputPath: options.inputPath,
97
+ duration: options.duration,
98
+ range,
99
+ paddingMs: options.paddingMs,
100
+ }),
101
+ )
102
+ }
103
+ return refined
104
+ }
105
+
106
+ type SpeechBoundaryDirection = 'before' | 'after'
107
+
108
+ async function findSilenceBoundary(options: {
109
+ inputPath: string
110
+ duration: number
111
+ targetTime: number
112
+ direction: SpeechBoundaryDirection
113
+ }): Promise<number | null> {
114
+ const maxWindowSeconds = getMaxSilenceSearchSeconds(options)
115
+ if (maxWindowSeconds <= 0.01) {
116
+ return null
117
+ }
118
+ const windowStart =
119
+ options.direction === 'before'
120
+ ? Math.max(0, options.targetTime - maxWindowSeconds)
121
+ : options.targetTime
122
+ const windowEnd =
123
+ options.direction === 'before'
124
+ ? options.targetTime
125
+ : Math.min(options.duration, options.targetTime + maxWindowSeconds)
126
+ const windowDuration = windowEnd - windowStart
127
+ if (windowDuration <= 0.01) {
128
+ return null
129
+ }
130
+ const samples = await readAudioSamples({
131
+ inputPath: options.inputPath,
132
+ start: windowStart,
133
+ duration: windowDuration,
134
+ sampleRate: CONFIG.vadSampleRate,
135
+ })
136
+ if (samples.length === 0) {
137
+ return null
138
+ }
139
+
140
+ const boundary = findLowestAmplitudeBoundaryProgressive({
141
+ samples,
142
+ sampleRate: CONFIG.vadSampleRate,
143
+ direction: options.direction,
144
+ rmsWindowMs: CONFIG.commandSilenceRmsWindowMs,
145
+ rmsThreshold: CONFIG.commandSilenceRmsThreshold,
146
+ startWindowSeconds: EDIT_CONFIG.silenceSearchStartSeconds,
147
+ stepSeconds: EDIT_CONFIG.silenceSearchStepSeconds,
148
+ maxWindowSeconds: windowDuration,
149
+ })
150
+ return boundary === null ? null : windowStart + boundary
151
+ }
152
+
153
+ function buildSilenceError(options: {
154
+ direction: SpeechBoundaryDirection
155
+ targetTime: number
156
+ maxWindowSeconds: number
157
+ }): string {
158
+ const directionLabel = options.direction === 'before' ? 'before' : 'after'
159
+ return `No low-amplitude boundary found ${directionLabel} ${options.targetTime.toFixed(3)}s within ${options.maxWindowSeconds.toFixed(2)}s.`
160
+ }
161
+
162
+ function getMaxSilenceSearchSeconds(options: {
163
+ duration: number
164
+ targetTime: number
165
+ direction: SpeechBoundaryDirection
166
+ }): number {
167
+ const availableSeconds =
168
+ options.direction === 'before'
169
+ ? options.targetTime
170
+ : options.duration - options.targetTime
171
+ return Math.min(EDIT_CONFIG.silenceSearchMaxSeconds, availableSeconds)
172
+ }
@@ -0,0 +1,105 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { diffTranscripts, validateEditedTranscript } from './transcript-diff'
3
+ import type { TranscriptWordWithIndex } from './types'
4
+
5
+ function createWord(
6
+ word: string,
7
+ index: number,
8
+ start = index * 0.5,
9
+ end = start + 0.4,
10
+ ): TranscriptWordWithIndex {
11
+ return { word, index, start, end }
12
+ }
13
+
14
+ function createWords(...words: string[]): TranscriptWordWithIndex[] {
15
+ return words.map((word, index) => createWord(word, index))
16
+ }
17
+
18
+ test('diffTranscripts removes words from the middle', () => {
19
+ const originalWords = createWords('hello', 'this', 'is', 'a', 'test')
20
+ const result = diffTranscripts({
21
+ originalWords,
22
+ editedText: 'hello this a test',
23
+ })
24
+ expect(result.success).toBe(true)
25
+ expect(result.removedWords.map((word) => word.word)).toEqual(['is'])
26
+ })
27
+
28
+ test('diffTranscripts removes words at the start', () => {
29
+ const originalWords = createWords('hello', 'this', 'is', 'a', 'test')
30
+ const result = diffTranscripts({
31
+ originalWords,
32
+ editedText: 'this is a test',
33
+ })
34
+ expect(result.success).toBe(true)
35
+ expect(result.removedWords.map((word) => word.word)).toEqual(['hello'])
36
+ })
37
+
38
+ test('diffTranscripts removes words at the end', () => {
39
+ const originalWords = createWords('hello', 'this', 'is', 'a', 'test')
40
+ const result = diffTranscripts({
41
+ originalWords,
42
+ editedText: 'hello this is a',
43
+ })
44
+ expect(result.success).toBe(true)
45
+ expect(result.removedWords.map((word) => word.word)).toEqual(['test'])
46
+ })
47
+
48
+ test('diffTranscripts removes multiple disjoint words', () => {
49
+ const originalWords = createWords('hello', 'this', 'is', 'a', 'test', 'today')
50
+ const result = diffTranscripts({
51
+ originalWords,
52
+ editedText: 'hello is test today',
53
+ })
54
+ expect(result.success).toBe(true)
55
+ expect(result.removedWords.map((word) => word.word)).toEqual(['this', 'a'])
56
+ })
57
+
58
+ test('validateEditedTranscript errors on added words', () => {
59
+ const originalWords = createWords('hello', 'world')
60
+ const result = validateEditedTranscript({
61
+ originalWords,
62
+ editedText: 'hello brave world',
63
+ })
64
+ expect(result.valid).toBe(false)
65
+ expect(result.error).toContain('Transcript mismatch')
66
+ })
67
+
68
+ test('validateEditedTranscript errors on modified words', () => {
69
+ const originalWords = createWords('processing', 'pipeline')
70
+ const result = validateEditedTranscript({
71
+ originalWords,
72
+ editedText: 'prosessing pipeline',
73
+ })
74
+ expect(result.valid).toBe(false)
75
+ expect(result.error).toContain('Transcript mismatch')
76
+ })
77
+
78
+ test('validateEditedTranscript errors on empty edited text', () => {
79
+ const originalWords = createWords('hello', 'world')
80
+ const result = validateEditedTranscript({
81
+ originalWords,
82
+ editedText: ' ',
83
+ })
84
+ expect(result.valid).toBe(false)
85
+ })
86
+
87
+ test('diffTranscripts ignores whitespace differences', () => {
88
+ const originalWords = createWords('hello', 'world')
89
+ const result = diffTranscripts({
90
+ originalWords,
91
+ editedText: 'hello\n world\t',
92
+ })
93
+ expect(result.success).toBe(true)
94
+ expect(result.removedWords).toHaveLength(0)
95
+ })
96
+
97
+ test('diffTranscripts is case insensitive', () => {
98
+ const originalWords = createWords('hello', 'world')
99
+ const result = diffTranscripts({
100
+ originalWords,
101
+ editedText: 'Hello WORLD',
102
+ })
103
+ expect(result.success).toBe(true)
104
+ expect(result.removedWords).toHaveLength(0)
105
+ })