eprec 1.10.2 → 1.12.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/app/assets/styles.css +43 -0
- package/app/client/editing-workspace.tsx +260 -78
- package/package.json +1 -1
- package/process-course/edits/cli.ts +59 -22
- package/process-course/edits/combined-video-editor.ts +23 -0
- package/process-course/edits/video-editor.ts +23 -0
- package/server/processing-queue.ts +441 -0
- package/src/app-server.ts +4 -0
- package/src/cli.ts +37 -12
- package/src/speech-detection.ts +31 -0
- package/src/whispercpp-transcribe.ts +14 -2
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
findSpeechStartWithRmsFallback,
|
|
15
15
|
} from '../utils/audio-analysis'
|
|
16
16
|
import { allocateJoinPadding } from '../utils/video-editing'
|
|
17
|
+
import type { StepProgressReporter } from '../../progress-reporter'
|
|
17
18
|
|
|
18
19
|
export interface CombineVideosOptions {
|
|
19
20
|
video1Path: string
|
|
@@ -26,6 +27,8 @@ export interface CombineVideosOptions {
|
|
|
26
27
|
video2Duration?: number
|
|
27
28
|
outputPath: string
|
|
28
29
|
overlapPaddingMs?: number
|
|
30
|
+
progress?: StepProgressReporter
|
|
31
|
+
editProgressFactory?: (detail: string) => StepProgressReporter | undefined
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export interface CombineVideosResult {
|
|
@@ -39,12 +42,17 @@ export interface CombineVideosResult {
|
|
|
39
42
|
export async function combineVideos(
|
|
40
43
|
options: CombineVideosOptions,
|
|
41
44
|
): Promise<CombineVideosResult> {
|
|
45
|
+
const progress = options.progress
|
|
46
|
+
const totalSteps = 5
|
|
47
|
+
progress?.start({ stepCount: totalSteps, label: 'Preparing edits' })
|
|
48
|
+
|
|
42
49
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-combine-'))
|
|
43
50
|
try {
|
|
44
51
|
const { video1Path, video2Path } = await applyOptionalEdits(
|
|
45
52
|
options,
|
|
46
53
|
tempDir,
|
|
47
54
|
)
|
|
55
|
+
progress?.step('Measuring durations')
|
|
48
56
|
const editsApplied =
|
|
49
57
|
options.video1EditedTextPath || options.video2EditedTextPath
|
|
50
58
|
const video1Duration = editsApplied
|
|
@@ -54,6 +62,8 @@ export async function combineVideos(
|
|
|
54
62
|
? await getMediaDurationSeconds(video2Path)
|
|
55
63
|
: (options.video2Duration ?? (await getMediaDurationSeconds(video2Path)))
|
|
56
64
|
|
|
65
|
+
progress?.step('Detecting speech')
|
|
66
|
+
progress?.setLabel('Checking first video')
|
|
57
67
|
const video1HasSpeech = await checkSegmentHasSpeech(
|
|
58
68
|
video1Path,
|
|
59
69
|
video1Duration,
|
|
@@ -67,6 +77,7 @@ export async function combineVideos(
|
|
|
67
77
|
}
|
|
68
78
|
}
|
|
69
79
|
|
|
80
|
+
progress?.setLabel('Finding first video speech end')
|
|
70
81
|
const paddingSeconds =
|
|
71
82
|
(options.overlapPaddingMs ?? EDIT_CONFIG.speechBoundaryPaddingMs) / 1000
|
|
72
83
|
|
|
@@ -74,6 +85,7 @@ export async function combineVideos(
|
|
|
74
85
|
inputPath: video1Path,
|
|
75
86
|
duration: video1Duration,
|
|
76
87
|
})
|
|
88
|
+
progress?.setLabel('Finding second video speech bounds')
|
|
77
89
|
const { speechStart: video2SpeechStart, speechEnd: video2SpeechEnd } =
|
|
78
90
|
await findVideo2SpeechBounds({
|
|
79
91
|
inputPath: video2Path,
|
|
@@ -103,8 +115,10 @@ export async function combineVideos(
|
|
|
103
115
|
video2Duration,
|
|
104
116
|
)
|
|
105
117
|
|
|
118
|
+
progress?.step('Trimming segments')
|
|
106
119
|
const segment1Path = path.join(tempDir, 'segment-1.mp4')
|
|
107
120
|
const segment2Path = path.join(tempDir, 'segment-2.mp4')
|
|
121
|
+
progress?.setLabel('Extracting segment 1/2')
|
|
108
122
|
await extractChapterSegmentAccurate({
|
|
109
123
|
inputPath: video1Path,
|
|
110
124
|
outputPath: segment1Path,
|
|
@@ -119,6 +133,7 @@ export async function combineVideos(
|
|
|
119
133
|
video2TrimStart,
|
|
120
134
|
}
|
|
121
135
|
}
|
|
136
|
+
progress?.setLabel('Extracting segment 2/2')
|
|
122
137
|
await extractChapterSegmentAccurate({
|
|
123
138
|
inputPath: video2Path,
|
|
124
139
|
outputPath: segment2Path,
|
|
@@ -126,6 +141,7 @@ export async function combineVideos(
|
|
|
126
141
|
end: video2TrimEnd,
|
|
127
142
|
})
|
|
128
143
|
|
|
144
|
+
progress?.setLabel('Validating trimmed speech')
|
|
129
145
|
const segment2HasSpeech = await checkSegmentHasSpeech(
|
|
130
146
|
segment2Path,
|
|
131
147
|
video2TrimEnd - video2TrimStart,
|
|
@@ -139,6 +155,7 @@ export async function combineVideos(
|
|
|
139
155
|
}
|
|
140
156
|
}
|
|
141
157
|
|
|
158
|
+
progress?.step('Combining output')
|
|
142
159
|
const resolvedOutputPath = await resolveOutputPath(
|
|
143
160
|
options.outputPath,
|
|
144
161
|
video1Path,
|
|
@@ -151,6 +168,7 @@ export async function combineVideos(
|
|
|
151
168
|
outputPath: resolvedOutputPath,
|
|
152
169
|
})
|
|
153
170
|
await finalizeOutput(resolvedOutputPath, options.outputPath)
|
|
171
|
+
progress?.finish('Complete')
|
|
154
172
|
|
|
155
173
|
return {
|
|
156
174
|
success: true,
|
|
@@ -176,18 +194,21 @@ async function applyOptionalEdits(
|
|
|
176
194
|
): Promise<{ video1Path: string; video2Path: string }> {
|
|
177
195
|
let video1Path = options.video1Path
|
|
178
196
|
let video2Path = options.video2Path
|
|
197
|
+
const editProgressFactory = options.editProgressFactory
|
|
179
198
|
|
|
180
199
|
if (options.video1EditedTextPath) {
|
|
181
200
|
if (!options.video1TranscriptJsonPath) {
|
|
182
201
|
throw new Error('Missing transcript JSON for first video edits.')
|
|
183
202
|
}
|
|
184
203
|
const editedPath = path.join(tempDir, 'video1-edited.mp4')
|
|
204
|
+
const progress = editProgressFactory?.('Edit first video')
|
|
185
205
|
const result = await editVideo({
|
|
186
206
|
inputPath: options.video1Path,
|
|
187
207
|
transcriptJsonPath: options.video1TranscriptJsonPath,
|
|
188
208
|
editedTextPath: options.video1EditedTextPath,
|
|
189
209
|
outputPath: editedPath,
|
|
190
210
|
paddingMs: options.overlapPaddingMs,
|
|
211
|
+
progress,
|
|
191
212
|
})
|
|
192
213
|
if (!result.success) {
|
|
193
214
|
throw new Error(result.error ?? 'Failed to edit first video.')
|
|
@@ -200,12 +221,14 @@ async function applyOptionalEdits(
|
|
|
200
221
|
throw new Error('Missing transcript JSON for second video edits.')
|
|
201
222
|
}
|
|
202
223
|
const editedPath = path.join(tempDir, 'video2-edited.mp4')
|
|
224
|
+
const progress = editProgressFactory?.('Edit second video')
|
|
203
225
|
const result = await editVideo({
|
|
204
226
|
inputPath: options.video2Path,
|
|
205
227
|
transcriptJsonPath: options.video2TranscriptJsonPath,
|
|
206
228
|
editedTextPath: options.video2EditedTextPath,
|
|
207
229
|
outputPath: editedPath,
|
|
208
230
|
paddingMs: options.overlapPaddingMs,
|
|
231
|
+
progress,
|
|
209
232
|
})
|
|
210
233
|
if (!result.success) {
|
|
211
234
|
throw new Error(result.error ?? 'Failed to edit second video.')
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './timestamp-refinement'
|
|
12
12
|
import type { TimeRange } from '../types'
|
|
13
13
|
import type { TranscriptJson, TranscriptWordWithIndex } from './types'
|
|
14
|
+
import type { StepProgressReporter } from '../../progress-reporter'
|
|
14
15
|
|
|
15
16
|
export interface EditVideoOptions {
|
|
16
17
|
inputPath: string
|
|
@@ -18,6 +19,7 @@ export interface EditVideoOptions {
|
|
|
18
19
|
editedTextPath: string
|
|
19
20
|
outputPath: string
|
|
20
21
|
paddingMs?: number
|
|
22
|
+
progress?: StepProgressReporter
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface EditVideoResult {
|
|
@@ -37,8 +39,13 @@ export async function editVideo(
|
|
|
37
39
|
options: EditVideoOptions,
|
|
38
40
|
): Promise<EditVideoResult> {
|
|
39
41
|
try {
|
|
42
|
+
const progress = options.progress
|
|
43
|
+
const totalSteps = 5
|
|
44
|
+
progress?.start({ stepCount: totalSteps, label: 'Loading transcript' })
|
|
45
|
+
|
|
40
46
|
const transcript = await readTranscriptJson(options.transcriptJsonPath)
|
|
41
47
|
const editedText = await Bun.file(options.editedTextPath).text()
|
|
48
|
+
progress?.step('Validating edits')
|
|
42
49
|
const validation = validateEditedTranscript({
|
|
43
50
|
originalWords: transcript.words,
|
|
44
51
|
editedText,
|
|
@@ -51,6 +58,7 @@ export async function editVideo(
|
|
|
51
58
|
removedRanges: [],
|
|
52
59
|
}
|
|
53
60
|
}
|
|
61
|
+
progress?.step('Diffing transcript')
|
|
54
62
|
const diffResult = diffTranscripts({
|
|
55
63
|
originalWords: transcript.words,
|
|
56
64
|
editedText,
|
|
@@ -64,9 +72,12 @@ export async function editVideo(
|
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
progress?.step('Planning edits')
|
|
67
76
|
const removedWords = diffResult.removedWords
|
|
68
77
|
if (removedWords.length === 0) {
|
|
78
|
+
progress?.step('Rendering output')
|
|
69
79
|
await ensureOutputCopy(options.inputPath, options.outputPath)
|
|
80
|
+
progress?.finish('No edits')
|
|
70
81
|
return {
|
|
71
82
|
success: true,
|
|
72
83
|
outputPath: options.outputPath,
|
|
@@ -77,7 +88,9 @@ export async function editVideo(
|
|
|
77
88
|
|
|
78
89
|
const removalRanges = wordsToTimeRanges(removedWords)
|
|
79
90
|
if (removalRanges.length === 0) {
|
|
91
|
+
progress?.step('Rendering output')
|
|
80
92
|
await ensureOutputCopy(options.inputPath, options.outputPath)
|
|
93
|
+
progress?.finish('No ranges')
|
|
81
94
|
return {
|
|
82
95
|
success: true,
|
|
83
96
|
outputPath: options.outputPath,
|
|
@@ -86,6 +99,7 @@ export async function editVideo(
|
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
|
|
102
|
+
progress?.setLabel('Refining ranges')
|
|
89
103
|
const refinedRanges = await refineAllRemovalRanges({
|
|
90
104
|
inputPath: options.inputPath,
|
|
91
105
|
duration: transcript.source_duration,
|
|
@@ -111,6 +125,7 @@ export async function editVideo(
|
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
128
|
+
progress?.step('Rendering output')
|
|
114
129
|
await mkdir(path.dirname(options.outputPath), { recursive: true })
|
|
115
130
|
|
|
116
131
|
const isFullRange =
|
|
@@ -120,6 +135,7 @@ export async function editVideo(
|
|
|
120
135
|
keepRanges[0].end >= transcript.source_duration - 0.001
|
|
121
136
|
if (isFullRange) {
|
|
122
137
|
await ensureOutputCopy(options.inputPath, options.outputPath)
|
|
138
|
+
progress?.finish('Complete')
|
|
123
139
|
return {
|
|
124
140
|
success: true,
|
|
125
141
|
outputPath: options.outputPath,
|
|
@@ -129,12 +145,14 @@ export async function editVideo(
|
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
if (keepRanges.length === 1 && keepRanges[0]) {
|
|
148
|
+
progress?.setLabel('Extracting segment')
|
|
132
149
|
await extractChapterSegmentAccurate({
|
|
133
150
|
inputPath: options.inputPath,
|
|
134
151
|
outputPath: options.outputPath,
|
|
135
152
|
start: keepRanges[0].start,
|
|
136
153
|
end: keepRanges[0].end,
|
|
137
154
|
})
|
|
155
|
+
progress?.finish('Complete')
|
|
138
156
|
return {
|
|
139
157
|
success: true,
|
|
140
158
|
outputPath: options.outputPath,
|
|
@@ -147,6 +165,9 @@ export async function editVideo(
|
|
|
147
165
|
try {
|
|
148
166
|
const segmentPaths: string[] = []
|
|
149
167
|
for (const [index, range] of keepRanges.entries()) {
|
|
168
|
+
progress?.setLabel(
|
|
169
|
+
`Extracting segment ${index + 1}/${keepRanges.length}`,
|
|
170
|
+
)
|
|
150
171
|
const segmentPath = path.join(tempDir, `segment-${index + 1}.mp4`)
|
|
151
172
|
await extractChapterSegmentAccurate({
|
|
152
173
|
inputPath: options.inputPath,
|
|
@@ -156,10 +177,12 @@ export async function editVideo(
|
|
|
156
177
|
})
|
|
157
178
|
segmentPaths.push(segmentPath)
|
|
158
179
|
}
|
|
180
|
+
progress?.setLabel('Concatenating segments')
|
|
159
181
|
await concatSegments({
|
|
160
182
|
segmentPaths,
|
|
161
183
|
outputPath: options.outputPath,
|
|
162
184
|
})
|
|
185
|
+
progress?.finish('Complete')
|
|
163
186
|
return {
|
|
164
187
|
success: true,
|
|
165
188
|
outputPath: options.outputPath,
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
type ProcessingCategory = 'chapter' | 'transcript' | 'export'
|
|
2
|
+
type ProcessingStatus = 'queued' | 'running' | 'done' | 'error'
|
|
3
|
+
type ProcessingAction =
|
|
4
|
+
| 'edit-chapter'
|
|
5
|
+
| 'combine-chapters'
|
|
6
|
+
| 'regenerate-transcript'
|
|
7
|
+
| 'detect-command-windows'
|
|
8
|
+
| 'render-preview'
|
|
9
|
+
| 'export-final'
|
|
10
|
+
|
|
11
|
+
type ProcessingProgress = {
|
|
12
|
+
step: number
|
|
13
|
+
totalSteps: number
|
|
14
|
+
label: string
|
|
15
|
+
percent: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ProcessingTask = {
|
|
19
|
+
id: string
|
|
20
|
+
title: string
|
|
21
|
+
detail: string
|
|
22
|
+
status: ProcessingStatus
|
|
23
|
+
category: ProcessingCategory
|
|
24
|
+
action: ProcessingAction
|
|
25
|
+
progress?: ProcessingProgress
|
|
26
|
+
errorMessage?: string
|
|
27
|
+
updatedAt: number
|
|
28
|
+
createdAt: number
|
|
29
|
+
simulateError?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ProcessingQueueSnapshot = {
|
|
33
|
+
tasks: ProcessingTask[]
|
|
34
|
+
activeTaskId: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type QueueListener = (snapshot: ProcessingQueueSnapshot) => void
|
|
38
|
+
|
|
39
|
+
const QUEUE_HEADERS = {
|
|
40
|
+
'Access-Control-Allow-Origin': '*',
|
|
41
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
42
|
+
'Access-Control-Allow-Headers': 'Accept, Content-Type',
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
const TASK_STEPS: Record<ProcessingAction, string[]> = {
|
|
46
|
+
'edit-chapter': [
|
|
47
|
+
'Collecting edit ranges',
|
|
48
|
+
'Updating cut list',
|
|
49
|
+
'Preparing edit workspace',
|
|
50
|
+
],
|
|
51
|
+
'combine-chapters': [
|
|
52
|
+
'Loading chapter outputs',
|
|
53
|
+
'Aligning audio padding',
|
|
54
|
+
'Rendering combined preview',
|
|
55
|
+
],
|
|
56
|
+
'regenerate-transcript': [
|
|
57
|
+
'Extracting audio',
|
|
58
|
+
'Running Whisper alignment',
|
|
59
|
+
'Refreshing transcript cues',
|
|
60
|
+
],
|
|
61
|
+
'detect-command-windows': [
|
|
62
|
+
'Scanning transcript markers',
|
|
63
|
+
'Refining command windows',
|
|
64
|
+
'Updating cut ranges',
|
|
65
|
+
],
|
|
66
|
+
'render-preview': ['Rendering preview clip', 'Optimizing output', 'Verifying'],
|
|
67
|
+
'export-final': [
|
|
68
|
+
'Rendering chapters',
|
|
69
|
+
'Packaging exports',
|
|
70
|
+
'Verifying outputs',
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const STEP_DELAY_MS = 850
|
|
75
|
+
const STEP_JITTER_MS = 350
|
|
76
|
+
|
|
77
|
+
let tasks: ProcessingTask[] = []
|
|
78
|
+
let activeTaskId: string | null = null
|
|
79
|
+
let nextTaskId = 1
|
|
80
|
+
let runController: AbortController | null = null
|
|
81
|
+
const listeners = new Set<QueueListener>()
|
|
82
|
+
|
|
83
|
+
function buildSnapshot(): ProcessingQueueSnapshot {
|
|
84
|
+
return {
|
|
85
|
+
tasks,
|
|
86
|
+
activeTaskId,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emitSnapshot() {
|
|
91
|
+
const snapshot = buildSnapshot()
|
|
92
|
+
for (const listener of listeners) {
|
|
93
|
+
listener(snapshot)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function updateQueueState(mutate: () => void) {
|
|
98
|
+
mutate()
|
|
99
|
+
emitSnapshot()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function updateTask(taskId: string, patch: Partial<ProcessingTask>) {
|
|
103
|
+
updateQueueState(() => {
|
|
104
|
+
tasks = tasks.map((task) =>
|
|
105
|
+
task.id === taskId
|
|
106
|
+
? { ...task, ...patch, updatedAt: Date.now() }
|
|
107
|
+
: task,
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function enqueueTask(options: {
|
|
113
|
+
title: string
|
|
114
|
+
detail: string
|
|
115
|
+
category: ProcessingCategory
|
|
116
|
+
action: ProcessingAction
|
|
117
|
+
simulateError?: boolean
|
|
118
|
+
}) {
|
|
119
|
+
const task: ProcessingTask = {
|
|
120
|
+
id: `task-${nextTaskId++}`,
|
|
121
|
+
title: options.title,
|
|
122
|
+
detail: options.detail,
|
|
123
|
+
status: 'queued',
|
|
124
|
+
category: options.category,
|
|
125
|
+
action: options.action,
|
|
126
|
+
createdAt: Date.now(),
|
|
127
|
+
updatedAt: Date.now(),
|
|
128
|
+
simulateError: options.simulateError,
|
|
129
|
+
}
|
|
130
|
+
updateQueueState(() => {
|
|
131
|
+
tasks = [...tasks, task]
|
|
132
|
+
})
|
|
133
|
+
return task
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function removeTask(taskId: string) {
|
|
137
|
+
updateQueueState(() => {
|
|
138
|
+
tasks = tasks.filter((task) => task.id !== taskId)
|
|
139
|
+
if (activeTaskId === taskId) {
|
|
140
|
+
activeTaskId = null
|
|
141
|
+
runController?.abort()
|
|
142
|
+
runController = null
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clearCompleted() {
|
|
148
|
+
updateQueueState(() => {
|
|
149
|
+
tasks = tasks.filter((task) => task.status !== 'done')
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function markActiveDone() {
|
|
154
|
+
if (!activeTaskId) return
|
|
155
|
+
updateQueueState(() => {
|
|
156
|
+
tasks = tasks.map((task) =>
|
|
157
|
+
task.id === activeTaskId
|
|
158
|
+
? {
|
|
159
|
+
...task,
|
|
160
|
+
status: 'done',
|
|
161
|
+
progress: task.progress
|
|
162
|
+
? { ...task.progress, percent: 100, label: 'Complete' }
|
|
163
|
+
: undefined,
|
|
164
|
+
updatedAt: Date.now(),
|
|
165
|
+
}
|
|
166
|
+
: task,
|
|
167
|
+
)
|
|
168
|
+
activeTaskId = null
|
|
169
|
+
runController?.abort()
|
|
170
|
+
runController = null
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildProgress(step: number, totalSteps: number, label: string) {
|
|
175
|
+
const percent =
|
|
176
|
+
totalSteps > 0 ? Math.round((step / totalSteps) * 100) : 0
|
|
177
|
+
return { step, totalSteps, label, percent }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sleep(duration: number, signal?: AbortSignal) {
|
|
181
|
+
return new Promise<void>((resolve, reject) => {
|
|
182
|
+
const timeout = setTimeout(resolve, duration)
|
|
183
|
+
const onAbort = () => {
|
|
184
|
+
clearTimeout(timeout)
|
|
185
|
+
reject(new Error('aborted'))
|
|
186
|
+
}
|
|
187
|
+
if (signal) {
|
|
188
|
+
if (signal.aborted) {
|
|
189
|
+
onAbort()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function runTask(task: ProcessingTask) {
|
|
198
|
+
const steps = TASK_STEPS[task.action] ?? ['Starting', 'Working', 'Complete']
|
|
199
|
+
const controller = new AbortController()
|
|
200
|
+
runController = controller
|
|
201
|
+
updateQueueState(() => {
|
|
202
|
+
activeTaskId = task.id
|
|
203
|
+
tasks = tasks.map((entry) =>
|
|
204
|
+
entry.id === task.id
|
|
205
|
+
? {
|
|
206
|
+
...entry,
|
|
207
|
+
status: 'running',
|
|
208
|
+
progress: buildProgress(0, steps.length, 'Starting'),
|
|
209
|
+
errorMessage: undefined,
|
|
210
|
+
updatedAt: Date.now(),
|
|
211
|
+
}
|
|
212
|
+
: entry,
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const failAtStep = task.simulateError
|
|
217
|
+
? Math.max(1, Math.ceil(steps.length * 0.6))
|
|
218
|
+
: null
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
for (let index = 0; index < steps.length; index++) {
|
|
222
|
+
if (controller.signal.aborted) return
|
|
223
|
+
const label = steps[index]
|
|
224
|
+
updateTask(task.id, {
|
|
225
|
+
progress: buildProgress(index + 1, steps.length, label),
|
|
226
|
+
})
|
|
227
|
+
if (failAtStep && index + 1 === failAtStep) {
|
|
228
|
+
throw new Error('Processing failed during render.')
|
|
229
|
+
}
|
|
230
|
+
const delay =
|
|
231
|
+
STEP_DELAY_MS + Math.round(Math.random() * STEP_JITTER_MS)
|
|
232
|
+
await sleep(delay, controller.signal)
|
|
233
|
+
}
|
|
234
|
+
updateTask(task.id, {
|
|
235
|
+
status: 'done',
|
|
236
|
+
progress: buildProgress(steps.length, steps.length, 'Complete'),
|
|
237
|
+
})
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (controller.signal.aborted) return
|
|
240
|
+
updateTask(task.id, {
|
|
241
|
+
status: 'error',
|
|
242
|
+
errorMessage:
|
|
243
|
+
error instanceof Error ? error.message : 'Processing failed.',
|
|
244
|
+
})
|
|
245
|
+
} finally {
|
|
246
|
+
runController = null
|
|
247
|
+
updateQueueState(() => {
|
|
248
|
+
if (activeTaskId === task.id) {
|
|
249
|
+
activeTaskId = null
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function runNextTask() {
|
|
256
|
+
if (activeTaskId) return
|
|
257
|
+
const nextTask = tasks.find((task) => task.status === 'queued')
|
|
258
|
+
if (!nextTask) return
|
|
259
|
+
void runTask(nextTask)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function jsonResponse(payload: unknown, status = 200) {
|
|
263
|
+
return new Response(JSON.stringify(payload), {
|
|
264
|
+
status,
|
|
265
|
+
headers: {
|
|
266
|
+
'Content-Type': 'application/json',
|
|
267
|
+
...QUEUE_HEADERS,
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createEventStream(request: Request) {
|
|
273
|
+
const encoder = new TextEncoder()
|
|
274
|
+
return new Response(
|
|
275
|
+
new ReadableStream({
|
|
276
|
+
start(controller) {
|
|
277
|
+
let isClosed = false
|
|
278
|
+
const send = (event: string, data: unknown) => {
|
|
279
|
+
if (isClosed) return
|
|
280
|
+
controller.enqueue(
|
|
281
|
+
encoder.encode(
|
|
282
|
+
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
const listener = (snapshot: ProcessingQueueSnapshot) => {
|
|
287
|
+
send('snapshot', snapshot)
|
|
288
|
+
}
|
|
289
|
+
listeners.add(listener)
|
|
290
|
+
send('snapshot', buildSnapshot())
|
|
291
|
+
const ping = setInterval(() => {
|
|
292
|
+
send('ping', { time: Date.now() })
|
|
293
|
+
}, 15000)
|
|
294
|
+
const close = () => {
|
|
295
|
+
if (isClosed) return
|
|
296
|
+
isClosed = true
|
|
297
|
+
clearInterval(ping)
|
|
298
|
+
listeners.delete(listener)
|
|
299
|
+
controller.close()
|
|
300
|
+
}
|
|
301
|
+
request.signal.addEventListener('abort', close)
|
|
302
|
+
},
|
|
303
|
+
cancel() {
|
|
304
|
+
// handled via abort
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
{
|
|
308
|
+
headers: {
|
|
309
|
+
'Content-Type': 'text/event-stream',
|
|
310
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
311
|
+
Connection: 'keep-alive',
|
|
312
|
+
...QUEUE_HEADERS,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isProcessingCategory(value: unknown): value is ProcessingCategory {
|
|
319
|
+
return value === 'chapter' || value === 'transcript' || value === 'export'
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isProcessingAction(value: unknown): value is ProcessingAction {
|
|
323
|
+
return (
|
|
324
|
+
value === 'edit-chapter' ||
|
|
325
|
+
value === 'combine-chapters' ||
|
|
326
|
+
value === 'regenerate-transcript' ||
|
|
327
|
+
value === 'detect-command-windows' ||
|
|
328
|
+
value === 'render-preview' ||
|
|
329
|
+
value === 'export-final'
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function handleProcessingQueueRequest(request: Request) {
|
|
334
|
+
if (request.method === 'OPTIONS') {
|
|
335
|
+
return new Response(null, { status: 204, headers: QUEUE_HEADERS })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const url = new URL(request.url)
|
|
339
|
+
const pathname = url.pathname
|
|
340
|
+
|
|
341
|
+
if (pathname === '/api/processing-queue/stream') {
|
|
342
|
+
if (request.method !== 'GET') {
|
|
343
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
344
|
+
}
|
|
345
|
+
return createEventStream(request)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (pathname === '/api/processing-queue') {
|
|
349
|
+
if (request.method !== 'GET') {
|
|
350
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
351
|
+
}
|
|
352
|
+
return jsonResponse(buildSnapshot())
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (pathname === '/api/processing-queue/enqueue') {
|
|
356
|
+
if (request.method !== 'POST') {
|
|
357
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
358
|
+
}
|
|
359
|
+
let payload: unknown = null
|
|
360
|
+
try {
|
|
361
|
+
payload = await request.json()
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return jsonResponse({ error: 'Invalid JSON payload.' }, 400)
|
|
364
|
+
}
|
|
365
|
+
if (
|
|
366
|
+
!payload ||
|
|
367
|
+
typeof payload !== 'object' ||
|
|
368
|
+
!('title' in payload) ||
|
|
369
|
+
!('detail' in payload) ||
|
|
370
|
+
!('category' in payload) ||
|
|
371
|
+
!('action' in payload)
|
|
372
|
+
) {
|
|
373
|
+
return jsonResponse({ error: 'Missing task fields.' }, 400)
|
|
374
|
+
}
|
|
375
|
+
const data = payload as {
|
|
376
|
+
title?: unknown
|
|
377
|
+
detail?: unknown
|
|
378
|
+
category?: unknown
|
|
379
|
+
action?: unknown
|
|
380
|
+
simulateError?: unknown
|
|
381
|
+
}
|
|
382
|
+
if (typeof data.title !== 'string' || data.title.trim().length === 0) {
|
|
383
|
+
return jsonResponse({ error: 'Task title is required.' }, 400)
|
|
384
|
+
}
|
|
385
|
+
if (typeof data.detail !== 'string') {
|
|
386
|
+
return jsonResponse({ error: 'Task detail is required.' }, 400)
|
|
387
|
+
}
|
|
388
|
+
if (!isProcessingCategory(data.category)) {
|
|
389
|
+
return jsonResponse({ error: 'Invalid task category.' }, 400)
|
|
390
|
+
}
|
|
391
|
+
if (!isProcessingAction(data.action)) {
|
|
392
|
+
return jsonResponse({ error: 'Invalid task action.' }, 400)
|
|
393
|
+
}
|
|
394
|
+
enqueueTask({
|
|
395
|
+
title: data.title,
|
|
396
|
+
detail: data.detail,
|
|
397
|
+
category: data.category,
|
|
398
|
+
action: data.action,
|
|
399
|
+
simulateError: data.simulateError === true,
|
|
400
|
+
})
|
|
401
|
+
return jsonResponse(buildSnapshot())
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (pathname === '/api/processing-queue/run-next') {
|
|
405
|
+
if (request.method !== 'POST') {
|
|
406
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
407
|
+
}
|
|
408
|
+
runNextTask()
|
|
409
|
+
return jsonResponse(buildSnapshot())
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (pathname === '/api/processing-queue/mark-done') {
|
|
413
|
+
if (request.method !== 'POST') {
|
|
414
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
415
|
+
}
|
|
416
|
+
markActiveDone()
|
|
417
|
+
return jsonResponse(buildSnapshot())
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (pathname === '/api/processing-queue/clear-completed') {
|
|
421
|
+
if (request.method !== 'POST') {
|
|
422
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
423
|
+
}
|
|
424
|
+
clearCompleted()
|
|
425
|
+
return jsonResponse(buildSnapshot())
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (pathname.startsWith('/api/processing-queue/task/')) {
|
|
429
|
+
if (request.method !== 'DELETE') {
|
|
430
|
+
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
431
|
+
}
|
|
432
|
+
const taskId = pathname.replace('/api/processing-queue/task/', '')
|
|
433
|
+
if (!taskId) {
|
|
434
|
+
return jsonResponse({ error: 'Task id is required.' }, 400)
|
|
435
|
+
}
|
|
436
|
+
removeTask(taskId)
|
|
437
|
+
return jsonResponse(buildSnapshot())
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return jsonResponse({ error: 'Not found' }, 404)
|
|
441
|
+
}
|
package/src/app-server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getEnv } from '../app/config/env.ts'
|
|
|
6
6
|
import { createAppRouter } from '../app/router.tsx'
|
|
7
7
|
import { handleVideoRequest } from '../app/video-api.ts'
|
|
8
8
|
import { createBundlingRoutes } from '../server/bundling.ts'
|
|
9
|
+
import { handleProcessingQueueRequest } from '../server/processing-queue.ts'
|
|
9
10
|
|
|
10
11
|
type AppServerOptions = {
|
|
11
12
|
host?: string
|
|
@@ -196,6 +197,9 @@ function startServer(port: number, hostname: string) {
|
|
|
196
197
|
if (url.pathname === '/api/video') {
|
|
197
198
|
return await handleVideoRequest(request)
|
|
198
199
|
}
|
|
200
|
+
if (url.pathname.startsWith('/api/processing-queue')) {
|
|
201
|
+
return await handleProcessingQueueRequest(request)
|
|
202
|
+
}
|
|
199
203
|
return await router.fetch(request)
|
|
200
204
|
} catch (error) {
|
|
201
205
|
console.error(error)
|