eprec 1.7.0 → 1.9.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.
@@ -1,3 +1,4 @@
1
+ import { matchSorter } from 'match-sorter'
1
2
  import type { Handle } from 'remix/component'
2
3
  import {
3
4
  sampleEditSession,
@@ -8,19 +9,78 @@ import {
8
9
  type TranscriptWord,
9
10
  } from './edit-session-data.ts'
10
11
 
12
+ type AppConfig = {
13
+ initialVideoPath?: string
14
+ }
15
+
16
+ declare global {
17
+ interface Window {
18
+ __EPREC_APP__?: AppConfig
19
+ }
20
+ }
21
+
11
22
  const MIN_CUT_LENGTH = 0.2
12
23
  const DEFAULT_CUT_LENGTH = 2.4
13
24
  const PLAYHEAD_STEP = 0.1
25
+ const DEFAULT_PREVIEW_URL = '/e2e-test.mp4'
26
+
27
+ function readInitialVideoPath() {
28
+ if (typeof window === 'undefined') return ''
29
+ const raw = window.__EPREC_APP__?.initialVideoPath
30
+ if (typeof raw !== 'string') return ''
31
+ return raw.trim()
32
+ }
33
+
34
+ function buildVideoPreviewUrl(value: string) {
35
+ return `/api/video?path=${encodeURIComponent(value)}`
36
+ }
37
+
38
+ function extractVideoName(value: string) {
39
+ const normalized = value.replace(/\\/g, '/')
40
+ const parts = normalized.split('/')
41
+ const last = parts[parts.length - 1]
42
+ return last && last.length > 0 ? last : value
43
+ }
44
+
45
+ type ProcessingStatus = 'queued' | 'running' | 'done'
46
+ type ProcessingCategory = 'chapter' | 'transcript' | 'export'
47
+ type ProcessingTask = {
48
+ id: string
49
+ title: string
50
+ detail: string
51
+ status: ProcessingStatus
52
+ category: ProcessingCategory
53
+ }
14
54
 
15
55
  export function EditingWorkspace(handle: Handle) {
16
56
  const duration = sampleEditSession.duration
17
57
  const transcript = sampleEditSession.transcript
18
58
  const commands = sampleEditSession.commands
59
+ const transcriptIndex = transcript.map((word) => ({
60
+ ...word,
61
+ context: buildContext(transcript, word.index, 3),
62
+ }))
63
+ const initialVideoPath = readInitialVideoPath()
64
+ let sourceName = sampleEditSession.sourceName
65
+ let sourcePath = ''
66
+ let previewUrl = DEFAULT_PREVIEW_URL
67
+ let previewSource: 'demo' | 'path' = 'demo'
68
+ let videoPathInput = initialVideoPath
69
+ let pathStatus: 'idle' | 'loading' | 'ready' | 'error' = initialVideoPath
70
+ ? 'loading'
71
+ : 'idle'
72
+ let pathError = ''
73
+ let previewError = ''
19
74
  let cutRanges = sampleEditSession.cuts.map((range) => ({ ...range }))
20
75
  let chapters = sampleEditSession.chapters.map((chapter) => ({ ...chapter }))
21
76
  let playhead = 18.2
22
77
  let selectedRangeId = cutRanges[0]?.id ?? null
23
78
  let searchQuery = ''
79
+ let primaryChapterId = chapters[0]?.id ?? ''
80
+ let secondaryChapterId = chapters[1]?.id ?? chapters[0]?.id ?? ''
81
+ let processingQueue: ProcessingTask[] = []
82
+ let activeTaskId: string | null = null
83
+ let processingCount = 1
24
84
  let manualCutId = 1
25
85
  let previewDuration = 0
26
86
  let previewReady = false
@@ -29,6 +89,94 @@ export function EditingWorkspace(handle: Handle) {
29
89
  let lastSyncedPlayhead = playhead
30
90
  let isScrubbing = false
31
91
 
92
+ const resetPreviewState = () => {
93
+ previewReady = false
94
+ previewPlaying = false
95
+ previewDuration = 0
96
+ previewError = ''
97
+ lastSyncedPlayhead = playhead
98
+ }
99
+
100
+ const updateVideoPathInput = (value: string) => {
101
+ videoPathInput = value
102
+ if (pathError) pathError = ''
103
+ if (pathStatus === 'error') pathStatus = 'idle'
104
+ handle.update()
105
+ }
106
+
107
+ const applyPreviewSource = (options: {
108
+ url: string
109
+ name: string
110
+ source: 'demo' | 'path'
111
+ path?: string
112
+ }) => {
113
+ previewUrl = options.url
114
+ sourceName = options.name
115
+ sourcePath = options.path ?? ''
116
+ previewSource = options.source
117
+ resetPreviewState()
118
+ handle.update()
119
+ }
120
+
121
+ const loadVideoFromPath = async (override?: string) => {
122
+ const candidate = (override ?? videoPathInput).trim()
123
+ if (!candidate) {
124
+ pathError = 'Enter a video file path to load.'
125
+ pathStatus = 'error'
126
+ handle.update()
127
+ return
128
+ }
129
+ videoPathInput = candidate
130
+ pathStatus = 'loading'
131
+ pathError = ''
132
+ previewError = ''
133
+ handle.update()
134
+ const preview = buildVideoPreviewUrl(candidate)
135
+ try {
136
+ const response = await fetch(preview, {
137
+ method: 'HEAD',
138
+ cache: 'no-store',
139
+ signal: handle.signal,
140
+ })
141
+ if (!response.ok) {
142
+ const message =
143
+ response.status === 404
144
+ ? 'Video file not found. Check the path.'
145
+ : `Unable to load the video (status ${response.status}).`
146
+ throw new Error(message)
147
+ }
148
+ if (handle.signal.aborted) return
149
+ pathStatus = 'ready'
150
+ applyPreviewSource({
151
+ url: preview,
152
+ name: extractVideoName(candidate),
153
+ source: 'path',
154
+ path: candidate,
155
+ })
156
+ } catch (error) {
157
+ if (handle.signal.aborted) return
158
+ pathStatus = 'error'
159
+ pathError =
160
+ error instanceof Error ? error.message : 'Unable to load the video.'
161
+ handle.update()
162
+ }
163
+ }
164
+
165
+ const resetToDemo = () => {
166
+ pathStatus = 'idle'
167
+ pathError = ''
168
+ videoPathInput = ''
169
+ applyPreviewSource({
170
+ url: DEFAULT_PREVIEW_URL,
171
+ name: sampleEditSession.sourceName,
172
+ source: 'demo',
173
+ })
174
+ }
175
+
176
+ if (initialVideoPath) {
177
+ void loadVideoFromPath(initialVideoPath)
178
+ }
179
+
32
180
  const setPlayhead = (value: number) => {
33
181
  playhead = clamp(value, 0, duration)
34
182
  syncVideoToPlayhead(playhead)
@@ -144,6 +292,127 @@ export function EditingWorkspace(handle: Handle) {
144
292
  handle.update()
145
293
  }
146
294
 
295
+ const findChapter = (chapterId: string) =>
296
+ chapters.find((chapter) => chapter.id === chapterId) ?? null
297
+
298
+ const updatePrimaryChapter = (chapterId: string) => {
299
+ primaryChapterId = chapterId
300
+ if (secondaryChapterId === chapterId) {
301
+ secondaryChapterId =
302
+ chapters.find((chapter) => chapter.id !== chapterId)?.id ?? chapterId
303
+ }
304
+ handle.update()
305
+ }
306
+
307
+ const updateSecondaryChapter = (chapterId: string) => {
308
+ secondaryChapterId = chapterId
309
+ handle.update()
310
+ }
311
+
312
+ const queueTask = (
313
+ title: string,
314
+ detail: string,
315
+ category: ProcessingCategory,
316
+ ) => {
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()
326
+ }
327
+
328
+ const queueChapterEdit = () => {
329
+ const chapter = findChapter(primaryChapterId)
330
+ if (!chapter) return
331
+ queueTask(
332
+ `Edit ${chapter.title}`,
333
+ `Review trims for ${formatTimestamp(chapter.start)} - ${formatTimestamp(
334
+ chapter.end,
335
+ )}.`,
336
+ 'chapter',
337
+ )
338
+ }
339
+
340
+ const queueCombineChapters = () => {
341
+ const primary = findChapter(primaryChapterId)
342
+ const secondary = findChapter(secondaryChapterId)
343
+ if (!primary || !secondary || primary.id === secondary.id) return
344
+ queueTask(
345
+ `Combine ${primary.title} + ${secondary.title}`,
346
+ 'Merge both chapters into a single preview export.',
347
+ 'chapter',
348
+ )
349
+ }
350
+
351
+ const queueTranscriptRegeneration = () => {
352
+ queueTask(
353
+ 'Regenerate transcript',
354
+ 'Run Whisper alignment and refresh search cues.',
355
+ 'transcript',
356
+ )
357
+ }
358
+
359
+ const queueCommandScan = () => {
360
+ queueTask(
361
+ 'Detect command windows',
362
+ 'Scan for Jarvis commands and update cut ranges.',
363
+ 'transcript',
364
+ )
365
+ }
366
+
367
+ const queuePreviewRender = () => {
368
+ queueTask(
369
+ 'Render preview clip',
370
+ 'Bake a short MP4 with current edits applied.',
371
+ 'export',
372
+ )
373
+ }
374
+
375
+ const queueFinalExport = () => {
376
+ queueTask(
377
+ 'Export edited chapters',
378
+ 'Render final chapters and write the export package.',
379
+ 'export',
380
+ )
381
+ }
382
+
383
+ 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()
392
+ }
393
+
394
+ 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()
401
+ }
402
+
403
+ const clearCompletedTasks = () => {
404
+ processingQueue = processingQueue.filter((task) => task.status !== 'done')
405
+ handle.update()
406
+ }
407
+
408
+ const removeTask = (taskId: string) => {
409
+ processingQueue = processingQueue.filter((task) => task.id !== taskId)
410
+ if (activeTaskId === taskId) {
411
+ activeTaskId = null
412
+ }
413
+ handle.update()
414
+ }
415
+
147
416
  const syncVideoToPlayhead = (value: number) => {
148
417
  if (
149
418
  !previewNode ||
@@ -179,20 +448,44 @@ export function EditingWorkspace(handle: Handle) {
179
448
  const currentContext = currentWord
180
449
  ? buildContext(transcript, currentWord.index, 4)
181
450
  : 'No transcript cues found for the playhead.'
182
- const query = searchQuery.trim().toLowerCase()
451
+ const query = searchQuery.trim()
183
452
  const searchResults = query
184
- ? transcript
185
- .filter((word) => word.word.toLowerCase().includes(query))
186
- .slice(0, 12)
453
+ ? matchSorter(transcriptIndex, query, {
454
+ keys: ['word'],
455
+ }).slice(0, 12)
187
456
  : []
457
+ const queuedCount = processingQueue.filter(
458
+ (task) => task.status === 'queued',
459
+ ).length
460
+ const completedCount = processingQueue.filter(
461
+ (task) => task.status === 'done',
462
+ ).length
463
+ const runningTask =
464
+ processingQueue.find((task) => task.status === 'running') ?? null
465
+ const canCombineChapters =
466
+ primaryChapterId.length > 0 &&
467
+ secondaryChapterId.length > 0 &&
468
+ primaryChapterId !== secondaryChapterId
188
469
  const commandPreview = buildCommandPreview(
189
- sampleEditSession.sourceName,
470
+ sourceName,
190
471
  chapters,
472
+ sourcePath,
191
473
  )
192
474
  const previewTime =
193
475
  previewReady && previewDuration > 0
194
476
  ? (playhead / duration) * previewDuration
195
477
  : 0
478
+ const previewStatus = previewError
479
+ ? { label: 'Error', className: 'status-pill--danger' }
480
+ : previewReady
481
+ ? previewPlaying
482
+ ? { label: 'Playing', className: 'status-pill--info' }
483
+ : { label: 'Ready', className: 'status-pill--success' }
484
+ : { label: 'Loading', className: 'status-pill--warning' }
485
+ const sourceStatus =
486
+ previewSource === 'path'
487
+ ? { label: 'Path', className: 'status-pill--success' }
488
+ : { label: 'Demo', className: 'status-pill--info' }
196
489
 
197
490
  return (
198
491
  <main class="app-shell">
@@ -205,12 +498,77 @@ export function EditingWorkspace(handle: Handle) {
205
498
  </p>
206
499
  </header>
207
500
 
501
+ <section class="app-card app-card--full source-card">
502
+ <div class="source-header">
503
+ <div>
504
+ <h2>Source video</h2>
505
+ <p class="app-muted">
506
+ Paste a full video path to preview it and update the CLI export.
507
+ </p>
508
+ </div>
509
+ <span
510
+ class={classNames('status-pill', sourceStatus.className)}
511
+ title={`Preview source: ${sourceStatus.label}`}
512
+ >
513
+ {sourceStatus.label}
514
+ </span>
515
+ </div>
516
+ <div class="source-grid">
517
+ <div class="source-fields">
518
+ <label class="input-label">
519
+ Video file path
520
+ <input
521
+ class="text-input"
522
+ type="text"
523
+ placeholder="/path/to/video.mp4"
524
+ value={videoPathInput}
525
+ on={{
526
+ input: (event) => {
527
+ const target =
528
+ event.currentTarget as HTMLInputElement
529
+ updateVideoPathInput(target.value)
530
+ },
531
+ }}
532
+ />
533
+ </label>
534
+ <div class="source-actions">
535
+ <button
536
+ class="button button--primary"
537
+ type="button"
538
+ disabled={
539
+ pathStatus === 'loading' ||
540
+ videoPathInput.trim().length === 0
541
+ }
542
+ on={{ click: () => void loadVideoFromPath() }}
543
+ >
544
+ {pathStatus === 'loading' ? 'Checking...' : 'Load from path'}
545
+ </button>
546
+ <button
547
+ class="button button--ghost"
548
+ type="button"
549
+ on={{ click: resetToDemo }}
550
+ >
551
+ Use demo video
552
+ </button>
553
+ </div>
554
+ {pathStatus === 'error' && pathError ? (
555
+ <p class="status-note status-note--danger">{pathError}</p>
556
+ ) : null}
557
+ </div>
558
+ </div>
559
+ </section>
560
+
208
561
  <section class="app-card app-card--full">
209
562
  <h2>Session summary</h2>
210
563
  <div class="summary-grid">
211
564
  <div class="summary-item">
212
565
  <span class="summary-label">Source video</span>
213
- <span class="summary-value">{sampleEditSession.sourceName}</span>
566
+ <span class="summary-value">{sourceName}</span>
567
+ {sourcePath ? (
568
+ <span class="summary-subtext">{sourcePath}</span>
569
+ ) : (
570
+ <span class="summary-subtext">Demo fixture video</span>
571
+ )}
214
572
  <span class="summary-subtext">
215
573
  Duration {formatTimestamp(duration)}
216
574
  </span>
@@ -246,6 +604,227 @@ export function EditingWorkspace(handle: Handle) {
246
604
  </div>
247
605
  </section>
248
606
 
607
+ <section class="app-card app-card--full actions-card">
608
+ <div class="actions-header">
609
+ <div>
610
+ <h2>Processing actions</h2>
611
+ <p class="app-muted">
612
+ Queue chapter edits, transcript cleanup, and export jobs
613
+ directly from the workspace.
614
+ </p>
615
+ </div>
616
+ <div class="actions-meta">
617
+ <div class="summary-item">
618
+ <span class="summary-label">Queue</span>
619
+ <span class="summary-value">{queuedCount} queued</span>
620
+ <span class="summary-subtext">
621
+ {runningTask ? `Running: ${runningTask.title}` : 'Idle'}
622
+ </span>
623
+ </div>
624
+ </div>
625
+ <div class="actions-buttons">
626
+ <button
627
+ class="button button--primary"
628
+ type="button"
629
+ disabled={queuedCount === 0 || Boolean(runningTask)}
630
+ on={{ click: startNextTask }}
631
+ >
632
+ Run next
633
+ </button>
634
+ <button
635
+ class="button button--ghost"
636
+ type="button"
637
+ disabled={!runningTask}
638
+ on={{ click: markActiveDone }}
639
+ >
640
+ Mark running done
641
+ </button>
642
+ <button
643
+ class="button button--ghost"
644
+ type="button"
645
+ disabled={completedCount === 0}
646
+ on={{ click: clearCompletedTasks }}
647
+ >
648
+ Clear completed
649
+ </button>
650
+ </div>
651
+ </div>
652
+
653
+ <div class="actions-grid">
654
+ <article class="actions-panel">
655
+ <div class="panel-header">
656
+ <h3>Chapter processing</h3>
657
+ <span class="status-pill status-pill--info">Chapter</span>
658
+ </div>
659
+ <label class="input-label">
660
+ Primary chapter
661
+ <select
662
+ class="text-input"
663
+ value={primaryChapterId}
664
+ on={{
665
+ change: (event) => {
666
+ const target = event.currentTarget as HTMLSelectElement
667
+ updatePrimaryChapter(target.value)
668
+ },
669
+ }}
670
+ >
671
+ {chapters.map((chapter) => (
672
+ <option value={chapter.id}>{chapter.title}</option>
673
+ ))}
674
+ </select>
675
+ </label>
676
+ <label class="input-label">
677
+ Secondary chapter
678
+ <select
679
+ class="text-input"
680
+ value={secondaryChapterId}
681
+ on={{
682
+ change: (event) => {
683
+ const target = event.currentTarget as HTMLSelectElement
684
+ updateSecondaryChapter(target.value)
685
+ },
686
+ }}
687
+ >
688
+ {chapters.map((chapter) => (
689
+ <option value={chapter.id}>{chapter.title}</option>
690
+ ))}
691
+ </select>
692
+ </label>
693
+ <div class="actions-button-row">
694
+ <button
695
+ class="button button--primary"
696
+ type="button"
697
+ disabled={!primaryChapterId}
698
+ on={{ click: queueChapterEdit }}
699
+ >
700
+ Edit chapter
701
+ </button>
702
+ <button
703
+ class="button button--ghost"
704
+ type="button"
705
+ disabled={!canCombineChapters}
706
+ on={{ click: queueCombineChapters }}
707
+ >
708
+ Combine chapters
709
+ </button>
710
+ </div>
711
+ <p class="app-muted">
712
+ Stage edits or merge two chapters without leaving this view.
713
+ </p>
714
+ </article>
715
+
716
+ <article class="actions-panel">
717
+ <div class="panel-header">
718
+ <h3>Transcript utilities</h3>
719
+ <span class="status-pill status-pill--warning">Transcript</span>
720
+ </div>
721
+ <div class="actions-button-row">
722
+ <button
723
+ class="button button--ghost"
724
+ type="button"
725
+ on={{ click: queueTranscriptRegeneration }}
726
+ >
727
+ Regenerate transcript
728
+ </button>
729
+ <button
730
+ class="button button--ghost"
731
+ type="button"
732
+ on={{ click: queueCommandScan }}
733
+ >
734
+ Detect command windows
735
+ </button>
736
+ </div>
737
+ <p class="app-muted">
738
+ Refresh the transcript or scan for command markers on demand.
739
+ </p>
740
+ </article>
741
+
742
+ <article class="actions-panel">
743
+ <div class="panel-header">
744
+ <h3>Exports</h3>
745
+ <span class="status-pill status-pill--success">Export</span>
746
+ </div>
747
+ <div class="actions-button-row">
748
+ <button
749
+ class="button button--ghost"
750
+ type="button"
751
+ on={{ click: queuePreviewRender }}
752
+ >
753
+ Render preview clip
754
+ </button>
755
+ <button
756
+ class="button button--ghost"
757
+ type="button"
758
+ on={{ click: queueFinalExport }}
759
+ >
760
+ Export edited chapters
761
+ </button>
762
+ </div>
763
+ <p class="app-muted">
764
+ Trigger preview renders or finalize exports for the pipeline.
765
+ </p>
766
+ </article>
767
+ </div>
768
+
769
+ <div class="actions-queue">
770
+ <div class="panel-header">
771
+ <h3>Processing queue</h3>
772
+ <span class="summary-subtext">
773
+ {processingQueue.length} total
774
+ </span>
775
+ </div>
776
+ {processingQueue.length === 0 ? (
777
+ <p class="app-muted">
778
+ No actions queued yet. Use the buttons above to stage work.
779
+ </p>
780
+ ) : (
781
+ <ul class="stacked-list processing-list">
782
+ {processingQueue.map((task) => (
783
+ <li
784
+ class={classNames(
785
+ 'stacked-item',
786
+ 'processing-row',
787
+ task.status === 'running' && 'is-running',
788
+ task.status === 'done' && 'is-complete',
789
+ )}
790
+ >
791
+ <div class="processing-row-header">
792
+ <div>
793
+ <h4>{task.title}</h4>
794
+ <p class="app-muted">{task.detail}</p>
795
+ </div>
796
+ <span
797
+ class={classNames(
798
+ 'status-pill',
799
+ task.status === 'queued' && 'status-pill--info',
800
+ task.status === 'running' && 'status-pill--warning',
801
+ task.status === 'done' && 'status-pill--success',
802
+ )}
803
+ >
804
+ {task.status}
805
+ </span>
806
+ </div>
807
+ <div class="processing-row-meta">
808
+ <span class="summary-subtext">
809
+ {formatProcessingCategory(task.category)}
810
+ </span>
811
+ {task.status === 'queued' ? (
812
+ <button
813
+ class="button button--ghost"
814
+ type="button"
815
+ on={{ click: () => removeTask(task.id) }}
816
+ >
817
+ Remove
818
+ </button>
819
+ ) : null}
820
+ </div>
821
+ </li>
822
+ ))}
823
+ </ul>
824
+ )}
825
+ </div>
826
+ </section>
827
+
249
828
  <section class="app-card app-card--full timeline-card">
250
829
  <div class="timeline-header">
251
830
  <div>
@@ -277,23 +856,15 @@ export function EditingWorkspace(handle: Handle) {
277
856
  <span
278
857
  class={classNames(
279
858
  'status-pill',
280
- previewReady
281
- ? previewPlaying
282
- ? 'status-pill--info'
283
- : 'status-pill--success'
284
- : 'status-pill--warning',
859
+ previewStatus.className,
285
860
  )}
286
861
  >
287
- {previewReady
288
- ? previewPlaying
289
- ? 'Playing'
290
- : 'Ready'
291
- : 'Loading'}
862
+ {previewStatus.label}
292
863
  </span>
293
864
  </div>
294
865
  <video
295
866
  class="timeline-video-player"
296
- src="/e2e-test.mp4"
867
+ src={previewUrl}
297
868
  controls
298
869
  preload="metadata"
299
870
  connect={(node: HTMLVideoElement, signal) => {
@@ -304,6 +875,7 @@ export function EditingWorkspace(handle: Handle) {
304
875
  ? nextDuration
305
876
  : 0
306
877
  previewReady = previewDuration > 0
878
+ previewError = ''
307
879
  syncVideoToPlayhead(playhead)
308
880
  handle.update()
309
881
  }
@@ -327,6 +899,12 @@ export function EditingWorkspace(handle: Handle) {
327
899
  previewPlaying = false
328
900
  handle.update()
329
901
  }
902
+ const handleError = () => {
903
+ previewError = 'Unable to load the preview video.'
904
+ previewReady = false
905
+ previewPlaying = false
906
+ handle.update()
907
+ }
330
908
  node.addEventListener(
331
909
  'loadedmetadata',
332
910
  handleLoadedMetadata,
@@ -334,6 +912,7 @@ export function EditingWorkspace(handle: Handle) {
334
912
  node.addEventListener('timeupdate', handleTimeUpdate)
335
913
  node.addEventListener('play', handlePlay)
336
914
  node.addEventListener('pause', handlePause)
915
+ node.addEventListener('error', handleError)
337
916
  signal.addEventListener('abort', () => {
338
917
  node.removeEventListener(
339
918
  'loadedmetadata',
@@ -342,6 +921,7 @@ export function EditingWorkspace(handle: Handle) {
342
921
  node.removeEventListener('timeupdate', handleTimeUpdate)
343
922
  node.removeEventListener('play', handlePlay)
344
923
  node.removeEventListener('pause', handlePause)
924
+ node.removeEventListener('error', handleError)
345
925
  if (previewNode === node) {
346
926
  previewNode = null
347
927
  }
@@ -354,6 +934,9 @@ export function EditingWorkspace(handle: Handle) {
354
934
  Timeline {formatTimestamp(playhead)}
355
935
  </span>
356
936
  </div>
937
+ {previewError ? (
938
+ <p class="status-note status-note--danger">{previewError}</p>
939
+ ) : null}
357
940
  </div>
358
941
  <div
359
942
  class="timeline-track"
@@ -523,10 +1106,11 @@ export function EditingWorkspace(handle: Handle) {
523
1106
  )}
524
1107
 
525
1108
  <h3>Cut list</h3>
526
- <ul class="cut-list">
1109
+ <ul class="cut-list stacked-list">
527
1110
  {sortedCuts.map((range) => (
528
1111
  <li
529
1112
  class={classNames(
1113
+ 'stacked-item',
530
1114
  'cut-row',
531
1115
  range.id === selectedRangeId && 'is-selected',
532
1116
  )}
@@ -562,9 +1146,9 @@ export function EditingWorkspace(handle: Handle) {
562
1146
  <p class="app-muted">
563
1147
  Update output names and mark chapters to skip before export.
564
1148
  </p>
565
- <div class="chapter-list">
1149
+ <div class="chapter-list stacked-list">
566
1150
  {chapters.map((chapter) => (
567
- <article class="chapter-row">
1151
+ <article class="chapter-row stacked-item">
568
1152
  <div class="chapter-header">
569
1153
  <div>
570
1154
  <h3>{chapter.title}</h3>
@@ -630,11 +1214,11 @@ export function EditingWorkspace(handle: Handle) {
630
1214
  <p class="app-muted">
631
1215
  Apply Jarvis commands to your cut list or chapter metadata.
632
1216
  </p>
633
- <div class="command-list">
1217
+ <div class="command-list stacked-list">
634
1218
  {commands.map((command) => {
635
1219
  const applied = isCommandApplied(command, sortedCuts, chapters)
636
1220
  return (
637
- <article class="command-row">
1221
+ <article class="command-row stacked-item">
638
1222
  <div class="command-header">
639
1223
  <h3>{command.label}</h3>
640
1224
  <span
@@ -705,10 +1289,14 @@ export function EditingWorkspace(handle: Handle) {
705
1289
  <p class="app-muted transcript-empty">
706
1290
  Type to search the transcript words. Click a result to jump to it.
707
1291
  </p>
1292
+ ) : searchResults.length === 0 ? (
1293
+ <p class="app-muted transcript-empty">
1294
+ No results found for "{query}".
1295
+ </p>
708
1296
  ) : (
709
- <ul class="transcript-results">
1297
+ <ul class="transcript-results stacked-list">
710
1298
  {searchResults.map((word) => (
711
- <li>
1299
+ <li class="stacked-item">
712
1300
  <button
713
1301
  class="transcript-result"
714
1302
  type="button"
@@ -717,9 +1305,7 @@ export function EditingWorkspace(handle: Handle) {
717
1305
  <span class="transcript-time">
718
1306
  {formatTimestamp(word.start)}
719
1307
  </span>
720
- <span class="transcript-snippet">
721
- {buildContext(transcript, word.index, 3)}
722
- </span>
1308
+ <span class="transcript-snippet">{word.context}</span>
723
1309
  </button>
724
1310
  </li>
725
1311
  ))}
@@ -786,6 +1372,12 @@ function formatSeconds(value: number) {
786
1372
  return `${value.toFixed(1)}s`
787
1373
  }
788
1374
 
1375
+ function formatProcessingCategory(category: ProcessingCategory) {
1376
+ if (category === 'chapter') return 'Chapter task'
1377
+ if (category === 'transcript') return 'Transcript task'
1378
+ return 'Export task'
1379
+ }
1380
+
789
1381
  function classNames(...values: Array<string | false | null | undefined>) {
790
1382
  return values.filter(Boolean).join(' ')
791
1383
  }
@@ -819,13 +1411,21 @@ function buildTimelineTicks(duration: number, count: number) {
819
1411
  )
820
1412
  }
821
1413
 
822
- function buildCommandPreview(sourceName: string, chapters: ChapterPlan[]) {
1414
+ function buildCommandPreview(
1415
+ sourceName: string,
1416
+ chapters: ChapterPlan[],
1417
+ sourcePath?: string,
1418
+ ) {
823
1419
  const outputName =
824
1420
  chapters.find((chapter) => chapter.status !== 'skipped')?.outputName ??
825
1421
  'edited-output.mp4'
1422
+ const inputPath =
1423
+ typeof sourcePath === 'string' && sourcePath.trim().length > 0
1424
+ ? sourcePath
1425
+ : sourceName
826
1426
  return [
827
1427
  'bun process-course/edits/cli.ts edit-video \\',
828
- ` --input "${sourceName}" \\`,
1428
+ ` --input "${inputPath}" \\`,
829
1429
  ' --transcript "transcript.json" \\',
830
1430
  ' --edited "transcript.txt" \\',
831
1431
  ` --output "${outputName}"`,