eprec 0.0.1 → 1.1.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/LICENSE +21 -0
- package/README.md +122 -29
- package/app/assets/styles.css +129 -0
- package/app/client/app.tsx +37 -0
- package/app/client/counter.tsx +22 -0
- package/app/client/entry.tsx +8 -0
- package/app/components/layout.tsx +37 -0
- package/app/config/env.ts +31 -0
- package/app/config/import-map.ts +9 -0
- package/app/config/init-env.ts +3 -0
- package/app/config/routes.ts +5 -0
- package/app/helpers/render.ts +6 -0
- package/app/router.tsx +102 -0
- package/app/routes/index.tsx +50 -0
- package/app-server.ts +60 -0
- package/cli.ts +173 -0
- package/package.json +46 -7
- package/process-course/chapter-processor.ts +1037 -0
- package/process-course/cli.ts +236 -0
- package/process-course/config.ts +50 -0
- package/process-course/edits/cli.ts +167 -0
- package/process-course/edits/combined-video-editor.ts +316 -0
- package/process-course/edits/edit-workspace.ts +90 -0
- package/process-course/edits/index.ts +20 -0
- package/process-course/edits/regenerate-transcript.ts +84 -0
- package/process-course/edits/remove-ranges.test.ts +36 -0
- package/process-course/edits/remove-ranges.ts +287 -0
- package/process-course/edits/timestamp-refinement.test.ts +25 -0
- package/process-course/edits/timestamp-refinement.ts +172 -0
- package/process-course/edits/transcript-diff.test.ts +105 -0
- package/process-course/edits/transcript-diff.ts +214 -0
- package/process-course/edits/transcript-output.test.ts +50 -0
- package/process-course/edits/transcript-output.ts +36 -0
- package/process-course/edits/types.ts +26 -0
- package/process-course/edits/video-editor.ts +246 -0
- package/process-course/errors.test.ts +63 -0
- package/process-course/errors.ts +82 -0
- package/process-course/ffmpeg.ts +449 -0
- package/process-course/jarvis-commands/handlers.ts +71 -0
- package/process-course/jarvis-commands/index.ts +14 -0
- package/process-course/jarvis-commands/parser.test.ts +348 -0
- package/process-course/jarvis-commands/parser.ts +257 -0
- package/process-course/jarvis-commands/types.ts +46 -0
- package/process-course/jarvis-commands/windows.ts +254 -0
- package/process-course/logging.ts +24 -0
- package/process-course/paths.test.ts +59 -0
- package/process-course/paths.ts +53 -0
- package/process-course/summary.test.ts +209 -0
- package/process-course/summary.ts +210 -0
- package/process-course/types.ts +85 -0
- package/process-course/utils/audio-analysis.test.ts +348 -0
- package/process-course/utils/audio-analysis.ts +463 -0
- package/process-course/utils/chapter-selection.test.ts +307 -0
- package/process-course/utils/chapter-selection.ts +136 -0
- package/process-course/utils/file-utils.test.ts +83 -0
- package/process-course/utils/file-utils.ts +57 -0
- package/process-course/utils/filename.test.ts +27 -0
- package/process-course/utils/filename.ts +12 -0
- package/process-course/utils/time-ranges.test.ts +221 -0
- package/process-course/utils/time-ranges.ts +86 -0
- package/process-course/utils/transcript.test.ts +257 -0
- package/process-course/utils/transcript.ts +86 -0
- package/process-course/utils/video-editing.ts +44 -0
- package/process-course-video.ts +389 -0
- package/public/robots.txt +2 -0
- package/server/bundling.ts +210 -0
- package/speech-detection.ts +355 -0
- package/utils.ts +138 -0
- package/whispercpp-transcribe.ts +343 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import yargs from 'yargs/yargs'
|
|
3
|
+
import { hideBin } from 'yargs/helpers'
|
|
4
|
+
import type { Argv, Arguments } from 'yargs'
|
|
5
|
+
import { getDefaultWhisperModelPath } from '../whispercpp-transcribe'
|
|
6
|
+
import { DEFAULT_MIN_CHAPTER_SECONDS, TRANSCRIPTION_PHRASES } from './config'
|
|
7
|
+
import { normalizeSkipPhrases } from './utils/transcript'
|
|
8
|
+
import { parseChapterSelection } from './utils/chapter-selection'
|
|
9
|
+
import type { ChapterSelection } from './types'
|
|
10
|
+
|
|
11
|
+
export interface CliArgs {
|
|
12
|
+
inputPaths: string[]
|
|
13
|
+
outputDir: string | null
|
|
14
|
+
minChapterDurationSeconds: number
|
|
15
|
+
dryRun: boolean
|
|
16
|
+
keepIntermediates: boolean
|
|
17
|
+
writeLogs: boolean
|
|
18
|
+
enableTranscription: boolean
|
|
19
|
+
whisperModelPath: string
|
|
20
|
+
whisperLanguage: string
|
|
21
|
+
whisperBinaryPath: string | undefined
|
|
22
|
+
whisperSkipPhrases: string[]
|
|
23
|
+
chapterSelection: ChapterSelection | null
|
|
24
|
+
shouldExit: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function configureProcessCommand(
|
|
28
|
+
command: Argv,
|
|
29
|
+
defaultWhisperModelPath = getDefaultWhisperModelPath(),
|
|
30
|
+
) {
|
|
31
|
+
return command
|
|
32
|
+
.positional('input', {
|
|
33
|
+
type: 'string',
|
|
34
|
+
array: true,
|
|
35
|
+
describe: 'Input video file(s)',
|
|
36
|
+
})
|
|
37
|
+
.option('output-dir', {
|
|
38
|
+
type: 'string',
|
|
39
|
+
alias: 'o',
|
|
40
|
+
describe:
|
|
41
|
+
'Output directory (optional - if not specified, creates directory next to each input file)',
|
|
42
|
+
})
|
|
43
|
+
.option('min-chapter-seconds', {
|
|
44
|
+
type: 'number',
|
|
45
|
+
alias: 'm',
|
|
46
|
+
describe: 'Skip chapters shorter than this duration in seconds',
|
|
47
|
+
default: DEFAULT_MIN_CHAPTER_SECONDS,
|
|
48
|
+
})
|
|
49
|
+
.option('dry-run', {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
alias: 'd',
|
|
52
|
+
describe: 'Skip writing output files and running ffmpeg',
|
|
53
|
+
default: false,
|
|
54
|
+
})
|
|
55
|
+
.option('keep-intermediates', {
|
|
56
|
+
type: 'boolean',
|
|
57
|
+
alias: 'k',
|
|
58
|
+
describe: 'Keep intermediate files for debugging',
|
|
59
|
+
default: false,
|
|
60
|
+
})
|
|
61
|
+
.option('write-logs', {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
alias: 'l',
|
|
64
|
+
describe: 'Write log files when skipping/fallbacks happen',
|
|
65
|
+
default: false,
|
|
66
|
+
})
|
|
67
|
+
.option('enable-transcription', {
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
describe: 'Enable whisper.cpp transcription skip checks',
|
|
70
|
+
default: true,
|
|
71
|
+
})
|
|
72
|
+
.option('whisper-model-path', {
|
|
73
|
+
type: 'string',
|
|
74
|
+
describe: 'Path to whisper.cpp model file',
|
|
75
|
+
default: defaultWhisperModelPath,
|
|
76
|
+
})
|
|
77
|
+
.option('whisper-language', {
|
|
78
|
+
type: 'string',
|
|
79
|
+
describe: 'Language passed to whisper.cpp',
|
|
80
|
+
default: 'en',
|
|
81
|
+
})
|
|
82
|
+
.option('whisper-binary-path', {
|
|
83
|
+
type: 'string',
|
|
84
|
+
describe: 'Path to whisper.cpp CLI (whisper-cli)',
|
|
85
|
+
})
|
|
86
|
+
.option('whisper-skip-phrase', {
|
|
87
|
+
type: 'string',
|
|
88
|
+
array: true,
|
|
89
|
+
describe: 'Phrase to skip chapters when found in transcript (repeatable)',
|
|
90
|
+
default: TRANSCRIPTION_PHRASES,
|
|
91
|
+
})
|
|
92
|
+
.option('chapter', {
|
|
93
|
+
type: 'string',
|
|
94
|
+
array: true,
|
|
95
|
+
alias: 'c',
|
|
96
|
+
describe: 'Only process selected chapters (e.g. 4, 4-6, 4,6,9-12, 4-*)',
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function normalizeProcessArgs(
|
|
101
|
+
argv: Arguments,
|
|
102
|
+
defaultWhisperModelPath = getDefaultWhisperModelPath(),
|
|
103
|
+
): CliArgs {
|
|
104
|
+
let inputPaths = Array.isArray(argv.input)
|
|
105
|
+
? argv.input.filter((p): p is string => typeof p === 'string')
|
|
106
|
+
: typeof argv.input === 'string'
|
|
107
|
+
? [argv.input]
|
|
108
|
+
: []
|
|
109
|
+
|
|
110
|
+
// If output-dir is not explicitly set, check if the last positional arg
|
|
111
|
+
// doesn't look like a video file (no video extension). If so, treat it as the output directory
|
|
112
|
+
let outputDir =
|
|
113
|
+
typeof argv['output-dir'] === 'string' &&
|
|
114
|
+
argv['output-dir'].trim().length > 0
|
|
115
|
+
? argv['output-dir']
|
|
116
|
+
: null
|
|
117
|
+
|
|
118
|
+
if (!outputDir && inputPaths.length > 0) {
|
|
119
|
+
const outputCandidate = inputPaths.at(-1)
|
|
120
|
+
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
|
+
outputCandidate.toLowerCase().endsWith(ext),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if (!hasVideoExtension) {
|
|
135
|
+
// Last argument is likely the output directory
|
|
136
|
+
outputDir = outputCandidate
|
|
137
|
+
inputPaths = inputPaths.slice(0, -1) // Remove the last argument from inputs
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (inputPaths.length === 0) {
|
|
143
|
+
throw new Error('At least one input file is required.')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const minChapterDurationSeconds = Number(argv['min-chapter-seconds'])
|
|
147
|
+
if (
|
|
148
|
+
!Number.isFinite(minChapterDurationSeconds) ||
|
|
149
|
+
minChapterDurationSeconds < 0
|
|
150
|
+
) {
|
|
151
|
+
throw new Error('min-chapter-seconds must be a non-negative number.')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
inputPaths,
|
|
156
|
+
outputDir,
|
|
157
|
+
minChapterDurationSeconds,
|
|
158
|
+
dryRun: Boolean(argv['dry-run']),
|
|
159
|
+
keepIntermediates: Boolean(argv['keep-intermediates']),
|
|
160
|
+
writeLogs: Boolean(argv['write-logs']),
|
|
161
|
+
enableTranscription: Boolean(argv['enable-transcription']),
|
|
162
|
+
whisperModelPath:
|
|
163
|
+
typeof argv['whisper-model-path'] === 'string' &&
|
|
164
|
+
argv['whisper-model-path'].trim().length > 0
|
|
165
|
+
? argv['whisper-model-path']
|
|
166
|
+
: defaultWhisperModelPath,
|
|
167
|
+
whisperLanguage:
|
|
168
|
+
typeof argv['whisper-language'] === 'string' &&
|
|
169
|
+
argv['whisper-language'].trim().length > 0
|
|
170
|
+
? argv['whisper-language'].trim()
|
|
171
|
+
: 'en',
|
|
172
|
+
whisperBinaryPath:
|
|
173
|
+
typeof argv['whisper-binary-path'] === 'string' &&
|
|
174
|
+
argv['whisper-binary-path'].trim().length > 0
|
|
175
|
+
? argv['whisper-binary-path'].trim()
|
|
176
|
+
: undefined,
|
|
177
|
+
whisperSkipPhrases: normalizeSkipPhrases(argv['whisper-skip-phrase']),
|
|
178
|
+
chapterSelection:
|
|
179
|
+
argv.chapter === undefined ? null : parseChapterSelection(argv.chapter),
|
|
180
|
+
shouldExit: false,
|
|
181
|
+
} as CliArgs
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function parseCliArgs(rawArgs = hideBin(process.argv)): CliArgs {
|
|
185
|
+
const defaultWhisperModelPath = getDefaultWhisperModelPath()
|
|
186
|
+
const parser = yargs(rawArgs)
|
|
187
|
+
.scriptName('process-course-video')
|
|
188
|
+
.usage(
|
|
189
|
+
"Usage: $0 <input.mp4|input.mkv> [input2.mp4 ...] [output-dir] [--output-dir <dir>] [--min-chapter-seconds <number>] [--dry-run] [--keep-intermediates] [--write-logs] [--enable-transcription]\n If the last positional argument doesn't have a video extension, it's treated as the output directory.",
|
|
190
|
+
)
|
|
191
|
+
.command(
|
|
192
|
+
'$0 <input...>',
|
|
193
|
+
'Process chapters into separate files',
|
|
194
|
+
(command: Argv) =>
|
|
195
|
+
configureProcessCommand(command, defaultWhisperModelPath),
|
|
196
|
+
)
|
|
197
|
+
.check((args: Arguments) => {
|
|
198
|
+
const minChapterSeconds = args['min-chapter-seconds']
|
|
199
|
+
if (minChapterSeconds !== undefined) {
|
|
200
|
+
if (
|
|
201
|
+
typeof minChapterSeconds !== 'number' ||
|
|
202
|
+
!Number.isFinite(minChapterSeconds) ||
|
|
203
|
+
minChapterSeconds < 0
|
|
204
|
+
) {
|
|
205
|
+
throw new Error('min-chapter-seconds must be a non-negative number.')
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
})
|
|
210
|
+
.strict()
|
|
211
|
+
.help()
|
|
212
|
+
|
|
213
|
+
if (rawArgs.length === 0) {
|
|
214
|
+
parser.showHelp((message) => {
|
|
215
|
+
console.log(message)
|
|
216
|
+
})
|
|
217
|
+
return {
|
|
218
|
+
inputPaths: [],
|
|
219
|
+
outputDir: null,
|
|
220
|
+
minChapterDurationSeconds: DEFAULT_MIN_CHAPTER_SECONDS,
|
|
221
|
+
dryRun: false,
|
|
222
|
+
keepIntermediates: false,
|
|
223
|
+
writeLogs: false,
|
|
224
|
+
enableTranscription: true,
|
|
225
|
+
whisperModelPath: defaultWhisperModelPath,
|
|
226
|
+
whisperLanguage: 'en',
|
|
227
|
+
whisperBinaryPath: undefined,
|
|
228
|
+
whisperSkipPhrases: TRANSCRIPTION_PHRASES,
|
|
229
|
+
chapterSelection: null,
|
|
230
|
+
shouldExit: true,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const argv = parser.parseSync()
|
|
235
|
+
return normalizeProcessArgs(argv, defaultWhisperModelPath)
|
|
236
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const CONFIG = {
|
|
2
|
+
preSpeechPaddingSeconds: 0.25,
|
|
3
|
+
postSpeechPaddingSeconds: 0.35,
|
|
4
|
+
rawTrimPaddingSeconds: 0.1,
|
|
5
|
+
vadSampleRate: 16000,
|
|
6
|
+
vadWindowSamples: 512,
|
|
7
|
+
vadSpeechThreshold: 0.65,
|
|
8
|
+
vadNegThreshold: 0.55,
|
|
9
|
+
vadMinSpeechDurationMs: 250,
|
|
10
|
+
vadMinSilenceDurationMs: 120,
|
|
11
|
+
vadSpeechPadMs: 10,
|
|
12
|
+
vadModelUrl:
|
|
13
|
+
'https://huggingface.co/freddyaboulton/silero-vad/resolve/main/silero_vad.onnx',
|
|
14
|
+
normalizePrefilterEnabled: true,
|
|
15
|
+
normalizePrefilter: 'highpass=f=80,afftdn',
|
|
16
|
+
loudnessTargetI: -16,
|
|
17
|
+
loudnessTargetLra: 11,
|
|
18
|
+
loudnessTargetTp: -1.5,
|
|
19
|
+
videoReencodeForAccurateTrim: false,
|
|
20
|
+
audioCodec: 'aac',
|
|
21
|
+
audioBitrate: '192k',
|
|
22
|
+
commandTrimPaddingSeconds: 0.25,
|
|
23
|
+
commandSpliceReencode: true,
|
|
24
|
+
commandSilenceSearchSeconds: 0.6,
|
|
25
|
+
commandSilenceMinDurationMs: 120,
|
|
26
|
+
commandSilenceRmsWindowMs: 6,
|
|
27
|
+
commandSilenceRmsThreshold: 0.035,
|
|
28
|
+
commandSilenceMaxBackwardSeconds: 0.2,
|
|
29
|
+
commandTailMaxSeconds: 12,
|
|
30
|
+
// Transcript analysis
|
|
31
|
+
minTranscriptWords: 10,
|
|
32
|
+
// Trim window validation
|
|
33
|
+
minTrimWindowSeconds: 0.05,
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
export const EDIT_CONFIG = {
|
|
37
|
+
speechBoundaryPaddingMs: 125,
|
|
38
|
+
speechSearchWindowSeconds: 2.0,
|
|
39
|
+
silenceSearchStartSeconds: 0.1,
|
|
40
|
+
silenceSearchStepSeconds: 0.1,
|
|
41
|
+
silenceSearchMaxSeconds: 2.0,
|
|
42
|
+
autoCreateEditsDirectory: true,
|
|
43
|
+
keepEditIntermediates: false,
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_MIN_CHAPTER_SECONDS = 15
|
|
47
|
+
export const TRANSCRIPTION_PHRASES = ['jarvis bad take', 'bad take jarvis']
|
|
48
|
+
export const COMMAND_WAKE_WORD = 'jarvis'
|
|
49
|
+
export const COMMAND_CLOSE_WORD = 'thanks'
|
|
50
|
+
export const TRANSCRIPTION_SAMPLE_RATE = 16000
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import type { Argv, Arguments, CommandBuilder, CommandHandler } from 'yargs'
|
|
3
|
+
import yargs from 'yargs/yargs'
|
|
4
|
+
import { hideBin } from 'yargs/helpers'
|
|
5
|
+
import { editVideo, buildEditedOutputPath } from './video-editor'
|
|
6
|
+
import { combineVideos } from './combined-video-editor'
|
|
7
|
+
|
|
8
|
+
export type EditVideoCommandArgs = {
|
|
9
|
+
input: string
|
|
10
|
+
transcript: string
|
|
11
|
+
edited: string
|
|
12
|
+
output?: string
|
|
13
|
+
'padding-ms'?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type CombineVideosCommandArgs = {
|
|
17
|
+
video1: string
|
|
18
|
+
transcript1?: string
|
|
19
|
+
edited1?: string
|
|
20
|
+
video2: string
|
|
21
|
+
transcript2?: string
|
|
22
|
+
edited2?: string
|
|
23
|
+
output: string
|
|
24
|
+
'padding-ms'?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function configureEditVideoCommand(command: Argv) {
|
|
28
|
+
return command
|
|
29
|
+
.option('input', {
|
|
30
|
+
type: 'string',
|
|
31
|
+
demandOption: true,
|
|
32
|
+
describe: 'Input video file',
|
|
33
|
+
})
|
|
34
|
+
.option('transcript', {
|
|
35
|
+
type: 'string',
|
|
36
|
+
demandOption: true,
|
|
37
|
+
describe: 'Transcript JSON path',
|
|
38
|
+
})
|
|
39
|
+
.option('edited', {
|
|
40
|
+
type: 'string',
|
|
41
|
+
demandOption: true,
|
|
42
|
+
describe: 'Edited transcript text path',
|
|
43
|
+
})
|
|
44
|
+
.option('output', {
|
|
45
|
+
type: 'string',
|
|
46
|
+
describe: 'Output video path (defaults to .edited)',
|
|
47
|
+
})
|
|
48
|
+
.option('padding-ms', {
|
|
49
|
+
type: 'number',
|
|
50
|
+
describe: 'Padding around speech boundaries in ms',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
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}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function configureCombineVideosCommand(command: Argv) {
|
|
76
|
+
return command
|
|
77
|
+
.option('video1', {
|
|
78
|
+
type: 'string',
|
|
79
|
+
demandOption: true,
|
|
80
|
+
describe: 'First video path',
|
|
81
|
+
})
|
|
82
|
+
.option('transcript1', {
|
|
83
|
+
type: 'string',
|
|
84
|
+
describe: 'Transcript JSON for first video',
|
|
85
|
+
})
|
|
86
|
+
.option('edited1', {
|
|
87
|
+
type: 'string',
|
|
88
|
+
describe: 'Edited transcript text for first video',
|
|
89
|
+
})
|
|
90
|
+
.option('video2', {
|
|
91
|
+
type: 'string',
|
|
92
|
+
demandOption: true,
|
|
93
|
+
describe: 'Second video path',
|
|
94
|
+
})
|
|
95
|
+
.option('transcript2', {
|
|
96
|
+
type: 'string',
|
|
97
|
+
describe: 'Transcript JSON for second video',
|
|
98
|
+
})
|
|
99
|
+
.option('edited2', {
|
|
100
|
+
type: 'string',
|
|
101
|
+
describe: 'Edited transcript text for second video',
|
|
102
|
+
})
|
|
103
|
+
.option('output', {
|
|
104
|
+
type: 'string',
|
|
105
|
+
demandOption: true,
|
|
106
|
+
describe: 'Output video path',
|
|
107
|
+
})
|
|
108
|
+
.option('padding-ms', {
|
|
109
|
+
type: 'number',
|
|
110
|
+
describe: 'Padding around speech boundaries in ms',
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
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)
|
|
134
|
+
}
|
|
135
|
+
console.log(`Combined video written to ${result.outputPath}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runEditsCli() {
|
|
139
|
+
const parser = yargs(hideBin(process.argv))
|
|
140
|
+
.scriptName('video-edits')
|
|
141
|
+
.command(
|
|
142
|
+
'edit-video',
|
|
143
|
+
'Edit a single video using transcript text edits',
|
|
144
|
+
configureEditVideoCommand as CommandBuilder,
|
|
145
|
+
handleEditVideoCommand as CommandHandler,
|
|
146
|
+
)
|
|
147
|
+
.command(
|
|
148
|
+
'combine-videos',
|
|
149
|
+
'Combine two videos with speech-aligned padding',
|
|
150
|
+
configureCombineVideosCommand as CommandBuilder,
|
|
151
|
+
handleCombineVideosCommand as CommandHandler,
|
|
152
|
+
)
|
|
153
|
+
.demandCommand(1)
|
|
154
|
+
.strict()
|
|
155
|
+
.help()
|
|
156
|
+
|
|
157
|
+
await parser.parseAsync()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (import.meta.main) {
|
|
161
|
+
runEditsCli().catch((error) => {
|
|
162
|
+
console.error(
|
|
163
|
+
`[error] ${error instanceof Error ? error.message : String(error)}`,
|
|
164
|
+
)
|
|
165
|
+
process.exit(1)
|
|
166
|
+
})
|
|
167
|
+
}
|