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,214 @@
|
|
|
1
|
+
import { normalizeWords } from '../utils/transcript'
|
|
2
|
+
import type { TranscriptMismatchError, TranscriptWordWithIndex } from './types'
|
|
3
|
+
|
|
4
|
+
export type DiffResult = {
|
|
5
|
+
success: boolean
|
|
6
|
+
removedWords: TranscriptWordWithIndex[]
|
|
7
|
+
error?: string
|
|
8
|
+
mismatch?: TranscriptMismatchError
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ValidationResult = {
|
|
12
|
+
valid: boolean
|
|
13
|
+
error?: string
|
|
14
|
+
mismatch?: TranscriptMismatchError
|
|
15
|
+
details?: {
|
|
16
|
+
unexpectedWord: string
|
|
17
|
+
position: number
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function diffTranscripts(options: {
|
|
22
|
+
originalWords: TranscriptWordWithIndex[]
|
|
23
|
+
editedText: string
|
|
24
|
+
}): DiffResult {
|
|
25
|
+
const validation = validateEditedTranscript(options)
|
|
26
|
+
if (!validation.valid) {
|
|
27
|
+
return {
|
|
28
|
+
success: false,
|
|
29
|
+
removedWords: [],
|
|
30
|
+
error: validation.error,
|
|
31
|
+
mismatch: validation.mismatch,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const editedWords = tokenizeEditedText(options.editedText)
|
|
36
|
+
const removedWords: TranscriptWordWithIndex[] = []
|
|
37
|
+
let originalIndex = 0
|
|
38
|
+
let editedIndex = 0
|
|
39
|
+
|
|
40
|
+
while (originalIndex < options.originalWords.length) {
|
|
41
|
+
const originalWord = options.originalWords[originalIndex]
|
|
42
|
+
if (!originalWord) {
|
|
43
|
+
originalIndex += 1
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const editedWord = editedWords[editedIndex]
|
|
47
|
+
if (editedWord && originalWord.word === editedWord) {
|
|
48
|
+
originalIndex += 1
|
|
49
|
+
editedIndex += 1
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
removedWords.push(originalWord)
|
|
53
|
+
originalIndex += 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (editedIndex < editedWords.length) {
|
|
57
|
+
const mismatch = buildMismatchError({
|
|
58
|
+
type: 'word_added',
|
|
59
|
+
position: editedIndex,
|
|
60
|
+
editedWord: editedWords[editedIndex],
|
|
61
|
+
originalWord: null,
|
|
62
|
+
})
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
removedWords: [],
|
|
66
|
+
error: mismatch.message,
|
|
67
|
+
mismatch,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { success: true, removedWords }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateEditedTranscript(options: {
|
|
75
|
+
originalWords: TranscriptWordWithIndex[]
|
|
76
|
+
editedText: string
|
|
77
|
+
}): ValidationResult {
|
|
78
|
+
const editedWords = tokenizeEditedText(options.editedText)
|
|
79
|
+
if (options.originalWords.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
valid: false,
|
|
82
|
+
error: 'Original transcript has no words. Regenerate the transcript.',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (editedWords.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
valid: false,
|
|
88
|
+
error:
|
|
89
|
+
'Edited transcript is empty. Regenerate the transcript if this was unintentional.',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let originalIndex = 0
|
|
94
|
+
let editedIndex = 0
|
|
95
|
+
|
|
96
|
+
while (
|
|
97
|
+
originalIndex < options.originalWords.length &&
|
|
98
|
+
editedIndex < editedWords.length
|
|
99
|
+
) {
|
|
100
|
+
const originalWord = options.originalWords[originalIndex]
|
|
101
|
+
const editedWord = editedWords[editedIndex]
|
|
102
|
+
if (!originalWord || !editedWord) {
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
if (originalWord.word === editedWord) {
|
|
106
|
+
originalIndex += 1
|
|
107
|
+
editedIndex += 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const nextMatchIndex = findNextMatchIndex(
|
|
112
|
+
options.originalWords,
|
|
113
|
+
editedWord,
|
|
114
|
+
originalIndex + 1,
|
|
115
|
+
)
|
|
116
|
+
if (nextMatchIndex === -1) {
|
|
117
|
+
const mismatchType = resolveMismatchType(
|
|
118
|
+
options.originalWords,
|
|
119
|
+
editedWord,
|
|
120
|
+
originalIndex,
|
|
121
|
+
)
|
|
122
|
+
const mismatch = buildMismatchError({
|
|
123
|
+
type: mismatchType,
|
|
124
|
+
position: editedIndex,
|
|
125
|
+
editedWord,
|
|
126
|
+
originalWord: originalWord.word,
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
valid: false,
|
|
130
|
+
error: mismatch.message,
|
|
131
|
+
mismatch,
|
|
132
|
+
details: {
|
|
133
|
+
unexpectedWord: editedWord,
|
|
134
|
+
position: editedIndex,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
originalIndex += 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (editedIndex < editedWords.length) {
|
|
142
|
+
const mismatch = buildMismatchError({
|
|
143
|
+
type: 'word_added',
|
|
144
|
+
position: editedIndex,
|
|
145
|
+
editedWord: editedWords[editedIndex],
|
|
146
|
+
originalWord: null,
|
|
147
|
+
})
|
|
148
|
+
return {
|
|
149
|
+
valid: false,
|
|
150
|
+
error: mismatch.message,
|
|
151
|
+
mismatch,
|
|
152
|
+
details: {
|
|
153
|
+
unexpectedWord: editedWords[editedIndex] ?? '',
|
|
154
|
+
position: editedIndex,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { valid: true }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function tokenizeEditedText(text: string): string[] {
|
|
163
|
+
return normalizeWords(text)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function findNextMatchIndex(
|
|
167
|
+
words: TranscriptWordWithIndex[],
|
|
168
|
+
target: string,
|
|
169
|
+
startIndex: number,
|
|
170
|
+
): number {
|
|
171
|
+
for (let index = startIndex; index < words.length; index += 1) {
|
|
172
|
+
if (words[index]?.word === target) {
|
|
173
|
+
return index
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return -1
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolveMismatchType(
|
|
180
|
+
words: TranscriptWordWithIndex[],
|
|
181
|
+
editedWord: string,
|
|
182
|
+
originalIndex: number,
|
|
183
|
+
): TranscriptMismatchError['type'] {
|
|
184
|
+
const anyIndex = words.findIndex((word) => word.word === editedWord)
|
|
185
|
+
if (anyIndex >= 0 && anyIndex < originalIndex) {
|
|
186
|
+
return 'word_out_of_order'
|
|
187
|
+
}
|
|
188
|
+
return anyIndex >= 0 ? 'word_out_of_order' : 'word_modified'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildMismatchError(options: {
|
|
192
|
+
type: TranscriptMismatchError['type']
|
|
193
|
+
position: number
|
|
194
|
+
editedWord: string | undefined
|
|
195
|
+
originalWord: string | null
|
|
196
|
+
}): TranscriptMismatchError {
|
|
197
|
+
const expected =
|
|
198
|
+
options.originalWord === null ? 'end of transcript' : options.originalWord
|
|
199
|
+
const found = options.editedWord ?? 'end of transcript'
|
|
200
|
+
const typeLabel =
|
|
201
|
+
options.type === 'word_added'
|
|
202
|
+
? 'Unexpected word'
|
|
203
|
+
: options.type === 'word_out_of_order'
|
|
204
|
+
? 'Word out of order'
|
|
205
|
+
: 'Word modified'
|
|
206
|
+
const message = `Error: Transcript mismatch at word position ${options.position}.\nExpected: "${expected}"\nFound: "${found}"\n\nThe edited transcript contains changes that don't match the original.\nPlease regenerate the transcript and try again.`
|
|
207
|
+
return {
|
|
208
|
+
type: options.type,
|
|
209
|
+
position: options.position,
|
|
210
|
+
originalWord: options.originalWord ?? undefined,
|
|
211
|
+
editedWord: options.editedWord ?? undefined,
|
|
212
|
+
message: `${typeLabel}. ${message}`,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import type { TranscriptSegment } from '../../whispercpp-transcribe'
|
|
3
|
+
import {
|
|
4
|
+
buildTranscriptWordsWithIndices,
|
|
5
|
+
generateTranscriptJson,
|
|
6
|
+
generateTranscriptText,
|
|
7
|
+
} from './transcript-output'
|
|
8
|
+
|
|
9
|
+
function createSegment(
|
|
10
|
+
start: number,
|
|
11
|
+
end: number,
|
|
12
|
+
text: string,
|
|
13
|
+
): TranscriptSegment {
|
|
14
|
+
return { start, end, text }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('buildTranscriptWordsWithIndices assigns indices in order', () => {
|
|
18
|
+
const segments = [createSegment(0, 2, 'Hello world')]
|
|
19
|
+
const words = buildTranscriptWordsWithIndices(segments)
|
|
20
|
+
expect(words).toHaveLength(2)
|
|
21
|
+
expect(words[0]).toMatchObject({ word: 'hello', index: 0, start: 0, end: 1 })
|
|
22
|
+
expect(words[1]).toMatchObject({ word: 'world', index: 1, start: 1, end: 2 })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('generateTranscriptText returns readable prose', () => {
|
|
26
|
+
const segments = [createSegment(0, 2, 'Hello world')]
|
|
27
|
+
const words = buildTranscriptWordsWithIndices(segments)
|
|
28
|
+
expect(generateTranscriptText(words)).toBe('hello world\n')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('generateTranscriptJson outputs valid metadata', () => {
|
|
32
|
+
const segments = [createSegment(0, 2, 'Hello world')]
|
|
33
|
+
const words = buildTranscriptWordsWithIndices(segments)
|
|
34
|
+
const json = generateTranscriptJson({
|
|
35
|
+
sourceVideo: 'chapter-01.mp4',
|
|
36
|
+
sourceDuration: 2,
|
|
37
|
+
words,
|
|
38
|
+
})
|
|
39
|
+
const parsed = JSON.parse(json) as {
|
|
40
|
+
version: number
|
|
41
|
+
source_video: string
|
|
42
|
+
source_duration: number
|
|
43
|
+
words: Array<{ word: string; start: number; end: number; index: number }>
|
|
44
|
+
}
|
|
45
|
+
expect(parsed.version).toBe(1)
|
|
46
|
+
expect(parsed.source_video).toBe('chapter-01.mp4')
|
|
47
|
+
expect(parsed.source_duration).toBe(2)
|
|
48
|
+
expect(parsed.words).toHaveLength(2)
|
|
49
|
+
expect(parsed.words[0]).toHaveProperty('word', 'hello')
|
|
50
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TranscriptSegment } from '../../whispercpp-transcribe'
|
|
2
|
+
import { buildTranscriptWords } from '../jarvis-commands/parser'
|
|
3
|
+
import type { TranscriptJson, TranscriptWordWithIndex } from './types'
|
|
4
|
+
|
|
5
|
+
export function buildTranscriptWordsWithIndices(
|
|
6
|
+
segments: TranscriptSegment[],
|
|
7
|
+
): TranscriptWordWithIndex[] {
|
|
8
|
+
const words = buildTranscriptWords(segments)
|
|
9
|
+
return words.map((word, index) => ({
|
|
10
|
+
...word,
|
|
11
|
+
index,
|
|
12
|
+
}))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateTranscriptText(
|
|
16
|
+
words: TranscriptWordWithIndex[],
|
|
17
|
+
): string {
|
|
18
|
+
if (words.length === 0) {
|
|
19
|
+
return ''
|
|
20
|
+
}
|
|
21
|
+
return `${words.map((word) => word.word).join(' ')}\n`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generateTranscriptJson(options: {
|
|
25
|
+
sourceVideo: string
|
|
26
|
+
sourceDuration: number
|
|
27
|
+
words: TranscriptWordWithIndex[]
|
|
28
|
+
}): string {
|
|
29
|
+
const payload: TranscriptJson = {
|
|
30
|
+
version: 1,
|
|
31
|
+
source_video: options.sourceVideo,
|
|
32
|
+
source_duration: options.sourceDuration,
|
|
33
|
+
words: options.words,
|
|
34
|
+
}
|
|
35
|
+
return `${JSON.stringify(payload, null, 2)}\n`
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type TranscriptWordWithIndex = {
|
|
2
|
+
word: string
|
|
3
|
+
start: number
|
|
4
|
+
end: number
|
|
5
|
+
index: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type TranscriptJson = {
|
|
9
|
+
version: 1
|
|
10
|
+
source_video: string
|
|
11
|
+
source_duration: number
|
|
12
|
+
words: TranscriptWordWithIndex[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TranscriptMismatchType =
|
|
16
|
+
| 'word_added'
|
|
17
|
+
| 'word_modified'
|
|
18
|
+
| 'word_out_of_order'
|
|
19
|
+
|
|
20
|
+
export type TranscriptMismatchError = {
|
|
21
|
+
type: TranscriptMismatchType
|
|
22
|
+
position: number
|
|
23
|
+
originalWord?: string
|
|
24
|
+
editedWord?: string
|
|
25
|
+
message: string
|
|
26
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { extractChapterSegmentAccurate, concatSegments } from '../ffmpeg'
|
|
5
|
+
import { buildKeepRanges, mergeTimeRanges } from '../utils/time-ranges'
|
|
6
|
+
import { EDIT_CONFIG } from '../config'
|
|
7
|
+
import { diffTranscripts, validateEditedTranscript } from './transcript-diff'
|
|
8
|
+
import {
|
|
9
|
+
refineAllRemovalRanges,
|
|
10
|
+
wordsToTimeRanges,
|
|
11
|
+
} from './timestamp-refinement'
|
|
12
|
+
import type { TimeRange } from '../types'
|
|
13
|
+
import type { TranscriptJson, TranscriptWordWithIndex } from './types'
|
|
14
|
+
|
|
15
|
+
export interface EditVideoOptions {
|
|
16
|
+
inputPath: string
|
|
17
|
+
transcriptJsonPath: string
|
|
18
|
+
editedTextPath: string
|
|
19
|
+
outputPath: string
|
|
20
|
+
paddingMs?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EditVideoResult {
|
|
24
|
+
success: boolean
|
|
25
|
+
error?: string
|
|
26
|
+
outputPath?: string
|
|
27
|
+
removedWords: TranscriptWordWithIndex[]
|
|
28
|
+
removedRanges: TimeRange[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildEditedOutputPath(inputPath: string): string {
|
|
32
|
+
const parsed = path.parse(inputPath)
|
|
33
|
+
return path.join(parsed.dir, `${parsed.name}.edited${parsed.ext}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function editVideo(
|
|
37
|
+
options: EditVideoOptions,
|
|
38
|
+
): Promise<EditVideoResult> {
|
|
39
|
+
try {
|
|
40
|
+
const transcript = await readTranscriptJson(options.transcriptJsonPath)
|
|
41
|
+
const editedText = await Bun.file(options.editedTextPath).text()
|
|
42
|
+
const validation = validateEditedTranscript({
|
|
43
|
+
originalWords: transcript.words,
|
|
44
|
+
editedText,
|
|
45
|
+
})
|
|
46
|
+
if (!validation.valid) {
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
error: validation.error,
|
|
50
|
+
removedWords: [],
|
|
51
|
+
removedRanges: [],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const diffResult = diffTranscripts({
|
|
55
|
+
originalWords: transcript.words,
|
|
56
|
+
editedText,
|
|
57
|
+
})
|
|
58
|
+
if (!diffResult.success) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
error: diffResult.error,
|
|
62
|
+
removedWords: [],
|
|
63
|
+
removedRanges: [],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const removedWords = diffResult.removedWords
|
|
68
|
+
if (removedWords.length === 0) {
|
|
69
|
+
await ensureOutputCopy(options.inputPath, options.outputPath)
|
|
70
|
+
return {
|
|
71
|
+
success: true,
|
|
72
|
+
outputPath: options.outputPath,
|
|
73
|
+
removedWords,
|
|
74
|
+
removedRanges: [],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const removalRanges = wordsToTimeRanges(removedWords)
|
|
79
|
+
if (removalRanges.length === 0) {
|
|
80
|
+
await ensureOutputCopy(options.inputPath, options.outputPath)
|
|
81
|
+
return {
|
|
82
|
+
success: true,
|
|
83
|
+
outputPath: options.outputPath,
|
|
84
|
+
removedWords,
|
|
85
|
+
removedRanges: [],
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const refinedRanges = await refineAllRemovalRanges({
|
|
90
|
+
inputPath: options.inputPath,
|
|
91
|
+
duration: transcript.source_duration,
|
|
92
|
+
ranges: removalRanges,
|
|
93
|
+
paddingMs: options.paddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs,
|
|
94
|
+
})
|
|
95
|
+
const mergedRanges = mergeTimeRanges(
|
|
96
|
+
refinedRanges.map((range) => range.refined),
|
|
97
|
+
)
|
|
98
|
+
const keepRanges = buildKeepRanges(
|
|
99
|
+
0,
|
|
100
|
+
transcript.source_duration,
|
|
101
|
+
mergedRanges,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if (keepRanges.length === 0) {
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error:
|
|
108
|
+
'Edits remove the entire video. Regenerate the transcript and retry.',
|
|
109
|
+
removedWords,
|
|
110
|
+
removedRanges: mergedRanges,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await mkdir(path.dirname(options.outputPath), { recursive: true })
|
|
115
|
+
|
|
116
|
+
const isFullRange =
|
|
117
|
+
keepRanges.length === 1 &&
|
|
118
|
+
keepRanges[0] &&
|
|
119
|
+
keepRanges[0].start <= 0.001 &&
|
|
120
|
+
keepRanges[0].end >= transcript.source_duration - 0.001
|
|
121
|
+
if (isFullRange) {
|
|
122
|
+
await ensureOutputCopy(options.inputPath, options.outputPath)
|
|
123
|
+
return {
|
|
124
|
+
success: true,
|
|
125
|
+
outputPath: options.outputPath,
|
|
126
|
+
removedWords,
|
|
127
|
+
removedRanges: mergedRanges,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (keepRanges.length === 1 && keepRanges[0]) {
|
|
132
|
+
await extractChapterSegmentAccurate({
|
|
133
|
+
inputPath: options.inputPath,
|
|
134
|
+
outputPath: options.outputPath,
|
|
135
|
+
start: keepRanges[0].start,
|
|
136
|
+
end: keepRanges[0].end,
|
|
137
|
+
})
|
|
138
|
+
return {
|
|
139
|
+
success: true,
|
|
140
|
+
outputPath: options.outputPath,
|
|
141
|
+
removedWords,
|
|
142
|
+
removedRanges: mergedRanges,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-edit-'))
|
|
147
|
+
try {
|
|
148
|
+
const segmentPaths: string[] = []
|
|
149
|
+
for (const [index, range] of keepRanges.entries()) {
|
|
150
|
+
const segmentPath = path.join(tempDir, `segment-${index + 1}.mp4`)
|
|
151
|
+
await extractChapterSegmentAccurate({
|
|
152
|
+
inputPath: options.inputPath,
|
|
153
|
+
outputPath: segmentPath,
|
|
154
|
+
start: range.start,
|
|
155
|
+
end: range.end,
|
|
156
|
+
})
|
|
157
|
+
segmentPaths.push(segmentPath)
|
|
158
|
+
}
|
|
159
|
+
await concatSegments({
|
|
160
|
+
segmentPaths,
|
|
161
|
+
outputPath: options.outputPath,
|
|
162
|
+
})
|
|
163
|
+
return {
|
|
164
|
+
success: true,
|
|
165
|
+
outputPath: options.outputPath,
|
|
166
|
+
removedWords,
|
|
167
|
+
removedRanges: mergedRanges,
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
|
+
removedWords: [],
|
|
177
|
+
removedRanges: [],
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function ensureOutputCopy(inputPath: string, outputPath: string) {
|
|
183
|
+
const resolvedInput = path.resolve(inputPath)
|
|
184
|
+
const resolvedOutput = path.resolve(outputPath)
|
|
185
|
+
if (resolvedInput === resolvedOutput) {
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
await mkdir(path.dirname(outputPath), { recursive: true })
|
|
189
|
+
await copyFile(inputPath, outputPath)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function readTranscriptJson(
|
|
193
|
+
transcriptJsonPath: string,
|
|
194
|
+
): Promise<TranscriptJson> {
|
|
195
|
+
const raw = await Bun.file(transcriptJsonPath).text()
|
|
196
|
+
let parsed: unknown
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(raw)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Transcript JSON parse error: ${error instanceof Error ? error.message : String(error)}`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
205
|
+
throw new Error('Transcript JSON is not an object.')
|
|
206
|
+
}
|
|
207
|
+
const payload = parsed as TranscriptJson
|
|
208
|
+
if (payload.version !== 1) {
|
|
209
|
+
throw new Error('Unsupported transcript JSON version.')
|
|
210
|
+
}
|
|
211
|
+
if (!payload.source_video || typeof payload.source_video !== 'string') {
|
|
212
|
+
throw new Error('Transcript JSON missing source_video.')
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
typeof payload.source_duration !== 'number' ||
|
|
216
|
+
!Number.isFinite(payload.source_duration) ||
|
|
217
|
+
payload.source_duration <= 0
|
|
218
|
+
) {
|
|
219
|
+
throw new Error('Transcript JSON has invalid source_duration.')
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(payload.words)) {
|
|
222
|
+
throw new Error('Transcript JSON missing words array.')
|
|
223
|
+
}
|
|
224
|
+
const words: TranscriptWordWithIndex[] = payload.words.map((word, index) => {
|
|
225
|
+
if (!word || typeof word !== 'object') {
|
|
226
|
+
throw new Error(`Transcript JSON word ${index} is invalid.`)
|
|
227
|
+
}
|
|
228
|
+
const entry = word as TranscriptWordWithIndex
|
|
229
|
+
if (typeof entry.word !== 'string') {
|
|
230
|
+
throw new Error(`Transcript JSON word ${index} missing word.`)
|
|
231
|
+
}
|
|
232
|
+
if (typeof entry.start !== 'number' || typeof entry.end !== 'number') {
|
|
233
|
+
throw new Error(`Transcript JSON word ${index} missing timing.`)
|
|
234
|
+
}
|
|
235
|
+
if (typeof entry.index !== 'number') {
|
|
236
|
+
throw new Error(`Transcript JSON word ${index} missing index.`)
|
|
237
|
+
}
|
|
238
|
+
return entry
|
|
239
|
+
})
|
|
240
|
+
return {
|
|
241
|
+
version: 1,
|
|
242
|
+
source_video: payload.source_video,
|
|
243
|
+
source_duration: payload.source_duration,
|
|
244
|
+
words,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BadTakeError,
|
|
4
|
+
ChapterProcessingError,
|
|
5
|
+
ChapterTooShortError,
|
|
6
|
+
CommandParseError,
|
|
7
|
+
SpliceError,
|
|
8
|
+
TranscriptTooShortError,
|
|
9
|
+
TrimWindowError,
|
|
10
|
+
} from './errors'
|
|
11
|
+
|
|
12
|
+
test('ChapterProcessingError exposes metadata', () => {
|
|
13
|
+
const error = new ChapterProcessingError('Failed', 2, 'Intro')
|
|
14
|
+
expect(error.name).toBe('ChapterProcessingError')
|
|
15
|
+
expect(error.message).toBe('Failed')
|
|
16
|
+
expect(error.chapterIndex).toBe(2)
|
|
17
|
+
expect(error.chapterTitle).toBe('Intro')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('ChapterTooShortError formats message with duration', () => {
|
|
21
|
+
const error = new ChapterTooShortError(1, 'Basics', 4.1234, 5)
|
|
22
|
+
expect(error.name).toBe('ChapterTooShortError')
|
|
23
|
+
expect(error.message).toBe('Chapter "Basics" is too short (4.12s < 5s)')
|
|
24
|
+
expect(error.duration).toBe(4.1234)
|
|
25
|
+
expect(error.minDuration).toBe(5)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('CommandParseError stores transcript context', () => {
|
|
29
|
+
const error = new CommandParseError('Bad command', 'Jarvis bad take')
|
|
30
|
+
expect(error.name).toBe('CommandParseError')
|
|
31
|
+
expect(error.message).toBe('Bad command')
|
|
32
|
+
expect(error.transcript).toBe('Jarvis bad take')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('TranscriptTooShortError formats message with word count', () => {
|
|
36
|
+
const error = new TranscriptTooShortError(0, 'Intro', 5, 10)
|
|
37
|
+
expect(error.name).toBe('TranscriptTooShortError')
|
|
38
|
+
expect(error.message).toBe(
|
|
39
|
+
'Chapter "Intro" transcript too short (5 words < 10)',
|
|
40
|
+
)
|
|
41
|
+
expect(error.wordCount).toBe(5)
|
|
42
|
+
expect(error.minWordCount).toBe(10)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('BadTakeError sets name and message', () => {
|
|
46
|
+
const error = new BadTakeError(3, 'Outro')
|
|
47
|
+
expect(error.name).toBe('BadTakeError')
|
|
48
|
+
expect(error.message).toBe('Chapter "Outro" marked as bad take')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('SpliceError uses custom name', () => {
|
|
52
|
+
const error = new SpliceError('Failed to splice')
|
|
53
|
+
expect(error.name).toBe('SpliceError')
|
|
54
|
+
expect(error.message).toBe('Failed to splice')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('TrimWindowError formats message with precision', () => {
|
|
58
|
+
const error = new TrimWindowError(1.23456, 1.23999)
|
|
59
|
+
expect(error.name).toBe('TrimWindowError')
|
|
60
|
+
expect(error.message).toBe('Trim window too small (1.235s -> 1.240s)')
|
|
61
|
+
expect(error.start).toBe(1.23456)
|
|
62
|
+
expect(error.end).toBe(1.23999)
|
|
63
|
+
})
|