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,316 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, rename, rm } from 'node:fs/promises'
|
|
4
|
+
import {
|
|
5
|
+
detectSpeechBounds,
|
|
6
|
+
checkSegmentHasSpeech,
|
|
7
|
+
} from '../../speech-detection'
|
|
8
|
+
import { extractChapterSegmentAccurate, concatSegments } from '../ffmpeg'
|
|
9
|
+
import { clamp, getMediaDurationSeconds } from '../../utils'
|
|
10
|
+
import { EDIT_CONFIG } from '../config'
|
|
11
|
+
import { editVideo } from './video-editor'
|
|
12
|
+
import {
|
|
13
|
+
findSpeechEndWithRmsFallback,
|
|
14
|
+
findSpeechStartWithRmsFallback,
|
|
15
|
+
} from '../utils/audio-analysis'
|
|
16
|
+
import { allocateJoinPadding } from '../utils/video-editing'
|
|
17
|
+
|
|
18
|
+
export interface CombineVideosOptions {
|
|
19
|
+
video1Path: string
|
|
20
|
+
video1TranscriptJsonPath?: string
|
|
21
|
+
video1EditedTextPath?: string
|
|
22
|
+
video1Duration?: number
|
|
23
|
+
video2Path: string
|
|
24
|
+
video2TranscriptJsonPath?: string
|
|
25
|
+
video2EditedTextPath?: string
|
|
26
|
+
video2Duration?: number
|
|
27
|
+
outputPath: string
|
|
28
|
+
overlapPaddingMs?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CombineVideosResult {
|
|
32
|
+
success: boolean
|
|
33
|
+
error?: string
|
|
34
|
+
outputPath?: string
|
|
35
|
+
video1TrimEnd: number
|
|
36
|
+
video2TrimStart: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function combineVideos(
|
|
40
|
+
options: CombineVideosOptions,
|
|
41
|
+
): Promise<CombineVideosResult> {
|
|
42
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-combine-'))
|
|
43
|
+
try {
|
|
44
|
+
const { video1Path, video2Path } = await applyOptionalEdits(
|
|
45
|
+
options,
|
|
46
|
+
tempDir,
|
|
47
|
+
)
|
|
48
|
+
const editsApplied =
|
|
49
|
+
options.video1EditedTextPath || options.video2EditedTextPath
|
|
50
|
+
const video1Duration = editsApplied
|
|
51
|
+
? await getMediaDurationSeconds(video1Path)
|
|
52
|
+
: (options.video1Duration ?? (await getMediaDurationSeconds(video1Path)))
|
|
53
|
+
const video2Duration = editsApplied
|
|
54
|
+
? await getMediaDurationSeconds(video2Path)
|
|
55
|
+
: (options.video2Duration ?? (await getMediaDurationSeconds(video2Path)))
|
|
56
|
+
|
|
57
|
+
const video1HasSpeech = await checkSegmentHasSpeech(
|
|
58
|
+
video1Path,
|
|
59
|
+
video1Duration,
|
|
60
|
+
)
|
|
61
|
+
if (!video1HasSpeech) {
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error: 'First video has no speech; cannot combine.',
|
|
65
|
+
video1TrimEnd: 0,
|
|
66
|
+
video2TrimStart: 0,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const paddingSeconds =
|
|
71
|
+
(options.overlapPaddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs) / 1000
|
|
72
|
+
|
|
73
|
+
const video1SpeechEnd = await findVideo1SpeechEnd({
|
|
74
|
+
inputPath: video1Path,
|
|
75
|
+
duration: video1Duration,
|
|
76
|
+
})
|
|
77
|
+
const { speechStart: video2SpeechStart, speechEnd: video2SpeechEnd } =
|
|
78
|
+
await findVideo2SpeechBounds({
|
|
79
|
+
inputPath: video2Path,
|
|
80
|
+
duration: video2Duration,
|
|
81
|
+
})
|
|
82
|
+
const video1AvailableSilence = Math.max(0, video1Duration - video1SpeechEnd)
|
|
83
|
+
const video2AvailableSilence = Math.max(0, video2SpeechStart)
|
|
84
|
+
const { previousPaddingSeconds, currentPaddingSeconds } =
|
|
85
|
+
allocateJoinPadding({
|
|
86
|
+
paddingSeconds,
|
|
87
|
+
previousAvailableSeconds: video1AvailableSilence,
|
|
88
|
+
currentAvailableSeconds: video2AvailableSilence,
|
|
89
|
+
})
|
|
90
|
+
const video1TrimEnd = clamp(
|
|
91
|
+
video1SpeechEnd + previousPaddingSeconds,
|
|
92
|
+
0,
|
|
93
|
+
video1Duration,
|
|
94
|
+
)
|
|
95
|
+
const video2TrimStart = clamp(
|
|
96
|
+
video2SpeechStart - currentPaddingSeconds,
|
|
97
|
+
0,
|
|
98
|
+
video2Duration,
|
|
99
|
+
)
|
|
100
|
+
const video2TrimEnd = clamp(
|
|
101
|
+
video2SpeechEnd + paddingSeconds,
|
|
102
|
+
0,
|
|
103
|
+
video2Duration,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const segment1Path = path.join(tempDir, 'segment-1.mp4')
|
|
107
|
+
const segment2Path = path.join(tempDir, 'segment-2.mp4')
|
|
108
|
+
await extractChapterSegmentAccurate({
|
|
109
|
+
inputPath: video1Path,
|
|
110
|
+
outputPath: segment1Path,
|
|
111
|
+
start: 0,
|
|
112
|
+
end: video1TrimEnd,
|
|
113
|
+
})
|
|
114
|
+
if (video2TrimEnd <= video2TrimStart + 0.005) {
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
error: `Invalid trim bounds for second video: start (${video2TrimStart.toFixed(3)}s) >= end (${video2TrimEnd.toFixed(3)}s)`,
|
|
118
|
+
video1TrimEnd,
|
|
119
|
+
video2TrimStart,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await extractChapterSegmentAccurate({
|
|
123
|
+
inputPath: video2Path,
|
|
124
|
+
outputPath: segment2Path,
|
|
125
|
+
start: video2TrimStart,
|
|
126
|
+
end: video2TrimEnd,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const segment2HasSpeech = await checkSegmentHasSpeech(
|
|
130
|
+
segment2Path,
|
|
131
|
+
video2TrimEnd - video2TrimStart,
|
|
132
|
+
)
|
|
133
|
+
if (!segment2HasSpeech) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: 'Second video has no speech after trimming.',
|
|
137
|
+
video1TrimEnd,
|
|
138
|
+
video2TrimStart,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolvedOutputPath = await resolveOutputPath(
|
|
143
|
+
options.outputPath,
|
|
144
|
+
video1Path,
|
|
145
|
+
video2Path,
|
|
146
|
+
tempDir,
|
|
147
|
+
)
|
|
148
|
+
await mkdir(path.dirname(resolvedOutputPath), { recursive: true })
|
|
149
|
+
await concatSegments({
|
|
150
|
+
segmentPaths: [segment1Path, segment2Path],
|
|
151
|
+
outputPath: resolvedOutputPath,
|
|
152
|
+
})
|
|
153
|
+
await finalizeOutput(resolvedOutputPath, options.outputPath)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
success: true,
|
|
157
|
+
outputPath: options.outputPath,
|
|
158
|
+
video1TrimEnd,
|
|
159
|
+
video2TrimStart,
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
error: error instanceof Error ? error.message : String(error),
|
|
165
|
+
video1TrimEnd: 0,
|
|
166
|
+
video2TrimStart: 0,
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function applyOptionalEdits(
|
|
174
|
+
options: CombineVideosOptions,
|
|
175
|
+
tempDir: string,
|
|
176
|
+
): Promise<{ video1Path: string; video2Path: string }> {
|
|
177
|
+
let video1Path = options.video1Path
|
|
178
|
+
let video2Path = options.video2Path
|
|
179
|
+
|
|
180
|
+
if (options.video1EditedTextPath) {
|
|
181
|
+
if (!options.video1TranscriptJsonPath) {
|
|
182
|
+
throw new Error('Missing transcript JSON for first video edits.')
|
|
183
|
+
}
|
|
184
|
+
const editedPath = path.join(tempDir, 'video1-edited.mp4')
|
|
185
|
+
const result = await editVideo({
|
|
186
|
+
inputPath: options.video1Path,
|
|
187
|
+
transcriptJsonPath: options.video1TranscriptJsonPath,
|
|
188
|
+
editedTextPath: options.video1EditedTextPath,
|
|
189
|
+
outputPath: editedPath,
|
|
190
|
+
paddingMs: options.overlapPaddingMs,
|
|
191
|
+
})
|
|
192
|
+
if (!result.success) {
|
|
193
|
+
throw new Error(result.error ?? 'Failed to edit first video.')
|
|
194
|
+
}
|
|
195
|
+
video1Path = editedPath
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (options.video2EditedTextPath) {
|
|
199
|
+
if (!options.video2TranscriptJsonPath) {
|
|
200
|
+
throw new Error('Missing transcript JSON for second video edits.')
|
|
201
|
+
}
|
|
202
|
+
const editedPath = path.join(tempDir, 'video2-edited.mp4')
|
|
203
|
+
const result = await editVideo({
|
|
204
|
+
inputPath: options.video2Path,
|
|
205
|
+
transcriptJsonPath: options.video2TranscriptJsonPath,
|
|
206
|
+
editedTextPath: options.video2EditedTextPath,
|
|
207
|
+
outputPath: editedPath,
|
|
208
|
+
paddingMs: options.overlapPaddingMs,
|
|
209
|
+
})
|
|
210
|
+
if (!result.success) {
|
|
211
|
+
throw new Error(result.error ?? 'Failed to edit second video.')
|
|
212
|
+
}
|
|
213
|
+
video2Path = editedPath
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { video1Path, video2Path }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function findVideo1SpeechEnd(options: {
|
|
220
|
+
inputPath: string
|
|
221
|
+
duration: number
|
|
222
|
+
}): Promise<number> {
|
|
223
|
+
const endSearchWindow = Math.min(
|
|
224
|
+
options.duration * 0.3,
|
|
225
|
+
EDIT_CONFIG.speechSearchWindowSeconds * 2,
|
|
226
|
+
)
|
|
227
|
+
const endSearchStart = Math.max(0, options.duration - endSearchWindow)
|
|
228
|
+
const speechBounds = await detectSpeechBounds(
|
|
229
|
+
options.inputPath,
|
|
230
|
+
endSearchStart,
|
|
231
|
+
options.duration,
|
|
232
|
+
options.duration,
|
|
233
|
+
)
|
|
234
|
+
const speechEnd = speechBounds.note
|
|
235
|
+
? speechBounds.end
|
|
236
|
+
: endSearchStart + speechBounds.end
|
|
237
|
+
let effectiveSpeechEnd = speechEnd
|
|
238
|
+
if (speechBounds.note || options.duration - speechEnd < 0.05) {
|
|
239
|
+
const rmsSpeechEnd = await findSpeechEndWithRmsFallback({
|
|
240
|
+
inputPath: options.inputPath,
|
|
241
|
+
start: endSearchStart,
|
|
242
|
+
duration: options.duration - endSearchStart,
|
|
243
|
+
})
|
|
244
|
+
if (rmsSpeechEnd !== null) {
|
|
245
|
+
effectiveSpeechEnd = endSearchStart + rmsSpeechEnd
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return effectiveSpeechEnd
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function findVideo2SpeechBounds(options: {
|
|
252
|
+
inputPath: string
|
|
253
|
+
duration: number
|
|
254
|
+
}): Promise<{ speechStart: number; speechEnd: number }> {
|
|
255
|
+
const speechBounds = await detectSpeechBounds(
|
|
256
|
+
options.inputPath,
|
|
257
|
+
0,
|
|
258
|
+
options.duration,
|
|
259
|
+
options.duration,
|
|
260
|
+
)
|
|
261
|
+
let speechStart = speechBounds.start
|
|
262
|
+
if (speechBounds.note || speechBounds.start <= 0.05) {
|
|
263
|
+
const rmsSpeechStart = await findSpeechStartWithRmsFallback({
|
|
264
|
+
inputPath: options.inputPath,
|
|
265
|
+
start: 0,
|
|
266
|
+
duration: options.duration,
|
|
267
|
+
})
|
|
268
|
+
if (rmsSpeechStart !== null) {
|
|
269
|
+
speechStart = rmsSpeechStart
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let speechEnd = speechBounds.end
|
|
273
|
+
if (speechBounds.note || options.duration - speechBounds.end < 0.05) {
|
|
274
|
+
const rmsSpeechEnd = await findSpeechEndWithRmsFallback({
|
|
275
|
+
inputPath: options.inputPath,
|
|
276
|
+
start: 0,
|
|
277
|
+
duration: options.duration,
|
|
278
|
+
})
|
|
279
|
+
if (rmsSpeechEnd !== null) {
|
|
280
|
+
speechEnd = rmsSpeechEnd
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
speechStart,
|
|
285
|
+
speechEnd,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function resolveOutputPath(
|
|
290
|
+
outputPath: string,
|
|
291
|
+
video1Path: string,
|
|
292
|
+
video2Path: string,
|
|
293
|
+
tempDir: string,
|
|
294
|
+
): Promise<string> {
|
|
295
|
+
const resolvedOutput = path.resolve(outputPath)
|
|
296
|
+
const resolvedVideo1 = path.resolve(video1Path)
|
|
297
|
+
const resolvedVideo2 = path.resolve(video2Path)
|
|
298
|
+
if (resolvedOutput === resolvedVideo1 || resolvedOutput === resolvedVideo2) {
|
|
299
|
+
return path.join(tempDir, `combined-output${path.extname(outputPath)}`)
|
|
300
|
+
}
|
|
301
|
+
return outputPath
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function finalizeOutput(tempOutputPath: string, outputPath: string) {
|
|
305
|
+
const resolvedTemp = path.resolve(tempOutputPath)
|
|
306
|
+
const resolvedOutput = path.resolve(outputPath)
|
|
307
|
+
if (resolvedTemp === resolvedOutput) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
await rm(outputPath, { force: true })
|
|
311
|
+
try {
|
|
312
|
+
await rename(tempOutputPath, outputPath)
|
|
313
|
+
} catch {
|
|
314
|
+
await copyFile(tempOutputPath, outputPath)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { copyFile, mkdir } from 'node:fs/promises'
|
|
3
|
+
import type { TranscriptSegment } from '../../whispercpp-transcribe'
|
|
4
|
+
import {
|
|
5
|
+
buildTranscriptWordsWithIndices,
|
|
6
|
+
generateTranscriptJson,
|
|
7
|
+
generateTranscriptText,
|
|
8
|
+
} from './transcript-output'
|
|
9
|
+
|
|
10
|
+
export type EditWorkspace = {
|
|
11
|
+
editsDirectory: string
|
|
12
|
+
transcriptTextPath: string
|
|
13
|
+
transcriptJsonPath: string
|
|
14
|
+
originalVideoPath: string
|
|
15
|
+
instructionsPath: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function createEditWorkspace(options: {
|
|
19
|
+
outputDir: string
|
|
20
|
+
sourceVideoPath: string
|
|
21
|
+
sourceDuration: number
|
|
22
|
+
segments: TranscriptSegment[]
|
|
23
|
+
}): Promise<EditWorkspace> {
|
|
24
|
+
const editsRoot = path.join(options.outputDir, 'edits')
|
|
25
|
+
const parsed = path.parse(options.sourceVideoPath)
|
|
26
|
+
const editsDirectory = path.join(editsRoot, parsed.name)
|
|
27
|
+
await mkdir(editsDirectory, { recursive: true })
|
|
28
|
+
|
|
29
|
+
const originalVideoPath = path.join(editsDirectory, `original${parsed.ext}`)
|
|
30
|
+
await copyFile(options.sourceVideoPath, originalVideoPath)
|
|
31
|
+
|
|
32
|
+
const words = buildTranscriptWordsWithIndices(options.segments)
|
|
33
|
+
const transcriptTextPath = path.join(editsDirectory, 'transcript.txt')
|
|
34
|
+
const transcriptJsonPath = path.join(editsDirectory, 'transcript.json')
|
|
35
|
+
await Bun.write(transcriptTextPath, generateTranscriptText(words))
|
|
36
|
+
await Bun.write(
|
|
37
|
+
transcriptJsonPath,
|
|
38
|
+
generateTranscriptJson({
|
|
39
|
+
sourceVideo: path.basename(options.sourceVideoPath),
|
|
40
|
+
sourceDuration: options.sourceDuration,
|
|
41
|
+
words,
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const instructionsPath = path.join(editsDirectory, 'edit-instructions.md')
|
|
46
|
+
await Bun.write(
|
|
47
|
+
instructionsPath,
|
|
48
|
+
buildInstructions({
|
|
49
|
+
editsDirectory,
|
|
50
|
+
originalVideoPath,
|
|
51
|
+
transcriptJsonPath,
|
|
52
|
+
transcriptTextPath,
|
|
53
|
+
outputBasename: `${parsed.name}.edited${parsed.ext}`,
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
editsDirectory,
|
|
59
|
+
transcriptTextPath,
|
|
60
|
+
transcriptJsonPath,
|
|
61
|
+
originalVideoPath,
|
|
62
|
+
instructionsPath,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildInstructions(options: {
|
|
67
|
+
editsDirectory: string
|
|
68
|
+
originalVideoPath: string
|
|
69
|
+
transcriptJsonPath: string
|
|
70
|
+
transcriptTextPath: string
|
|
71
|
+
outputBasename: string
|
|
72
|
+
}): string {
|
|
73
|
+
return [
|
|
74
|
+
'# Manual edit workflow',
|
|
75
|
+
'',
|
|
76
|
+
'1) Edit `transcript.txt` and delete whole words only.',
|
|
77
|
+
'2) Run:',
|
|
78
|
+
'',
|
|
79
|
+
` bun process-course/edits/cli.ts edit-video \\`,
|
|
80
|
+
` --input "${options.originalVideoPath}" \\`,
|
|
81
|
+
` --transcript "${options.transcriptJsonPath}" \\`,
|
|
82
|
+
` --edited "${options.transcriptTextPath}" \\`,
|
|
83
|
+
` --output "${path.join(options.editsDirectory, options.outputBasename)}"`,
|
|
84
|
+
'',
|
|
85
|
+
'If the transcript no longer matches, regenerate it with:',
|
|
86
|
+
'',
|
|
87
|
+
` bun process-course/edits/regenerate-transcript.ts --dir "${options.editsDirectory}"`,
|
|
88
|
+
'',
|
|
89
|
+
].join('\n')
|
|
90
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
TranscriptJson,
|
|
3
|
+
TranscriptWordWithIndex,
|
|
4
|
+
TranscriptMismatchError,
|
|
5
|
+
} from './types'
|
|
6
|
+
export {
|
|
7
|
+
buildTranscriptWordsWithIndices,
|
|
8
|
+
generateTranscriptJson,
|
|
9
|
+
generateTranscriptText,
|
|
10
|
+
} from './transcript-output'
|
|
11
|
+
export { diffTranscripts, validateEditedTranscript } from './transcript-diff'
|
|
12
|
+
export {
|
|
13
|
+
wordsToTimeRanges,
|
|
14
|
+
refineRemovalRange,
|
|
15
|
+
refineAllRemovalRanges,
|
|
16
|
+
} from './timestamp-refinement'
|
|
17
|
+
export { editVideo, buildEditedOutputPath } from './video-editor'
|
|
18
|
+
export { combineVideos } from './combined-video-editor'
|
|
19
|
+
export type { EditWorkspace } from './edit-workspace'
|
|
20
|
+
export { createEditWorkspace } from './edit-workspace'
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
5
|
+
import yargs from 'yargs/yargs'
|
|
6
|
+
import { hideBin } from 'yargs/helpers'
|
|
7
|
+
import { extractTranscriptionAudio } from '../ffmpeg'
|
|
8
|
+
import { transcribeAudio } from '../../whispercpp-transcribe'
|
|
9
|
+
import { scaleTranscriptSegments } from '../jarvis-commands/parser'
|
|
10
|
+
import { EDIT_CONFIG } from '../config'
|
|
11
|
+
import {
|
|
12
|
+
buildTranscriptWordsWithIndices,
|
|
13
|
+
generateTranscriptJson,
|
|
14
|
+
generateTranscriptText,
|
|
15
|
+
} from './transcript-output'
|
|
16
|
+
import { getMediaDurationSeconds } from '../../utils'
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const argv = yargs(hideBin(process.argv))
|
|
20
|
+
.scriptName('regenerate-transcript')
|
|
21
|
+
.option('dir', {
|
|
22
|
+
type: 'string',
|
|
23
|
+
demandOption: true,
|
|
24
|
+
describe: 'Edits directory containing transcript files',
|
|
25
|
+
})
|
|
26
|
+
.help()
|
|
27
|
+
.parseSync()
|
|
28
|
+
|
|
29
|
+
const editsDir = path.resolve(String(argv.dir))
|
|
30
|
+
const originalPath = await findOriginalVideo(editsDir)
|
|
31
|
+
const duration = await getMediaDurationSeconds(originalPath)
|
|
32
|
+
|
|
33
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'edit-transcript-'))
|
|
34
|
+
const audioPath = path.join(tempDir, 'transcribe.wav')
|
|
35
|
+
const outputBasePath = path.join(tempDir, 'transcript')
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await extractTranscriptionAudio({
|
|
39
|
+
inputPath: originalPath,
|
|
40
|
+
outputPath: audioPath,
|
|
41
|
+
start: 0,
|
|
42
|
+
end: duration,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const transcription = await transcribeAudio(audioPath, {
|
|
46
|
+
outputBasePath,
|
|
47
|
+
})
|
|
48
|
+
const segments =
|
|
49
|
+
transcription.segmentsSource === 'tokens'
|
|
50
|
+
? transcription.segments
|
|
51
|
+
: scaleTranscriptSegments(transcription.segments, duration)
|
|
52
|
+
const words = buildTranscriptWordsWithIndices(segments)
|
|
53
|
+
|
|
54
|
+
const transcriptText = generateTranscriptText(words)
|
|
55
|
+
const transcriptJson = generateTranscriptJson({
|
|
56
|
+
sourceVideo: path.basename(originalPath),
|
|
57
|
+
sourceDuration: duration,
|
|
58
|
+
words,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await Bun.write(path.join(editsDir, 'transcript.txt'), transcriptText)
|
|
62
|
+
await Bun.write(path.join(editsDir, 'transcript.json'), transcriptJson)
|
|
63
|
+
} finally {
|
|
64
|
+
if (!EDIT_CONFIG.keepEditIntermediates) {
|
|
65
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function findOriginalVideo(editsDir: string): Promise<string> {
|
|
71
|
+
const entries = await readdir(editsDir)
|
|
72
|
+
const originalFile = entries.find((entry) => entry.startsWith('original.'))
|
|
73
|
+
if (!originalFile) {
|
|
74
|
+
throw new Error(`No original video found in ${editsDir}.`)
|
|
75
|
+
}
|
|
76
|
+
return path.join(editsDir, originalFile)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main().catch((error) => {
|
|
80
|
+
console.error(
|
|
81
|
+
`[error] ${error instanceof Error ? error.message : String(error)}`,
|
|
82
|
+
)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { normalizeRemovalRanges, parseTimeRanges } from './remove-ranges'
|
|
3
|
+
import type { TimeRange } from '../types'
|
|
4
|
+
|
|
5
|
+
function createRange(start: number, end: number): TimeRange {
|
|
6
|
+
return { start, end }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createRanges(...pairs: [number, number][]): TimeRange[] {
|
|
10
|
+
return pairs.map(([start, end]) => createRange(start, end))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('parseTimeRanges parses comma and whitespace ranges', () => {
|
|
14
|
+
const ranges = parseTimeRanges('0-1, 2 - 3 4-5')
|
|
15
|
+
expect(ranges).toEqual(createRanges([0, 1], [2, 3], [4, 5]))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('parseTimeRanges parses hh:mm:ss style timestamps', () => {
|
|
19
|
+
const ranges = parseTimeRanges('1:02-1:04.5')
|
|
20
|
+
expect(ranges).toHaveLength(1)
|
|
21
|
+
expect(ranges[0]?.start).toBeCloseTo(62, 6)
|
|
22
|
+
expect(ranges[0]?.end).toBeCloseTo(64.5, 6)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('parseTimeRanges throws when end is missing', () => {
|
|
26
|
+
expect(() => parseTimeRanges('5-')).toThrow('Start and end required')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('normalizeRemovalRanges clamps and merges ranges', () => {
|
|
30
|
+
const { ranges, warnings } = normalizeRemovalRanges({
|
|
31
|
+
ranges: createRanges([-1, 1], [0.8, 1.2], [4, 10]),
|
|
32
|
+
duration: 5,
|
|
33
|
+
})
|
|
34
|
+
expect(ranges).toEqual(createRanges([0, 1.2], [4, 5]))
|
|
35
|
+
expect(warnings.length).toBeGreaterThan(0)
|
|
36
|
+
})
|