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.
- package/app/assets/styles.css +6 -2
- package/app/client/app.tsx +1 -3
- package/app/client/editing-workspace.tsx +75 -31
- package/cli.ts +278 -45
- package/package.json +4 -2
- package/process-course/cli.ts +11 -10
- package/process-course/edits/cli-prompts.test.ts +108 -0
- package/process-course/edits/cli.ts +256 -48
- package/process-course/logging.ts +28 -3
package/app/assets/styles.css
CHANGED
|
@@ -261,7 +261,9 @@ p {
|
|
|
261
261
|
cursor: pointer;
|
|
262
262
|
left: var(--range-left);
|
|
263
263
|
width: var(--range-width);
|
|
264
|
-
transition:
|
|
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:
|
|
375
|
+
transition:
|
|
376
|
+
background 0.15s ease,
|
|
377
|
+
color 0.15s ease;
|
|
374
378
|
}
|
|
375
379
|
|
|
376
380
|
.button--primary {
|
package/app/client/app.tsx
CHANGED
|
@@ -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(
|
|
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 (
|
|
148
|
+
if (
|
|
149
|
+
!previewNode ||
|
|
150
|
+
!previewReady ||
|
|
151
|
+
duration <= 0 ||
|
|
152
|
+
previewDuration <= 0
|
|
153
|
+
) {
|
|
134
154
|
return
|
|
135
155
|
}
|
|
136
|
-
const targetTime = clamp(
|
|
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(
|
|
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">
|
|
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
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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(
|
|
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(
|
|
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, {
|
|
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 =
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
65
|
+
'process [input...]',
|
|
38
66
|
'Process chapters into separate files',
|
|
39
67
|
configureProcessCommand,
|
|
40
68
|
async (argv) => {
|
|
41
|
-
const
|
|
42
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
console.log(
|
|
131
|
-
|
|
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
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
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
|
}
|
package/process-course/cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
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}`)
|
|
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
|
|
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)
|
|
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
|
-
|
|
300
|
+
const prompter = createInquirerPrompter()
|
|
301
|
+
return { interactive, pathPicker: createPathPicker(prompter) }
|
|
136
302
|
}
|
|
137
303
|
|
|
138
|
-
export async function runEditsCli() {
|
|
139
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|