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,82 @@
1
+ // Custom error types for better error handling and debugging
2
+
3
+ export class ChapterProcessingError extends Error {
4
+ constructor(
5
+ message: string,
6
+ public readonly chapterIndex: number,
7
+ public readonly chapterTitle: string,
8
+ ) {
9
+ super(message)
10
+ this.name = 'ChapterProcessingError'
11
+ }
12
+ }
13
+
14
+ export class ChapterTooShortError extends ChapterProcessingError {
15
+ constructor(
16
+ chapterIndex: number,
17
+ chapterTitle: string,
18
+ public readonly duration: number,
19
+ public readonly minDuration: number,
20
+ ) {
21
+ super(
22
+ `Chapter "${chapterTitle}" is too short (${duration.toFixed(2)}s < ${minDuration}s)`,
23
+ chapterIndex,
24
+ chapterTitle,
25
+ )
26
+ this.name = 'ChapterTooShortError'
27
+ }
28
+ }
29
+
30
+ export class CommandParseError extends Error {
31
+ constructor(
32
+ message: string,
33
+ public readonly transcript?: string,
34
+ ) {
35
+ super(message)
36
+ this.name = 'CommandParseError'
37
+ }
38
+ }
39
+
40
+ export class TranscriptTooShortError extends ChapterProcessingError {
41
+ constructor(
42
+ chapterIndex: number,
43
+ chapterTitle: string,
44
+ public readonly wordCount: number,
45
+ public readonly minWordCount: number,
46
+ ) {
47
+ super(
48
+ `Chapter "${chapterTitle}" transcript too short (${wordCount} words < ${minWordCount})`,
49
+ chapterIndex,
50
+ chapterTitle,
51
+ )
52
+ this.name = 'TranscriptTooShortError'
53
+ }
54
+ }
55
+
56
+ export class BadTakeError extends ChapterProcessingError {
57
+ constructor(chapterIndex: number, chapterTitle: string) {
58
+ super(
59
+ `Chapter "${chapterTitle}" marked as bad take`,
60
+ chapterIndex,
61
+ chapterTitle,
62
+ )
63
+ this.name = 'BadTakeError'
64
+ }
65
+ }
66
+
67
+ export class SpliceError extends Error {
68
+ constructor(message: string) {
69
+ super(message)
70
+ this.name = 'SpliceError'
71
+ }
72
+ }
73
+
74
+ export class TrimWindowError extends Error {
75
+ constructor(
76
+ public readonly start: number,
77
+ public readonly end: number,
78
+ ) {
79
+ super(`Trim window too small (${start.toFixed(3)}s -> ${end.toFixed(3)}s)`)
80
+ this.name = 'TrimWindowError'
81
+ }
82
+ }
@@ -0,0 +1,449 @@
1
+ import {
2
+ runCommand as runCommandBase,
3
+ runCommandBinary as runCommandBinaryBase,
4
+ formatSeconds,
5
+ } from '../utils'
6
+ import { CONFIG, TRANSCRIPTION_SAMPLE_RATE } from './config'
7
+ import { logCommand, logInfo, logWarn } from './logging'
8
+ import type { Chapter, LoudnormAnalysis } from './types'
9
+
10
+ async function runCommand(command: string[], allowFailure = false) {
11
+ return runCommandBase(command, { allowFailure, logCommand })
12
+ }
13
+
14
+ async function runCommandBinary(command: string[], allowFailure = false) {
15
+ return runCommandBinaryBase(command, { allowFailure, logCommand })
16
+ }
17
+
18
+ export async function ensureFfmpegAvailable() {
19
+ const ffmpeg = await runCommand(['ffmpeg', '-version'], true)
20
+ const ffprobe = await runCommand(['ffprobe', '-version'], true)
21
+ if (ffmpeg.exitCode !== 0 || ffprobe.exitCode !== 0) {
22
+ throw new Error(
23
+ 'ffmpeg/ffprobe not available. Install them and ensure they are on PATH.',
24
+ )
25
+ }
26
+ }
27
+
28
+ export async function getChapters(inputPath: string): Promise<Chapter[]> {
29
+ const result = await runCommand([
30
+ 'ffprobe',
31
+ '-v',
32
+ 'error',
33
+ '-print_format',
34
+ 'json',
35
+ '-show_chapters',
36
+ '-i',
37
+ inputPath,
38
+ ])
39
+
40
+ const payload = JSON.parse(result.stdout)
41
+ const chapters = payload.chapters ?? []
42
+ if (chapters.length === 0) {
43
+ return []
44
+ }
45
+
46
+ return chapters.map((chapter: any, index: number) => {
47
+ const start = Number.parseFloat(chapter.start_time)
48
+ const end = Number.parseFloat(chapter.end_time)
49
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
50
+ throw new Error(`Invalid chapter time data at index ${index}`)
51
+ }
52
+ return {
53
+ index,
54
+ start,
55
+ end,
56
+ title: chapter.tags?.title
57
+ ? String(chapter.tags.title)
58
+ : `chapter-${index + 1}`,
59
+ }
60
+ })
61
+ }
62
+
63
+ export async function readAudioSamples(options: {
64
+ inputPath: string
65
+ start: number
66
+ duration: number
67
+ sampleRate: number
68
+ }) {
69
+ const result = await runCommandBinary(
70
+ [
71
+ 'ffmpeg',
72
+ '-hide_banner',
73
+ '-ss',
74
+ options.start.toFixed(3),
75
+ '-t',
76
+ options.duration.toFixed(3),
77
+ '-i',
78
+ options.inputPath,
79
+ '-vn',
80
+ '-sn',
81
+ '-dn',
82
+ '-ac',
83
+ '1',
84
+ '-ar',
85
+ String(options.sampleRate),
86
+ '-f',
87
+ 'f32le',
88
+ '-',
89
+ ],
90
+ true,
91
+ )
92
+
93
+ const bytes = result.stdout
94
+ if (bytes.byteLength === 0) {
95
+ return new Float32Array()
96
+ }
97
+ const buffer = bytes.buffer.slice(
98
+ bytes.byteOffset,
99
+ bytes.byteOffset + bytes.byteLength,
100
+ )
101
+ return new Float32Array(buffer)
102
+ }
103
+
104
+ function isValidLoudnessValue(
105
+ value: string,
106
+ min?: number,
107
+ max?: number,
108
+ ): boolean {
109
+ const num = Number.parseFloat(value)
110
+ if (!Number.isFinite(num)) {
111
+ return false
112
+ }
113
+ if (min !== undefined && num < min) {
114
+ return false
115
+ }
116
+ if (max !== undefined && num > max) {
117
+ return false
118
+ }
119
+ return true
120
+ }
121
+
122
+ function buildNormalizeAudioFilter(options: {
123
+ printFormat: 'json' | 'summary'
124
+ analysis: LoudnormAnalysis | null
125
+ }) {
126
+ const prefilter =
127
+ CONFIG.normalizePrefilterEnabled && CONFIG.normalizePrefilter
128
+ ? `${CONFIG.normalizePrefilter},`
129
+ : ''
130
+ const loudnorm = [
131
+ `loudnorm=I=${CONFIG.loudnessTargetI}`,
132
+ `LRA=${CONFIG.loudnessTargetLra}`,
133
+ `TP=${CONFIG.loudnessTargetTp}`,
134
+ ]
135
+
136
+ // Only use analysis if all values are valid (not -inf/inf)
137
+ // input_i must be in range [-99, 0] per ffmpeg loudnorm filter
138
+ if (options.analysis) {
139
+ const hasValidAnalysis =
140
+ isValidLoudnessValue(options.analysis.input_i, -99, 0) &&
141
+ isValidLoudnessValue(options.analysis.input_tp) &&
142
+ isValidLoudnessValue(options.analysis.input_lra) &&
143
+ isValidLoudnessValue(options.analysis.input_thresh) &&
144
+ isValidLoudnessValue(options.analysis.target_offset)
145
+
146
+ if (hasValidAnalysis) {
147
+ loudnorm.push(
148
+ `measured_I=${options.analysis.input_i}`,
149
+ `measured_TP=${options.analysis.input_tp}`,
150
+ `measured_LRA=${options.analysis.input_lra}`,
151
+ `measured_thresh=${options.analysis.input_thresh}`,
152
+ `offset=${options.analysis.target_offset}`,
153
+ 'linear=true',
154
+ )
155
+ } else {
156
+ logWarn(
157
+ `Audio analysis contains invalid values (input_i=${options.analysis.input_i}), skipping measured normalization. Using default loudnorm settings.`,
158
+ )
159
+ }
160
+ }
161
+
162
+ loudnorm.push(`print_format=${options.printFormat}`)
163
+
164
+ return `${prefilter}${loudnorm.join(':')}`
165
+ }
166
+
167
+ export async function analyzeLoudness(
168
+ inputPath: string,
169
+ absoluteStart: number,
170
+ absoluteEnd: number,
171
+ ): Promise<LoudnormAnalysis> {
172
+ const clipDuration = absoluteEnd - absoluteStart
173
+ if (clipDuration <= 0) {
174
+ throw new Error(
175
+ `Invalid analysis window (${formatSeconds(absoluteStart)} -> ${formatSeconds(
176
+ absoluteEnd,
177
+ )})`,
178
+ )
179
+ }
180
+
181
+ const filter = buildNormalizeAudioFilter({
182
+ printFormat: 'json',
183
+ analysis: null,
184
+ })
185
+ const result = await runCommand(
186
+ [
187
+ 'ffmpeg',
188
+ '-hide_banner',
189
+ '-ss',
190
+ absoluteStart.toFixed(3),
191
+ '-t',
192
+ clipDuration.toFixed(3),
193
+ '-i',
194
+ inputPath,
195
+ '-vn',
196
+ '-sn',
197
+ '-dn',
198
+ '-af',
199
+ filter,
200
+ '-f',
201
+ 'null',
202
+ '-',
203
+ ],
204
+ true,
205
+ )
206
+
207
+ const analysisJson = result.stderr.match(/\{[\s\S]*?\}/)?.[0]
208
+ if (!analysisJson) {
209
+ throw new Error('Failed to parse loudnorm analysis output.')
210
+ }
211
+
212
+ const payload = JSON.parse(analysisJson)
213
+ return {
214
+ input_i: payload.input_i,
215
+ input_tp: payload.input_tp,
216
+ input_lra: payload.input_lra,
217
+ input_thresh: payload.input_thresh,
218
+ target_offset: payload.target_offset,
219
+ }
220
+ }
221
+
222
+ export async function renderChapter(options: {
223
+ inputPath: string
224
+ outputPath: string
225
+ absoluteStart: number
226
+ absoluteEnd: number
227
+ analysis: LoudnormAnalysis
228
+ }) {
229
+ const clipDuration = options.absoluteEnd - options.absoluteStart
230
+ if (clipDuration <= 0) {
231
+ throw new Error(
232
+ `Invalid render window (${formatSeconds(options.absoluteStart)} -> ${formatSeconds(
233
+ options.absoluteEnd,
234
+ )})`,
235
+ )
236
+ }
237
+
238
+ const loudnorm = buildNormalizeAudioFilter({
239
+ printFormat: 'summary',
240
+ analysis: options.analysis,
241
+ })
242
+
243
+ const args = [
244
+ 'ffmpeg',
245
+ '-hide_banner',
246
+ '-y',
247
+ '-ss',
248
+ options.absoluteStart.toFixed(3),
249
+ '-t',
250
+ clipDuration.toFixed(3),
251
+ '-i',
252
+ options.inputPath,
253
+ '-dn',
254
+ '-map_chapters',
255
+ '-1',
256
+ '-map',
257
+ '0:v?',
258
+ '-map',
259
+ '0:a?',
260
+ '-map',
261
+ '0:s?',
262
+ ]
263
+
264
+ if (CONFIG.videoReencodeForAccurateTrim) {
265
+ args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18')
266
+ } else {
267
+ args.push('-c:v', 'copy')
268
+ }
269
+
270
+ args.push(
271
+ '-c:a',
272
+ CONFIG.audioCodec,
273
+ '-b:a',
274
+ CONFIG.audioBitrate,
275
+ '-af',
276
+ loudnorm,
277
+ '-c:s',
278
+ 'copy',
279
+ options.outputPath,
280
+ )
281
+
282
+ await runCommand(args)
283
+ logInfo(`Wrote ${options.outputPath}`)
284
+ }
285
+
286
+ export async function extractChapterSegment(options: {
287
+ inputPath: string
288
+ outputPath: string
289
+ start: number
290
+ end: number
291
+ }) {
292
+ const clipDuration = options.end - options.start
293
+ if (clipDuration <= 0) {
294
+ throw new Error(
295
+ `Invalid segment window (${formatSeconds(options.start)} -> ${formatSeconds(
296
+ options.end,
297
+ )})`,
298
+ )
299
+ }
300
+
301
+ const args = [
302
+ 'ffmpeg',
303
+ '-hide_banner',
304
+ '-y',
305
+ '-ss',
306
+ options.start.toFixed(3),
307
+ '-t',
308
+ clipDuration.toFixed(3),
309
+ '-i',
310
+ options.inputPath,
311
+ '-dn',
312
+ '-map_chapters',
313
+ '-1',
314
+ '-map',
315
+ '0:v?',
316
+ '-map',
317
+ '0:a?',
318
+ '-map',
319
+ '0:s?',
320
+ '-c:v',
321
+ 'copy',
322
+ '-c:a',
323
+ 'copy',
324
+ '-c:s',
325
+ 'copy',
326
+ options.outputPath,
327
+ ]
328
+
329
+ await runCommand(args)
330
+ }
331
+
332
+ export async function extractChapterSegmentAccurate(options: {
333
+ inputPath: string
334
+ outputPath: string
335
+ start: number
336
+ end: number
337
+ }) {
338
+ const clipDuration = options.end - options.start
339
+ if (clipDuration <= 0) {
340
+ throw new Error(
341
+ `Invalid segment window (${formatSeconds(options.start)} -> ${formatSeconds(
342
+ options.end,
343
+ )})`,
344
+ )
345
+ }
346
+
347
+ const args = [
348
+ 'ffmpeg',
349
+ '-hide_banner',
350
+ '-y',
351
+ '-i',
352
+ options.inputPath,
353
+ '-ss',
354
+ options.start.toFixed(3),
355
+ '-t',
356
+ clipDuration.toFixed(3),
357
+ '-dn',
358
+ '-map_chapters',
359
+ '-1',
360
+ '-map',
361
+ '0:v?',
362
+ '-map',
363
+ '0:a?',
364
+ '-map',
365
+ '0:s?',
366
+ '-c:v',
367
+ 'libx264',
368
+ '-preset',
369
+ 'medium',
370
+ '-crf',
371
+ '18',
372
+ '-c:a',
373
+ CONFIG.audioCodec,
374
+ '-b:a',
375
+ CONFIG.audioBitrate,
376
+ '-c:s',
377
+ 'copy',
378
+ options.outputPath,
379
+ ]
380
+
381
+ await runCommand(args)
382
+ }
383
+
384
+ export async function extractTranscriptionAudio(options: {
385
+ inputPath: string
386
+ outputPath: string
387
+ start: number
388
+ end: number
389
+ }) {
390
+ const clipDuration = options.end - options.start
391
+ if (clipDuration <= 0) {
392
+ throw new Error(
393
+ `Invalid transcription window (${formatSeconds(options.start)} -> ${formatSeconds(
394
+ options.end,
395
+ )})`,
396
+ )
397
+ }
398
+
399
+ const args = [
400
+ 'ffmpeg',
401
+ '-hide_banner',
402
+ '-y',
403
+ '-ss',
404
+ options.start.toFixed(3),
405
+ '-t',
406
+ clipDuration.toFixed(3),
407
+ '-i',
408
+ options.inputPath,
409
+ '-vn',
410
+ '-sn',
411
+ '-dn',
412
+ '-ac',
413
+ '1',
414
+ '-ar',
415
+ String(TRANSCRIPTION_SAMPLE_RATE),
416
+ '-c:a',
417
+ 'pcm_s16le',
418
+ options.outputPath,
419
+ ]
420
+
421
+ await runCommand(args)
422
+ }
423
+
424
+ export async function concatSegments(options: {
425
+ segmentPaths: string[]
426
+ outputPath: string
427
+ }) {
428
+ if (options.segmentPaths.length < 2) {
429
+ throw new Error('Splice requires at least two segments to concat.')
430
+ }
431
+ const args = ['ffmpeg', '-hide_banner', '-y']
432
+ for (const segmentPath of options.segmentPaths) {
433
+ args.push('-i', segmentPath)
434
+ }
435
+ const inputLabels = options.segmentPaths
436
+ .map((_, index) => `[${index}:v:0][${index}:a:0]`)
437
+ .join('')
438
+ const concatFilter = `${inputLabels}concat=n=${options.segmentPaths.length}:v=1:a=1[v][a]`
439
+ const filter = `${concatFilter};[a]aresample=async=1:first_pts=0[aout]`
440
+ args.push('-filter_complex', filter, '-map', '[v]', '-map', '[aout]')
441
+ if (CONFIG.commandSpliceReencode) {
442
+ args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18')
443
+ } else {
444
+ args.push('-c:v', 'copy')
445
+ }
446
+ args.push('-c:a', CONFIG.audioCodec, '-b:a', CONFIG.audioBitrate)
447
+ args.push(options.outputPath)
448
+ await runCommand(args)
449
+ }
@@ -0,0 +1,71 @@
1
+ import { CONFIG } from '../config'
2
+ import { countTranscriptWords } from '../utils/transcript'
3
+ import type { TranscriptCommand, CommandAnalysisResult } from './types'
4
+
5
+ /**
6
+ * Analyze commands and determine chapter processing behavior.
7
+ */
8
+ export function analyzeCommands(
9
+ commands: TranscriptCommand[],
10
+ transcript: string,
11
+ ): CommandAnalysisResult {
12
+ const filenameCommand = commands.find(
13
+ (command) => command.type === 'filename' && command.value?.trim(),
14
+ )
15
+ const filenameOverride = filenameCommand?.value ?? null
16
+
17
+ const hasBadTake = commands.some((command) => command.type === 'bad-take')
18
+ const hasEdit = commands.some((command) => command.type === 'edit')
19
+ const hasCombinePrevious = commands.some(
20
+ (command) => command.type === 'combine-previous',
21
+ )
22
+
23
+ const notes = commands
24
+ .filter((command) => command.type === 'note' && command.value?.trim())
25
+ .map((command) => ({
26
+ value: command.value!,
27
+ window: command.window,
28
+ }))
29
+
30
+ const splits = commands
31
+ .filter((command) => command.type === 'split')
32
+ .map((command) => ({
33
+ window: command.window,
34
+ }))
35
+
36
+ const transcriptWordCount = countTranscriptWords(transcript)
37
+
38
+ // Determine if we should skip this chapter
39
+ let shouldSkip = false
40
+ let skipReason: string | undefined
41
+
42
+ if (
43
+ transcriptWordCount <= CONFIG.minTranscriptWords &&
44
+ commands.length === 0
45
+ ) {
46
+ shouldSkip = true
47
+ skipReason = `transcript too short (${transcriptWordCount} words)`
48
+ } else if (hasBadTake) {
49
+ shouldSkip = true
50
+ skipReason = 'bad take command detected'
51
+ }
52
+
53
+ return {
54
+ commands,
55
+ filenameOverride,
56
+ hasBadTake,
57
+ hasEdit,
58
+ hasCombinePrevious,
59
+ notes,
60
+ splits,
61
+ shouldSkip,
62
+ skipReason,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get command types as a comma-separated string for logging.
68
+ */
69
+ export function formatCommandTypes(commands: TranscriptCommand[]): string {
70
+ return commands.map((command) => command.type).join(', ')
71
+ }
@@ -0,0 +1,14 @@
1
+ // Public API for jarvis commands module
2
+
3
+ export { extractTranscriptCommands, scaleTranscriptSegments } from './parser'
4
+ export { buildCommandWindows, refineCommandWindows } from './windows'
5
+ export { analyzeCommands, formatCommandTypes } from './handlers'
6
+
7
+ export type {
8
+ CommandType,
9
+ TranscriptCommand,
10
+ TranscriptWord,
11
+ CommandExtractionOptions,
12
+ CommandWindowOptions,
13
+ CommandAnalysisResult,
14
+ } from './types'