eprec 1.2.0 → 1.4.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/cli.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  import path from 'node:path'
3
- import type { CommandBuilder, CommandHandler } from 'yargs'
3
+ import type { Arguments, CommandBuilder, CommandHandler } from 'yargs'
4
4
  import yargs from 'yargs/yargs'
5
5
  import { hideBin } from 'yargs/helpers'
6
6
  import { startAppServer } from './app-server'
7
+ import { setLogHooks } from './process-course/logging'
7
8
  import { ensureFfmpegAvailable } from './process-course/ffmpeg'
8
9
  import {
10
+ VIDEO_EXTENSIONS,
9
11
  normalizeProcessArgs,
10
12
  configureProcessCommand,
11
13
  } from './process-course/cli'
@@ -13,46 +15,86 @@ import { runProcessCourse } from './process-course-video'
13
15
  import {
14
16
  configureEditVideoCommand,
15
17
  configureCombineVideosCommand,
16
- handleCombineVideosCommand,
17
- handleEditVideoCommand,
18
+ createCombineVideosHandler,
19
+ createEditVideoHandler,
18
20
  } from './process-course/edits/cli'
19
21
  import { detectSpeechSegmentsForFile } from './speech-detection'
20
22
  import {
21
23
  getDefaultWhisperModelPath,
22
24
  transcribeAudio,
23
25
  } from './whispercpp-transcribe'
26
+ import {
27
+ PromptCancelled,
28
+ createInquirerPrompter,
29
+ createPathPicker,
30
+ isInteractive,
31
+ pauseActiveSpinner,
32
+ resumeActiveSpinner,
33
+ resolveOptionalString,
34
+ type PathPicker,
35
+ type Prompter,
36
+ withSpinner,
37
+ } from './cli-ux'
24
38
 
25
- function resolveOptionalString(value: unknown) {
26
- if (typeof value !== 'string') {
27
- return undefined
28
- }
29
- const trimmed = value.trim()
30
- return trimmed.length > 0 ? trimmed : undefined
39
+ type CliUxContext = {
40
+ interactive: boolean
41
+ prompter?: Prompter
42
+ pathPicker?: PathPicker
31
43
  }
32
44
 
33
- async function main() {
34
- const parser = yargs(hideBin(process.argv))
45
+ async function main(rawArgs = hideBin(process.argv)) {
46
+ const context = createCliUxContext()
47
+ let args = rawArgs
48
+
49
+ if (context.interactive && args.length === 0 && context.prompter) {
50
+ const selection = await promptForCommand(context.prompter)
51
+ if (!selection) {
52
+ return
53
+ }
54
+ args = selection
55
+ }
56
+
57
+ const handlerOptions = {
58
+ interactive: context.interactive,
59
+ pathPicker: context.pathPicker,
60
+ }
61
+
62
+ const parser = yargs(args)
35
63
  .scriptName('eprec')
36
64
  .command(
37
- 'process <input...>',
65
+ 'process [input...]',
38
66
  'Process chapters into separate files',
39
67
  configureProcessCommand,
40
68
  async (argv) => {
41
- const args = normalizeProcessArgs(argv)
42
- await runProcessCourse(args)
69
+ const processArgs = await resolveProcessArgs(argv, context)
70
+ await withSpinner(
71
+ 'Processing course',
72
+ async () => {
73
+ setLogHooks({
74
+ beforeLog: pauseActiveSpinner,
75
+ afterLog: resumeActiveSpinner,
76
+ })
77
+ try {
78
+ await runProcessCourse(processArgs)
79
+ } finally {
80
+ setLogHooks({})
81
+ }
82
+ },
83
+ { successText: 'Processing complete', enabled: context.interactive },
84
+ )
43
85
  },
44
86
  )
45
87
  .command(
46
88
  'edit',
47
89
  'Edit a single video using transcript text edits',
48
90
  configureEditVideoCommand as CommandBuilder,
49
- handleEditVideoCommand as CommandHandler,
91
+ createEditVideoHandler(handlerOptions) as CommandHandler,
50
92
  )
51
93
  .command(
52
94
  'combine',
53
95
  'Combine two videos with speech-aligned padding',
54
96
  configureCombineVideosCommand as CommandBuilder,
55
- handleCombineVideosCommand as CommandHandler,
97
+ createCombineVideosHandler(handlerOptions) as CommandHandler,
56
98
  )
57
99
  .command(
58
100
  'app start',
@@ -77,7 +119,7 @@ async function main() {
77
119
  },
78
120
  )
79
121
  .command(
80
- 'transcribe <input>',
122
+ 'transcribe [input]',
81
123
  'Transcribe a single audio/video file',
82
124
  (command) =>
83
125
  command
@@ -108,31 +150,34 @@ async function main() {
108
150
  describe: 'Output base path (without extension)',
109
151
  }),
110
152
  async (argv) => {
111
- const inputPath = path.resolve(String(argv.input))
112
- const outputBasePath =
113
- resolveOptionalString(argv['output-base']) ??
114
- path.join(
115
- path.dirname(inputPath),
116
- `${path.parse(inputPath).name}-transcript`,
117
- )
118
- const threads =
119
- typeof argv.threads === 'number' && Number.isFinite(argv.threads)
120
- ? argv.threads
121
- : undefined
122
- const result = await transcribeAudio(inputPath, {
123
- modelPath: resolveOptionalString(argv['model-path']),
124
- language: resolveOptionalString(argv.language),
125
- threads,
126
- binaryPath: resolveOptionalString(argv['binary-path']),
127
- outputBasePath,
128
- })
129
- console.log(`Transcript written to ${outputBasePath}.txt`)
130
- console.log(`Segments written to ${outputBasePath}.json`)
131
- console.log(result.text)
153
+ const transcribeArgs = await resolveTranscribeArgs(argv, context)
154
+ let resultText = ''
155
+ await withSpinner(
156
+ 'Transcribing audio',
157
+ async () => {
158
+ const result = await transcribeAudio(transcribeArgs.inputPath, {
159
+ modelPath: transcribeArgs.modelPath,
160
+ language: transcribeArgs.language,
161
+ threads: transcribeArgs.threads,
162
+ binaryPath: transcribeArgs.binaryPath,
163
+ outputBasePath: transcribeArgs.outputBasePath,
164
+ })
165
+ resultText = result.text
166
+ },
167
+ {
168
+ successText: 'Transcription complete',
169
+ enabled: context.interactive,
170
+ },
171
+ )
172
+ console.log(
173
+ `Transcript written to ${transcribeArgs.outputBasePath}.txt`,
174
+ )
175
+ console.log(`Segments written to ${transcribeArgs.outputBasePath}.json`)
176
+ console.log(resultText)
132
177
  },
133
178
  )
134
179
  .command(
135
- 'detect-speech <input>',
180
+ 'detect-speech [input]',
136
181
  'Show detected speech segments for a file',
137
182
  (command) =>
138
183
  command
@@ -149,12 +194,26 @@ async function main() {
149
194
  describe: 'End time in seconds',
150
195
  }),
151
196
  async (argv) => {
152
- await ensureFfmpegAvailable()
153
- const segments = await detectSpeechSegmentsForFile({
154
- inputPath: String(argv.input),
155
- start: typeof argv.start === 'number' ? argv.start : undefined,
156
- end: typeof argv.end === 'number' ? argv.end : undefined,
157
- })
197
+ const { inputPath, start, end } = await resolveDetectSpeechArgs(
198
+ argv,
199
+ context,
200
+ )
201
+ let segments: unknown = []
202
+ await withSpinner(
203
+ 'Detecting speech',
204
+ async () => {
205
+ await ensureFfmpegAvailable()
206
+ segments = await detectSpeechSegmentsForFile({
207
+ inputPath,
208
+ start,
209
+ end,
210
+ })
211
+ },
212
+ {
213
+ successText: 'Speech detection complete',
214
+ enabled: context.interactive,
215
+ },
216
+ )
158
217
  console.log(JSON.stringify(segments, null, 2))
159
218
  },
160
219
  )
@@ -165,7 +224,188 @@ async function main() {
165
224
  await parser.parseAsync()
166
225
  }
167
226
 
227
+ function createCliUxContext(): CliUxContext {
228
+ const interactive = isInteractive()
229
+ if (!interactive) {
230
+ return { interactive }
231
+ }
232
+ const prompter = createInquirerPrompter()
233
+ const pathPicker = createPathPicker(prompter)
234
+ return { interactive, prompter, pathPicker }
235
+ }
236
+
237
+ async function promptForCommand(prompter: Prompter): Promise<string[] | null> {
238
+ const selection = await prompter.select('Choose a command', [
239
+ {
240
+ name: 'Process chapters into separate files',
241
+ value: 'process',
242
+ },
243
+ {
244
+ name: 'Edit a single video using transcript text edits',
245
+ value: 'edit',
246
+ },
247
+ {
248
+ name: 'Combine two videos with speech-aligned padding',
249
+ value: 'combine',
250
+ },
251
+ {
252
+ name: 'Start the web UI server',
253
+ value: 'app-start',
254
+ },
255
+ {
256
+ name: 'Transcribe a single audio/video file',
257
+ value: 'transcribe',
258
+ },
259
+ {
260
+ name: 'Show detected speech segments for a file',
261
+ value: 'detect-speech',
262
+ },
263
+ { name: 'Show help', value: 'help' },
264
+ { name: 'Exit', value: 'exit' },
265
+ ])
266
+ switch (selection) {
267
+ case 'exit':
268
+ return null
269
+ case 'help':
270
+ return ['--help']
271
+ case 'app-start':
272
+ return ['app', 'start']
273
+ default:
274
+ return [selection]
275
+ }
276
+ }
277
+
278
+ async function resolveProcessArgs(argv: Arguments, context: CliUxContext) {
279
+ let inputPaths = collectStringArray(argv.input)
280
+ if (inputPaths.length === 0) {
281
+ if (!context.interactive || !context.pathPicker || !context.prompter) {
282
+ throw new Error('At least one input file is required.')
283
+ }
284
+ inputPaths = await promptForInputFiles(context)
285
+ }
286
+
287
+ let outputDir = resolveOptionalString(argv['output-dir'])
288
+ if (
289
+ !outputDir &&
290
+ context.interactive &&
291
+ context.prompter &&
292
+ context.pathPicker
293
+ ) {
294
+ const chooseOutput = await context.prompter.confirm(
295
+ 'Choose a custom output directory?',
296
+ { defaultValue: false },
297
+ )
298
+ if (chooseOutput) {
299
+ outputDir = await context.pathPicker.pickExistingDirectory({
300
+ message: 'Select output directory',
301
+ })
302
+ }
303
+ }
304
+
305
+ const updatedArgs = {
306
+ ...argv,
307
+ input: inputPaths,
308
+ 'output-dir': outputDir ?? argv['output-dir'],
309
+ } as Arguments
310
+ return normalizeProcessArgs(updatedArgs)
311
+ }
312
+
313
+ async function promptForInputFiles(context: CliUxContext) {
314
+ if (!context.prompter || !context.pathPicker) {
315
+ throw new Error('Interactive prompts are not available.')
316
+ }
317
+ const inputPaths: string[] = []
318
+ let addAnother = true
319
+ while (addAnother) {
320
+ const inputPath = await context.pathPicker.pickExistingFile({
321
+ message:
322
+ inputPaths.length === 0
323
+ ? 'Select input video file'
324
+ : 'Select another input video file',
325
+ extensions: VIDEO_EXTENSIONS,
326
+ })
327
+ inputPaths.push(inputPath)
328
+ addAnother = await context.prompter.confirm('Add another input file?', {
329
+ defaultValue: false,
330
+ })
331
+ }
332
+ return inputPaths
333
+ }
334
+
335
+ async function resolveTranscribeArgs(argv: Arguments, context: CliUxContext) {
336
+ let input = resolveOptionalString(argv.input)
337
+ if (!input) {
338
+ if (!context.interactive || !context.pathPicker) {
339
+ throw new Error('Input audio/video file is required.')
340
+ }
341
+ input = await context.pathPicker.pickExistingFile({
342
+ message: 'Select input audio/video file',
343
+ })
344
+ }
345
+ const inputPath = path.resolve(input)
346
+ const outputBasePath =
347
+ resolveOptionalString(argv['output-base']) ??
348
+ buildTranscribeOutputBase(inputPath)
349
+ const threads = resolveOptionalNumber(argv.threads)
350
+ return {
351
+ inputPath,
352
+ outputBasePath,
353
+ threads,
354
+ modelPath: resolveOptionalString(argv['model-path']),
355
+ language: resolveOptionalString(argv.language),
356
+ binaryPath: resolveOptionalString(argv['binary-path']),
357
+ }
358
+ }
359
+
360
+ async function resolveDetectSpeechArgs(argv: Arguments, context: CliUxContext) {
361
+ let input = resolveOptionalString(argv.input)
362
+ if (!input) {
363
+ if (!context.interactive || !context.pathPicker) {
364
+ throw new Error('Input audio/video file is required.')
365
+ }
366
+ input = await context.pathPicker.pickExistingFile({
367
+ message: 'Select input audio/video file',
368
+ })
369
+ }
370
+ return {
371
+ inputPath: String(input),
372
+ start: resolveOptionalNumber(argv.start),
373
+ end: resolveOptionalNumber(argv.end),
374
+ }
375
+ }
376
+
377
+ function buildTranscribeOutputBase(inputPath: string) {
378
+ return path.join(
379
+ path.dirname(inputPath),
380
+ `${path.parse(inputPath).name}-transcript`,
381
+ )
382
+ }
383
+
384
+ function collectStringArray(value: unknown) {
385
+ if (Array.isArray(value)) {
386
+ return value.filter(
387
+ (entry): entry is string =>
388
+ typeof entry === 'string' && entry.trim().length > 0,
389
+ )
390
+ }
391
+ if (typeof value === 'string' && value.trim().length > 0) {
392
+ return [value]
393
+ }
394
+ return []
395
+ }
396
+
397
+ function resolveOptionalNumber(value: unknown) {
398
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
399
+ return undefined
400
+ }
401
+ return value
402
+ }
403
+
168
404
  main().catch((error) => {
405
+ if (error instanceof PromptCancelled) {
406
+ console.log('[info] Cancelled.')
407
+ return
408
+ }
169
409
  console.error(
170
410
  `[error] ${error instanceof Error ? error.message : String(error)}`,
171
411
  )
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.2.0",
4
+ "version": "1.4.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/epicweb-dev/eprec"
9
9
  },
10
10
  "scripts": {
11
- "app:start": "bun ./app-server.ts",
11
+ "app:start": "bun --watch ./cli.ts app start",
12
12
  "format": "prettier --write .",
13
13
  "test": "bun test process-course utils.test.ts",
14
- "test:e2e": "bun test e2e",
14
+ "test:e2e": "bun test ./e2e",
15
15
  "test:smoke": "bunx playwright test -c playwright-smoke-config.ts",
16
16
  "test:all": "bun test '**/*.test.ts'",
17
17
  "validate": "bun run test"
@@ -44,7 +44,9 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "get-port": "^7.1.0",
47
+ "inquirer": "^13.2.1",
47
48
  "onnxruntime-node": "^1.23.2",
49
+ "ora": "^9.1.0",
48
50
  "remix": "3.0.0-alpha.0",
49
51
  "yargs": "^18.0.0"
50
52
  }
@@ -8,6 +8,16 @@ import { normalizeSkipPhrases } from './utils/transcript'
8
8
  import { parseChapterSelection } from './utils/chapter-selection'
9
9
  import type { ChapterSelection } from './types'
10
10
 
11
+ export const VIDEO_EXTENSIONS = [
12
+ '.mp4',
13
+ '.mkv',
14
+ '.avi',
15
+ '.mov',
16
+ '.webm',
17
+ '.flv',
18
+ '.m4v',
19
+ ]
20
+
11
21
  export interface CliArgs {
12
22
  inputPaths: string[]
13
23
  outputDir: string | null
@@ -118,16 +128,7 @@ export function normalizeProcessArgs(
118
128
  if (!outputDir && inputPaths.length > 0) {
119
129
  const outputCandidate = inputPaths.at(-1)
120
130
  if (outputCandidate !== undefined) {
121
- const videoExtensions = [
122
- '.mp4',
123
- '.mkv',
124
- '.avi',
125
- '.mov',
126
- '.webm',
127
- '.flv',
128
- '.m4v',
129
- ]
130
- const hasVideoExtension = videoExtensions.some((ext) =>
131
+ const hasVideoExtension = VIDEO_EXTENSIONS.some((ext) =>
131
132
  outputCandidate.toLowerCase().endsWith(ext),
132
133
  )
133
134
 
@@ -0,0 +1,108 @@
1
+ import { test, expect } from 'bun:test'
2
+ import type { Arguments } from 'yargs'
3
+ import {
4
+ resolveEditVideoArgs,
5
+ resolveCombineVideosArgs,
6
+ buildCombinedOutputPath,
7
+ } from './cli'
8
+ import { buildEditedOutputPath } from './video-editor'
9
+ import type { PathPicker } from '../../cli-ux'
10
+
11
+ function createArgs(values: Record<string, unknown>): Arguments {
12
+ return values as Arguments
13
+ }
14
+
15
+ function createPathPicker(options?: {
16
+ files?: string[]
17
+ outputs?: string[]
18
+ directories?: string[]
19
+ }): PathPicker {
20
+ const fileResponses = options?.files ?? []
21
+ const outputResponses = options?.outputs ?? []
22
+ const directoryResponses = options?.directories ?? []
23
+ let fileIndex = 0
24
+ let outputIndex = 0
25
+ let directoryIndex = 0
26
+
27
+ return {
28
+ async pickExistingFile() {
29
+ const response = fileResponses[fileIndex]
30
+ fileIndex += 1
31
+ if (!response) {
32
+ throw new Error('Missing file response')
33
+ }
34
+ return response
35
+ },
36
+ async pickExistingDirectory() {
37
+ const response = directoryResponses[directoryIndex]
38
+ directoryIndex += 1
39
+ if (!response) {
40
+ throw new Error('Missing directory response')
41
+ }
42
+ return response
43
+ },
44
+ async pickOutputPath({ defaultPath }) {
45
+ const response = outputResponses[outputIndex] ?? defaultPath
46
+ outputIndex += 1
47
+ if (!response) {
48
+ throw new Error('Missing output response')
49
+ }
50
+ return response
51
+ },
52
+ }
53
+ }
54
+
55
+ test('resolveEditVideoArgs prompts for missing required paths', async () => {
56
+ const args = createArgs({})
57
+ const pathPicker = createPathPicker({
58
+ files: ['input.mp4', 'transcript.json', 'edited.txt'],
59
+ })
60
+ const result = await resolveEditVideoArgs(args, {
61
+ interactive: true,
62
+ pathPicker,
63
+ })
64
+
65
+ expect(result).toEqual({
66
+ input: 'input.mp4',
67
+ transcript: 'transcript.json',
68
+ edited: 'edited.txt',
69
+ output: buildEditedOutputPath('input.mp4'),
70
+ 'padding-ms': undefined,
71
+ })
72
+ })
73
+
74
+ test('resolveCombineVideosArgs prompts for transcript when edited provided', async () => {
75
+ const args = createArgs({
76
+ video1: 'video1.mp4',
77
+ edited1: 'edited1.txt',
78
+ video2: 'video2.mp4',
79
+ output: 'combined.mp4',
80
+ })
81
+ const pathPicker = createPathPicker({
82
+ files: ['transcript1.json'],
83
+ })
84
+ const result = await resolveCombineVideosArgs(args, {
85
+ interactive: true,
86
+ pathPicker,
87
+ })
88
+
89
+ expect(result.transcript1).toBe('transcript1.json')
90
+ expect(result.transcript2).toBeUndefined()
91
+ expect(result.output).toBe('combined.mp4')
92
+ })
93
+
94
+ test('resolveCombineVideosArgs uses default output when missing', async () => {
95
+ const args = createArgs({
96
+ video1: 'video1.mov',
97
+ video2: 'video2.mp4',
98
+ })
99
+ const pathPicker = createPathPicker()
100
+ const result = await resolveCombineVideosArgs(args, {
101
+ interactive: true,
102
+ pathPicker,
103
+ })
104
+
105
+ expect(result.output).toBe(
106
+ buildCombinedOutputPath('video1.mov', 'video2.mp4'),
107
+ )
108
+ })