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,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
+ })