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.
@@ -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)