eprec 1.1.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.
@@ -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(
@@ -31,9 +31,7 @@ async function resolvePackageExport(
31
31
 
32
32
  if (!(await packageJsonFile.exists())) return null
33
33
 
34
- const packageJson = JSON.parse(
35
- await packageJsonFile.text(),
36
- ) as PackageJson
34
+ const packageJson = JSON.parse(await packageJsonFile.text()) as PackageJson
37
35
 
38
36
  if (!packageJson.exports) {
39
37
  const entryFile = packageJson.module || packageJson.main
@@ -49,9 +47,7 @@ async function resolvePackageExport(
49
47
  if (!exportEntry) return null
50
48
 
51
49
  const exportPath =
52
- typeof exportEntry === 'string'
53
- ? exportEntry
54
- : exportEntry.default
50
+ typeof exportEntry === 'string' ? exportEntry : exportEntry.default
55
51
 
56
52
  if (!exportPath) return null
57
53
 
@@ -106,40 +102,40 @@ export function createBundlingRoutes(rootDir: string) {
106
102
  })
107
103
  }
108
104
 
109
- const buildResult = await Bun.build({
110
- entrypoints: [resolved],
111
- target: 'browser',
112
- minify: Bun.env.NODE_ENV === 'production',
113
- splitting: false,
114
- format: 'esm',
115
- sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
116
- jsx: { importSource: 'remix/component' },
117
- })
118
-
119
- if (!buildResult.success) {
120
- const errorMessage = buildResult.logs
121
- .map((log) => log.message)
122
- .join('\n')
123
- return new Response(errorMessage || 'Build failed', {
124
- status: 500,
105
+ const buildResult = await Bun.build({
106
+ entrypoints: [resolved],
107
+ target: 'browser',
108
+ minify: Bun.env.NODE_ENV === 'production',
109
+ splitting: false,
110
+ format: 'esm',
111
+ sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
112
+ jsx: { importSource: 'remix/component' },
113
+ })
114
+
115
+ if (!buildResult.success) {
116
+ const errorMessage = buildResult.logs
117
+ .map((log) => log.message)
118
+ .join('\n')
119
+ return new Response(errorMessage || 'Build failed', {
120
+ status: 500,
121
+ headers: {
122
+ 'Content-Type': 'text/plain',
123
+ ...BUNDLING_CORS_HEADERS,
124
+ },
125
+ })
126
+ }
127
+
128
+ const output = buildResult.outputs[0]
129
+ return new Response(output, {
125
130
  headers: {
126
- 'Content-Type': 'text/plain',
131
+ 'Content-Type': 'application/javascript',
132
+ 'Cache-Control':
133
+ Bun.env.NODE_ENV === 'production'
134
+ ? 'public, max-age=31536000, immutable'
135
+ : 'no-cache',
127
136
  ...BUNDLING_CORS_HEADERS,
128
137
  },
129
138
  })
130
- }
131
-
132
- const output = buildResult.outputs[0]
133
- return new Response(output, {
134
- headers: {
135
- 'Content-Type': 'application/javascript',
136
- 'Cache-Control':
137
- Bun.env.NODE_ENV === 'production'
138
- ? 'public, max-age=31536000, immutable'
139
- : 'no-cache',
140
- ...BUNDLING_CORS_HEADERS,
141
- },
142
- })
143
139
  },
144
140
 
145
141
  '/node_modules/*': async (request: Request) => {
@@ -172,39 +168,39 @@ export function createBundlingRoutes(rootDir: string) {
172
168
  })
173
169
  }
174
170
 
175
- const buildResult = await Bun.build({
176
- entrypoints: [filepath],
177
- target: 'browser',
178
- minify: Bun.env.NODE_ENV === 'production',
179
- splitting: false,
180
- format: 'esm',
181
- sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
182
- })
183
-
184
- if (!buildResult.success) {
185
- const errorMessage = buildResult.logs
186
- .map((log) => log.message)
187
- .join('\n')
188
- return new Response(errorMessage || 'Build failed', {
189
- status: 500,
171
+ const buildResult = await Bun.build({
172
+ entrypoints: [filepath],
173
+ target: 'browser',
174
+ minify: Bun.env.NODE_ENV === 'production',
175
+ splitting: false,
176
+ format: 'esm',
177
+ sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
178
+ })
179
+
180
+ if (!buildResult.success) {
181
+ const errorMessage = buildResult.logs
182
+ .map((log) => log.message)
183
+ .join('\n')
184
+ return new Response(errorMessage || 'Build failed', {
185
+ status: 500,
186
+ headers: {
187
+ 'Content-Type': 'text/plain',
188
+ ...BUNDLING_CORS_HEADERS,
189
+ },
190
+ })
191
+ }
192
+
193
+ const output = buildResult.outputs[0]
194
+ return new Response(output, {
190
195
  headers: {
191
- 'Content-Type': 'text/plain',
196
+ 'Content-Type': 'application/javascript',
197
+ 'Cache-Control':
198
+ Bun.env.NODE_ENV === 'production'
199
+ ? 'public, max-age=31536000, immutable'
200
+ : 'no-cache',
192
201
  ...BUNDLING_CORS_HEADERS,
193
202
  },
194
203
  })
195
- }
196
-
197
- const output = buildResult.outputs[0]
198
- return new Response(output, {
199
- headers: {
200
- 'Content-Type': 'application/javascript',
201
- 'Cache-Control':
202
- Bun.env.NODE_ENV === 'production'
203
- ? 'public, max-age=31536000, immutable'
204
- : 'no-cache',
205
- ...BUNDLING_CORS_HEADERS,
206
- },
207
- })
208
204
  },
209
205
  }
210
206
  }