eprec 0.0.1 → 1.0.1
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 +52 -29
- package/cli.ts +150 -0
- package/package.json +39 -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/speech-detection.ts +355 -0
- package/utils.ts +138 -0
- package/whispercpp-transcribe.ts +345 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { clamp } from '../../utils'
|
|
2
|
+
import { detectSpeechSegmentsWithVad } from '../../speech-detection'
|
|
3
|
+
import { readAudioSamples } from '../ffmpeg'
|
|
4
|
+
import { CONFIG } from '../config'
|
|
5
|
+
import { logInfo } from '../logging'
|
|
6
|
+
import { formatSeconds } from '../../utils'
|
|
7
|
+
import { mergeTimeRanges } from '../utils/time-ranges'
|
|
8
|
+
import {
|
|
9
|
+
buildSilenceGapsFromSpeech,
|
|
10
|
+
findSilenceBoundaryFromGaps,
|
|
11
|
+
findSilenceBoundaryWithRms,
|
|
12
|
+
computeRms,
|
|
13
|
+
computeMinWindowRms,
|
|
14
|
+
} from '../utils/audio-analysis'
|
|
15
|
+
import type { TimeRange, SilenceBoundaryDirection } from '../types'
|
|
16
|
+
import type { TranscriptCommand, CommandWindowOptions } from './types'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build time windows to remove based on detected commands.
|
|
20
|
+
*/
|
|
21
|
+
export function buildCommandWindows(
|
|
22
|
+
commands: TranscriptCommand[],
|
|
23
|
+
options: CommandWindowOptions,
|
|
24
|
+
): TimeRange[] {
|
|
25
|
+
if (commands.length === 0) {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
const windows = commands
|
|
29
|
+
.map((command) => {
|
|
30
|
+
const start = clamp(
|
|
31
|
+
options.offset + command.window.start - options.paddingSeconds,
|
|
32
|
+
options.min,
|
|
33
|
+
options.max,
|
|
34
|
+
)
|
|
35
|
+
const end = clamp(
|
|
36
|
+
options.offset + command.window.end + options.paddingSeconds,
|
|
37
|
+
options.min,
|
|
38
|
+
options.max,
|
|
39
|
+
)
|
|
40
|
+
if (end <= start) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
return { start, end }
|
|
44
|
+
})
|
|
45
|
+
.filter((window): window is TimeRange => Boolean(window))
|
|
46
|
+
return mergeTimeRanges(windows)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Refine command windows to align with silence boundaries.
|
|
51
|
+
*/
|
|
52
|
+
export async function refineCommandWindows(options: {
|
|
53
|
+
commandWindows: TimeRange[]
|
|
54
|
+
inputPath: string
|
|
55
|
+
duration: number
|
|
56
|
+
}): Promise<TimeRange[]> {
|
|
57
|
+
if (options.commandWindows.length === 0) {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
const refined: TimeRange[] = []
|
|
61
|
+
for (const window of options.commandWindows) {
|
|
62
|
+
const shouldKeepStart = await isSilenceAtTarget({
|
|
63
|
+
inputPath: options.inputPath,
|
|
64
|
+
duration: options.duration,
|
|
65
|
+
targetTime: window.start,
|
|
66
|
+
label: 'start',
|
|
67
|
+
})
|
|
68
|
+
let refinedStart = shouldKeepStart
|
|
69
|
+
? window.start
|
|
70
|
+
: await findSilenceBoundary({
|
|
71
|
+
inputPath: options.inputPath,
|
|
72
|
+
duration: options.duration,
|
|
73
|
+
targetTime: window.start,
|
|
74
|
+
direction: 'before',
|
|
75
|
+
maxSearchSeconds: CONFIG.commandSilenceSearchSeconds,
|
|
76
|
+
})
|
|
77
|
+
if (
|
|
78
|
+
refinedStart !== null &&
|
|
79
|
+
window.start - refinedStart > CONFIG.commandSilenceMaxBackwardSeconds
|
|
80
|
+
) {
|
|
81
|
+
refinedStart = window.start
|
|
82
|
+
}
|
|
83
|
+
const shouldKeepEnd = await isSilenceAtTarget({
|
|
84
|
+
inputPath: options.inputPath,
|
|
85
|
+
duration: options.duration,
|
|
86
|
+
targetTime: window.end,
|
|
87
|
+
label: 'end',
|
|
88
|
+
})
|
|
89
|
+
const refinedEnd = shouldKeepEnd
|
|
90
|
+
? window.end
|
|
91
|
+
: await findSilenceBoundary({
|
|
92
|
+
inputPath: options.inputPath,
|
|
93
|
+
duration: options.duration,
|
|
94
|
+
targetTime: window.end,
|
|
95
|
+
direction: 'after',
|
|
96
|
+
maxSearchSeconds: CONFIG.commandSilenceSearchSeconds,
|
|
97
|
+
})
|
|
98
|
+
const start = clamp(refinedStart ?? window.start, 0, options.duration)
|
|
99
|
+
const end = clamp(refinedEnd ?? window.end, 0, options.duration)
|
|
100
|
+
if (end <= start + 0.01) {
|
|
101
|
+
refined.push({ start: window.start, end: window.end })
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
Math.abs(start - window.start) > 0.01 ||
|
|
106
|
+
Math.abs(end - window.end) > 0.01
|
|
107
|
+
) {
|
|
108
|
+
logInfo(
|
|
109
|
+
`Refined command window ${formatSeconds(window.start)}-${formatSeconds(
|
|
110
|
+
window.end,
|
|
111
|
+
)} to ${formatSeconds(start)}-${formatSeconds(end)}`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
refined.push({ start, end })
|
|
115
|
+
}
|
|
116
|
+
return mergeTimeRanges(refined)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function findSilenceBoundary(options: {
|
|
120
|
+
inputPath: string
|
|
121
|
+
duration: number
|
|
122
|
+
targetTime: number
|
|
123
|
+
direction: SilenceBoundaryDirection
|
|
124
|
+
maxSearchSeconds: number
|
|
125
|
+
}): Promise<number | null> {
|
|
126
|
+
const searchStart =
|
|
127
|
+
options.direction === 'before'
|
|
128
|
+
? Math.max(0, options.targetTime - options.maxSearchSeconds)
|
|
129
|
+
: options.targetTime
|
|
130
|
+
const searchEnd =
|
|
131
|
+
options.direction === 'before'
|
|
132
|
+
? options.targetTime
|
|
133
|
+
: Math.min(
|
|
134
|
+
options.duration,
|
|
135
|
+
options.targetTime + options.maxSearchSeconds,
|
|
136
|
+
)
|
|
137
|
+
const searchDuration = searchEnd - searchStart
|
|
138
|
+
if (searchDuration <= 0.05) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
const samples = await readAudioSamples({
|
|
142
|
+
inputPath: options.inputPath,
|
|
143
|
+
start: searchStart,
|
|
144
|
+
duration: searchDuration,
|
|
145
|
+
sampleRate: CONFIG.vadSampleRate,
|
|
146
|
+
})
|
|
147
|
+
if (samples.length === 0) {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const targetOffset = options.targetTime - searchStart
|
|
152
|
+
let boundary =
|
|
153
|
+
(await findSilenceBoundaryWithVad({
|
|
154
|
+
samples,
|
|
155
|
+
duration: searchDuration,
|
|
156
|
+
targetOffset,
|
|
157
|
+
direction: options.direction,
|
|
158
|
+
})) ??
|
|
159
|
+
findSilenceBoundaryWithRms({
|
|
160
|
+
samples,
|
|
161
|
+
sampleRate: CONFIG.vadSampleRate,
|
|
162
|
+
direction: options.direction,
|
|
163
|
+
rmsWindowMs: CONFIG.commandSilenceRmsWindowMs,
|
|
164
|
+
rmsThreshold: CONFIG.commandSilenceRmsThreshold,
|
|
165
|
+
minSilenceMs: CONFIG.commandSilenceMinDurationMs,
|
|
166
|
+
})
|
|
167
|
+
if (boundary === null || !Number.isFinite(boundary)) {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
boundary = clamp(boundary, 0, searchDuration)
|
|
171
|
+
return searchStart + boundary
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function isSilenceAtTarget(options: {
|
|
175
|
+
inputPath: string
|
|
176
|
+
duration: number
|
|
177
|
+
targetTime: number
|
|
178
|
+
label?: string
|
|
179
|
+
}): Promise<boolean> {
|
|
180
|
+
const halfWindowSeconds = Math.max(
|
|
181
|
+
0.005,
|
|
182
|
+
(CONFIG.commandSilenceRmsWindowMs / 1000) * 1.5,
|
|
183
|
+
)
|
|
184
|
+
const windowStart = clamp(
|
|
185
|
+
options.targetTime - halfWindowSeconds,
|
|
186
|
+
0,
|
|
187
|
+
options.duration,
|
|
188
|
+
)
|
|
189
|
+
const windowEnd = clamp(
|
|
190
|
+
options.targetTime + halfWindowSeconds,
|
|
191
|
+
0,
|
|
192
|
+
options.duration,
|
|
193
|
+
)
|
|
194
|
+
const windowDuration = windowEnd - windowStart
|
|
195
|
+
if (windowDuration <= 0.01) {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
const samples = await readAudioSamples({
|
|
199
|
+
inputPath: options.inputPath,
|
|
200
|
+
start: windowStart,
|
|
201
|
+
duration: windowDuration,
|
|
202
|
+
sampleRate: CONFIG.vadSampleRate,
|
|
203
|
+
})
|
|
204
|
+
if (samples.length === 0) {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
const windowSamples = Math.max(
|
|
208
|
+
1,
|
|
209
|
+
Math.round(
|
|
210
|
+
(CONFIG.vadSampleRate * CONFIG.commandSilenceRmsWindowMs) / 1000,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
const rms = computeRms(samples)
|
|
214
|
+
const minRms = computeMinWindowRms(samples, windowSamples)
|
|
215
|
+
const label = options.label ? ` ${options.label}` : ''
|
|
216
|
+
logInfo(
|
|
217
|
+
`Command window${label} RMS at ${formatSeconds(options.targetTime)}: avg ${rms.toFixed(
|
|
218
|
+
4,
|
|
219
|
+
)}, min ${minRms.toFixed(4)} (threshold ${CONFIG.commandSilenceRmsThreshold})`,
|
|
220
|
+
)
|
|
221
|
+
return minRms < CONFIG.commandSilenceRmsThreshold
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function findSilenceBoundaryWithVad(options: {
|
|
225
|
+
samples: Float32Array
|
|
226
|
+
duration: number
|
|
227
|
+
targetOffset: number
|
|
228
|
+
direction: SilenceBoundaryDirection
|
|
229
|
+
}): Promise<number | null> {
|
|
230
|
+
try {
|
|
231
|
+
const vadSegments = await detectSpeechSegmentsWithVad(
|
|
232
|
+
options.samples,
|
|
233
|
+
CONFIG.vadSampleRate,
|
|
234
|
+
CONFIG,
|
|
235
|
+
)
|
|
236
|
+
if (vadSegments.length === 0) {
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
const silenceGaps = buildSilenceGapsFromSpeech(
|
|
240
|
+
vadSegments,
|
|
241
|
+
options.duration,
|
|
242
|
+
)
|
|
243
|
+
return findSilenceBoundaryFromGaps(
|
|
244
|
+
silenceGaps,
|
|
245
|
+
options.targetOffset,
|
|
246
|
+
options.direction,
|
|
247
|
+
)
|
|
248
|
+
} catch {
|
|
249
|
+
logInfo(
|
|
250
|
+
`VAD silence scan failed (${options.direction}); using RMS fallback.`,
|
|
251
|
+
)
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { formatCommand } from '../utils'
|
|
2
|
+
import { buildChapterLogPath } from './paths'
|
|
3
|
+
|
|
4
|
+
export function logCommand(command: string[]) {
|
|
5
|
+
console.log(`[cmd] ${formatCommand(command)}`)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function logInfo(message: string) {
|
|
9
|
+
console.log(`[info] ${message}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function logWarn(message: string) {
|
|
13
|
+
console.warn(`[warn] ${message}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeChapterLog(
|
|
17
|
+
tmpDir: string,
|
|
18
|
+
outputPath: string,
|
|
19
|
+
lines: string[],
|
|
20
|
+
) {
|
|
21
|
+
const logPath = buildChapterLogPath(tmpDir, outputPath)
|
|
22
|
+
const body = `${lines.join('\n')}\n`
|
|
23
|
+
await Bun.write(logPath, body)
|
|
24
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { test, expect } from 'bun:test'
|
|
3
|
+
import {
|
|
4
|
+
buildChapterLogPath,
|
|
5
|
+
buildIntermediateAudioPath,
|
|
6
|
+
buildIntermediatePath,
|
|
7
|
+
buildJarvisEditLogPath,
|
|
8
|
+
buildJarvisOutputBase,
|
|
9
|
+
buildJarvisWarningLogPath,
|
|
10
|
+
buildSummaryLogPath,
|
|
11
|
+
buildTranscriptionOutputBase,
|
|
12
|
+
} from './paths'
|
|
13
|
+
|
|
14
|
+
test('buildIntermediatePath appends suffix with original extension', () => {
|
|
15
|
+
const result = buildIntermediatePath('/tmp', '/videos/lesson.mp4', 'trimmed')
|
|
16
|
+
expect(result).toBe(path.join('/tmp', 'lesson-trimmed.mp4'))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('buildIntermediateAudioPath forces wav extension', () => {
|
|
20
|
+
const result = buildIntermediateAudioPath(
|
|
21
|
+
'/tmp',
|
|
22
|
+
'/videos/lesson.mp4',
|
|
23
|
+
'speech',
|
|
24
|
+
)
|
|
25
|
+
expect(result).toBe(path.join('/tmp', 'lesson-speech.wav'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('buildTranscriptionOutputBase appends transcribe suffix', () => {
|
|
29
|
+
const result = buildTranscriptionOutputBase('/tmp', '/videos/lesson.mp4')
|
|
30
|
+
expect(result).toBe(path.join('/tmp', 'lesson-transcribe'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('buildJarvisOutputBase appends jarvis suffix', () => {
|
|
34
|
+
const result = buildJarvisOutputBase('/tmp', '/videos/lesson.mp4')
|
|
35
|
+
expect(result).toBe(path.join('/tmp', 'lesson-jarvis'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('buildSummaryLogPath uses process-summary.log', () => {
|
|
39
|
+
expect(buildSummaryLogPath('/tmp')).toBe(
|
|
40
|
+
path.join('/tmp', 'process-summary.log'),
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('buildJarvisWarningLogPath uses jarvis-warnings.log', () => {
|
|
45
|
+
expect(buildJarvisWarningLogPath('/out')).toBe(
|
|
46
|
+
path.join('/out', 'jarvis-warnings.log'),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('buildJarvisEditLogPath uses jarvis-edits.log', () => {
|
|
51
|
+
expect(buildJarvisEditLogPath('/out')).toBe(
|
|
52
|
+
path.join('/out', 'jarvis-edits.log'),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('buildChapterLogPath uses output filename for log', () => {
|
|
57
|
+
const result = buildChapterLogPath('/tmp', '/videos/lesson.mp4')
|
|
58
|
+
expect(result).toBe(path.join('/tmp', 'lesson.log'))
|
|
59
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export function buildIntermediatePath(
|
|
4
|
+
tmpDir: string,
|
|
5
|
+
outputPath: string,
|
|
6
|
+
suffix: string,
|
|
7
|
+
) {
|
|
8
|
+
const parsed = path.parse(outputPath)
|
|
9
|
+
return path.join(tmpDir, `${parsed.name}-${suffix}${parsed.ext}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildIntermediateAudioPath(
|
|
13
|
+
tmpDir: string,
|
|
14
|
+
outputPath: string,
|
|
15
|
+
suffix: string,
|
|
16
|
+
) {
|
|
17
|
+
const parsed = path.parse(outputPath)
|
|
18
|
+
return path.join(tmpDir, `${parsed.name}-${suffix}.wav`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildTranscriptionOutputBase(
|
|
22
|
+
tmpDir: string,
|
|
23
|
+
outputPath: string,
|
|
24
|
+
) {
|
|
25
|
+
const parsed = path.parse(outputPath)
|
|
26
|
+
return path.join(tmpDir, `${parsed.name}-transcribe`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildJarvisOutputBase(tmpDir: string, outputPath: string) {
|
|
30
|
+
const parsed = path.parse(outputPath)
|
|
31
|
+
return path.join(tmpDir, `${parsed.name}-jarvis`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildSummaryLogPath(tmpDir: string) {
|
|
35
|
+
return path.join(tmpDir, 'process-summary.log')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildJarvisWarningLogPath(outputDir: string) {
|
|
39
|
+
return path.join(outputDir, 'jarvis-warnings.log')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildJarvisEditLogPath(outputDir: string) {
|
|
43
|
+
return path.join(outputDir, 'jarvis-edits.log')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildJarvisNoteLogPath(outputDir: string) {
|
|
47
|
+
return path.join(outputDir, 'jarvis-notes.log')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildChapterLogPath(tmpDir: string, outputPath: string) {
|
|
51
|
+
const parsed = path.parse(outputPath)
|
|
52
|
+
return path.join(tmpDir, `${parsed.name}.log`)
|
|
53
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { mkdtemp, rm, mkdir } from 'node:fs/promises'
|
|
5
|
+
import {
|
|
6
|
+
buildJarvisEditLogPath,
|
|
7
|
+
buildJarvisWarningLogPath,
|
|
8
|
+
buildSummaryLogPath,
|
|
9
|
+
} from './paths'
|
|
10
|
+
import type {
|
|
11
|
+
Chapter,
|
|
12
|
+
JarvisEdit,
|
|
13
|
+
JarvisWarning,
|
|
14
|
+
TimeRange,
|
|
15
|
+
EditWorkspaceInfo,
|
|
16
|
+
} from './types'
|
|
17
|
+
import { writeJarvisLogs, writeSummaryLogs } from './summary'
|
|
18
|
+
|
|
19
|
+
async function createTempDir(): Promise<string> {
|
|
20
|
+
return mkdtemp(path.join(os.tmpdir(), 'summary-logs-'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createChapter(index: number, title = `Chapter ${index + 1}`): Chapter {
|
|
24
|
+
return { index, start: 0, end: 10, title }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createTimestamp(start: number, end: number): TimeRange {
|
|
28
|
+
return { start, end }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createWarning(
|
|
32
|
+
index: number,
|
|
33
|
+
outputPath: string,
|
|
34
|
+
timestamps: TimeRange[] = [],
|
|
35
|
+
): JarvisWarning {
|
|
36
|
+
return { chapter: createChapter(index), outputPath, timestamps }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createEdit(index: number, outputPath: string): JarvisEdit {
|
|
40
|
+
return { chapter: createChapter(index), outputPath }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createEditWorkspace(
|
|
44
|
+
index: number,
|
|
45
|
+
outputPath: string,
|
|
46
|
+
editsDirectory: string,
|
|
47
|
+
): EditWorkspaceInfo {
|
|
48
|
+
return {
|
|
49
|
+
chapter: createChapter(index),
|
|
50
|
+
outputPath,
|
|
51
|
+
reason: 'edit-command',
|
|
52
|
+
editsDirectory,
|
|
53
|
+
transcriptTextPath: path.join(editsDirectory, 'transcript.txt'),
|
|
54
|
+
transcriptJsonPath: path.join(editsDirectory, 'transcript.json'),
|
|
55
|
+
originalVideoPath: path.join(editsDirectory, 'original.mp4'),
|
|
56
|
+
instructionsPath: path.join(editsDirectory, 'edit-instructions.md'),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('writeJarvisLogs writes warning and edit logs', async () => {
|
|
61
|
+
const tmpDir = await createTempDir()
|
|
62
|
+
const outputDir = path.join(tmpDir, 'output')
|
|
63
|
+
await mkdir(outputDir)
|
|
64
|
+
try {
|
|
65
|
+
const warning = createWarning(0, path.join(outputDir, 'chapter-01.mp4'), [
|
|
66
|
+
createTimestamp(1, 1.5),
|
|
67
|
+
createTimestamp(4.25, 4.75),
|
|
68
|
+
])
|
|
69
|
+
const edit = createEdit(1, path.join(outputDir, 'chapter-02.mp4'))
|
|
70
|
+
|
|
71
|
+
await writeJarvisLogs({
|
|
72
|
+
outputDir,
|
|
73
|
+
inputPath: '/videos/course.mp4',
|
|
74
|
+
jarvisWarnings: [warning],
|
|
75
|
+
jarvisEdits: [edit],
|
|
76
|
+
jarvisNotes: [],
|
|
77
|
+
editWorkspaces: [
|
|
78
|
+
createEditWorkspace(
|
|
79
|
+
1,
|
|
80
|
+
path.join(outputDir, 'chapter-02.mp4'),
|
|
81
|
+
path.join(outputDir, 'edits', 'chapter-02'),
|
|
82
|
+
),
|
|
83
|
+
],
|
|
84
|
+
dryRun: false,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const warningLog = await Bun.file(
|
|
88
|
+
buildJarvisWarningLogPath(outputDir),
|
|
89
|
+
).text()
|
|
90
|
+
expect(warningLog).toContain('Input: /videos/course.mp4')
|
|
91
|
+
expect(warningLog).toContain('Jarvis warnings: 1')
|
|
92
|
+
expect(warningLog).toContain('Detected in:')
|
|
93
|
+
expect(warningLog).toContain('Chapter 1')
|
|
94
|
+
expect(warningLog).toContain('chapter-01.mp4')
|
|
95
|
+
expect(warningLog).toContain('Jarvis timestamps: 1.00s-1.50s, 4.25s-4.75s')
|
|
96
|
+
|
|
97
|
+
const editLog = await Bun.file(buildJarvisEditLogPath(outputDir)).text()
|
|
98
|
+
expect(editLog).toContain('Input: /videos/course.mp4')
|
|
99
|
+
expect(editLog).toContain('Edit commands: 1')
|
|
100
|
+
expect(editLog).toContain('Files needing edits:')
|
|
101
|
+
expect(editLog).toContain('Chapter 2')
|
|
102
|
+
expect(editLog).toContain('chapter-02.mp4')
|
|
103
|
+
} finally {
|
|
104
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('writeJarvisLogs handles empty warning and edit lists', async () => {
|
|
109
|
+
const tmpDir = await createTempDir()
|
|
110
|
+
const outputDir = path.join(tmpDir, 'output')
|
|
111
|
+
await mkdir(outputDir)
|
|
112
|
+
try {
|
|
113
|
+
await writeJarvisLogs({
|
|
114
|
+
outputDir,
|
|
115
|
+
inputPath: '/videos/course.mp4',
|
|
116
|
+
jarvisWarnings: [],
|
|
117
|
+
jarvisEdits: [],
|
|
118
|
+
jarvisNotes: [],
|
|
119
|
+
editWorkspaces: [],
|
|
120
|
+
dryRun: false,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const warningLog = await Bun.file(
|
|
124
|
+
buildJarvisWarningLogPath(outputDir),
|
|
125
|
+
).text()
|
|
126
|
+
expect(warningLog).toContain('Detected in: none')
|
|
127
|
+
|
|
128
|
+
const editLog = await Bun.file(buildJarvisEditLogPath(outputDir)).text()
|
|
129
|
+
expect(editLog).toContain('Files needing edits: none')
|
|
130
|
+
} finally {
|
|
131
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('writeSummaryLogs writes summary file with details', async () => {
|
|
136
|
+
const tmpDir = await createTempDir()
|
|
137
|
+
const outputDir = path.join(tmpDir, 'output')
|
|
138
|
+
await mkdir(outputDir)
|
|
139
|
+
try {
|
|
140
|
+
await writeSummaryLogs({
|
|
141
|
+
tmpDir,
|
|
142
|
+
outputDir,
|
|
143
|
+
inputPath: '/videos/course.mp4',
|
|
144
|
+
summary: {
|
|
145
|
+
totalSelected: 3,
|
|
146
|
+
processed: 2,
|
|
147
|
+
skippedShortInitial: 0,
|
|
148
|
+
skippedShortTrimmed: 1,
|
|
149
|
+
skippedTranscription: 0,
|
|
150
|
+
fallbackNotes: 1,
|
|
151
|
+
logsWritten: 2,
|
|
152
|
+
jarvisWarnings: 0,
|
|
153
|
+
editsPending: 1,
|
|
154
|
+
},
|
|
155
|
+
summaryDetails: ['- Chapter 2 skipped (short)'],
|
|
156
|
+
jarvisWarnings: [],
|
|
157
|
+
jarvisEdits: [],
|
|
158
|
+
editWorkspaces: [
|
|
159
|
+
createEditWorkspace(
|
|
160
|
+
1,
|
|
161
|
+
path.join(outputDir, 'chapter-02.mp4'),
|
|
162
|
+
path.join(outputDir, 'edits', 'chapter-02'),
|
|
163
|
+
),
|
|
164
|
+
],
|
|
165
|
+
dryRun: false,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const summaryLog = await Bun.file(buildSummaryLogPath(tmpDir)).text()
|
|
169
|
+
expect(summaryLog).toContain('Input: /videos/course.mp4')
|
|
170
|
+
expect(summaryLog).toContain('Processed chapters: 2')
|
|
171
|
+
expect(summaryLog).toContain('Details:')
|
|
172
|
+
expect(summaryLog).toContain('- Chapter 2 skipped (short)')
|
|
173
|
+
} finally {
|
|
174
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('writeSummaryLogs skips writing file in dry run mode', async () => {
|
|
179
|
+
const tmpDir = await createTempDir()
|
|
180
|
+
const outputDir = path.join(tmpDir, 'output')
|
|
181
|
+
await mkdir(outputDir)
|
|
182
|
+
try {
|
|
183
|
+
await writeSummaryLogs({
|
|
184
|
+
tmpDir,
|
|
185
|
+
outputDir,
|
|
186
|
+
inputPath: '/videos/course.mp4',
|
|
187
|
+
summary: {
|
|
188
|
+
totalSelected: 1,
|
|
189
|
+
processed: 1,
|
|
190
|
+
skippedShortInitial: 0,
|
|
191
|
+
skippedShortTrimmed: 0,
|
|
192
|
+
skippedTranscription: 0,
|
|
193
|
+
fallbackNotes: 0,
|
|
194
|
+
logsWritten: 0,
|
|
195
|
+
jarvisWarnings: 0,
|
|
196
|
+
editsPending: 0,
|
|
197
|
+
},
|
|
198
|
+
summaryDetails: [],
|
|
199
|
+
jarvisWarnings: [],
|
|
200
|
+
jarvisEdits: [],
|
|
201
|
+
editWorkspaces: [],
|
|
202
|
+
dryRun: true,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(await Bun.file(buildSummaryLogPath(tmpDir)).exists()).toBe(false)
|
|
206
|
+
} finally {
|
|
207
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
208
|
+
}
|
|
209
|
+
})
|