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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -29
  3. package/app/assets/styles.css +129 -0
  4. package/app/client/app.tsx +37 -0
  5. package/app/client/counter.tsx +22 -0
  6. package/app/client/entry.tsx +8 -0
  7. package/app/components/layout.tsx +37 -0
  8. package/app/config/env.ts +31 -0
  9. package/app/config/import-map.ts +9 -0
  10. package/app/config/init-env.ts +3 -0
  11. package/app/config/routes.ts +5 -0
  12. package/app/helpers/render.ts +6 -0
  13. package/app/router.tsx +102 -0
  14. package/app/routes/index.tsx +50 -0
  15. package/app-server.ts +60 -0
  16. package/cli.ts +173 -0
  17. package/package.json +46 -7
  18. package/process-course/chapter-processor.ts +1037 -0
  19. package/process-course/cli.ts +236 -0
  20. package/process-course/config.ts +50 -0
  21. package/process-course/edits/cli.ts +167 -0
  22. package/process-course/edits/combined-video-editor.ts +316 -0
  23. package/process-course/edits/edit-workspace.ts +90 -0
  24. package/process-course/edits/index.ts +20 -0
  25. package/process-course/edits/regenerate-transcript.ts +84 -0
  26. package/process-course/edits/remove-ranges.test.ts +36 -0
  27. package/process-course/edits/remove-ranges.ts +287 -0
  28. package/process-course/edits/timestamp-refinement.test.ts +25 -0
  29. package/process-course/edits/timestamp-refinement.ts +172 -0
  30. package/process-course/edits/transcript-diff.test.ts +105 -0
  31. package/process-course/edits/transcript-diff.ts +214 -0
  32. package/process-course/edits/transcript-output.test.ts +50 -0
  33. package/process-course/edits/transcript-output.ts +36 -0
  34. package/process-course/edits/types.ts +26 -0
  35. package/process-course/edits/video-editor.ts +246 -0
  36. package/process-course/errors.test.ts +63 -0
  37. package/process-course/errors.ts +82 -0
  38. package/process-course/ffmpeg.ts +449 -0
  39. package/process-course/jarvis-commands/handlers.ts +71 -0
  40. package/process-course/jarvis-commands/index.ts +14 -0
  41. package/process-course/jarvis-commands/parser.test.ts +348 -0
  42. package/process-course/jarvis-commands/parser.ts +257 -0
  43. package/process-course/jarvis-commands/types.ts +46 -0
  44. package/process-course/jarvis-commands/windows.ts +254 -0
  45. package/process-course/logging.ts +24 -0
  46. package/process-course/paths.test.ts +59 -0
  47. package/process-course/paths.ts +53 -0
  48. package/process-course/summary.test.ts +209 -0
  49. package/process-course/summary.ts +210 -0
  50. package/process-course/types.ts +85 -0
  51. package/process-course/utils/audio-analysis.test.ts +348 -0
  52. package/process-course/utils/audio-analysis.ts +463 -0
  53. package/process-course/utils/chapter-selection.test.ts +307 -0
  54. package/process-course/utils/chapter-selection.ts +136 -0
  55. package/process-course/utils/file-utils.test.ts +83 -0
  56. package/process-course/utils/file-utils.ts +57 -0
  57. package/process-course/utils/filename.test.ts +27 -0
  58. package/process-course/utils/filename.ts +12 -0
  59. package/process-course/utils/time-ranges.test.ts +221 -0
  60. package/process-course/utils/time-ranges.ts +86 -0
  61. package/process-course/utils/transcript.test.ts +257 -0
  62. package/process-course/utils/transcript.ts +86 -0
  63. package/process-course/utils/video-editing.ts +44 -0
  64. package/process-course-video.ts +389 -0
  65. package/public/robots.txt +2 -0
  66. package/server/bundling.ts +210 -0
  67. package/speech-detection.ts +355 -0
  68. package/utils.ts +138 -0
  69. 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
+ }