eprec 1.11.0 → 1.13.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.
@@ -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': [
67
+ 'Rendering preview clip',
68
+ 'Optimizing output',
69
+ 'Verifying',
70
+ ],
71
+ 'export-final': [
72
+ 'Rendering chapters',
73
+ 'Packaging exports',
74
+ 'Verifying outputs',
75
+ ],
76
+ }
77
+
78
+ const STEP_DELAY_MS = 850
79
+ const STEP_JITTER_MS = 350
80
+
81
+ let tasks: ProcessingTask[] = []
82
+ let activeTaskId: string | null = null
83
+ let nextTaskId = 1
84
+ let runController: AbortController | null = null
85
+ const listeners = new Set<QueueListener>()
86
+
87
+ function buildSnapshot(): ProcessingQueueSnapshot {
88
+ return {
89
+ tasks,
90
+ activeTaskId,
91
+ }
92
+ }
93
+
94
+ function emitSnapshot() {
95
+ const snapshot = buildSnapshot()
96
+ for (const listener of listeners) {
97
+ listener(snapshot)
98
+ }
99
+ }
100
+
101
+ function updateQueueState(mutate: () => void) {
102
+ mutate()
103
+ emitSnapshot()
104
+ }
105
+
106
+ function updateTask(taskId: string, patch: Partial<ProcessingTask>) {
107
+ updateQueueState(() => {
108
+ tasks = tasks.map((task) =>
109
+ task.id === taskId ? { ...task, ...patch, updatedAt: Date.now() } : task,
110
+ )
111
+ })
112
+ }
113
+
114
+ function enqueueTask(options: {
115
+ title: string
116
+ detail: string
117
+ category: ProcessingCategory
118
+ action: ProcessingAction
119
+ simulateError?: boolean
120
+ }) {
121
+ const task: ProcessingTask = {
122
+ id: `task-${nextTaskId++}`,
123
+ title: options.title,
124
+ detail: options.detail,
125
+ status: 'queued',
126
+ category: options.category,
127
+ action: options.action,
128
+ createdAt: Date.now(),
129
+ updatedAt: Date.now(),
130
+ simulateError: options.simulateError,
131
+ }
132
+ updateQueueState(() => {
133
+ tasks = [...tasks, task]
134
+ })
135
+ return task
136
+ }
137
+
138
+ function removeTask(taskId: string) {
139
+ updateQueueState(() => {
140
+ tasks = tasks.filter((task) => task.id !== taskId)
141
+ if (activeTaskId === taskId) {
142
+ activeTaskId = null
143
+ runController?.abort()
144
+ runController = null
145
+ }
146
+ })
147
+ }
148
+
149
+ function clearCompleted() {
150
+ updateQueueState(() => {
151
+ tasks = tasks.filter((task) => task.status !== 'done')
152
+ })
153
+ }
154
+
155
+ function markActiveDone() {
156
+ if (!activeTaskId) return
157
+ updateQueueState(() => {
158
+ tasks = tasks.map((task) =>
159
+ task.id === activeTaskId
160
+ ? {
161
+ ...task,
162
+ status: 'done',
163
+ progress: task.progress
164
+ ? { ...task.progress, percent: 100, label: 'Complete' }
165
+ : undefined,
166
+ updatedAt: Date.now(),
167
+ }
168
+ : task,
169
+ )
170
+ activeTaskId = null
171
+ runController?.abort()
172
+ runController = null
173
+ })
174
+ }
175
+
176
+ function buildProgress(step: number, totalSteps: number, label: string) {
177
+ const percent = totalSteps > 0 ? Math.round((step / totalSteps) * 100) : 0
178
+ return { step, totalSteps, label, percent }
179
+ }
180
+
181
+ function sleep(duration: number, signal?: AbortSignal) {
182
+ return new Promise<void>((resolve, reject) => {
183
+ const timeout = setTimeout(resolve, duration)
184
+ const onAbort = () => {
185
+ clearTimeout(timeout)
186
+ reject(new Error('aborted'))
187
+ }
188
+ if (signal) {
189
+ if (signal.aborted) {
190
+ onAbort()
191
+ return
192
+ }
193
+ signal.addEventListener('abort', onAbort, { once: true })
194
+ }
195
+ })
196
+ }
197
+
198
+ async function runTask(task: ProcessingTask) {
199
+ const steps = TASK_STEPS[task.action] ?? ['Starting', 'Working', 'Complete']
200
+ const controller = new AbortController()
201
+ runController = controller
202
+ updateQueueState(() => {
203
+ activeTaskId = task.id
204
+ tasks = tasks.map((entry) =>
205
+ entry.id === task.id
206
+ ? {
207
+ ...entry,
208
+ status: 'running',
209
+ progress: buildProgress(0, steps.length, 'Starting'),
210
+ errorMessage: undefined,
211
+ updatedAt: Date.now(),
212
+ }
213
+ : entry,
214
+ )
215
+ })
216
+
217
+ const failAtStep = task.simulateError
218
+ ? Math.max(1, Math.ceil(steps.length * 0.6))
219
+ : null
220
+
221
+ try {
222
+ for (let index = 0; index < steps.length; index++) {
223
+ if (controller.signal.aborted) return
224
+ const label = steps[index]
225
+ updateTask(task.id, {
226
+ progress: buildProgress(index + 1, steps.length, label),
227
+ })
228
+ if (failAtStep && index + 1 === failAtStep) {
229
+ throw new Error('Processing failed during render.')
230
+ }
231
+ const delay = 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
@@ -4,8 +4,10 @@ import '../app/config/init-env.ts'
4
4
  import getPort from 'get-port'
5
5
  import { getEnv } from '../app/config/env.ts'
6
6
  import { createAppRouter } from '../app/router.tsx'
7
+ import { handleTrimRequest } from '../app/trim-api.ts'
7
8
  import { handleVideoRequest } from '../app/video-api.ts'
8
9
  import { createBundlingRoutes } from '../server/bundling.ts'
10
+ import { handleProcessingQueueRequest } from '../server/processing-queue.ts'
9
11
 
10
12
  type AppServerOptions = {
11
13
  host?: string
@@ -196,6 +198,12 @@ function startServer(port: number, hostname: string) {
196
198
  if (url.pathname === '/api/video') {
197
199
  return await handleVideoRequest(request)
198
200
  }
201
+ if (url.pathname === '/api/trim') {
202
+ return await handleTrimRequest(request)
203
+ }
204
+ if (url.pathname.startsWith('/api/processing-queue')) {
205
+ return await handleProcessingQueueRequest(request)
206
+ }
199
207
  return await router.fetch(request)
200
208
  } catch (error) {
201
209
  console.error(error)
package/src/cli.ts CHANGED
@@ -169,17 +169,14 @@ async function main(rawArgs = hideBin(process.argv)) {
169
169
  afterLog: resumeActiveSpinner,
170
170
  })
171
171
  try {
172
- const result = await transcribeAudio(
173
- transcribeArgs.inputPath,
174
- {
175
- modelPath: transcribeArgs.modelPath,
176
- language: transcribeArgs.language,
177
- threads: transcribeArgs.threads,
178
- binaryPath: transcribeArgs.binaryPath,
179
- outputBasePath: transcribeArgs.outputBasePath,
180
- progress,
181
- },
182
- )
172
+ const result = await transcribeAudio(transcribeArgs.inputPath, {
173
+ modelPath: transcribeArgs.modelPath,
174
+ language: transcribeArgs.language,
175
+ threads: transcribeArgs.threads,
176
+ binaryPath: transcribeArgs.binaryPath,
177
+ outputBasePath: transcribeArgs.outputBasePath,
178
+ progress,
179
+ })
183
180
  resultText = result.text
184
181
  } finally {
185
182
  setLogHooks({})