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,82 @@
|
|
|
1
|
+
// Custom error types for better error handling and debugging
|
|
2
|
+
|
|
3
|
+
export class ChapterProcessingError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
message: string,
|
|
6
|
+
public readonly chapterIndex: number,
|
|
7
|
+
public readonly chapterTitle: string,
|
|
8
|
+
) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'ChapterProcessingError'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ChapterTooShortError extends ChapterProcessingError {
|
|
15
|
+
constructor(
|
|
16
|
+
chapterIndex: number,
|
|
17
|
+
chapterTitle: string,
|
|
18
|
+
public readonly duration: number,
|
|
19
|
+
public readonly minDuration: number,
|
|
20
|
+
) {
|
|
21
|
+
super(
|
|
22
|
+
`Chapter "${chapterTitle}" is too short (${duration.toFixed(2)}s < ${minDuration}s)`,
|
|
23
|
+
chapterIndex,
|
|
24
|
+
chapterTitle,
|
|
25
|
+
)
|
|
26
|
+
this.name = 'ChapterTooShortError'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class CommandParseError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
public readonly transcript?: string,
|
|
34
|
+
) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'CommandParseError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TranscriptTooShortError extends ChapterProcessingError {
|
|
41
|
+
constructor(
|
|
42
|
+
chapterIndex: number,
|
|
43
|
+
chapterTitle: string,
|
|
44
|
+
public readonly wordCount: number,
|
|
45
|
+
public readonly minWordCount: number,
|
|
46
|
+
) {
|
|
47
|
+
super(
|
|
48
|
+
`Chapter "${chapterTitle}" transcript too short (${wordCount} words < ${minWordCount})`,
|
|
49
|
+
chapterIndex,
|
|
50
|
+
chapterTitle,
|
|
51
|
+
)
|
|
52
|
+
this.name = 'TranscriptTooShortError'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class BadTakeError extends ChapterProcessingError {
|
|
57
|
+
constructor(chapterIndex: number, chapterTitle: string) {
|
|
58
|
+
super(
|
|
59
|
+
`Chapter "${chapterTitle}" marked as bad take`,
|
|
60
|
+
chapterIndex,
|
|
61
|
+
chapterTitle,
|
|
62
|
+
)
|
|
63
|
+
this.name = 'BadTakeError'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class SpliceError extends Error {
|
|
68
|
+
constructor(message: string) {
|
|
69
|
+
super(message)
|
|
70
|
+
this.name = 'SpliceError'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class TrimWindowError extends Error {
|
|
75
|
+
constructor(
|
|
76
|
+
public readonly start: number,
|
|
77
|
+
public readonly end: number,
|
|
78
|
+
) {
|
|
79
|
+
super(`Trim window too small (${start.toFixed(3)}s -> ${end.toFixed(3)}s)`)
|
|
80
|
+
this.name = 'TrimWindowError'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runCommand as runCommandBase,
|
|
3
|
+
runCommandBinary as runCommandBinaryBase,
|
|
4
|
+
formatSeconds,
|
|
5
|
+
} from '../utils'
|
|
6
|
+
import { CONFIG, TRANSCRIPTION_SAMPLE_RATE } from './config'
|
|
7
|
+
import { logCommand, logInfo, logWarn } from './logging'
|
|
8
|
+
import type { Chapter, LoudnormAnalysis } from './types'
|
|
9
|
+
|
|
10
|
+
async function runCommand(command: string[], allowFailure = false) {
|
|
11
|
+
return runCommandBase(command, { allowFailure, logCommand })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runCommandBinary(command: string[], allowFailure = false) {
|
|
15
|
+
return runCommandBinaryBase(command, { allowFailure, logCommand })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function ensureFfmpegAvailable() {
|
|
19
|
+
const ffmpeg = await runCommand(['ffmpeg', '-version'], true)
|
|
20
|
+
const ffprobe = await runCommand(['ffprobe', '-version'], true)
|
|
21
|
+
if (ffmpeg.exitCode !== 0 || ffprobe.exitCode !== 0) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'ffmpeg/ffprobe not available. Install them and ensure they are on PATH.',
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getChapters(inputPath: string): Promise<Chapter[]> {
|
|
29
|
+
const result = await runCommand([
|
|
30
|
+
'ffprobe',
|
|
31
|
+
'-v',
|
|
32
|
+
'error',
|
|
33
|
+
'-print_format',
|
|
34
|
+
'json',
|
|
35
|
+
'-show_chapters',
|
|
36
|
+
'-i',
|
|
37
|
+
inputPath,
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const payload = JSON.parse(result.stdout)
|
|
41
|
+
const chapters = payload.chapters ?? []
|
|
42
|
+
if (chapters.length === 0) {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return chapters.map((chapter: any, index: number) => {
|
|
47
|
+
const start = Number.parseFloat(chapter.start_time)
|
|
48
|
+
const end = Number.parseFloat(chapter.end_time)
|
|
49
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
50
|
+
throw new Error(`Invalid chapter time data at index ${index}`)
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
index,
|
|
54
|
+
start,
|
|
55
|
+
end,
|
|
56
|
+
title: chapter.tags?.title
|
|
57
|
+
? String(chapter.tags.title)
|
|
58
|
+
: `chapter-${index + 1}`,
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readAudioSamples(options: {
|
|
64
|
+
inputPath: string
|
|
65
|
+
start: number
|
|
66
|
+
duration: number
|
|
67
|
+
sampleRate: number
|
|
68
|
+
}) {
|
|
69
|
+
const result = await runCommandBinary(
|
|
70
|
+
[
|
|
71
|
+
'ffmpeg',
|
|
72
|
+
'-hide_banner',
|
|
73
|
+
'-ss',
|
|
74
|
+
options.start.toFixed(3),
|
|
75
|
+
'-t',
|
|
76
|
+
options.duration.toFixed(3),
|
|
77
|
+
'-i',
|
|
78
|
+
options.inputPath,
|
|
79
|
+
'-vn',
|
|
80
|
+
'-sn',
|
|
81
|
+
'-dn',
|
|
82
|
+
'-ac',
|
|
83
|
+
'1',
|
|
84
|
+
'-ar',
|
|
85
|
+
String(options.sampleRate),
|
|
86
|
+
'-f',
|
|
87
|
+
'f32le',
|
|
88
|
+
'-',
|
|
89
|
+
],
|
|
90
|
+
true,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const bytes = result.stdout
|
|
94
|
+
if (bytes.byteLength === 0) {
|
|
95
|
+
return new Float32Array()
|
|
96
|
+
}
|
|
97
|
+
const buffer = bytes.buffer.slice(
|
|
98
|
+
bytes.byteOffset,
|
|
99
|
+
bytes.byteOffset + bytes.byteLength,
|
|
100
|
+
)
|
|
101
|
+
return new Float32Array(buffer)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isValidLoudnessValue(
|
|
105
|
+
value: string,
|
|
106
|
+
min?: number,
|
|
107
|
+
max?: number,
|
|
108
|
+
): boolean {
|
|
109
|
+
const num = Number.parseFloat(value)
|
|
110
|
+
if (!Number.isFinite(num)) {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
if (min !== undefined && num < min) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
if (max !== undefined && num > max) {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildNormalizeAudioFilter(options: {
|
|
123
|
+
printFormat: 'json' | 'summary'
|
|
124
|
+
analysis: LoudnormAnalysis | null
|
|
125
|
+
}) {
|
|
126
|
+
const prefilter =
|
|
127
|
+
CONFIG.normalizePrefilterEnabled && CONFIG.normalizePrefilter
|
|
128
|
+
? `${CONFIG.normalizePrefilter},`
|
|
129
|
+
: ''
|
|
130
|
+
const loudnorm = [
|
|
131
|
+
`loudnorm=I=${CONFIG.loudnessTargetI}`,
|
|
132
|
+
`LRA=${CONFIG.loudnessTargetLra}`,
|
|
133
|
+
`TP=${CONFIG.loudnessTargetTp}`,
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
// Only use analysis if all values are valid (not -inf/inf)
|
|
137
|
+
// input_i must be in range [-99, 0] per ffmpeg loudnorm filter
|
|
138
|
+
if (options.analysis) {
|
|
139
|
+
const hasValidAnalysis =
|
|
140
|
+
isValidLoudnessValue(options.analysis.input_i, -99, 0) &&
|
|
141
|
+
isValidLoudnessValue(options.analysis.input_tp) &&
|
|
142
|
+
isValidLoudnessValue(options.analysis.input_lra) &&
|
|
143
|
+
isValidLoudnessValue(options.analysis.input_thresh) &&
|
|
144
|
+
isValidLoudnessValue(options.analysis.target_offset)
|
|
145
|
+
|
|
146
|
+
if (hasValidAnalysis) {
|
|
147
|
+
loudnorm.push(
|
|
148
|
+
`measured_I=${options.analysis.input_i}`,
|
|
149
|
+
`measured_TP=${options.analysis.input_tp}`,
|
|
150
|
+
`measured_LRA=${options.analysis.input_lra}`,
|
|
151
|
+
`measured_thresh=${options.analysis.input_thresh}`,
|
|
152
|
+
`offset=${options.analysis.target_offset}`,
|
|
153
|
+
'linear=true',
|
|
154
|
+
)
|
|
155
|
+
} else {
|
|
156
|
+
logWarn(
|
|
157
|
+
`Audio analysis contains invalid values (input_i=${options.analysis.input_i}), skipping measured normalization. Using default loudnorm settings.`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
loudnorm.push(`print_format=${options.printFormat}`)
|
|
163
|
+
|
|
164
|
+
return `${prefilter}${loudnorm.join(':')}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function analyzeLoudness(
|
|
168
|
+
inputPath: string,
|
|
169
|
+
absoluteStart: number,
|
|
170
|
+
absoluteEnd: number,
|
|
171
|
+
): Promise<LoudnormAnalysis> {
|
|
172
|
+
const clipDuration = absoluteEnd - absoluteStart
|
|
173
|
+
if (clipDuration <= 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Invalid analysis window (${formatSeconds(absoluteStart)} -> ${formatSeconds(
|
|
176
|
+
absoluteEnd,
|
|
177
|
+
)})`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const filter = buildNormalizeAudioFilter({
|
|
182
|
+
printFormat: 'json',
|
|
183
|
+
analysis: null,
|
|
184
|
+
})
|
|
185
|
+
const result = await runCommand(
|
|
186
|
+
[
|
|
187
|
+
'ffmpeg',
|
|
188
|
+
'-hide_banner',
|
|
189
|
+
'-ss',
|
|
190
|
+
absoluteStart.toFixed(3),
|
|
191
|
+
'-t',
|
|
192
|
+
clipDuration.toFixed(3),
|
|
193
|
+
'-i',
|
|
194
|
+
inputPath,
|
|
195
|
+
'-vn',
|
|
196
|
+
'-sn',
|
|
197
|
+
'-dn',
|
|
198
|
+
'-af',
|
|
199
|
+
filter,
|
|
200
|
+
'-f',
|
|
201
|
+
'null',
|
|
202
|
+
'-',
|
|
203
|
+
],
|
|
204
|
+
true,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const analysisJson = result.stderr.match(/\{[\s\S]*?\}/)?.[0]
|
|
208
|
+
if (!analysisJson) {
|
|
209
|
+
throw new Error('Failed to parse loudnorm analysis output.')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const payload = JSON.parse(analysisJson)
|
|
213
|
+
return {
|
|
214
|
+
input_i: payload.input_i,
|
|
215
|
+
input_tp: payload.input_tp,
|
|
216
|
+
input_lra: payload.input_lra,
|
|
217
|
+
input_thresh: payload.input_thresh,
|
|
218
|
+
target_offset: payload.target_offset,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function renderChapter(options: {
|
|
223
|
+
inputPath: string
|
|
224
|
+
outputPath: string
|
|
225
|
+
absoluteStart: number
|
|
226
|
+
absoluteEnd: number
|
|
227
|
+
analysis: LoudnormAnalysis
|
|
228
|
+
}) {
|
|
229
|
+
const clipDuration = options.absoluteEnd - options.absoluteStart
|
|
230
|
+
if (clipDuration <= 0) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Invalid render window (${formatSeconds(options.absoluteStart)} -> ${formatSeconds(
|
|
233
|
+
options.absoluteEnd,
|
|
234
|
+
)})`,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const loudnorm = buildNormalizeAudioFilter({
|
|
239
|
+
printFormat: 'summary',
|
|
240
|
+
analysis: options.analysis,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const args = [
|
|
244
|
+
'ffmpeg',
|
|
245
|
+
'-hide_banner',
|
|
246
|
+
'-y',
|
|
247
|
+
'-ss',
|
|
248
|
+
options.absoluteStart.toFixed(3),
|
|
249
|
+
'-t',
|
|
250
|
+
clipDuration.toFixed(3),
|
|
251
|
+
'-i',
|
|
252
|
+
options.inputPath,
|
|
253
|
+
'-dn',
|
|
254
|
+
'-map_chapters',
|
|
255
|
+
'-1',
|
|
256
|
+
'-map',
|
|
257
|
+
'0:v?',
|
|
258
|
+
'-map',
|
|
259
|
+
'0:a?',
|
|
260
|
+
'-map',
|
|
261
|
+
'0:s?',
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
if (CONFIG.videoReencodeForAccurateTrim) {
|
|
265
|
+
args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18')
|
|
266
|
+
} else {
|
|
267
|
+
args.push('-c:v', 'copy')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
args.push(
|
|
271
|
+
'-c:a',
|
|
272
|
+
CONFIG.audioCodec,
|
|
273
|
+
'-b:a',
|
|
274
|
+
CONFIG.audioBitrate,
|
|
275
|
+
'-af',
|
|
276
|
+
loudnorm,
|
|
277
|
+
'-c:s',
|
|
278
|
+
'copy',
|
|
279
|
+
options.outputPath,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
await runCommand(args)
|
|
283
|
+
logInfo(`Wrote ${options.outputPath}`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function extractChapterSegment(options: {
|
|
287
|
+
inputPath: string
|
|
288
|
+
outputPath: string
|
|
289
|
+
start: number
|
|
290
|
+
end: number
|
|
291
|
+
}) {
|
|
292
|
+
const clipDuration = options.end - options.start
|
|
293
|
+
if (clipDuration <= 0) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Invalid segment window (${formatSeconds(options.start)} -> ${formatSeconds(
|
|
296
|
+
options.end,
|
|
297
|
+
)})`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const args = [
|
|
302
|
+
'ffmpeg',
|
|
303
|
+
'-hide_banner',
|
|
304
|
+
'-y',
|
|
305
|
+
'-ss',
|
|
306
|
+
options.start.toFixed(3),
|
|
307
|
+
'-t',
|
|
308
|
+
clipDuration.toFixed(3),
|
|
309
|
+
'-i',
|
|
310
|
+
options.inputPath,
|
|
311
|
+
'-dn',
|
|
312
|
+
'-map_chapters',
|
|
313
|
+
'-1',
|
|
314
|
+
'-map',
|
|
315
|
+
'0:v?',
|
|
316
|
+
'-map',
|
|
317
|
+
'0:a?',
|
|
318
|
+
'-map',
|
|
319
|
+
'0:s?',
|
|
320
|
+
'-c:v',
|
|
321
|
+
'copy',
|
|
322
|
+
'-c:a',
|
|
323
|
+
'copy',
|
|
324
|
+
'-c:s',
|
|
325
|
+
'copy',
|
|
326
|
+
options.outputPath,
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
await runCommand(args)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function extractChapterSegmentAccurate(options: {
|
|
333
|
+
inputPath: string
|
|
334
|
+
outputPath: string
|
|
335
|
+
start: number
|
|
336
|
+
end: number
|
|
337
|
+
}) {
|
|
338
|
+
const clipDuration = options.end - options.start
|
|
339
|
+
if (clipDuration <= 0) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Invalid segment window (${formatSeconds(options.start)} -> ${formatSeconds(
|
|
342
|
+
options.end,
|
|
343
|
+
)})`,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const args = [
|
|
348
|
+
'ffmpeg',
|
|
349
|
+
'-hide_banner',
|
|
350
|
+
'-y',
|
|
351
|
+
'-i',
|
|
352
|
+
options.inputPath,
|
|
353
|
+
'-ss',
|
|
354
|
+
options.start.toFixed(3),
|
|
355
|
+
'-t',
|
|
356
|
+
clipDuration.toFixed(3),
|
|
357
|
+
'-dn',
|
|
358
|
+
'-map_chapters',
|
|
359
|
+
'-1',
|
|
360
|
+
'-map',
|
|
361
|
+
'0:v?',
|
|
362
|
+
'-map',
|
|
363
|
+
'0:a?',
|
|
364
|
+
'-map',
|
|
365
|
+
'0:s?',
|
|
366
|
+
'-c:v',
|
|
367
|
+
'libx264',
|
|
368
|
+
'-preset',
|
|
369
|
+
'medium',
|
|
370
|
+
'-crf',
|
|
371
|
+
'18',
|
|
372
|
+
'-c:a',
|
|
373
|
+
CONFIG.audioCodec,
|
|
374
|
+
'-b:a',
|
|
375
|
+
CONFIG.audioBitrate,
|
|
376
|
+
'-c:s',
|
|
377
|
+
'copy',
|
|
378
|
+
options.outputPath,
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
await runCommand(args)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function extractTranscriptionAudio(options: {
|
|
385
|
+
inputPath: string
|
|
386
|
+
outputPath: string
|
|
387
|
+
start: number
|
|
388
|
+
end: number
|
|
389
|
+
}) {
|
|
390
|
+
const clipDuration = options.end - options.start
|
|
391
|
+
if (clipDuration <= 0) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`Invalid transcription window (${formatSeconds(options.start)} -> ${formatSeconds(
|
|
394
|
+
options.end,
|
|
395
|
+
)})`,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const args = [
|
|
400
|
+
'ffmpeg',
|
|
401
|
+
'-hide_banner',
|
|
402
|
+
'-y',
|
|
403
|
+
'-ss',
|
|
404
|
+
options.start.toFixed(3),
|
|
405
|
+
'-t',
|
|
406
|
+
clipDuration.toFixed(3),
|
|
407
|
+
'-i',
|
|
408
|
+
options.inputPath,
|
|
409
|
+
'-vn',
|
|
410
|
+
'-sn',
|
|
411
|
+
'-dn',
|
|
412
|
+
'-ac',
|
|
413
|
+
'1',
|
|
414
|
+
'-ar',
|
|
415
|
+
String(TRANSCRIPTION_SAMPLE_RATE),
|
|
416
|
+
'-c:a',
|
|
417
|
+
'pcm_s16le',
|
|
418
|
+
options.outputPath,
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
await runCommand(args)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function concatSegments(options: {
|
|
425
|
+
segmentPaths: string[]
|
|
426
|
+
outputPath: string
|
|
427
|
+
}) {
|
|
428
|
+
if (options.segmentPaths.length < 2) {
|
|
429
|
+
throw new Error('Splice requires at least two segments to concat.')
|
|
430
|
+
}
|
|
431
|
+
const args = ['ffmpeg', '-hide_banner', '-y']
|
|
432
|
+
for (const segmentPath of options.segmentPaths) {
|
|
433
|
+
args.push('-i', segmentPath)
|
|
434
|
+
}
|
|
435
|
+
const inputLabels = options.segmentPaths
|
|
436
|
+
.map((_, index) => `[${index}:v:0][${index}:a:0]`)
|
|
437
|
+
.join('')
|
|
438
|
+
const concatFilter = `${inputLabels}concat=n=${options.segmentPaths.length}:v=1:a=1[v][a]`
|
|
439
|
+
const filter = `${concatFilter};[a]aresample=async=1:first_pts=0[aout]`
|
|
440
|
+
args.push('-filter_complex', filter, '-map', '[v]', '-map', '[aout]')
|
|
441
|
+
if (CONFIG.commandSpliceReencode) {
|
|
442
|
+
args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18')
|
|
443
|
+
} else {
|
|
444
|
+
args.push('-c:v', 'copy')
|
|
445
|
+
}
|
|
446
|
+
args.push('-c:a', CONFIG.audioCodec, '-b:a', CONFIG.audioBitrate)
|
|
447
|
+
args.push(options.outputPath)
|
|
448
|
+
await runCommand(args)
|
|
449
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CONFIG } from '../config'
|
|
2
|
+
import { countTranscriptWords } from '../utils/transcript'
|
|
3
|
+
import type { TranscriptCommand, CommandAnalysisResult } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Analyze commands and determine chapter processing behavior.
|
|
7
|
+
*/
|
|
8
|
+
export function analyzeCommands(
|
|
9
|
+
commands: TranscriptCommand[],
|
|
10
|
+
transcript: string,
|
|
11
|
+
): CommandAnalysisResult {
|
|
12
|
+
const filenameCommand = commands.find(
|
|
13
|
+
(command) => command.type === 'filename' && command.value?.trim(),
|
|
14
|
+
)
|
|
15
|
+
const filenameOverride = filenameCommand?.value ?? null
|
|
16
|
+
|
|
17
|
+
const hasBadTake = commands.some((command) => command.type === 'bad-take')
|
|
18
|
+
const hasEdit = commands.some((command) => command.type === 'edit')
|
|
19
|
+
const hasCombinePrevious = commands.some(
|
|
20
|
+
(command) => command.type === 'combine-previous',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const notes = commands
|
|
24
|
+
.filter((command) => command.type === 'note' && command.value?.trim())
|
|
25
|
+
.map((command) => ({
|
|
26
|
+
value: command.value!,
|
|
27
|
+
window: command.window,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
const splits = commands
|
|
31
|
+
.filter((command) => command.type === 'split')
|
|
32
|
+
.map((command) => ({
|
|
33
|
+
window: command.window,
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
const transcriptWordCount = countTranscriptWords(transcript)
|
|
37
|
+
|
|
38
|
+
// Determine if we should skip this chapter
|
|
39
|
+
let shouldSkip = false
|
|
40
|
+
let skipReason: string | undefined
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
transcriptWordCount <= CONFIG.minTranscriptWords &&
|
|
44
|
+
commands.length === 0
|
|
45
|
+
) {
|
|
46
|
+
shouldSkip = true
|
|
47
|
+
skipReason = `transcript too short (${transcriptWordCount} words)`
|
|
48
|
+
} else if (hasBadTake) {
|
|
49
|
+
shouldSkip = true
|
|
50
|
+
skipReason = 'bad take command detected'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
commands,
|
|
55
|
+
filenameOverride,
|
|
56
|
+
hasBadTake,
|
|
57
|
+
hasEdit,
|
|
58
|
+
hasCombinePrevious,
|
|
59
|
+
notes,
|
|
60
|
+
splits,
|
|
61
|
+
shouldSkip,
|
|
62
|
+
skipReason,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get command types as a comma-separated string for logging.
|
|
68
|
+
*/
|
|
69
|
+
export function formatCommandTypes(commands: TranscriptCommand[]): string {
|
|
70
|
+
return commands.map((command) => command.type).join(', ')
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Public API for jarvis commands module
|
|
2
|
+
|
|
3
|
+
export { extractTranscriptCommands, scaleTranscriptSegments } from './parser'
|
|
4
|
+
export { buildCommandWindows, refineCommandWindows } from './windows'
|
|
5
|
+
export { analyzeCommands, formatCommandTypes } from './handlers'
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
CommandType,
|
|
9
|
+
TranscriptCommand,
|
|
10
|
+
TranscriptWord,
|
|
11
|
+
CommandExtractionOptions,
|
|
12
|
+
CommandWindowOptions,
|
|
13
|
+
CommandAnalysisResult,
|
|
14
|
+
} from './types'
|