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/README.md +4 -4
- package/app/assets/styles.css +6 -2
- package/app/client/app.tsx +1 -3
- package/app/client/editing-workspace.tsx +75 -31
- package/app-server.ts +173 -6
- package/cli.ts +285 -45
- package/package.json +5 -3
- package/process-course/cli.ts +11 -10
- package/process-course/edits/cli-prompts.test.ts +108 -0
- package/process-course/edits/cli.ts +260 -48
- package/process-course/logging.ts +28 -3
|
@@ -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,209 @@ 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(
|
|
44
|
+
video1Path: string,
|
|
45
|
+
video2Path: string,
|
|
46
|
+
) {
|
|
47
|
+
const dir = path.dirname(video1Path)
|
|
48
|
+
const ext = path.extname(video1Path) || path.extname(video2Path) || '.mp4'
|
|
49
|
+
const name1 = path.parse(video1Path).name
|
|
50
|
+
const name2 = path.parse(video2Path).name
|
|
51
|
+
return path.join(dir, `combined-${name1}-${name2}${ext}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function resolveEditVideoArgs(
|
|
55
|
+
argv: Arguments,
|
|
56
|
+
options: CliUxOptions,
|
|
57
|
+
): Promise<EditVideoCommandArgs> {
|
|
58
|
+
const pathPicker = options.pathPicker
|
|
59
|
+
let input = resolveOptionalString(argv.input)
|
|
60
|
+
if (!input) {
|
|
61
|
+
if (!options.interactive || !pathPicker) {
|
|
62
|
+
throw new Error('Input video path is required.')
|
|
63
|
+
}
|
|
64
|
+
input = await pathPicker.pickExistingFile({
|
|
65
|
+
message: 'Select input video file',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
let transcript = resolveOptionalString(argv.transcript)
|
|
69
|
+
if (!transcript) {
|
|
70
|
+
if (!options.interactive || !pathPicker) {
|
|
71
|
+
throw new Error('Transcript JSON path is required.')
|
|
72
|
+
}
|
|
73
|
+
transcript = await pathPicker.pickExistingFile({
|
|
74
|
+
message: 'Select transcript JSON file',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
let edited = resolveOptionalString(argv.edited)
|
|
78
|
+
if (!edited) {
|
|
79
|
+
if (!options.interactive || !pathPicker) {
|
|
80
|
+
throw new Error('Edited transcript path is required.')
|
|
81
|
+
}
|
|
82
|
+
edited = await pathPicker.pickExistingFile({
|
|
83
|
+
message: 'Select edited transcript text file',
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
const defaultOutputPath = buildEditedOutputPath(input)
|
|
87
|
+
const outputPath = resolveOptionalString(argv.output) ?? defaultOutputPath
|
|
88
|
+
const paddingMs = resolvePaddingMs(argv['padding-ms'])
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
input,
|
|
92
|
+
transcript,
|
|
93
|
+
edited,
|
|
94
|
+
output: outputPath,
|
|
95
|
+
'padding-ms': paddingMs,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function resolveCombineVideosArgs(
|
|
100
|
+
argv: Arguments,
|
|
101
|
+
options: CliUxOptions,
|
|
102
|
+
): Promise<CombineVideosCommandArgs> {
|
|
103
|
+
const pathPicker = options.pathPicker
|
|
104
|
+
let video1 = resolveOptionalString(argv.video1)
|
|
105
|
+
if (!video1) {
|
|
106
|
+
if (!options.interactive || !pathPicker) {
|
|
107
|
+
throw new Error('First video path is required.')
|
|
108
|
+
}
|
|
109
|
+
video1 = await pathPicker.pickExistingFile({
|
|
110
|
+
message: 'Select first video',
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
let video2 = resolveOptionalString(argv.video2)
|
|
114
|
+
if (!video2) {
|
|
115
|
+
if (!options.interactive || !pathPicker) {
|
|
116
|
+
throw new Error('Second video path is required.')
|
|
117
|
+
}
|
|
118
|
+
video2 = await pathPicker.pickExistingFile({
|
|
119
|
+
message: 'Select second video',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
let transcript1 = resolveOptionalString(argv.transcript1)
|
|
123
|
+
let transcript2 = resolveOptionalString(argv.transcript2)
|
|
124
|
+
const edited1 = resolveOptionalString(argv.edited1)
|
|
125
|
+
const edited2 = resolveOptionalString(argv.edited2)
|
|
126
|
+
|
|
127
|
+
if (edited1 && !transcript1) {
|
|
128
|
+
if (!options.interactive || !pathPicker) {
|
|
129
|
+
throw new Error('Transcript JSON is required for edited1.')
|
|
130
|
+
}
|
|
131
|
+
transcript1 = await pathPicker.pickExistingFile({
|
|
132
|
+
message: 'Select transcript JSON for first video',
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
if (edited2 && !transcript2) {
|
|
136
|
+
if (!options.interactive || !pathPicker) {
|
|
137
|
+
throw new Error('Transcript JSON is required for edited2.')
|
|
138
|
+
}
|
|
139
|
+
transcript2 = await pathPicker.pickExistingFile({
|
|
140
|
+
message: 'Select transcript JSON for second video',
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let output = resolveOptionalString(argv.output)
|
|
145
|
+
if (!output) {
|
|
146
|
+
if (options.interactive && pathPicker) {
|
|
147
|
+
output = await pathPicker.pickOutputPath({
|
|
148
|
+
message: 'Select output video path',
|
|
149
|
+
defaultPath: buildCombinedOutputPath(video1, video2),
|
|
150
|
+
})
|
|
151
|
+
} else {
|
|
152
|
+
output = buildCombinedOutputPath(video1, video2)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const paddingMs = resolvePaddingMs(argv['padding-ms'])
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
video1,
|
|
159
|
+
transcript1,
|
|
160
|
+
edited1,
|
|
161
|
+
video2,
|
|
162
|
+
transcript2,
|
|
163
|
+
edited2,
|
|
164
|
+
output,
|
|
165
|
+
'padding-ms': paddingMs,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolvePaddingMs(value: unknown) {
|
|
170
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
return value
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createEditVideoHandler(options: CliUxOptions): CommandHandler {
|
|
177
|
+
return async (argv) => {
|
|
178
|
+
const args = await resolveEditVideoArgs(argv, options)
|
|
179
|
+
await withSpinner(
|
|
180
|
+
'Editing video',
|
|
181
|
+
async () => {
|
|
182
|
+
const result = await editVideo({
|
|
183
|
+
inputPath: String(args.input),
|
|
184
|
+
transcriptJsonPath: String(args.transcript),
|
|
185
|
+
editedTextPath: String(args.edited),
|
|
186
|
+
outputPath: String(args.output),
|
|
187
|
+
paddingMs: args['padding-ms'],
|
|
188
|
+
})
|
|
189
|
+
if (!result.success) {
|
|
190
|
+
throw new Error(result.error ?? 'Edit failed.')
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
{ successText: 'Edit complete' },
|
|
194
|
+
)
|
|
195
|
+
console.log(`Edited video written to ${args.output}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createCombineVideosHandler(
|
|
200
|
+
options: CliUxOptions,
|
|
201
|
+
): CommandHandler {
|
|
202
|
+
return async (argv) => {
|
|
203
|
+
const args = await resolveCombineVideosArgs(argv, options)
|
|
204
|
+
let outputPath = ''
|
|
205
|
+
await withSpinner(
|
|
206
|
+
'Combining videos',
|
|
207
|
+
async () => {
|
|
208
|
+
const result = await combineVideos({
|
|
209
|
+
video1Path: String(args.video1),
|
|
210
|
+
video1TranscriptJsonPath: args.transcript1,
|
|
211
|
+
video1EditedTextPath: args.edited1,
|
|
212
|
+
video2Path: String(args.video2),
|
|
213
|
+
video2TranscriptJsonPath: args.transcript2,
|
|
214
|
+
video2EditedTextPath: args.edited2,
|
|
215
|
+
outputPath: String(args.output),
|
|
216
|
+
overlapPaddingMs: args['padding-ms'],
|
|
217
|
+
})
|
|
218
|
+
if (!result.success) {
|
|
219
|
+
throw new Error(result.error ?? 'Combine failed.')
|
|
220
|
+
}
|
|
221
|
+
outputPath = result.outputPath
|
|
222
|
+
},
|
|
223
|
+
{ successText: 'Combine complete' },
|
|
224
|
+
)
|
|
225
|
+
console.log(`Combined video written to ${outputPath}`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
27
229
|
export function configureEditVideoCommand(command: Argv) {
|
|
28
230
|
return command
|
|
29
231
|
.option('input', {
|
|
30
232
|
type: 'string',
|
|
31
|
-
demandOption: true,
|
|
32
233
|
describe: 'Input video file',
|
|
33
234
|
})
|
|
34
235
|
.option('transcript', {
|
|
35
236
|
type: 'string',
|
|
36
|
-
demandOption: true,
|
|
37
237
|
describe: 'Transcript JSON path',
|
|
38
238
|
})
|
|
39
239
|
.option('edited', {
|
|
40
240
|
type: 'string',
|
|
41
|
-
demandOption: true,
|
|
42
241
|
describe: 'Edited transcript text path',
|
|
43
242
|
})
|
|
44
243
|
.option('output', {
|
|
@@ -52,31 +251,14 @@ export function configureEditVideoCommand(command: Argv) {
|
|
|
52
251
|
}
|
|
53
252
|
|
|
54
253
|
export async function handleEditVideoCommand(argv: Arguments) {
|
|
55
|
-
const
|
|
56
|
-
|
|
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}`)
|
|
254
|
+
const options = createDefaultCliUxOptions()
|
|
255
|
+
await createEditVideoHandler(options)(argv)
|
|
73
256
|
}
|
|
74
257
|
|
|
75
258
|
export function configureCombineVideosCommand(command: Argv) {
|
|
76
259
|
return command
|
|
77
260
|
.option('video1', {
|
|
78
261
|
type: 'string',
|
|
79
|
-
demandOption: true,
|
|
80
262
|
describe: 'First video path',
|
|
81
263
|
})
|
|
82
264
|
.option('transcript1', {
|
|
@@ -89,7 +271,6 @@ export function configureCombineVideosCommand(command: Argv) {
|
|
|
89
271
|
})
|
|
90
272
|
.option('video2', {
|
|
91
273
|
type: 'string',
|
|
92
|
-
demandOption: true,
|
|
93
274
|
describe: 'Second video path',
|
|
94
275
|
})
|
|
95
276
|
.option('transcript2', {
|
|
@@ -102,7 +283,6 @@ export function configureCombineVideosCommand(command: Argv) {
|
|
|
102
283
|
})
|
|
103
284
|
.option('output', {
|
|
104
285
|
type: 'string',
|
|
105
|
-
demandOption: true,
|
|
106
286
|
describe: 'Output video path',
|
|
107
287
|
})
|
|
108
288
|
.option('padding-ms', {
|
|
@@ -112,43 +292,47 @@ export function configureCombineVideosCommand(command: Argv) {
|
|
|
112
292
|
}
|
|
113
293
|
|
|
114
294
|
export async function handleCombineVideosCommand(argv: Arguments) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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)
|
|
295
|
+
const options = createDefaultCliUxOptions()
|
|
296
|
+
await createCombineVideosHandler(options)(argv)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function createDefaultCliUxOptions(): CliUxOptions {
|
|
300
|
+
const interactive = isInteractive()
|
|
301
|
+
if (!interactive) {
|
|
302
|
+
return { interactive }
|
|
134
303
|
}
|
|
135
|
-
|
|
304
|
+
const prompter = createInquirerPrompter()
|
|
305
|
+
return { interactive, pathPicker: createPathPicker(prompter) }
|
|
136
306
|
}
|
|
137
307
|
|
|
138
|
-
export async function runEditsCli() {
|
|
139
|
-
const
|
|
308
|
+
export async function runEditsCli(rawArgs = hideBin(process.argv)) {
|
|
309
|
+
const interactive = isInteractive()
|
|
310
|
+
const prompter = interactive ? createInquirerPrompter() : null
|
|
311
|
+
const pathPicker = prompter ? createPathPicker(prompter) : undefined
|
|
312
|
+
let args = rawArgs
|
|
313
|
+
|
|
314
|
+
if (interactive && args.length === 0 && prompter) {
|
|
315
|
+
const selection = await promptForEditsCommand(prompter)
|
|
316
|
+
if (!selection) {
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
args = selection
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const handlerOptions: CliUxOptions = { interactive, pathPicker }
|
|
323
|
+
const parser = yargs(args)
|
|
140
324
|
.scriptName('video-edits')
|
|
141
325
|
.command(
|
|
142
326
|
'edit-video',
|
|
143
327
|
'Edit a single video using transcript text edits',
|
|
144
328
|
configureEditVideoCommand as CommandBuilder,
|
|
145
|
-
|
|
329
|
+
createEditVideoHandler(handlerOptions),
|
|
146
330
|
)
|
|
147
331
|
.command(
|
|
148
332
|
'combine-videos',
|
|
149
333
|
'Combine two videos with speech-aligned padding',
|
|
150
334
|
configureCombineVideosCommand as CommandBuilder,
|
|
151
|
-
|
|
335
|
+
createCombineVideosHandler(handlerOptions),
|
|
152
336
|
)
|
|
153
337
|
.demandCommand(1)
|
|
154
338
|
.strict()
|
|
@@ -159,9 +343,37 @@ export async function runEditsCli() {
|
|
|
159
343
|
|
|
160
344
|
if (import.meta.main) {
|
|
161
345
|
runEditsCli().catch((error) => {
|
|
346
|
+
if (error instanceof PromptCancelled) {
|
|
347
|
+
console.log('[info] Cancelled.')
|
|
348
|
+
return
|
|
349
|
+
}
|
|
162
350
|
console.error(
|
|
163
351
|
`[error] ${error instanceof Error ? error.message : String(error)}`,
|
|
164
352
|
)
|
|
165
353
|
process.exit(1)
|
|
166
354
|
})
|
|
167
355
|
}
|
|
356
|
+
|
|
357
|
+
async function promptForEditsCommand(
|
|
358
|
+
prompter: Prompter,
|
|
359
|
+
): Promise<string[] | null> {
|
|
360
|
+
const selection = await prompter.select('Choose a command', [
|
|
361
|
+
{
|
|
362
|
+
name: 'Edit a single video using transcript text edits',
|
|
363
|
+
value: 'edit-video',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'Combine two videos with speech-aligned padding',
|
|
367
|
+
value: 'combine-videos',
|
|
368
|
+
},
|
|
369
|
+
{ name: 'Show help', value: 'help' },
|
|
370
|
+
{ name: 'Exit', value: 'exit' },
|
|
371
|
+
])
|
|
372
|
+
if (selection === 'exit') {
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
if (selection === 'help') {
|
|
376
|
+
return ['--help']
|
|
377
|
+
}
|
|
378
|
+
return [selection]
|
|
379
|
+
}
|
|
@@ -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
|
-
|
|
24
|
+
withLogHooks(() => {
|
|
25
|
+
console.log(`[cmd] ${formatCommand(command)}`)
|
|
26
|
+
})
|
|
6
27
|
}
|
|
7
28
|
|
|
8
29
|
export function logInfo(message: string) {
|
|
9
|
-
|
|
30
|
+
withLogHooks(() => {
|
|
31
|
+
console.log(`[info] ${message}`)
|
|
32
|
+
})
|
|
10
33
|
}
|
|
11
34
|
|
|
12
35
|
export function logWarn(message: string) {
|
|
13
|
-
|
|
36
|
+
withLogHooks(() => {
|
|
37
|
+
console.warn(`[warn] ${message}`)
|
|
38
|
+
})
|
|
14
39
|
}
|
|
15
40
|
|
|
16
41
|
export async function writeChapterLog(
|