eprec 1.11.0 → 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.
@@ -460,6 +460,40 @@ p {
460
460
  padding: 0 var(--spacing-md) var(--spacing-md);
461
461
  }
462
462
 
463
+ .processing-progress {
464
+ display: flex;
465
+ flex-direction: column;
466
+ gap: var(--spacing-xs);
467
+ padding: 0 var(--spacing-md) var(--spacing-sm);
468
+ }
469
+
470
+ .processing-progress-meta {
471
+ display: flex;
472
+ align-items: center;
473
+ justify-content: space-between;
474
+ font-size: var(--font-size-xs);
475
+ color: var(--color-text-muted);
476
+ }
477
+
478
+ .processing-progress-bar {
479
+ height: 6px;
480
+ border-radius: var(--radius-pill);
481
+ background: var(--color-border-muted);
482
+ overflow: hidden;
483
+ }
484
+
485
+ .processing-progress-fill {
486
+ display: block;
487
+ height: 100%;
488
+ width: var(--progress, 0%);
489
+ background: var(--color-primary);
490
+ transition: width var(--transition-normal);
491
+ }
492
+
493
+ .processing-error {
494
+ margin: 0 var(--spacing-md) var(--spacing-sm);
495
+ }
496
+
463
497
  .processing-row.is-running {
464
498
  box-shadow: inset 0 0 0 1px var(--color-warning-border);
465
499
  }
@@ -468,6 +502,15 @@ p {
468
502
  background: var(--color-success-surface);
469
503
  }
470
504
 
505
+ .processing-row.is-error {
506
+ background: var(--color-danger-surface);
507
+ border-color: var(--color-danger-border-strong);
508
+ }
509
+
510
+ .processing-row.is-error .processing-progress-fill {
511
+ background: var(--color-danger-border);
512
+ }
513
+
471
514
  .timeline-card {
472
515
  gap: var(--spacing-xl);
473
516
  }
@@ -23,6 +23,8 @@ const MIN_CUT_LENGTH = 0.2
23
23
  const DEFAULT_CUT_LENGTH = 2.4
24
24
  const PLAYHEAD_STEP = 0.1
25
25
  const DEFAULT_PREVIEW_URL = '/e2e-test.mp4'
26
+ const QUEUE_API_BASE = '/api/processing-queue'
27
+ const QUEUE_STREAM_URL = `${QUEUE_API_BASE}/stream`
26
28
 
27
29
  function readInitialVideoPath() {
28
30
  if (typeof window === 'undefined') return ''
@@ -42,14 +44,36 @@ function extractVideoName(value: string) {
42
44
  return last && last.length > 0 ? last : value
43
45
  }
44
46
 
45
- type ProcessingStatus = 'queued' | 'running' | 'done'
47
+ type ProcessingStatus = 'queued' | 'running' | 'done' | 'error'
46
48
  type ProcessingCategory = 'chapter' | 'transcript' | 'export'
49
+ type ProcessingAction =
50
+ | 'edit-chapter'
51
+ | 'combine-chapters'
52
+ | 'regenerate-transcript'
53
+ | 'detect-command-windows'
54
+ | 'render-preview'
55
+ | 'export-final'
56
+ type ProcessingProgress = {
57
+ step: number
58
+ totalSteps: number
59
+ label: string
60
+ percent: number
61
+ }
47
62
  type ProcessingTask = {
48
63
  id: string
49
64
  title: string
50
65
  detail: string
51
66
  status: ProcessingStatus
52
67
  category: ProcessingCategory
68
+ action: ProcessingAction
69
+ progress?: ProcessingProgress
70
+ errorMessage?: string
71
+ updatedAt: number
72
+ createdAt: number
73
+ }
74
+ type ProcessingQueueSnapshot = {
75
+ tasks: ProcessingTask[]
76
+ activeTaskId: string | null
53
77
  }
54
78
 
55
79
  export function EditingWorkspace(handle: Handle) {
@@ -79,8 +103,9 @@ export function EditingWorkspace(handle: Handle) {
79
103
  let primaryChapterId = chapters[0]?.id ?? ''
80
104
  let secondaryChapterId = chapters[1]?.id ?? chapters[0]?.id ?? ''
81
105
  let processingQueue: ProcessingTask[] = []
82
- let activeTaskId: string | null = null
83
- let processingCount = 1
106
+ let queueLoading = true
107
+ let queueError = ''
108
+ let queueStreamStatus: 'connecting' | 'open' | 'error' = 'connecting'
84
109
  let manualCutId = 1
85
110
  let previewDuration = 0
86
111
  let previewReady = false
@@ -173,6 +198,39 @@ export function EditingWorkspace(handle: Handle) {
173
198
  })
174
199
  }
175
200
 
201
+ const loadQueueSnapshot = async () => {
202
+ await requestQueue('', { method: 'GET' })
203
+ }
204
+
205
+ const connectQueueStream = () => {
206
+ if (typeof window === 'undefined') return
207
+ const stream = new EventSource(QUEUE_STREAM_URL)
208
+ const updateStatus = () => {
209
+ queueStreamStatus =
210
+ stream.readyState === EventSource.OPEN
211
+ ? 'open'
212
+ : stream.readyState === EventSource.CONNECTING
213
+ ? 'connecting'
214
+ : 'error'
215
+ handle.update()
216
+ }
217
+ stream.addEventListener('open', updateStatus)
218
+ stream.addEventListener('error', updateStatus)
219
+ stream.addEventListener('snapshot', (event) => {
220
+ try {
221
+ const snapshot = JSON.parse(
222
+ (event as MessageEvent<string>).data,
223
+ ) as ProcessingQueueSnapshot
224
+ applyQueueSnapshot(snapshot)
225
+ } catch (error) {
226
+ setQueueError('Unable to parse queue updates.')
227
+ }
228
+ })
229
+ handle.signal.addEventListener('abort', () => {
230
+ stream.close()
231
+ })
232
+ }
233
+
176
234
  if (initialVideoPath) {
177
235
  void loadVideoFromPath(initialVideoPath)
178
236
  }
@@ -309,26 +367,68 @@ export function EditingWorkspace(handle: Handle) {
309
367
  handle.update()
310
368
  }
311
369
 
370
+ const applyQueueSnapshot = (snapshot: ProcessingQueueSnapshot) => {
371
+ processingQueue = Array.isArray(snapshot.tasks) ? snapshot.tasks : []
372
+ queueLoading = false
373
+ queueError = ''
374
+ handle.update()
375
+ }
376
+
377
+ const setQueueError = (message: string) => {
378
+ queueError = message
379
+ queueLoading = false
380
+ handle.update()
381
+ }
382
+
383
+ const requestQueue = async (
384
+ path: string,
385
+ options: RequestInit & { body?: string } = {},
386
+ ) => {
387
+ try {
388
+ const response = await fetch(`${QUEUE_API_BASE}${path}`, {
389
+ ...options,
390
+ headers: {
391
+ Accept: 'application/json',
392
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
393
+ },
394
+ signal: handle.signal,
395
+ })
396
+ if (!response.ok) {
397
+ throw new Error(`Queue request failed (${response.status}).`)
398
+ }
399
+ const snapshot =
400
+ (await response.json()) as ProcessingQueueSnapshot
401
+ applyQueueSnapshot(snapshot)
402
+ } catch (error) {
403
+ if (handle.signal.aborted) return
404
+ setQueueError(
405
+ error instanceof Error
406
+ ? error.message
407
+ : 'Unable to reach the processing queue.',
408
+ )
409
+ }
410
+ }
411
+
412
+ void loadQueueSnapshot()
413
+ connectQueueStream()
414
+
312
415
  const queueTask = (
416
+ action: ProcessingAction,
313
417
  title: string,
314
418
  detail: string,
315
419
  category: ProcessingCategory,
316
420
  ) => {
317
- const task: ProcessingTask = {
318
- id: `task-${processingCount++}`,
319
- title,
320
- detail,
321
- status: 'queued',
322
- category,
323
- }
324
- processingQueue = [...processingQueue, task]
325
- handle.update()
421
+ void requestQueue('/enqueue', {
422
+ method: 'POST',
423
+ body: JSON.stringify({ action, title, detail, category }),
424
+ })
326
425
  }
327
426
 
328
427
  const queueChapterEdit = () => {
329
428
  const chapter = findChapter(primaryChapterId)
330
429
  if (!chapter) return
331
430
  queueTask(
431
+ 'edit-chapter',
332
432
  `Edit ${chapter.title}`,
333
433
  `Review trims for ${formatTimestamp(chapter.start)} - ${formatTimestamp(
334
434
  chapter.end,
@@ -342,6 +442,7 @@ export function EditingWorkspace(handle: Handle) {
342
442
  const secondary = findChapter(secondaryChapterId)
343
443
  if (!primary || !secondary || primary.id === secondary.id) return
344
444
  queueTask(
445
+ 'combine-chapters',
345
446
  `Combine ${primary.title} + ${secondary.title}`,
346
447
  'Merge both chapters into a single preview export.',
347
448
  'chapter',
@@ -350,6 +451,7 @@ export function EditingWorkspace(handle: Handle) {
350
451
 
351
452
  const queueTranscriptRegeneration = () => {
352
453
  queueTask(
454
+ 'regenerate-transcript',
353
455
  'Regenerate transcript',
354
456
  'Run Whisper alignment and refresh search cues.',
355
457
  'transcript',
@@ -358,6 +460,7 @@ export function EditingWorkspace(handle: Handle) {
358
460
 
359
461
  const queueCommandScan = () => {
360
462
  queueTask(
463
+ 'detect-command-windows',
361
464
  'Detect command windows',
362
465
  'Scan for Jarvis commands and update cut ranges.',
363
466
  'transcript',
@@ -366,6 +469,7 @@ export function EditingWorkspace(handle: Handle) {
366
469
 
367
470
  const queuePreviewRender = () => {
368
471
  queueTask(
472
+ 'render-preview',
369
473
  'Render preview clip',
370
474
  'Bake a short MP4 with current edits applied.',
371
475
  'export',
@@ -374,6 +478,7 @@ export function EditingWorkspace(handle: Handle) {
374
478
 
375
479
  const queueFinalExport = () => {
376
480
  queueTask(
481
+ 'export-final',
377
482
  'Export edited chapters',
378
483
  'Render final chapters and write the export package.',
379
484
  'export',
@@ -381,36 +486,21 @@ export function EditingWorkspace(handle: Handle) {
381
486
  }
382
487
 
383
488
  const startNextTask = () => {
384
- if (activeTaskId) return
385
- const next = processingQueue.find((task) => task.status === 'queued')
386
- if (!next) return
387
- activeTaskId = next.id
388
- processingQueue = processingQueue.map((task) =>
389
- task.id === next.id ? { ...task, status: 'running' } : task,
390
- )
391
- handle.update()
489
+ void requestQueue('/run-next', { method: 'POST' })
392
490
  }
393
491
 
394
492
  const markActiveDone = () => {
395
- if (!activeTaskId) return
396
- processingQueue = processingQueue.map((task) =>
397
- task.id === activeTaskId ? { ...task, status: 'done' } : task,
398
- )
399
- activeTaskId = null
400
- handle.update()
493
+ void requestQueue('/mark-done', { method: 'POST' })
401
494
  }
402
495
 
403
496
  const clearCompletedTasks = () => {
404
- processingQueue = processingQueue.filter((task) => task.status !== 'done')
405
- handle.update()
497
+ void requestQueue('/clear-completed', { method: 'POST' })
406
498
  }
407
499
 
408
500
  const removeTask = (taskId: string) => {
409
- processingQueue = processingQueue.filter((task) => task.id !== taskId)
410
- if (activeTaskId === taskId) {
411
- activeTaskId = null
412
- }
413
- handle.update()
501
+ void requestQueue(`/task/${encodeURIComponent(taskId)}`, {
502
+ method: 'DELETE',
503
+ })
414
504
  }
415
505
 
416
506
  const syncVideoToPlayhead = (value: number) => {
@@ -460,8 +550,28 @@ export function EditingWorkspace(handle: Handle) {
460
550
  const completedCount = processingQueue.filter(
461
551
  (task) => task.status === 'done',
462
552
  ).length
553
+ const errorCount = processingQueue.filter(
554
+ (task) => task.status === 'error',
555
+ ).length
463
556
  const runningTask =
464
557
  processingQueue.find((task) => task.status === 'running') ?? null
558
+ const queueStreamMeta =
559
+ queueStreamStatus === 'open'
560
+ ? { label: 'Live', className: 'status-pill--success' }
561
+ : queueStreamStatus === 'connecting'
562
+ ? { label: 'Connecting', className: 'status-pill--warning' }
563
+ : { label: 'Offline', className: 'status-pill--danger' }
564
+ const runningSummary = runningTask
565
+ ? runningTask.progress?.label
566
+ ? `Running: ${runningTask.title} · ${runningTask.progress.label}`
567
+ : `Running: ${runningTask.title}`
568
+ : 'Idle'
569
+ const queueStreamSummary =
570
+ queueStreamStatus === 'open'
571
+ ? 'Queue updates are live.'
572
+ : queueStreamStatus === 'connecting'
573
+ ? 'Connecting to queue updates.'
574
+ : 'Queue updates are offline.'
465
575
  const canCombineChapters =
466
576
  primaryChapterId.length > 0 &&
467
577
  secondaryChapterId.length > 0 &&
@@ -612,16 +722,37 @@ export function EditingWorkspace(handle: Handle) {
612
722
  <div class="summary-item">
613
723
  <span class="summary-label">Queue</span>
614
724
  <span class="summary-value">{queuedCount} queued</span>
725
+ <span class="summary-subtext">{runningSummary}</span>
726
+ </div>
727
+ <div class="summary-item">
728
+ <span class="summary-label">Errors</span>
729
+ <span class="summary-value">{errorCount}</span>
615
730
  <span class="summary-subtext">
616
- {runningTask ? `Running: ${runningTask.title}` : 'Idle'}
731
+ {errorCount > 0
732
+ ? 'Review failed tasks below.'
733
+ : 'No failures yet.'}
734
+ </span>
735
+ </div>
736
+ <div class="summary-item">
737
+ <span class="summary-label">Stream</span>
738
+ <span
739
+ class={classNames(
740
+ 'status-pill',
741
+ queueStreamMeta.className,
742
+ )}
743
+ >
744
+ {queueStreamMeta.label}
617
745
  </span>
746
+ <span class="summary-subtext">{queueStreamSummary}</span>
618
747
  </div>
619
748
  </div>
620
749
  <div class="actions-buttons">
621
750
  <button
622
751
  class="button button--primary"
623
752
  type="button"
624
- disabled={queuedCount === 0 || Boolean(runningTask)}
753
+ disabled={
754
+ queueLoading || queuedCount === 0 || Boolean(runningTask)
755
+ }
625
756
  on={{ click: startNextTask }}
626
757
  >
627
758
  Run next
@@ -629,7 +760,7 @@ export function EditingWorkspace(handle: Handle) {
629
760
  <button
630
761
  class="button button--ghost"
631
762
  type="button"
632
- disabled={!runningTask}
763
+ disabled={queueLoading || !runningTask}
633
764
  on={{ click: markActiveDone }}
634
765
  >
635
766
  Mark running done
@@ -637,7 +768,7 @@ export function EditingWorkspace(handle: Handle) {
637
768
  <button
638
769
  class="button button--ghost"
639
770
  type="button"
640
- disabled={completedCount === 0}
771
+ disabled={queueLoading || completedCount === 0}
641
772
  on={{ click: clearCompletedTasks }}
642
773
  >
643
774
  Clear completed
@@ -765,56 +896,107 @@ export function EditingWorkspace(handle: Handle) {
765
896
  <div class="panel-header">
766
897
  <h3>Processing queue</h3>
767
898
  <span class="summary-subtext">
768
- {processingQueue.length} total
899
+ {queueLoading
900
+ ? 'Loading...'
901
+ : `${processingQueue.length} total`}
769
902
  </span>
770
903
  </div>
771
- {processingQueue.length === 0 ? (
904
+ {queueError ? (
905
+ <p class="status-note status-note--danger">{queueError}</p>
906
+ ) : null}
907
+ {queueLoading ? (
908
+ <p class="app-muted">Loading queue updates...</p>
909
+ ) : processingQueue.length === 0 ? (
772
910
  <p class="app-muted">
773
911
  No actions queued yet. Use the buttons above to stage work.
774
912
  </p>
775
913
  ) : (
776
914
  <ul class="stacked-list processing-list">
777
- {processingQueue.map((task) => (
778
- <li
779
- class={classNames(
780
- 'stacked-item',
781
- 'processing-row',
782
- task.status === 'running' && 'is-running',
783
- task.status === 'done' && 'is-complete',
784
- )}
785
- >
786
- <div class="processing-row-header">
787
- <div>
788
- <h4>{task.title}</h4>
789
- <p class="app-muted">{task.detail}</p>
790
- </div>
791
- <span
792
- class={classNames(
793
- 'status-pill',
794
- task.status === 'queued' && 'status-pill--info',
795
- task.status === 'running' && 'status-pill--warning',
796
- task.status === 'done' && 'status-pill--success',
797
- )}
798
- >
799
- {task.status}
800
- </span>
801
- </div>
802
- <div class="processing-row-meta">
803
- <span class="summary-subtext">
804
- {formatProcessingCategory(task.category)}
805
- </span>
806
- {task.status === 'queued' ? (
807
- <button
808
- class="button button--ghost"
809
- type="button"
810
- on={{ click: () => removeTask(task.id) }}
915
+ {processingQueue.map((task) => {
916
+ const showProgress =
917
+ task.status === 'running' || task.status === 'error'
918
+ const progress =
919
+ task.progress ??
920
+ (task.status === 'running'
921
+ ? {
922
+ step: 0,
923
+ totalSteps: 0,
924
+ label: 'Starting',
925
+ percent: 0,
926
+ }
927
+ : null)
928
+ return (
929
+ <li
930
+ class={classNames(
931
+ 'stacked-item',
932
+ 'processing-row',
933
+ task.status === 'running' && 'is-running',
934
+ task.status === 'done' && 'is-complete',
935
+ task.status === 'error' && 'is-error',
936
+ )}
937
+ >
938
+ <div class="processing-row-header">
939
+ <div>
940
+ <h4>{task.title}</h4>
941
+ <p class="app-muted">{task.detail}</p>
942
+ </div>
943
+ <span
944
+ class={classNames(
945
+ 'status-pill',
946
+ task.status === 'queued' &&
947
+ 'status-pill--info',
948
+ task.status === 'running' &&
949
+ 'status-pill--warning',
950
+ task.status === 'done' &&
951
+ 'status-pill--success',
952
+ task.status === 'error' &&
953
+ 'status-pill--danger',
954
+ )}
811
955
  >
812
- Remove
813
- </button>
956
+ {task.status}
957
+ </span>
958
+ </div>
959
+ {showProgress && progress ? (
960
+ <div class="processing-progress">
961
+ <div class="processing-progress-meta">
962
+ <span>{progress.label}</span>
963
+ <span>
964
+ {progress.totalSteps > 0
965
+ ? `${progress.step}/${progress.totalSteps}`
966
+ : '...'}
967
+ </span>
968
+ </div>
969
+ <div class="processing-progress-bar">
970
+ <span
971
+ class="processing-progress-fill"
972
+ style={`--progress:${progress.percent}%`}
973
+ />
974
+ </div>
975
+ </div>
814
976
  ) : null}
815
- </div>
816
- </li>
817
- ))}
977
+ {task.status === 'error' && task.errorMessage ? (
978
+ <p class="status-note status-note--danger processing-error">
979
+ {task.errorMessage}
980
+ </p>
981
+ ) : null}
982
+ <div class="processing-row-meta">
983
+ <span class="summary-subtext">
984
+ {formatProcessingCategory(task.category)}
985
+ </span>
986
+ {task.status === 'queued' ||
987
+ task.status === 'error' ? (
988
+ <button
989
+ class="button button--ghost"
990
+ type="button"
991
+ on={{ click: () => removeTask(task.id) }}
992
+ >
993
+ Remove
994
+ </button>
995
+ ) : null}
996
+ </div>
997
+ </li>
998
+ )
999
+ })}
818
1000
  </ul>
819
1001
  )}
820
1002
  </div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.11.0",
4
+ "version": "1.12.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -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)
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({})