eprec 1.2.0 → 1.3.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.
@@ -261,7 +261,9 @@ p {
261
261
  cursor: pointer;
262
262
  left: var(--range-left);
263
263
  width: var(--range-width);
264
- transition: transform 0.15s ease, box-shadow 0.15s ease;
264
+ transition:
265
+ transform 0.15s ease,
266
+ box-shadow 0.15s ease;
265
267
  }
266
268
 
267
269
  .timeline-range--manual {
@@ -370,7 +372,9 @@ p {
370
372
  font-size: 13px;
371
373
  font-weight: 600;
372
374
  cursor: pointer;
373
- transition: background 0.15s ease, color 0.15s ease;
375
+ transition:
376
+ background 0.15s ease,
377
+ color 0.15s ease;
374
378
  }
375
379
 
376
380
  .button--primary {
@@ -1,7 +1,5 @@
1
1
  import { EditingWorkspace } from './editing-workspace.tsx'
2
2
 
3
3
  export function App() {
4
- return () => (
5
- <EditingWorkspace />
6
- )
4
+ return () => <EditingWorkspace />
7
5
  }
@@ -27,6 +27,7 @@ export function EditingWorkspace(handle: Handle) {
27
27
  let previewPlaying = false
28
28
  let previewNode: HTMLVideoElement | null = null
29
29
  let lastSyncedPlayhead = playhead
30
+ let isScrubbing = false
30
31
 
31
32
  const setPlayhead = (value: number) => {
32
33
  playhead = clamp(value, 0, duration)
@@ -34,6 +35,16 @@ export function EditingWorkspace(handle: Handle) {
34
35
  handle.update()
35
36
  }
36
37
 
38
+ const startScrubbing = () => {
39
+ isScrubbing = true
40
+ }
41
+
42
+ const stopScrubbing = () => {
43
+ if (!isScrubbing) return
44
+ isScrubbing = false
45
+ syncVideoToPlayhead(playhead)
46
+ }
47
+
37
48
  const selectRange = (rangeId: string) => {
38
49
  selectedRangeId = rangeId
39
50
  handle.update()
@@ -41,7 +52,11 @@ export function EditingWorkspace(handle: Handle) {
41
52
 
42
53
  const addManualCut = () => {
43
54
  const start = clamp(playhead, 0, duration - MIN_CUT_LENGTH)
44
- const end = clamp(start + DEFAULT_CUT_LENGTH, start + MIN_CUT_LENGTH, duration)
55
+ const end = clamp(
56
+ start + DEFAULT_CUT_LENGTH,
57
+ start + MIN_CUT_LENGTH,
58
+ duration,
59
+ )
45
60
  const newRange: CutRange = {
46
61
  id: `manual-${manualCutId++}`,
47
62
  start,
@@ -130,10 +145,19 @@ export function EditingWorkspace(handle: Handle) {
130
145
  }
131
146
 
132
147
  const syncVideoToPlayhead = (value: number) => {
133
- if (!previewNode || !previewReady || duration <= 0 || previewDuration <= 0) {
148
+ if (
149
+ !previewNode ||
150
+ !previewReady ||
151
+ duration <= 0 ||
152
+ previewDuration <= 0
153
+ ) {
134
154
  return
135
155
  }
136
- const targetTime = clamp((value / duration) * previewDuration, 0, previewDuration)
156
+ const targetTime = clamp(
157
+ (value / duration) * previewDuration,
158
+ 0,
159
+ previewDuration,
160
+ )
137
161
  lastSyncedPlayhead = value
138
162
  if (Math.abs(previewNode.currentTime - targetTime) > 0.05) {
139
163
  previewNode.currentTime = targetTime
@@ -143,7 +167,7 @@ export function EditingWorkspace(handle: Handle) {
143
167
  return () => {
144
168
  const sortedCuts = sortRanges(cutRanges)
145
169
  const selectedRange = selectedRangeId
146
- ? sortedCuts.find((range) => range.id === selectedRangeId) ?? null
170
+ ? (sortedCuts.find((range) => range.id === selectedRangeId) ?? null)
147
171
  : null
148
172
  const mergedCuts = mergeOverlappingRanges(sortedCuts)
149
173
  const totalRemoved = mergedCuts.reduce(
@@ -161,7 +185,10 @@ export function EditingWorkspace(handle: Handle) {
161
185
  .filter((word) => word.word.toLowerCase().includes(query))
162
186
  .slice(0, 12)
163
187
  : []
164
- const commandPreview = buildCommandPreview(sampleEditSession.sourceName, chapters)
188
+ const commandPreview = buildCommandPreview(
189
+ sampleEditSession.sourceName,
190
+ chapters,
191
+ )
165
192
  const previewTime =
166
193
  previewReady && previewDuration > 0
167
194
  ? (playhead / duration) * previewDuration
@@ -190,29 +217,29 @@ export function EditingWorkspace(handle: Handle) {
190
217
  </div>
191
218
  <div class="summary-item">
192
219
  <span class="summary-label">Cuts</span>
193
- <span class="summary-value">
194
- {sortedCuts.length} ranges
195
- </span>
220
+ <span class="summary-value">{sortedCuts.length} ranges</span>
196
221
  <span class="summary-subtext">
197
222
  {formatSeconds(totalRemoved)} removed
198
223
  </span>
199
224
  </div>
200
225
  <div class="summary-item">
201
226
  <span class="summary-label">Output length</span>
202
- <span class="summary-value">{formatTimestamp(finalDuration)}</span>
227
+ <span class="summary-value">
228
+ {formatTimestamp(finalDuration)}
229
+ </span>
203
230
  <span class="summary-subtext">
204
231
  {Math.round((finalDuration / duration) * 100)}% retained
205
232
  </span>
206
233
  </div>
207
234
  <div class="summary-item">
208
235
  <span class="summary-label">Commands</span>
209
- <span class="summary-value">
210
- {commands.length} detected
211
- </span>
236
+ <span class="summary-value">{commands.length} detected</span>
212
237
  <span class="summary-subtext">
213
- {commands.filter((command) =>
214
- isCommandApplied(command, sortedCuts, chapters),
215
- ).length}{' '}
238
+ {
239
+ commands.filter((command) =>
240
+ isCommandApplied(command, sortedCuts, chapters),
241
+ ).length
242
+ }{' '}
216
243
  applied
217
244
  </span>
218
245
  </div>
@@ -228,7 +255,11 @@ export function EditingWorkspace(handle: Handle) {
228
255
  trims.
229
256
  </p>
230
257
  </div>
231
- <button class="button button--primary" type="button" on={{ click: addManualCut }}>
258
+ <button
259
+ class="button button--primary"
260
+ type="button"
261
+ on={{ click: addManualCut }}
262
+ >
232
263
  Add cut at playhead
233
264
  </button>
234
265
  </div>
@@ -260,11 +291,11 @@ export function EditingWorkspace(handle: Handle) {
260
291
  : 'Loading'}
261
292
  </span>
262
293
  </div>
263
- <video
264
- class="timeline-video-player"
265
- src="/e2e-test.mp4"
266
- controls
267
- preload="metadata"
294
+ <video
295
+ class="timeline-video-player"
296
+ src="/e2e-test.mp4"
297
+ controls
298
+ preload="metadata"
268
299
  connect={(node: HTMLVideoElement, signal) => {
269
300
  previewNode = node
270
301
  const handleLoadedMetadata = () => {
@@ -278,6 +309,8 @@ export function EditingWorkspace(handle: Handle) {
278
309
  }
279
310
  const handleTimeUpdate = () => {
280
311
  if (!previewReady || previewDuration <= 0) return
312
+ if (isScrubbing) return
313
+ if (!previewPlaying) return
281
314
  const mapped =
282
315
  (node.currentTime / previewDuration) * duration
283
316
  if (Math.abs(mapped - lastSyncedPlayhead) <= 0.05) {
@@ -294,7 +327,10 @@ export function EditingWorkspace(handle: Handle) {
294
327
  previewPlaying = false
295
328
  handle.update()
296
329
  }
297
- node.addEventListener('loadedmetadata', handleLoadedMetadata)
330
+ node.addEventListener(
331
+ 'loadedmetadata',
332
+ handleLoadedMetadata,
333
+ )
298
334
  node.addEventListener('timeupdate', handleTimeUpdate)
299
335
  node.addEventListener('play', handlePlay)
300
336
  node.addEventListener('pause', handlePause)
@@ -313,9 +349,7 @@ export function EditingWorkspace(handle: Handle) {
313
349
  }}
314
350
  />
315
351
  <div class="timeline-video-meta">
316
- <span>
317
- Preview {formatTimestamp(previewTime)}
318
- </span>
352
+ <span>Preview {formatTimestamp(previewTime)}</span>
319
353
  <span class="app-muted">
320
354
  Timeline {formatTimestamp(playhead)}
321
355
  </span>
@@ -350,9 +384,7 @@ export function EditingWorkspace(handle: Handle) {
350
384
  <div class="timeline-controls">
351
385
  <label class="control-label">
352
386
  Playhead
353
- <span class="control-value">
354
- {formatTimestamp(playhead)}
355
- </span>
387
+ <span class="control-value">{formatTimestamp(playhead)}</span>
356
388
  </label>
357
389
  <input
358
390
  class="timeline-slider"
@@ -364,8 +396,15 @@ export function EditingWorkspace(handle: Handle) {
364
396
  on={{
365
397
  input: (event) => {
366
398
  const target = event.currentTarget as HTMLInputElement
399
+ startScrubbing()
367
400
  setPlayhead(Number(target.value))
368
401
  },
402
+ pointerdown: startScrubbing,
403
+ pointerup: stopScrubbing,
404
+ pointercancel: stopScrubbing,
405
+ keydown: startScrubbing,
406
+ keyup: stopScrubbing,
407
+ blur: stopScrubbing,
369
408
  }}
370
409
  />
371
410
  <button
@@ -385,7 +424,9 @@ export function EditingWorkspace(handle: Handle) {
385
424
  type="button"
386
425
  on={{
387
426
  click: () => {
388
- const next = sortedCuts.find((range) => range.start > playhead)
427
+ const next = sortedCuts.find(
428
+ (range) => range.start > playhead,
429
+ )
389
430
  if (next) setPlayhead(next.start)
390
431
  },
391
432
  }}
@@ -460,7 +501,9 @@ export function EditingWorkspace(handle: Handle) {
460
501
  on={{
461
502
  input: (event) => {
462
503
  const target = event.currentTarget as HTMLInputElement
463
- updateCutRange(selectedRange.id, { reason: target.value })
504
+ updateCutRange(selectedRange.id, {
505
+ reason: target.value,
506
+ })
464
507
  },
465
508
  }}
466
509
  />
@@ -562,7 +605,8 @@ export function EditingWorkspace(handle: Handle) {
562
605
  value={chapter.status}
563
606
  on={{
564
607
  change: (event) => {
565
- const target = event.currentTarget as HTMLSelectElement
608
+ const target =
609
+ event.currentTarget as HTMLSelectElement
566
610
  updateChapterStatus(
567
611
  chapter.id,
568
612
  target.value as ChapterStatus,
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,33 @@ 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
+ { successText: 'Transcription complete', enabled: context.interactive },
168
+ )
169
+ console.log(
170
+ `Transcript written to ${transcribeArgs.outputBasePath}.txt`,
171
+ )
172
+ console.log(
173
+ `Segments written to ${transcribeArgs.outputBasePath}.json`,
174
+ )
175
+ console.log(resultText)
132
176
  },
133
177
  )
134
178
  .command(
135
- 'detect-speech <input>',
179
+ 'detect-speech [input]',
136
180
  'Show detected speech segments for a file',
137
181
  (command) =>
138
182
  command
@@ -149,12 +193,23 @@ async function main() {
149
193
  describe: 'End time in seconds',
150
194
  }),
151
195
  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
- })
196
+ const { inputPath, start, end } = await resolveDetectSpeechArgs(
197
+ argv,
198
+ context,
199
+ )
200
+ let segments: unknown = []
201
+ await withSpinner(
202
+ 'Detecting speech',
203
+ async () => {
204
+ await ensureFfmpegAvailable()
205
+ segments = await detectSpeechSegmentsForFile({
206
+ inputPath,
207
+ start,
208
+ end,
209
+ })
210
+ },
211
+ { successText: 'Speech detection complete', enabled: context.interactive },
212
+ )
158
213
  console.log(JSON.stringify(segments, null, 2))
159
214
  },
160
215
  )
@@ -165,7 +220,185 @@ async function main() {
165
220
  await parser.parseAsync()
166
221
  }
167
222
 
223
+ function createCliUxContext(): CliUxContext {
224
+ const interactive = isInteractive()
225
+ if (!interactive) {
226
+ return { interactive }
227
+ }
228
+ const prompter = createInquirerPrompter()
229
+ const pathPicker = createPathPicker(prompter)
230
+ return { interactive, prompter, pathPicker }
231
+ }
232
+
233
+ async function promptForCommand(
234
+ prompter: Prompter,
235
+ ): Promise<string[] | null> {
236
+ const selection = await prompter.select('Choose a command', [
237
+ {
238
+ name: 'Process chapters into separate files',
239
+ value: 'process',
240
+ },
241
+ {
242
+ name: 'Edit a single video using transcript text edits',
243
+ value: 'edit',
244
+ },
245
+ {
246
+ name: 'Combine two videos with speech-aligned padding',
247
+ value: 'combine',
248
+ },
249
+ {
250
+ name: 'Start the web UI server',
251
+ value: 'app-start',
252
+ },
253
+ {
254
+ name: 'Transcribe a single audio/video file',
255
+ value: 'transcribe',
256
+ },
257
+ {
258
+ name: 'Show detected speech segments for a file',
259
+ value: 'detect-speech',
260
+ },
261
+ { name: 'Show help', value: 'help' },
262
+ { name: 'Exit', value: 'exit' },
263
+ ])
264
+ switch (selection) {
265
+ case 'exit':
266
+ return null
267
+ case 'help':
268
+ return ['--help']
269
+ case 'app-start':
270
+ return ['app', 'start']
271
+ default:
272
+ return [selection]
273
+ }
274
+ }
275
+
276
+ async function resolveProcessArgs(argv: Arguments, context: CliUxContext) {
277
+ let inputPaths = collectStringArray(argv.input)
278
+ if (inputPaths.length === 0) {
279
+ if (!context.interactive || !context.pathPicker || !context.prompter) {
280
+ throw new Error('At least one input file is required.')
281
+ }
282
+ inputPaths = await promptForInputFiles(context)
283
+ }
284
+
285
+ let outputDir = resolveOptionalString(argv['output-dir'])
286
+ if (!outputDir && context.interactive && context.prompter && context.pathPicker) {
287
+ const chooseOutput = await context.prompter.confirm(
288
+ 'Choose a custom output directory?',
289
+ { defaultValue: false },
290
+ )
291
+ if (chooseOutput) {
292
+ outputDir = await context.pathPicker.pickExistingDirectory({
293
+ message: 'Select output directory',
294
+ })
295
+ }
296
+ }
297
+
298
+ const updatedArgs = {
299
+ ...argv,
300
+ input: inputPaths,
301
+ 'output-dir': outputDir ?? argv['output-dir'],
302
+ } as Arguments
303
+ return normalizeProcessArgs(updatedArgs)
304
+ }
305
+
306
+ async function promptForInputFiles(context: CliUxContext) {
307
+ if (!context.prompter || !context.pathPicker) {
308
+ throw new Error('Interactive prompts are not available.')
309
+ }
310
+ const inputPaths: string[] = []
311
+ let addAnother = true
312
+ while (addAnother) {
313
+ const inputPath = await context.pathPicker.pickExistingFile({
314
+ message:
315
+ inputPaths.length === 0
316
+ ? 'Select input video file'
317
+ : 'Select another input video file',
318
+ extensions: VIDEO_EXTENSIONS,
319
+ })
320
+ inputPaths.push(inputPath)
321
+ addAnother = await context.prompter.confirm('Add another input file?', {
322
+ defaultValue: false,
323
+ })
324
+ }
325
+ return inputPaths
326
+ }
327
+
328
+ async function resolveTranscribeArgs(argv: Arguments, context: CliUxContext) {
329
+ let input = resolveOptionalString(argv.input)
330
+ if (!input) {
331
+ if (!context.interactive || !context.pathPicker) {
332
+ throw new Error('Input audio/video file is required.')
333
+ }
334
+ input = await context.pathPicker.pickExistingFile({
335
+ message: 'Select input audio/video file',
336
+ })
337
+ }
338
+ const inputPath = path.resolve(input)
339
+ const outputBasePath =
340
+ resolveOptionalString(argv['output-base']) ??
341
+ buildTranscribeOutputBase(inputPath)
342
+ const threads = resolveOptionalNumber(argv.threads)
343
+ return {
344
+ inputPath,
345
+ outputBasePath,
346
+ threads,
347
+ modelPath: resolveOptionalString(argv['model-path']),
348
+ language: resolveOptionalString(argv.language),
349
+ binaryPath: resolveOptionalString(argv['binary-path']),
350
+ }
351
+ }
352
+
353
+ async function resolveDetectSpeechArgs(argv: Arguments, context: CliUxContext) {
354
+ let input = resolveOptionalString(argv.input)
355
+ if (!input) {
356
+ if (!context.interactive || !context.pathPicker) {
357
+ throw new Error('Input audio/video file is required.')
358
+ }
359
+ input = await context.pathPicker.pickExistingFile({
360
+ message: 'Select input audio/video file',
361
+ })
362
+ }
363
+ return {
364
+ inputPath: String(input),
365
+ start: resolveOptionalNumber(argv.start),
366
+ end: resolveOptionalNumber(argv.end),
367
+ }
368
+ }
369
+
370
+ function buildTranscribeOutputBase(inputPath: string) {
371
+ return path.join(
372
+ path.dirname(inputPath),
373
+ `${path.parse(inputPath).name}-transcript`,
374
+ )
375
+ }
376
+
377
+ function collectStringArray(value: unknown) {
378
+ if (Array.isArray(value)) {
379
+ return value.filter(
380
+ (entry): entry is string =>
381
+ typeof entry === 'string' && entry.trim().length > 0,
382
+ )
383
+ }
384
+ if (typeof value === 'string' && value.trim().length > 0) {
385
+ return [value]
386
+ }
387
+ return []
388
+ }
389
+
390
+ function resolveOptionalNumber(value: unknown) {
391
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
392
+ return undefined
393
+ }
394
+ return value
395
+ }
396
+
168
397
  main().catch((error) => {
398
+ if (error instanceof PromptCancelled) {
399
+ console.log('[info] Cancelled.')
400
+ return
401
+ }
169
402
  console.error(
170
403
  `[error] ${error instanceof Error ? error.message : String(error)}`,
171
404
  )
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -11,7 +11,7 @@
11
11
  "app:start": "bun ./app-server.ts",
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
+ })
@@ -1,7 +1,18 @@
1
1
  #!/usr/bin/env bun
2
+ import path from 'node:path'
2
3
  import type { Argv, Arguments, CommandBuilder, CommandHandler } from 'yargs'
3
4
  import yargs from 'yargs/yargs'
4
5
  import { hideBin } from 'yargs/helpers'
6
+ import {
7
+ PromptCancelled,
8
+ createInquirerPrompter,
9
+ createPathPicker,
10
+ isInteractive,
11
+ resolveOptionalString,
12
+ type PathPicker,
13
+ type Prompter,
14
+ withSpinner,
15
+ } from '../../cli-ux'
5
16
  import { editVideo, buildEditedOutputPath } from './video-editor'
6
17
  import { combineVideos } from './combined-video-editor'
7
18
 
@@ -24,21 +35,205 @@ export type CombineVideosCommandArgs = {
24
35
  'padding-ms'?: number
25
36
  }
26
37
 
38
+ type CliUxOptions = {
39
+ interactive: boolean
40
+ pathPicker?: PathPicker
41
+ }
42
+
43
+ export function buildCombinedOutputPath(video1Path: string, video2Path: string) {
44
+ const dir = path.dirname(video1Path)
45
+ const ext = path.extname(video1Path) || path.extname(video2Path) || '.mp4'
46
+ const name1 = path.parse(video1Path).name
47
+ const name2 = path.parse(video2Path).name
48
+ return path.join(dir, `combined-${name1}-${name2}${ext}`)
49
+ }
50
+
51
+ export async function resolveEditVideoArgs(
52
+ argv: Arguments,
53
+ options: CliUxOptions,
54
+ ): Promise<EditVideoCommandArgs> {
55
+ const pathPicker = options.pathPicker
56
+ let input = resolveOptionalString(argv.input)
57
+ if (!input) {
58
+ if (!options.interactive || !pathPicker) {
59
+ throw new Error('Input video path is required.')
60
+ }
61
+ input = await pathPicker.pickExistingFile({
62
+ message: 'Select input video file',
63
+ })
64
+ }
65
+ let transcript = resolveOptionalString(argv.transcript)
66
+ if (!transcript) {
67
+ if (!options.interactive || !pathPicker) {
68
+ throw new Error('Transcript JSON path is required.')
69
+ }
70
+ transcript = await pathPicker.pickExistingFile({
71
+ message: 'Select transcript JSON file',
72
+ })
73
+ }
74
+ let edited = resolveOptionalString(argv.edited)
75
+ if (!edited) {
76
+ if (!options.interactive || !pathPicker) {
77
+ throw new Error('Edited transcript path is required.')
78
+ }
79
+ edited = await pathPicker.pickExistingFile({
80
+ message: 'Select edited transcript text file',
81
+ })
82
+ }
83
+ const defaultOutputPath = buildEditedOutputPath(input)
84
+ const outputPath =
85
+ resolveOptionalString(argv.output) ?? defaultOutputPath
86
+ const paddingMs = resolvePaddingMs(argv['padding-ms'])
87
+
88
+ return {
89
+ input,
90
+ transcript,
91
+ edited,
92
+ output: outputPath,
93
+ 'padding-ms': paddingMs,
94
+ }
95
+ }
96
+
97
+ export async function resolveCombineVideosArgs(
98
+ argv: Arguments,
99
+ options: CliUxOptions,
100
+ ): Promise<CombineVideosCommandArgs> {
101
+ const pathPicker = options.pathPicker
102
+ let video1 = resolveOptionalString(argv.video1)
103
+ if (!video1) {
104
+ if (!options.interactive || !pathPicker) {
105
+ throw new Error('First video path is required.')
106
+ }
107
+ video1 = await pathPicker.pickExistingFile({
108
+ message: 'Select first video',
109
+ })
110
+ }
111
+ let video2 = resolveOptionalString(argv.video2)
112
+ if (!video2) {
113
+ if (!options.interactive || !pathPicker) {
114
+ throw new Error('Second video path is required.')
115
+ }
116
+ video2 = await pathPicker.pickExistingFile({
117
+ message: 'Select second video',
118
+ })
119
+ }
120
+ let transcript1 = resolveOptionalString(argv.transcript1)
121
+ let transcript2 = resolveOptionalString(argv.transcript2)
122
+ const edited1 = resolveOptionalString(argv.edited1)
123
+ const edited2 = resolveOptionalString(argv.edited2)
124
+
125
+ if (edited1 && !transcript1) {
126
+ if (!options.interactive || !pathPicker) {
127
+ throw new Error('Transcript JSON is required for edited1.')
128
+ }
129
+ transcript1 = await pathPicker.pickExistingFile({
130
+ message: 'Select transcript JSON for first video',
131
+ })
132
+ }
133
+ if (edited2 && !transcript2) {
134
+ if (!options.interactive || !pathPicker) {
135
+ throw new Error('Transcript JSON is required for edited2.')
136
+ }
137
+ transcript2 = await pathPicker.pickExistingFile({
138
+ message: 'Select transcript JSON for second video',
139
+ })
140
+ }
141
+
142
+ let output = resolveOptionalString(argv.output)
143
+ if (!output) {
144
+ if (options.interactive && pathPicker) {
145
+ output = await pathPicker.pickOutputPath({
146
+ message: 'Select output video path',
147
+ defaultPath: buildCombinedOutputPath(video1, video2),
148
+ })
149
+ } else {
150
+ output = buildCombinedOutputPath(video1, video2)
151
+ }
152
+ }
153
+ const paddingMs = resolvePaddingMs(argv['padding-ms'])
154
+
155
+ return {
156
+ video1,
157
+ transcript1,
158
+ edited1,
159
+ video2,
160
+ transcript2,
161
+ edited2,
162
+ output,
163
+ 'padding-ms': paddingMs,
164
+ }
165
+ }
166
+
167
+ function resolvePaddingMs(value: unknown) {
168
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
169
+ return undefined
170
+ }
171
+ return value
172
+ }
173
+
174
+ export function createEditVideoHandler(options: CliUxOptions): CommandHandler {
175
+ return async (argv) => {
176
+ const args = await resolveEditVideoArgs(argv, options)
177
+ await withSpinner(
178
+ 'Editing video',
179
+ async () => {
180
+ const result = await editVideo({
181
+ inputPath: String(args.input),
182
+ transcriptJsonPath: String(args.transcript),
183
+ editedTextPath: String(args.edited),
184
+ outputPath: String(args.output),
185
+ paddingMs: args['padding-ms'],
186
+ })
187
+ if (!result.success) {
188
+ throw new Error(result.error ?? 'Edit failed.')
189
+ }
190
+ },
191
+ { successText: 'Edit complete' },
192
+ )
193
+ console.log(`Edited video written to ${args.output}`)
194
+ }
195
+ }
196
+
197
+ export function createCombineVideosHandler(options: CliUxOptions): CommandHandler {
198
+ return async (argv) => {
199
+ const args = await resolveCombineVideosArgs(argv, options)
200
+ let outputPath = ''
201
+ await withSpinner(
202
+ 'Combining videos',
203
+ async () => {
204
+ const result = await combineVideos({
205
+ video1Path: String(args.video1),
206
+ video1TranscriptJsonPath: args.transcript1,
207
+ video1EditedTextPath: args.edited1,
208
+ video2Path: String(args.video2),
209
+ video2TranscriptJsonPath: args.transcript2,
210
+ video2EditedTextPath: args.edited2,
211
+ outputPath: String(args.output),
212
+ overlapPaddingMs: args['padding-ms'],
213
+ })
214
+ if (!result.success) {
215
+ throw new Error(result.error ?? 'Combine failed.')
216
+ }
217
+ outputPath = result.outputPath
218
+ },
219
+ { successText: 'Combine complete' },
220
+ )
221
+ console.log(`Combined video written to ${outputPath}`)
222
+ }
223
+ }
224
+
27
225
  export function configureEditVideoCommand(command: Argv) {
28
226
  return command
29
227
  .option('input', {
30
228
  type: 'string',
31
- demandOption: true,
32
229
  describe: 'Input video file',
33
230
  })
34
231
  .option('transcript', {
35
232
  type: 'string',
36
- demandOption: true,
37
233
  describe: 'Transcript JSON path',
38
234
  })
39
235
  .option('edited', {
40
236
  type: 'string',
41
- demandOption: true,
42
237
  describe: 'Edited transcript text path',
43
238
  })
44
239
  .option('output', {
@@ -52,31 +247,14 @@ export function configureEditVideoCommand(command: Argv) {
52
247
  }
53
248
 
54
249
  export async function handleEditVideoCommand(argv: Arguments) {
55
- const args = argv as EditVideoCommandArgs
56
- const outputPath =
57
- typeof args.output === 'string' && args.output.trim().length > 0
58
- ? args.output
59
- : buildEditedOutputPath(String(args.input))
60
- const result = await editVideo({
61
- inputPath: String(args.input),
62
- transcriptJsonPath: String(args.transcript),
63
- editedTextPath: String(args.edited),
64
- outputPath,
65
- paddingMs:
66
- typeof args['padding-ms'] === 'number' ? args['padding-ms'] : undefined,
67
- })
68
- if (!result.success) {
69
- console.error(result.error ?? 'Edit failed.')
70
- process.exit(1)
71
- }
72
- console.log(`Edited video written to ${outputPath}`)
250
+ const options = createDefaultCliUxOptions()
251
+ await createEditVideoHandler(options)(argv)
73
252
  }
74
253
 
75
254
  export function configureCombineVideosCommand(command: Argv) {
76
255
  return command
77
256
  .option('video1', {
78
257
  type: 'string',
79
- demandOption: true,
80
258
  describe: 'First video path',
81
259
  })
82
260
  .option('transcript1', {
@@ -89,7 +267,6 @@ export function configureCombineVideosCommand(command: Argv) {
89
267
  })
90
268
  .option('video2', {
91
269
  type: 'string',
92
- demandOption: true,
93
270
  describe: 'Second video path',
94
271
  })
95
272
  .option('transcript2', {
@@ -102,7 +279,6 @@ export function configureCombineVideosCommand(command: Argv) {
102
279
  })
103
280
  .option('output', {
104
281
  type: 'string',
105
- demandOption: true,
106
282
  describe: 'Output video path',
107
283
  })
108
284
  .option('padding-ms', {
@@ -112,43 +288,47 @@ export function configureCombineVideosCommand(command: Argv) {
112
288
  }
113
289
 
114
290
  export async function handleCombineVideosCommand(argv: Arguments) {
115
- const args = argv as CombineVideosCommandArgs
116
- const result = await combineVideos({
117
- video1Path: String(args.video1),
118
- video1TranscriptJsonPath:
119
- typeof args.transcript1 === 'string' ? args.transcript1 : undefined,
120
- video1EditedTextPath:
121
- typeof args.edited1 === 'string' ? args.edited1 : undefined,
122
- video2Path: String(args.video2),
123
- video2TranscriptJsonPath:
124
- typeof args.transcript2 === 'string' ? args.transcript2 : undefined,
125
- video2EditedTextPath:
126
- typeof args.edited2 === 'string' ? args.edited2 : undefined,
127
- outputPath: String(args.output),
128
- overlapPaddingMs:
129
- typeof args['padding-ms'] === 'number' ? args['padding-ms'] : undefined,
130
- })
131
- if (!result.success) {
132
- console.error(result.error ?? 'Combine failed.')
133
- process.exit(1)
291
+ const options = createDefaultCliUxOptions()
292
+ await createCombineVideosHandler(options)(argv)
293
+ }
294
+
295
+ function createDefaultCliUxOptions(): CliUxOptions {
296
+ const interactive = isInteractive()
297
+ if (!interactive) {
298
+ return { interactive }
134
299
  }
135
- console.log(`Combined video written to ${result.outputPath}`)
300
+ const prompter = createInquirerPrompter()
301
+ return { interactive, pathPicker: createPathPicker(prompter) }
136
302
  }
137
303
 
138
- export async function runEditsCli() {
139
- const parser = yargs(hideBin(process.argv))
304
+ export async function runEditsCli(rawArgs = hideBin(process.argv)) {
305
+ const interactive = isInteractive()
306
+ const prompter = interactive ? createInquirerPrompter() : null
307
+ const pathPicker = prompter ? createPathPicker(prompter) : undefined
308
+ let args = rawArgs
309
+
310
+ if (interactive && args.length === 0 && prompter) {
311
+ const selection = await promptForEditsCommand(prompter)
312
+ if (!selection) {
313
+ return
314
+ }
315
+ args = selection
316
+ }
317
+
318
+ const handlerOptions: CliUxOptions = { interactive, pathPicker }
319
+ const parser = yargs(args)
140
320
  .scriptName('video-edits')
141
321
  .command(
142
322
  'edit-video',
143
323
  'Edit a single video using transcript text edits',
144
324
  configureEditVideoCommand as CommandBuilder,
145
- handleEditVideoCommand as CommandHandler,
325
+ createEditVideoHandler(handlerOptions),
146
326
  )
147
327
  .command(
148
328
  'combine-videos',
149
329
  'Combine two videos with speech-aligned padding',
150
330
  configureCombineVideosCommand as CommandBuilder,
151
- handleCombineVideosCommand as CommandHandler,
331
+ createCombineVideosHandler(handlerOptions),
152
332
  )
153
333
  .demandCommand(1)
154
334
  .strict()
@@ -159,9 +339,37 @@ export async function runEditsCli() {
159
339
 
160
340
  if (import.meta.main) {
161
341
  runEditsCli().catch((error) => {
342
+ if (error instanceof PromptCancelled) {
343
+ console.log('[info] Cancelled.')
344
+ return
345
+ }
162
346
  console.error(
163
347
  `[error] ${error instanceof Error ? error.message : String(error)}`,
164
348
  )
165
349
  process.exit(1)
166
350
  })
167
351
  }
352
+
353
+ async function promptForEditsCommand(
354
+ prompter: Prompter,
355
+ ): Promise<string[] | null> {
356
+ const selection = await prompter.select('Choose a command', [
357
+ {
358
+ name: 'Edit a single video using transcript text edits',
359
+ value: 'edit-video',
360
+ },
361
+ {
362
+ name: 'Combine two videos with speech-aligned padding',
363
+ value: 'combine-videos',
364
+ },
365
+ { name: 'Show help', value: 'help' },
366
+ { name: 'Exit', value: 'exit' },
367
+ ])
368
+ if (selection === 'exit') {
369
+ return null
370
+ }
371
+ if (selection === 'help') {
372
+ return ['--help']
373
+ }
374
+ return [selection]
375
+ }
@@ -1,16 +1,41 @@
1
1
  import { formatCommand } from '../utils'
2
2
  import { buildChapterLogPath } from './paths'
3
3
 
4
+ type LogHook = () => void
5
+
6
+ let beforeLogHook: LogHook | null = null
7
+ let afterLogHook: LogHook | null = null
8
+
9
+ export function setLogHooks(hooks: {
10
+ beforeLog?: LogHook
11
+ afterLog?: LogHook
12
+ }) {
13
+ beforeLogHook = hooks.beforeLog ?? null
14
+ afterLogHook = hooks.afterLog ?? null
15
+ }
16
+
17
+ function withLogHooks(callback: () => void) {
18
+ beforeLogHook?.()
19
+ callback()
20
+ afterLogHook?.()
21
+ }
22
+
4
23
  export function logCommand(command: string[]) {
5
- console.log(`[cmd] ${formatCommand(command)}`)
24
+ withLogHooks(() => {
25
+ console.log(`[cmd] ${formatCommand(command)}`)
26
+ })
6
27
  }
7
28
 
8
29
  export function logInfo(message: string) {
9
- console.log(`[info] ${message}`)
30
+ withLogHooks(() => {
31
+ console.log(`[info] ${message}`)
32
+ })
10
33
  }
11
34
 
12
35
  export function logWarn(message: string) {
13
- console.warn(`[warn] ${message}`)
36
+ withLogHooks(() => {
37
+ console.warn(`[warn] ${message}`)
38
+ })
14
39
  }
15
40
 
16
41
  export async function writeChapterLog(