eprec 1.6.0 → 1.8.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.
@@ -199,6 +199,34 @@ p {
199
199
  gap: var(--spacing-md);
200
200
  }
201
201
 
202
+ .stacked-list {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 0;
206
+ margin: 0;
207
+ padding: 0;
208
+ list-style: none;
209
+ }
210
+
211
+ .stacked-item {
212
+ border-radius: 0;
213
+ }
214
+
215
+ .stacked-item:first-child {
216
+ border-top-left-radius: var(--radius-lg);
217
+ border-top-right-radius: var(--radius-lg);
218
+ }
219
+
220
+ .stacked-item:last-child {
221
+ border-bottom-left-radius: var(--radius-lg);
222
+ border-bottom-right-radius: var(--radius-lg);
223
+ }
224
+
225
+ .stacked-item.is-selected {
226
+ background: var(--color-info-surface);
227
+ box-shadow: inset 0 0 0 1px var(--color-border-accent);
228
+ }
229
+
202
230
  .app-list {
203
231
  margin: 0;
204
232
  padding-left: var(--spacing-lg);
@@ -296,6 +324,93 @@ p {
296
324
  line-height: 1.4;
297
325
  }
298
326
 
327
+ .actions-card {
328
+ gap: var(--spacing-xl);
329
+ }
330
+
331
+ .actions-header {
332
+ display: flex;
333
+ flex-wrap: wrap;
334
+ align-items: flex-start;
335
+ justify-content: space-between;
336
+ gap: var(--spacing-lg);
337
+ }
338
+
339
+ .actions-meta {
340
+ min-width: 180px;
341
+ }
342
+
343
+ .actions-buttons {
344
+ display: flex;
345
+ flex-wrap: wrap;
346
+ gap: var(--spacing-sm);
347
+ }
348
+
349
+ .actions-grid {
350
+ display: grid;
351
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
352
+ gap: var(--spacing-lg);
353
+ }
354
+
355
+ .actions-panel {
356
+ border: 1px solid var(--color-border);
357
+ border-radius: var(--radius-lg);
358
+ padding: var(--spacing-lg);
359
+ background: var(--color-surface-muted);
360
+ display: flex;
361
+ flex-direction: column;
362
+ gap: var(--spacing-md);
363
+ }
364
+
365
+ .actions-button-row {
366
+ display: flex;
367
+ flex-wrap: wrap;
368
+ gap: var(--spacing-sm);
369
+ }
370
+
371
+ .actions-queue {
372
+ display: flex;
373
+ flex-direction: column;
374
+ gap: var(--spacing-md);
375
+ }
376
+
377
+ .processing-list .stacked-item {
378
+ background: var(--color-surface);
379
+ }
380
+
381
+ .processing-row {
382
+ border: 1px solid var(--color-border);
383
+ }
384
+
385
+ .processing-row-header {
386
+ display: flex;
387
+ align-items: flex-start;
388
+ justify-content: space-between;
389
+ gap: var(--spacing-md);
390
+ padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
391
+ }
392
+
393
+ .processing-row-header h4 {
394
+ margin: 0 0 var(--spacing-xs);
395
+ font-size: var(--font-size-base);
396
+ }
397
+
398
+ .processing-row-meta {
399
+ display: flex;
400
+ align-items: center;
401
+ justify-content: space-between;
402
+ gap: var(--spacing-md);
403
+ padding: 0 var(--spacing-md) var(--spacing-md);
404
+ }
405
+
406
+ .processing-row.is-running {
407
+ box-shadow: inset 0 0 0 1px var(--color-warning-border);
408
+ }
409
+
410
+ .processing-row.is-complete {
411
+ background: var(--color-success-surface);
412
+ }
413
+
299
414
  .timeline-card {
300
415
  gap: var(--spacing-xl);
301
416
  }
@@ -540,7 +655,7 @@ p {
540
655
  margin: 0;
541
656
  display: flex;
542
657
  flex-direction: column;
543
- gap: var(--spacing-sm);
658
+ gap: 0;
544
659
  }
545
660
 
546
661
  .cut-row {
@@ -549,14 +664,14 @@ p {
549
664
  gap: var(--spacing-md);
550
665
  justify-content: space-between;
551
666
  border: 1px solid var(--color-border);
552
- border-radius: var(--radius-lg);
667
+ border-radius: 0;
553
668
  padding: var(--spacing-sm) var(--spacing-md);
554
669
  background: var(--color-surface-muted);
555
670
  }
556
671
 
557
672
  .cut-row.is-selected {
558
- border-color: var(--color-border-accent);
559
673
  background: var(--color-info-surface);
674
+ box-shadow: inset 0 0 0 1px var(--color-border-accent);
560
675
  }
561
676
 
562
677
  .cut-select {
@@ -591,13 +706,13 @@ p {
591
706
  .command-list {
592
707
  display: flex;
593
708
  flex-direction: column;
594
- gap: var(--spacing-md);
709
+ gap: 0;
595
710
  }
596
711
 
597
712
  .chapter-row,
598
713
  .command-row {
599
714
  border: 1px solid var(--color-border);
600
- border-radius: var(--radius-lg);
715
+ border-radius: 0;
601
716
  padding: var(--spacing-md);
602
717
  background: var(--color-surface-muted);
603
718
  display: flex;
@@ -673,16 +788,22 @@ p {
673
788
  list-style: none;
674
789
  margin: 0;
675
790
  padding: 0;
676
- display: grid;
677
- gap: var(--spacing-sm);
791
+ display: flex;
792
+ flex-direction: column;
793
+ gap: 0;
794
+ }
795
+
796
+ .transcript-results .stacked-item {
797
+ border: 1px solid var(--color-border);
798
+ background: var(--color-surface);
678
799
  }
679
800
 
680
801
  .transcript-result {
681
802
  width: 100%;
682
- border: 1px solid var(--color-border);
683
- border-radius: var(--radius-md);
684
803
  padding: var(--spacing-sm) var(--spacing-md);
685
- background: var(--color-surface);
804
+ border: none;
805
+ border-radius: 0;
806
+ background: transparent;
686
807
  display: grid;
687
808
  grid-template-columns: auto 1fr;
688
809
  gap: var(--spacing-md);
@@ -713,6 +834,10 @@ p {
713
834
  overflow-x: auto;
714
835
  }
715
836
 
837
+ .stacked-list .stacked-item + .stacked-item {
838
+ border-top: none;
839
+ }
840
+
716
841
  @keyframes shimmer {
717
842
  0% {
718
843
  background-position: 0% 50%;
@@ -1,3 +1,4 @@
1
+ import { matchSorter } from 'match-sorter'
1
2
  import type { Handle } from 'remix/component'
2
3
  import {
3
4
  sampleEditSession,
@@ -12,15 +13,34 @@ const MIN_CUT_LENGTH = 0.2
12
13
  const DEFAULT_CUT_LENGTH = 2.4
13
14
  const PLAYHEAD_STEP = 0.1
14
15
 
16
+ type ProcessingStatus = 'queued' | 'running' | 'done'
17
+ type ProcessingCategory = 'chapter' | 'transcript' | 'export'
18
+ type ProcessingTask = {
19
+ id: string
20
+ title: string
21
+ detail: string
22
+ status: ProcessingStatus
23
+ category: ProcessingCategory
24
+ }
25
+
15
26
  export function EditingWorkspace(handle: Handle) {
16
27
  const duration = sampleEditSession.duration
17
28
  const transcript = sampleEditSession.transcript
18
29
  const commands = sampleEditSession.commands
30
+ const transcriptIndex = transcript.map((word) => ({
31
+ ...word,
32
+ context: buildContext(transcript, word.index, 3),
33
+ }))
19
34
  let cutRanges = sampleEditSession.cuts.map((range) => ({ ...range }))
20
35
  let chapters = sampleEditSession.chapters.map((chapter) => ({ ...chapter }))
21
36
  let playhead = 18.2
22
37
  let selectedRangeId = cutRanges[0]?.id ?? null
23
38
  let searchQuery = ''
39
+ let primaryChapterId = chapters[0]?.id ?? ''
40
+ let secondaryChapterId = chapters[1]?.id ?? chapters[0]?.id ?? ''
41
+ let processingQueue: ProcessingTask[] = []
42
+ let activeTaskId: string | null = null
43
+ let processingCount = 1
24
44
  let manualCutId = 1
25
45
  let previewDuration = 0
26
46
  let previewReady = false
@@ -144,6 +164,128 @@ export function EditingWorkspace(handle: Handle) {
144
164
  handle.update()
145
165
  }
146
166
 
167
+ const findChapter = (chapterId: string) =>
168
+ chapters.find((chapter) => chapter.id === chapterId) ?? null
169
+
170
+ const updatePrimaryChapter = (chapterId: string) => {
171
+ primaryChapterId = chapterId
172
+ if (secondaryChapterId === chapterId) {
173
+ secondaryChapterId =
174
+ chapters.find((chapter) => chapter.id !== chapterId)?.id ??
175
+ chapterId
176
+ }
177
+ handle.update()
178
+ }
179
+
180
+ const updateSecondaryChapter = (chapterId: string) => {
181
+ secondaryChapterId = chapterId
182
+ handle.update()
183
+ }
184
+
185
+ const queueTask = (
186
+ title: string,
187
+ detail: string,
188
+ category: ProcessingCategory,
189
+ ) => {
190
+ const task: ProcessingTask = {
191
+ id: `task-${processingCount++}`,
192
+ title,
193
+ detail,
194
+ status: 'queued',
195
+ category,
196
+ }
197
+ processingQueue = [...processingQueue, task]
198
+ handle.update()
199
+ }
200
+
201
+ const queueChapterEdit = () => {
202
+ const chapter = findChapter(primaryChapterId)
203
+ if (!chapter) return
204
+ queueTask(
205
+ `Edit ${chapter.title}`,
206
+ `Review trims for ${formatTimestamp(chapter.start)} - ${formatTimestamp(
207
+ chapter.end,
208
+ )}.`,
209
+ 'chapter',
210
+ )
211
+ }
212
+
213
+ const queueCombineChapters = () => {
214
+ const primary = findChapter(primaryChapterId)
215
+ const secondary = findChapter(secondaryChapterId)
216
+ if (!primary || !secondary || primary.id === secondary.id) return
217
+ queueTask(
218
+ `Combine ${primary.title} + ${secondary.title}`,
219
+ 'Merge both chapters into a single preview export.',
220
+ 'chapter',
221
+ )
222
+ }
223
+
224
+ const queueTranscriptRegeneration = () => {
225
+ queueTask(
226
+ 'Regenerate transcript',
227
+ 'Run Whisper alignment and refresh search cues.',
228
+ 'transcript',
229
+ )
230
+ }
231
+
232
+ const queueCommandScan = () => {
233
+ queueTask(
234
+ 'Detect command windows',
235
+ 'Scan for Jarvis commands and update cut ranges.',
236
+ 'transcript',
237
+ )
238
+ }
239
+
240
+ const queuePreviewRender = () => {
241
+ queueTask(
242
+ 'Render preview clip',
243
+ 'Bake a short MP4 with current edits applied.',
244
+ 'export',
245
+ )
246
+ }
247
+
248
+ const queueFinalExport = () => {
249
+ queueTask(
250
+ 'Export edited chapters',
251
+ 'Render final chapters and write the export package.',
252
+ 'export',
253
+ )
254
+ }
255
+
256
+ const startNextTask = () => {
257
+ if (activeTaskId) return
258
+ const next = processingQueue.find((task) => task.status === 'queued')
259
+ if (!next) return
260
+ activeTaskId = next.id
261
+ processingQueue = processingQueue.map((task) =>
262
+ task.id === next.id ? { ...task, status: 'running' } : task,
263
+ )
264
+ handle.update()
265
+ }
266
+
267
+ const markActiveDone = () => {
268
+ if (!activeTaskId) return
269
+ processingQueue = processingQueue.map((task) =>
270
+ task.id === activeTaskId ? { ...task, status: 'done' } : task,
271
+ )
272
+ activeTaskId = null
273
+ handle.update()
274
+ }
275
+
276
+ const clearCompletedTasks = () => {
277
+ processingQueue = processingQueue.filter((task) => task.status !== 'done')
278
+ handle.update()
279
+ }
280
+
281
+ const removeTask = (taskId: string) => {
282
+ processingQueue = processingQueue.filter((task) => task.id !== taskId)
283
+ if (activeTaskId === taskId) {
284
+ activeTaskId = null
285
+ }
286
+ handle.update()
287
+ }
288
+
147
289
  const syncVideoToPlayhead = (value: number) => {
148
290
  if (
149
291
  !previewNode ||
@@ -179,12 +321,24 @@ export function EditingWorkspace(handle: Handle) {
179
321
  const currentContext = currentWord
180
322
  ? buildContext(transcript, currentWord.index, 4)
181
323
  : 'No transcript cues found for the playhead.'
182
- const query = searchQuery.trim().toLowerCase()
324
+ const query = searchQuery.trim()
183
325
  const searchResults = query
184
- ? transcript
185
- .filter((word) => word.word.toLowerCase().includes(query))
186
- .slice(0, 12)
326
+ ? matchSorter(transcriptIndex, query, {
327
+ keys: ['word'],
328
+ }).slice(0, 12)
187
329
  : []
330
+ const queuedCount = processingQueue.filter(
331
+ (task) => task.status === 'queued',
332
+ ).length
333
+ const completedCount = processingQueue.filter(
334
+ (task) => task.status === 'done',
335
+ ).length
336
+ const runningTask =
337
+ processingQueue.find((task) => task.status === 'running') ?? null
338
+ const canCombineChapters =
339
+ primaryChapterId.length > 0 &&
340
+ secondaryChapterId.length > 0 &&
341
+ primaryChapterId !== secondaryChapterId
188
342
  const commandPreview = buildCommandPreview(
189
343
  sampleEditSession.sourceName,
190
344
  chapters,
@@ -246,6 +400,227 @@ export function EditingWorkspace(handle: Handle) {
246
400
  </div>
247
401
  </section>
248
402
 
403
+ <section class="app-card app-card--full actions-card">
404
+ <div class="actions-header">
405
+ <div>
406
+ <h2>Processing actions</h2>
407
+ <p class="app-muted">
408
+ Queue chapter edits, transcript cleanup, and export jobs directly
409
+ from the workspace.
410
+ </p>
411
+ </div>
412
+ <div class="actions-meta">
413
+ <div class="summary-item">
414
+ <span class="summary-label">Queue</span>
415
+ <span class="summary-value">{queuedCount} queued</span>
416
+ <span class="summary-subtext">
417
+ {runningTask ? `Running: ${runningTask.title}` : 'Idle'}
418
+ </span>
419
+ </div>
420
+ </div>
421
+ <div class="actions-buttons">
422
+ <button
423
+ class="button button--primary"
424
+ type="button"
425
+ disabled={queuedCount === 0 || Boolean(runningTask)}
426
+ on={{ click: startNextTask }}
427
+ >
428
+ Run next
429
+ </button>
430
+ <button
431
+ class="button button--ghost"
432
+ type="button"
433
+ disabled={!runningTask}
434
+ on={{ click: markActiveDone }}
435
+ >
436
+ Mark running done
437
+ </button>
438
+ <button
439
+ class="button button--ghost"
440
+ type="button"
441
+ disabled={completedCount === 0}
442
+ on={{ click: clearCompletedTasks }}
443
+ >
444
+ Clear completed
445
+ </button>
446
+ </div>
447
+ </div>
448
+
449
+ <div class="actions-grid">
450
+ <article class="actions-panel">
451
+ <div class="panel-header">
452
+ <h3>Chapter processing</h3>
453
+ <span class="status-pill status-pill--info">Chapter</span>
454
+ </div>
455
+ <label class="input-label">
456
+ Primary chapter
457
+ <select
458
+ class="text-input"
459
+ value={primaryChapterId}
460
+ on={{
461
+ change: (event) => {
462
+ const target = event.currentTarget as HTMLSelectElement
463
+ updatePrimaryChapter(target.value)
464
+ },
465
+ }}
466
+ >
467
+ {chapters.map((chapter) => (
468
+ <option value={chapter.id}>{chapter.title}</option>
469
+ ))}
470
+ </select>
471
+ </label>
472
+ <label class="input-label">
473
+ Secondary chapter
474
+ <select
475
+ class="text-input"
476
+ value={secondaryChapterId}
477
+ on={{
478
+ change: (event) => {
479
+ const target = event.currentTarget as HTMLSelectElement
480
+ updateSecondaryChapter(target.value)
481
+ },
482
+ }}
483
+ >
484
+ {chapters.map((chapter) => (
485
+ <option value={chapter.id}>{chapter.title}</option>
486
+ ))}
487
+ </select>
488
+ </label>
489
+ <div class="actions-button-row">
490
+ <button
491
+ class="button button--primary"
492
+ type="button"
493
+ disabled={!primaryChapterId}
494
+ on={{ click: queueChapterEdit }}
495
+ >
496
+ Edit chapter
497
+ </button>
498
+ <button
499
+ class="button button--ghost"
500
+ type="button"
501
+ disabled={!canCombineChapters}
502
+ on={{ click: queueCombineChapters }}
503
+ >
504
+ Combine chapters
505
+ </button>
506
+ </div>
507
+ <p class="app-muted">
508
+ Stage edits or merge two chapters without leaving this view.
509
+ </p>
510
+ </article>
511
+
512
+ <article class="actions-panel">
513
+ <div class="panel-header">
514
+ <h3>Transcript utilities</h3>
515
+ <span class="status-pill status-pill--warning">Transcript</span>
516
+ </div>
517
+ <div class="actions-button-row">
518
+ <button
519
+ class="button button--ghost"
520
+ type="button"
521
+ on={{ click: queueTranscriptRegeneration }}
522
+ >
523
+ Regenerate transcript
524
+ </button>
525
+ <button
526
+ class="button button--ghost"
527
+ type="button"
528
+ on={{ click: queueCommandScan }}
529
+ >
530
+ Detect command windows
531
+ </button>
532
+ </div>
533
+ <p class="app-muted">
534
+ Refresh the transcript or scan for command markers on demand.
535
+ </p>
536
+ </article>
537
+
538
+ <article class="actions-panel">
539
+ <div class="panel-header">
540
+ <h3>Exports</h3>
541
+ <span class="status-pill status-pill--success">Export</span>
542
+ </div>
543
+ <div class="actions-button-row">
544
+ <button
545
+ class="button button--ghost"
546
+ type="button"
547
+ on={{ click: queuePreviewRender }}
548
+ >
549
+ Render preview clip
550
+ </button>
551
+ <button
552
+ class="button button--ghost"
553
+ type="button"
554
+ on={{ click: queueFinalExport }}
555
+ >
556
+ Export edited chapters
557
+ </button>
558
+ </div>
559
+ <p class="app-muted">
560
+ Trigger preview renders or finalize exports for the pipeline.
561
+ </p>
562
+ </article>
563
+ </div>
564
+
565
+ <div class="actions-queue">
566
+ <div class="panel-header">
567
+ <h3>Processing queue</h3>
568
+ <span class="summary-subtext">
569
+ {processingQueue.length} total
570
+ </span>
571
+ </div>
572
+ {processingQueue.length === 0 ? (
573
+ <p class="app-muted">
574
+ No actions queued yet. Use the buttons above to stage work.
575
+ </p>
576
+ ) : (
577
+ <ul class="stacked-list processing-list">
578
+ {processingQueue.map((task) => (
579
+ <li
580
+ class={classNames(
581
+ 'stacked-item',
582
+ 'processing-row',
583
+ task.status === 'running' && 'is-running',
584
+ task.status === 'done' && 'is-complete',
585
+ )}
586
+ >
587
+ <div class="processing-row-header">
588
+ <div>
589
+ <h4>{task.title}</h4>
590
+ <p class="app-muted">{task.detail}</p>
591
+ </div>
592
+ <span
593
+ class={classNames(
594
+ 'status-pill',
595
+ task.status === 'queued' && 'status-pill--info',
596
+ task.status === 'running' && 'status-pill--warning',
597
+ task.status === 'done' && 'status-pill--success',
598
+ )}
599
+ >
600
+ {task.status}
601
+ </span>
602
+ </div>
603
+ <div class="processing-row-meta">
604
+ <span class="summary-subtext">
605
+ {formatProcessingCategory(task.category)}
606
+ </span>
607
+ {task.status === 'queued' ? (
608
+ <button
609
+ class="button button--ghost"
610
+ type="button"
611
+ on={{ click: () => removeTask(task.id) }}
612
+ >
613
+ Remove
614
+ </button>
615
+ ) : null}
616
+ </div>
617
+ </li>
618
+ ))}
619
+ </ul>
620
+ )}
621
+ </div>
622
+ </section>
623
+
249
624
  <section class="app-card app-card--full timeline-card">
250
625
  <div class="timeline-header">
251
626
  <div>
@@ -523,10 +898,11 @@ export function EditingWorkspace(handle: Handle) {
523
898
  )}
524
899
 
525
900
  <h3>Cut list</h3>
526
- <ul class="cut-list">
901
+ <ul class="cut-list stacked-list">
527
902
  {sortedCuts.map((range) => (
528
903
  <li
529
904
  class={classNames(
905
+ 'stacked-item',
530
906
  'cut-row',
531
907
  range.id === selectedRangeId && 'is-selected',
532
908
  )}
@@ -562,9 +938,9 @@ export function EditingWorkspace(handle: Handle) {
562
938
  <p class="app-muted">
563
939
  Update output names and mark chapters to skip before export.
564
940
  </p>
565
- <div class="chapter-list">
941
+ <div class="chapter-list stacked-list">
566
942
  {chapters.map((chapter) => (
567
- <article class="chapter-row">
943
+ <article class="chapter-row stacked-item">
568
944
  <div class="chapter-header">
569
945
  <div>
570
946
  <h3>{chapter.title}</h3>
@@ -630,11 +1006,11 @@ export function EditingWorkspace(handle: Handle) {
630
1006
  <p class="app-muted">
631
1007
  Apply Jarvis commands to your cut list or chapter metadata.
632
1008
  </p>
633
- <div class="command-list">
1009
+ <div class="command-list stacked-list">
634
1010
  {commands.map((command) => {
635
1011
  const applied = isCommandApplied(command, sortedCuts, chapters)
636
1012
  return (
637
- <article class="command-row">
1013
+ <article class="command-row stacked-item">
638
1014
  <div class="command-header">
639
1015
  <h3>{command.label}</h3>
640
1016
  <span
@@ -705,10 +1081,14 @@ export function EditingWorkspace(handle: Handle) {
705
1081
  <p class="app-muted transcript-empty">
706
1082
  Type to search the transcript words. Click a result to jump to it.
707
1083
  </p>
1084
+ ) : searchResults.length === 0 ? (
1085
+ <p class="app-muted transcript-empty">
1086
+ No results found for "{query}".
1087
+ </p>
708
1088
  ) : (
709
- <ul class="transcript-results">
1089
+ <ul class="transcript-results stacked-list">
710
1090
  {searchResults.map((word) => (
711
- <li>
1091
+ <li class="stacked-item">
712
1092
  <button
713
1093
  class="transcript-result"
714
1094
  type="button"
@@ -717,9 +1097,7 @@ export function EditingWorkspace(handle: Handle) {
717
1097
  <span class="transcript-time">
718
1098
  {formatTimestamp(word.start)}
719
1099
  </span>
720
- <span class="transcript-snippet">
721
- {buildContext(transcript, word.index, 3)}
722
- </span>
1100
+ <span class="transcript-snippet">{word.context}</span>
723
1101
  </button>
724
1102
  </li>
725
1103
  ))}
@@ -786,6 +1164,12 @@ function formatSeconds(value: number) {
786
1164
  return `${value.toFixed(1)}s`
787
1165
  }
788
1166
 
1167
+ function formatProcessingCategory(category: ProcessingCategory) {
1168
+ if (category === 'chapter') return 'Chapter task'
1169
+ if (category === 'transcript') return 'Transcript task'
1170
+ return 'Export task'
1171
+ }
1172
+
789
1173
  function classNames(...values: Array<string | false | null | undefined>) {
790
1174
  return values.filter(Boolean).join(' ')
791
1175
  }
@@ -6,4 +6,5 @@ export const baseImportMap = {
6
6
  'remix/component/jsx-runtime': '/node_modules/remix/component/jsx-runtime',
7
7
  'remix/component/jsx-dev-runtime':
8
8
  '/node_modules/remix/component/jsx-dev-runtime',
9
+ 'match-sorter': '/node_modules/match-sorter',
9
10
  } as const
@@ -17,6 +17,18 @@ const indexHandler = {
17
17
  exports.
18
18
  </p>
19
19
  </header>
20
+ <section class="app-card app-card--full">
21
+ <h2>Processing actions</h2>
22
+ <p class="app-muted">
23
+ Queue chapter edits, transcript cleanup, and export jobs once the
24
+ client loads.
25
+ </p>
26
+ <ul class="app-list">
27
+ <li>Edit a chapter with the latest cut list.</li>
28
+ <li>Combine two chapters into a merged preview.</li>
29
+ <li>Regenerate the transcript or detect command windows.</li>
30
+ </ul>
31
+ </section>
20
32
  <section class="app-card app-card--full">
21
33
  <h2>Timeline editor</h2>
22
34
  <p class="app-muted">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.6.0",
4
+ "version": "1.8.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -54,6 +54,14 @@ import type {
54
54
  } from './types'
55
55
  import { createEditWorkspace } from './edits'
56
56
 
57
+ export type ChapterProgressReporter = {
58
+ start: (options: { stepCount: number; label?: string }) => void
59
+ step: (label: string) => void
60
+ setLabel: (label: string) => void
61
+ finish: (label?: string) => void
62
+ skip: (label: string) => void
63
+ }
64
+
57
65
  export interface ChapterProcessingOptions {
58
66
  inputPath: string
59
67
  outputDir: string
@@ -67,6 +75,7 @@ export interface ChapterProcessingOptions {
67
75
  writeLogs: boolean
68
76
  dryRun: boolean
69
77
  previousProcessedChapter?: ProcessedChapterInfo | null
78
+ progress?: ChapterProgressReporter
70
79
  }
71
80
 
72
81
  export interface ChapterProcessingResult {
@@ -100,11 +109,16 @@ export async function processChapter(
100
109
  )
101
110
  }
102
111
 
112
+ const progress = options.progress
113
+ const stepCount = options.dryRun ? 1 : options.enableTranscription ? 8 : 7
114
+
103
115
  const outputBasePath = path.join(
104
116
  options.outputDir,
105
117
  `${formatChapterFilename(chapter)}${path.extname(options.inputPath)}`,
106
118
  )
107
119
 
120
+ progress?.start({ stepCount, label: 'Starting' })
121
+
108
122
  // Check minimum duration before processing
109
123
  if (duration < options.minChapterDurationSeconds) {
110
124
  logInfo(
@@ -121,6 +135,7 @@ export async function processChapter(
121
135
  ])
122
136
  logWritten = true
123
137
  }
138
+ progress?.skip('Skipped (short)')
124
139
  return { status: 'skipped', skipReason: 'short-initial', logWritten }
125
140
  }
126
141
 
@@ -129,6 +144,7 @@ export async function processChapter(
129
144
  logInfo(
130
145
  `[dry-run] Would process chapter ${chapter.index + 1}: ${chapter.title}`,
131
146
  )
147
+ progress?.finish('Dry run')
132
148
  return { status: 'processed', skipReason: 'dry-run', logWritten: false }
133
149
  }
134
150
 
@@ -139,6 +155,7 @@ export async function processChapter(
139
155
 
140
156
  try {
141
157
  // Step 1: Extract raw segment with padding trimmed
158
+ progress?.step('Extracting segment')
142
159
  const rawTrimStart = chapter.start + CONFIG.rawTrimPaddingSeconds
143
160
  const rawTrimEnd = chapter.end - CONFIG.rawTrimPaddingSeconds
144
161
  const rawDuration = rawTrimEnd - rawTrimStart
@@ -156,6 +173,7 @@ export async function processChapter(
156
173
  })
157
174
 
158
175
  // Step 2: Normalize audio
176
+ progress?.step('Normalizing audio')
159
177
  const analysis = await analyzeLoudness(paths.rawPath, 0, rawDuration)
160
178
  await renderChapter({
161
179
  inputPath: paths.rawPath,
@@ -170,8 +188,10 @@ export async function processChapter(
170
188
  let commandFilenameOverride: string | null = null
171
189
  let hasEditCommand = false
172
190
  let commandNotes: Array<{ value: string; window: TimeRange }> = []
191
+ let usedSpliceStep = false
173
192
 
174
193
  if (options.enableTranscription) {
194
+ progress?.step('Transcribing audio')
175
195
  const transcriptionResult = await transcribeAndAnalyze({
176
196
  normalizedPath: paths.normalizedPath,
177
197
  transcriptionAudioPath: paths.transcriptionAudioPath,
@@ -192,6 +212,7 @@ export async function processChapter(
192
212
  logWritten = true
193
213
  }
194
214
  await safeUnlink(outputBasePath)
215
+ progress?.skip('Skipped (transcript)')
195
216
  return {
196
217
  status: 'skipped',
197
218
  skipReason: transcriptionResult.hasBadTake
@@ -213,6 +234,8 @@ export async function processChapter(
213
234
  `Combine previous command detected for chapter ${chapter.index + 1}, but no previous chapter available. Processing normally.`,
214
235
  )
215
236
  } else {
237
+ progress?.step('Combining previous')
238
+ usedSpliceStep = true
216
239
  const combineResult = await handleCombinePrevious({
217
240
  chapter,
218
241
  previousProcessedChapter: options.previousProcessedChapter,
@@ -227,8 +250,10 @@ export async function processChapter(
227
250
  })
228
251
  // If combine failed (returned null), continue with normal processing
229
252
  if (combineResult !== null) {
253
+ progress?.finish('Combined')
230
254
  return combineResult
231
255
  }
256
+ progress?.setLabel('Splicing commands')
232
257
  // Otherwise, fall through to normal processing
233
258
  }
234
259
  }
@@ -242,6 +267,9 @@ export async function processChapter(
242
267
  )
243
268
 
244
269
  // Step 5: Handle command splicing
270
+ if (!usedSpliceStep) {
271
+ progress?.step('Splicing commands')
272
+ }
245
273
  const spliceResult = await handleCommandSplicing({
246
274
  commandWindows,
247
275
  normalizedPath: paths.normalizedPath,
@@ -252,6 +280,7 @@ export async function processChapter(
252
280
  })
253
281
 
254
282
  // Step 6: Detect speech bounds
283
+ progress?.step('Detecting speech')
255
284
  const speechBounds = await detectSpeechBounds(
256
285
  spliceResult.sourcePath,
257
286
  0,
@@ -275,6 +304,7 @@ export async function processChapter(
275
304
  }
276
305
 
277
306
  // Step 7: Apply speech padding
307
+ progress?.step('Trimming')
278
308
  const paddedStart = clamp(
279
309
  speechBounds.start - CONFIG.preSpeechPaddingSeconds,
280
310
  0,
@@ -314,10 +344,12 @@ export async function processChapter(
314
344
  logWritten = true
315
345
  }
316
346
  await safeUnlink(outputBasePath)
347
+ progress?.skip('Skipped (trimmed)')
317
348
  return { status: 'skipped', skipReason: 'short-trimmed', logWritten }
318
349
  }
319
350
 
320
351
  // Step 9: Write final output
352
+ progress?.step('Writing output')
321
353
  await extractChapterSegment({
322
354
  inputPath: spliceResult.sourcePath,
323
355
  outputPath: finalOutputPath,
@@ -326,6 +358,7 @@ export async function processChapter(
326
358
  })
327
359
 
328
360
  // Step 10: Verify no jarvis in final output
361
+ progress?.step('Verifying output')
329
362
  let jarvisWarning: JarvisWarning | undefined
330
363
  await extractTranscriptionAudio({
331
364
  inputPath: finalOutputPath,
@@ -412,6 +445,7 @@ export async function processChapter(
412
445
  processedDuration: trimmedDuration,
413
446
  }
414
447
 
448
+ progress?.finish('Complete')
415
449
  return {
416
450
  status: 'processed',
417
451
  jarvisWarning,
@@ -361,7 +361,8 @@ async function promptForEditsCommand(
361
361
  {
362
362
  name: 'edit-video - Edit a single video using transcript text edits',
363
363
  value: 'edit-video',
364
- description: 'edit-video --input <file> --transcript <json> --edited <txt>',
364
+ description:
365
+ 'edit-video --input <file> --transcript <json> --edited <txt>',
365
366
  keywords: ['transcript', 'cuts', 'remove', 'trim'],
366
367
  },
367
368
  {
@@ -10,6 +10,7 @@ import { writeJarvisLogs, writeSummaryLogs } from '../process-course/summary'
10
10
  import {
11
11
  processChapter,
12
12
  type ChapterProcessingOptions,
13
+ type ChapterProgressReporter,
13
14
  } from '../process-course/chapter-processor'
14
15
  import type {
15
16
  JarvisEdit,
@@ -20,6 +21,7 @@ import type {
20
21
  } from '../process-course/types'
21
22
  import { formatSeconds } from './utils'
22
23
  import { checkSegmentHasSpeech } from './speech-detection'
24
+ import { setActiveSpinnerText } from '../cli-ux'
23
25
 
24
26
  interface ProcessingSummary {
25
27
  totalSelected: number
@@ -35,6 +37,137 @@ interface ProcessingSummary {
35
37
 
36
38
  export type ProcessCourseOptions = Omit<CliArgs, 'shouldExit'>
37
39
 
40
+ const PROGRESS_BAR_WIDTH = 12
41
+
42
+ type SpinnerProgressContext = {
43
+ fileIndex: number
44
+ fileCount: number
45
+ fileName: string
46
+ chapterCount: number
47
+ }
48
+
49
+ type ChapterProgressContext = {
50
+ chapterIndex: number
51
+ chapterTitle: string
52
+ }
53
+
54
+ function clampProgress(value: number) {
55
+ return Math.max(0, Math.min(1, value))
56
+ }
57
+
58
+ function formatPercent(value: number) {
59
+ return `${Math.round(clampProgress(value) * 100)}%`
60
+ }
61
+
62
+ function formatProgressBar(value: number, width = PROGRESS_BAR_WIDTH) {
63
+ const clamped = clampProgress(value)
64
+ const filled = Math.round(clamped * width)
65
+ return `[${'#'.repeat(filled)}${'-'.repeat(width - filled)}]`
66
+ }
67
+
68
+ function truncateLabel(value: string, maxLength: number) {
69
+ const trimmed = value.trim()
70
+ if (trimmed.length <= maxLength) {
71
+ return trimmed
72
+ }
73
+ return `${trimmed.slice(0, Math.max(0, maxLength - 3))}...`
74
+ }
75
+
76
+ function buildProgressText(params: {
77
+ fileIndex: number
78
+ fileCount: number
79
+ fileName: string
80
+ chapterIndex: number
81
+ chapterCount: number
82
+ chapterTitle: string
83
+ stepIndex: number
84
+ stepCount: number
85
+ stepLabel: string
86
+ }) {
87
+ const chapterProgress =
88
+ params.stepCount > 0 ? params.stepIndex / params.stepCount : 0
89
+ const fileProgress =
90
+ params.chapterCount > 0
91
+ ? (params.chapterIndex - 1 + chapterProgress) / params.chapterCount
92
+ : 1
93
+ const fileLabel =
94
+ params.fileCount > 1
95
+ ? `File ${params.fileIndex}/${params.fileCount}`
96
+ : 'File'
97
+ const fileName = truncateLabel(params.fileName, 22)
98
+ const fileSegment = fileName ? `${fileLabel} ${fileName}` : fileLabel
99
+ const chapterLabel = `Chapter ${params.chapterIndex}/${params.chapterCount}`
100
+ const chapterTitle = truncateLabel(params.chapterTitle, 26)
101
+ const chapterSegment = chapterTitle
102
+ ? `${chapterLabel} ${chapterTitle}`
103
+ : chapterLabel
104
+ const stepSegment = truncateLabel(params.stepLabel, 28) || 'Working'
105
+ return `Processing course | ${fileSegment} ${formatPercent(fileProgress)} ${formatProgressBar(fileProgress)} | ${chapterSegment} ${formatPercent(chapterProgress)} ${formatProgressBar(chapterProgress)} | ${stepSegment}`
106
+ }
107
+
108
+ function createSpinnerProgressReporter(context: SpinnerProgressContext) {
109
+ const chapterCount = Math.max(1, context.chapterCount)
110
+ return {
111
+ createChapterProgress({ chapterIndex, chapterTitle }: ChapterProgressContext) {
112
+ let stepIndex = 0
113
+ let stepCount = 1
114
+ let stepLabel = 'Starting'
115
+
116
+ const normalizeStepCount = (value: number) =>
117
+ Math.max(1, Math.round(value))
118
+
119
+ const update = () => {
120
+ setActiveSpinnerText(
121
+ buildProgressText({
122
+ fileIndex: context.fileIndex,
123
+ fileCount: context.fileCount,
124
+ fileName: context.fileName,
125
+ chapterIndex,
126
+ chapterCount,
127
+ chapterTitle,
128
+ stepIndex,
129
+ stepCount,
130
+ stepLabel,
131
+ }),
132
+ )
133
+ }
134
+
135
+ const progress: ChapterProgressReporter = {
136
+ start({ stepCount: initialCount, label }) {
137
+ stepCount = normalizeStepCount(initialCount)
138
+ stepIndex = 0
139
+ stepLabel = label ?? 'Starting'
140
+ update()
141
+ },
142
+ step(label) {
143
+ stepCount = normalizeStepCount(stepCount)
144
+ stepIndex = Math.min(stepIndex + 1, stepCount)
145
+ stepLabel = label
146
+ update()
147
+ },
148
+ setLabel(label) {
149
+ stepLabel = label
150
+ update()
151
+ },
152
+ finish(label) {
153
+ stepCount = normalizeStepCount(stepCount)
154
+ stepIndex = stepCount
155
+ stepLabel = label ?? 'Complete'
156
+ update()
157
+ },
158
+ skip(label) {
159
+ stepCount = normalizeStepCount(stepCount)
160
+ stepIndex = stepCount
161
+ stepLabel = label
162
+ update()
163
+ },
164
+ }
165
+
166
+ return progress
167
+ },
168
+ }
169
+ }
170
+
38
171
  export async function runProcessCourse(options: ProcessCourseOptions) {
39
172
  const {
40
173
  inputPaths,
@@ -53,7 +186,7 @@ export async function runProcessCourse(options: ProcessCourseOptions) {
53
186
  await ensureFfmpegAvailable()
54
187
 
55
188
  // Process each input file in turn
56
- for (const inputPath of inputPaths) {
189
+ for (const [fileIndex, inputPath] of inputPaths.entries()) {
57
190
  // Determine output directory for this file
58
191
  let fileOutputDir: string
59
192
  if (outputDir) {
@@ -72,6 +205,8 @@ export async function runProcessCourse(options: ProcessCourseOptions) {
72
205
  }
73
206
 
74
207
  await processInputFile({
208
+ fileIndex: fileIndex + 1,
209
+ fileCount: inputPaths.length,
75
210
  inputPath,
76
211
  outputDir: fileOutputDir,
77
212
  minChapterDurationSeconds,
@@ -97,6 +232,8 @@ export async function runProcessCourseCli(rawArgs?: string[]) {
97
232
  }
98
233
 
99
234
  async function processInputFile(options: {
235
+ fileIndex: number
236
+ fileCount: number
100
237
  inputPath: string
101
238
  outputDir: string
102
239
  minChapterDurationSeconds: number
@@ -110,6 +247,8 @@ async function processInputFile(options: {
110
247
  whisperBinaryPath: string | undefined
111
248
  }) {
112
249
  const {
250
+ fileIndex,
251
+ fileCount,
113
252
  inputPath,
114
253
  outputDir,
115
254
  minChapterDurationSeconds,
@@ -168,6 +307,13 @@ async function processInputFile(options: {
168
307
  ? chapters.filter((chapter) => chapterIndexes.includes(chapter.index))
169
308
  : chapters
170
309
 
310
+ const progressReporter = createSpinnerProgressReporter({
311
+ fileIndex,
312
+ fileCount,
313
+ fileName: path.basename(inputPath),
314
+ chapterCount: selectedChapters.length,
315
+ })
316
+
171
317
  const summary: ProcessingSummary = {
172
318
  totalSelected: selectedChapters.length,
173
319
  processed: 0,
@@ -203,7 +349,11 @@ async function processInputFile(options: {
203
349
  const processedChaptersWithSpeech: ProcessedChapterInfo[] = []
204
350
  let previousProcessedChapter: ProcessedChapterInfo | null = null
205
351
 
206
- for (const chapter of selectedChapters) {
352
+ for (const [chapterOffset, chapter] of selectedChapters.entries()) {
353
+ const chapterProgress = progressReporter.createChapterProgress({
354
+ chapterIndex: chapterOffset + 1,
355
+ chapterTitle: chapter.title,
356
+ })
207
357
  // Determine which chapter to combine with
208
358
  // Always use the most recent processed chapter with speech (if any)
209
359
  const chapterToCombineWith: ProcessedChapterInfo | null =
@@ -222,6 +372,7 @@ async function processInputFile(options: {
222
372
  const result = await processChapter(chapter, {
223
373
  ...processingOptions,
224
374
  previousProcessedChapter: chapterToCombineWith,
375
+ progress: chapterProgress,
225
376
  })
226
377
 
227
378
  // Update summary based on result