eprec 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,869 @@
1
+ import type { Handle } from 'remix/component'
2
+ import {
3
+ sampleEditSession,
4
+ type ChapterPlan,
5
+ type ChapterStatus,
6
+ type CommandWindow,
7
+ type CutRange,
8
+ type TranscriptWord,
9
+ } from './edit-session-data.ts'
10
+
11
+ const MIN_CUT_LENGTH = 0.2
12
+ const DEFAULT_CUT_LENGTH = 2.4
13
+ const PLAYHEAD_STEP = 0.1
14
+
15
+ export function EditingWorkspace(handle: Handle) {
16
+ const duration = sampleEditSession.duration
17
+ const transcript = sampleEditSession.transcript
18
+ const commands = sampleEditSession.commands
19
+ let cutRanges = sampleEditSession.cuts.map((range) => ({ ...range }))
20
+ let chapters = sampleEditSession.chapters.map((chapter) => ({ ...chapter }))
21
+ let playhead = 18.2
22
+ let selectedRangeId = cutRanges[0]?.id ?? null
23
+ let searchQuery = ''
24
+ let manualCutId = 1
25
+ let previewDuration = 0
26
+ let previewReady = false
27
+ let previewPlaying = false
28
+ let previewNode: HTMLVideoElement | null = null
29
+ let lastSyncedPlayhead = playhead
30
+ let isScrubbing = false
31
+
32
+ const setPlayhead = (value: number) => {
33
+ playhead = clamp(value, 0, duration)
34
+ syncVideoToPlayhead(playhead)
35
+ handle.update()
36
+ }
37
+
38
+ const startScrubbing = () => {
39
+ isScrubbing = true
40
+ }
41
+
42
+ const stopScrubbing = () => {
43
+ if (!isScrubbing) return
44
+ isScrubbing = false
45
+ syncVideoToPlayhead(playhead)
46
+ }
47
+
48
+ const selectRange = (rangeId: string) => {
49
+ selectedRangeId = rangeId
50
+ handle.update()
51
+ }
52
+
53
+ const addManualCut = () => {
54
+ const start = clamp(playhead, 0, duration - MIN_CUT_LENGTH)
55
+ const end = clamp(
56
+ start + DEFAULT_CUT_LENGTH,
57
+ start + MIN_CUT_LENGTH,
58
+ duration,
59
+ )
60
+ const newRange: CutRange = {
61
+ id: `manual-${manualCutId++}`,
62
+ start,
63
+ end,
64
+ reason: 'Manual trim added from the timeline.',
65
+ source: 'manual',
66
+ }
67
+ cutRanges = sortRanges([...cutRanges, newRange])
68
+ selectedRangeId = newRange.id
69
+ handle.update()
70
+ }
71
+
72
+ const removeCut = (rangeId: string) => {
73
+ cutRanges = cutRanges.filter((range) => range.id !== rangeId)
74
+ if (selectedRangeId === rangeId) {
75
+ selectedRangeId = cutRanges[0]?.id ?? null
76
+ }
77
+ handle.update()
78
+ }
79
+
80
+ const updateCutRange = (rangeId: string, patch: Partial<CutRange>) => {
81
+ cutRanges = sortRanges(
82
+ cutRanges.map((range) => {
83
+ if (range.id !== rangeId) return range
84
+ return normalizeRange({ ...range, ...patch }, duration)
85
+ }),
86
+ )
87
+ selectedRangeId = rangeId
88
+ handle.update()
89
+ }
90
+
91
+ const applyCommand = (command: CommandWindow) => {
92
+ if (command.action === 'remove') {
93
+ if (isCommandApplied(command, cutRanges, chapters)) return
94
+ const range: CutRange = {
95
+ id: `command-${command.id}`,
96
+ start: command.start,
97
+ end: command.end,
98
+ reason: `Jarvis command: ${command.label}.`,
99
+ source: 'command',
100
+ sourceId: command.id,
101
+ }
102
+ cutRanges = sortRanges([...cutRanges, normalizeRange(range, duration)])
103
+ selectedRangeId = range.id
104
+ handle.update()
105
+ return
106
+ }
107
+
108
+ if (command.action === 'rename' && command.value) {
109
+ chapters = chapters.map((chapter) =>
110
+ chapter.id === command.chapterId
111
+ ? { ...chapter, outputName: command.value, status: 'review' }
112
+ : chapter,
113
+ )
114
+ handle.update()
115
+ return
116
+ }
117
+
118
+ if (command.action === 'skip') {
119
+ chapters = chapters.map((chapter) =>
120
+ chapter.id === command.chapterId
121
+ ? { ...chapter, status: 'skipped' }
122
+ : chapter,
123
+ )
124
+ handle.update()
125
+ }
126
+ }
127
+
128
+ const updateChapterStatus = (chapterId: string, status: ChapterStatus) => {
129
+ chapters = chapters.map((chapter) =>
130
+ chapter.id === chapterId ? { ...chapter, status } : chapter,
131
+ )
132
+ handle.update()
133
+ }
134
+
135
+ const updateChapterOutput = (chapterId: string, outputName: string) => {
136
+ chapters = chapters.map((chapter) =>
137
+ chapter.id === chapterId ? { ...chapter, outputName } : chapter,
138
+ )
139
+ handle.update()
140
+ }
141
+
142
+ const updateSearchQuery = (value: string) => {
143
+ searchQuery = value
144
+ handle.update()
145
+ }
146
+
147
+ const syncVideoToPlayhead = (value: number) => {
148
+ if (
149
+ !previewNode ||
150
+ !previewReady ||
151
+ duration <= 0 ||
152
+ previewDuration <= 0
153
+ ) {
154
+ return
155
+ }
156
+ const targetTime = clamp(
157
+ (value / duration) * previewDuration,
158
+ 0,
159
+ previewDuration,
160
+ )
161
+ lastSyncedPlayhead = value
162
+ if (Math.abs(previewNode.currentTime - targetTime) > 0.05) {
163
+ previewNode.currentTime = targetTime
164
+ }
165
+ }
166
+
167
+ return () => {
168
+ const sortedCuts = sortRanges(cutRanges)
169
+ const selectedRange = selectedRangeId
170
+ ? (sortedCuts.find((range) => range.id === selectedRangeId) ?? null)
171
+ : null
172
+ const mergedCuts = mergeOverlappingRanges(sortedCuts)
173
+ const totalRemoved = mergedCuts.reduce(
174
+ (total, range) => total + (range.end - range.start),
175
+ 0,
176
+ )
177
+ const finalDuration = Math.max(duration - totalRemoved, 0)
178
+ const currentWord = findWordAtTime(transcript, playhead)
179
+ const currentContext = currentWord
180
+ ? buildContext(transcript, currentWord.index, 4)
181
+ : 'No transcript cues found for the playhead.'
182
+ const query = searchQuery.trim().toLowerCase()
183
+ const searchResults = query
184
+ ? transcript
185
+ .filter((word) => word.word.toLowerCase().includes(query))
186
+ .slice(0, 12)
187
+ : []
188
+ const commandPreview = buildCommandPreview(
189
+ sampleEditSession.sourceName,
190
+ chapters,
191
+ )
192
+ const previewTime =
193
+ previewReady && previewDuration > 0
194
+ ? (playhead / duration) * previewDuration
195
+ : 0
196
+
197
+ return (
198
+ <main class="app-shell">
199
+ <header class="app-header">
200
+ <span class="app-kicker">Eprec Studio</span>
201
+ <h1 class="app-title">Editing workspace</h1>
202
+ <p class="app-subtitle">
203
+ Review transcript-based edits, refine command windows, and prepare
204
+ the final CLI export in one place.
205
+ </p>
206
+ </header>
207
+
208
+ <section class="app-card app-card--full">
209
+ <h2>Session summary</h2>
210
+ <div class="summary-grid">
211
+ <div class="summary-item">
212
+ <span class="summary-label">Source video</span>
213
+ <span class="summary-value">{sampleEditSession.sourceName}</span>
214
+ <span class="summary-subtext">
215
+ Duration {formatTimestamp(duration)}
216
+ </span>
217
+ </div>
218
+ <div class="summary-item">
219
+ <span class="summary-label">Cuts</span>
220
+ <span class="summary-value">{sortedCuts.length} ranges</span>
221
+ <span class="summary-subtext">
222
+ {formatSeconds(totalRemoved)} removed
223
+ </span>
224
+ </div>
225
+ <div class="summary-item">
226
+ <span class="summary-label">Output length</span>
227
+ <span class="summary-value">
228
+ {formatTimestamp(finalDuration)}
229
+ </span>
230
+ <span class="summary-subtext">
231
+ {Math.round((finalDuration / duration) * 100)}% retained
232
+ </span>
233
+ </div>
234
+ <div class="summary-item">
235
+ <span class="summary-label">Commands</span>
236
+ <span class="summary-value">{commands.length} detected</span>
237
+ <span class="summary-subtext">
238
+ {
239
+ commands.filter((command) =>
240
+ isCommandApplied(command, sortedCuts, chapters),
241
+ ).length
242
+ }{' '}
243
+ applied
244
+ </span>
245
+ </div>
246
+ </div>
247
+ </section>
248
+
249
+ <section class="app-card app-card--full timeline-card">
250
+ <div class="timeline-header">
251
+ <div>
252
+ <h2>Timeline editor</h2>
253
+ <p class="app-muted">
254
+ Adjust cut ranges, jump between transcript cues, and add manual
255
+ trims.
256
+ </p>
257
+ </div>
258
+ <button
259
+ class="button button--primary"
260
+ type="button"
261
+ on={{ click: addManualCut }}
262
+ >
263
+ Add cut at playhead
264
+ </button>
265
+ </div>
266
+
267
+ <div class="timeline-layout">
268
+ <div class="timeline-preview">
269
+ <div class="timeline-video">
270
+ <div class="timeline-video-header">
271
+ <div>
272
+ <span class="summary-label">Preview video</span>
273
+ <span class="summary-subtext">
274
+ Scrub the timeline or play to sync the preview.
275
+ </span>
276
+ </div>
277
+ <span
278
+ class={classNames(
279
+ 'status-pill',
280
+ previewReady
281
+ ? previewPlaying
282
+ ? 'status-pill--info'
283
+ : 'status-pill--success'
284
+ : 'status-pill--warning',
285
+ )}
286
+ >
287
+ {previewReady
288
+ ? previewPlaying
289
+ ? 'Playing'
290
+ : 'Ready'
291
+ : 'Loading'}
292
+ </span>
293
+ </div>
294
+ <video
295
+ class="timeline-video-player"
296
+ src="/e2e-test.mp4"
297
+ controls
298
+ preload="metadata"
299
+ connect={(node: HTMLVideoElement, signal) => {
300
+ previewNode = node
301
+ const handleLoadedMetadata = () => {
302
+ const nextDuration = Number(node.duration)
303
+ previewDuration = Number.isFinite(nextDuration)
304
+ ? nextDuration
305
+ : 0
306
+ previewReady = previewDuration > 0
307
+ syncVideoToPlayhead(playhead)
308
+ handle.update()
309
+ }
310
+ const handleTimeUpdate = () => {
311
+ if (!previewReady || previewDuration <= 0) return
312
+ if (isScrubbing) return
313
+ if (!previewPlaying) return
314
+ const mapped =
315
+ (node.currentTime / previewDuration) * duration
316
+ if (Math.abs(mapped - lastSyncedPlayhead) <= 0.05) {
317
+ return
318
+ }
319
+ playhead = clamp(mapped, 0, duration)
320
+ handle.update()
321
+ }
322
+ const handlePlay = () => {
323
+ previewPlaying = true
324
+ handle.update()
325
+ }
326
+ const handlePause = () => {
327
+ previewPlaying = false
328
+ handle.update()
329
+ }
330
+ node.addEventListener(
331
+ 'loadedmetadata',
332
+ handleLoadedMetadata,
333
+ )
334
+ node.addEventListener('timeupdate', handleTimeUpdate)
335
+ node.addEventListener('play', handlePlay)
336
+ node.addEventListener('pause', handlePause)
337
+ signal.addEventListener('abort', () => {
338
+ node.removeEventListener(
339
+ 'loadedmetadata',
340
+ handleLoadedMetadata,
341
+ )
342
+ node.removeEventListener('timeupdate', handleTimeUpdate)
343
+ node.removeEventListener('play', handlePlay)
344
+ node.removeEventListener('pause', handlePause)
345
+ if (previewNode === node) {
346
+ previewNode = null
347
+ }
348
+ })
349
+ }}
350
+ />
351
+ <div class="timeline-video-meta">
352
+ <span>Preview {formatTimestamp(previewTime)}</span>
353
+ <span class="app-muted">
354
+ Timeline {formatTimestamp(playhead)}
355
+ </span>
356
+ </div>
357
+ </div>
358
+ <div
359
+ class="timeline-track"
360
+ style={`--playhead:${(playhead / duration) * 100}%`}
361
+ >
362
+ {sortedCuts.map((range) => (
363
+ <button
364
+ type="button"
365
+ class={classNames(
366
+ 'timeline-range',
367
+ range.source === 'manual'
368
+ ? 'timeline-range--manual'
369
+ : 'timeline-range--command',
370
+ range.id === selectedRangeId && 'is-selected',
371
+ )}
372
+ style={`--range-left:${(range.start / duration) * 100}%; --range-width:${((range.end - range.start) / duration) * 100}%`}
373
+ on={{ click: () => selectRange(range.id) }}
374
+ title={`${range.reason} (${formatTimestamp(range.start)} - ${formatTimestamp(range.end)})`}
375
+ />
376
+ ))}
377
+ <span class="timeline-playhead" />
378
+ </div>
379
+ <div class="timeline-scale">
380
+ {buildTimelineTicks(duration, 6).map((tick) => (
381
+ <span>{formatTimestamp(tick)}</span>
382
+ ))}
383
+ </div>
384
+ <div class="timeline-controls">
385
+ <label class="control-label">
386
+ Playhead
387
+ <span class="control-value">{formatTimestamp(playhead)}</span>
388
+ </label>
389
+ <input
390
+ class="timeline-slider"
391
+ type="range"
392
+ min="0"
393
+ max={duration}
394
+ step={PLAYHEAD_STEP}
395
+ value={playhead}
396
+ on={{
397
+ input: (event) => {
398
+ const target = event.currentTarget as HTMLInputElement
399
+ startScrubbing()
400
+ setPlayhead(Number(target.value))
401
+ },
402
+ pointerdown: startScrubbing,
403
+ pointerup: stopScrubbing,
404
+ pointercancel: stopScrubbing,
405
+ keydown: startScrubbing,
406
+ keyup: stopScrubbing,
407
+ blur: stopScrubbing,
408
+ }}
409
+ />
410
+ <button
411
+ class="button button--ghost"
412
+ type="button"
413
+ on={{
414
+ click: () => {
415
+ const previous = findPreviousCut(sortedCuts, playhead)
416
+ if (previous) setPlayhead(previous.start)
417
+ },
418
+ }}
419
+ >
420
+ Prev cut
421
+ </button>
422
+ <button
423
+ class="button button--ghost"
424
+ type="button"
425
+ on={{
426
+ click: () => {
427
+ const next = sortedCuts.find(
428
+ (range) => range.start > playhead,
429
+ )
430
+ if (next) setPlayhead(next.start)
431
+ },
432
+ }}
433
+ >
434
+ Next cut
435
+ </button>
436
+ </div>
437
+ </div>
438
+
439
+ <div class="timeline-editor">
440
+ <div class="panel-header">
441
+ <h3>Selected cut</h3>
442
+ {selectedRange ? (
443
+ <span
444
+ class={classNames(
445
+ 'status-pill',
446
+ selectedRange.source === 'manual'
447
+ ? 'status-pill--warning'
448
+ : 'status-pill--danger',
449
+ )}
450
+ >
451
+ {selectedRange.source === 'manual' ? 'Manual' : 'Command'}
452
+ </span>
453
+ ) : null}
454
+ </div>
455
+ {selectedRange ? (
456
+ <div class="panel-grid">
457
+ <label class="input-label">
458
+ Start
459
+ <input
460
+ class="text-input text-input--compact"
461
+ type="number"
462
+ min="0"
463
+ max={duration}
464
+ step="0.1"
465
+ value={selectedRange.start.toFixed(2)}
466
+ on={{
467
+ input: (event) => {
468
+ const target = event.currentTarget as HTMLInputElement
469
+ updateCutRange(selectedRange.id, {
470
+ start: Number(target.value),
471
+ })
472
+ },
473
+ }}
474
+ />
475
+ </label>
476
+ <label class="input-label">
477
+ End
478
+ <input
479
+ class="text-input text-input--compact"
480
+ type="number"
481
+ min="0"
482
+ max={duration}
483
+ step="0.1"
484
+ value={selectedRange.end.toFixed(2)}
485
+ on={{
486
+ input: (event) => {
487
+ const target = event.currentTarget as HTMLInputElement
488
+ updateCutRange(selectedRange.id, {
489
+ end: Number(target.value),
490
+ })
491
+ },
492
+ }}
493
+ />
494
+ </label>
495
+ <label class="input-label input-label--full">
496
+ Reason
497
+ <input
498
+ class="text-input"
499
+ type="text"
500
+ value={selectedRange.reason}
501
+ on={{
502
+ input: (event) => {
503
+ const target = event.currentTarget as HTMLInputElement
504
+ updateCutRange(selectedRange.id, {
505
+ reason: target.value,
506
+ })
507
+ },
508
+ }}
509
+ />
510
+ </label>
511
+ <button
512
+ class="button button--danger"
513
+ type="button"
514
+ on={{ click: () => removeCut(selectedRange.id) }}
515
+ >
516
+ Remove cut
517
+ </button>
518
+ </div>
519
+ ) : (
520
+ <p class="app-muted">
521
+ Select a cut range to inspect or adjust.
522
+ </p>
523
+ )}
524
+
525
+ <h3>Cut list</h3>
526
+ <ul class="cut-list">
527
+ {sortedCuts.map((range) => (
528
+ <li
529
+ class={classNames(
530
+ 'cut-row',
531
+ range.id === selectedRangeId && 'is-selected',
532
+ )}
533
+ >
534
+ <button
535
+ class="cut-select"
536
+ type="button"
537
+ on={{ click: () => selectRange(range.id) }}
538
+ >
539
+ <span class="cut-time">
540
+ {formatTimestamp(range.start)} -{' '}
541
+ {formatTimestamp(range.end)}
542
+ </span>
543
+ <span class="cut-reason">{range.reason}</span>
544
+ </button>
545
+ <button
546
+ class="button button--ghost"
547
+ type="button"
548
+ on={{ click: () => removeCut(range.id) }}
549
+ >
550
+ Remove
551
+ </button>
552
+ </li>
553
+ ))}
554
+ </ul>
555
+ </div>
556
+ </div>
557
+ </section>
558
+
559
+ <div class="app-grid app-grid--two">
560
+ <section class="app-card">
561
+ <h2>Chapter plan</h2>
562
+ <p class="app-muted">
563
+ Update output names and mark chapters to skip before export.
564
+ </p>
565
+ <div class="chapter-list">
566
+ {chapters.map((chapter) => (
567
+ <article class="chapter-row">
568
+ <div class="chapter-header">
569
+ <div>
570
+ <h3>{chapter.title}</h3>
571
+ <span class="chapter-time">
572
+ {formatTimestamp(chapter.start)} -{' '}
573
+ {formatTimestamp(chapter.end)}
574
+ </span>
575
+ </div>
576
+ <span
577
+ class={classNames(
578
+ 'status-pill',
579
+ chapter.status === 'ready' && 'status-pill--success',
580
+ chapter.status === 'review' && 'status-pill--warning',
581
+ chapter.status === 'skipped' && 'status-pill--danger',
582
+ )}
583
+ >
584
+ {chapter.status}
585
+ </span>
586
+ </div>
587
+ <label class="input-label">
588
+ Output file
589
+ <input
590
+ class="text-input"
591
+ type="text"
592
+ value={chapter.outputName}
593
+ on={{
594
+ input: (event) => {
595
+ const target = event.currentTarget as HTMLInputElement
596
+ updateChapterOutput(chapter.id, target.value)
597
+ },
598
+ }}
599
+ />
600
+ </label>
601
+ <label class="input-label">
602
+ Status
603
+ <select
604
+ class="text-input"
605
+ value={chapter.status}
606
+ on={{
607
+ change: (event) => {
608
+ const target =
609
+ event.currentTarget as HTMLSelectElement
610
+ updateChapterStatus(
611
+ chapter.id,
612
+ target.value as ChapterStatus,
613
+ )
614
+ },
615
+ }}
616
+ >
617
+ <option value="ready">ready</option>
618
+ <option value="review">review</option>
619
+ <option value="skipped">skipped</option>
620
+ </select>
621
+ </label>
622
+ <p class="app-muted">{chapter.notes}</p>
623
+ </article>
624
+ ))}
625
+ </div>
626
+ </section>
627
+
628
+ <section class="app-card">
629
+ <h2>Command windows</h2>
630
+ <p class="app-muted">
631
+ Apply Jarvis commands to your cut list or chapter metadata.
632
+ </p>
633
+ <div class="command-list">
634
+ {commands.map((command) => {
635
+ const applied = isCommandApplied(command, sortedCuts, chapters)
636
+ return (
637
+ <article class="command-row">
638
+ <div class="command-header">
639
+ <h3>{command.label}</h3>
640
+ <span
641
+ class={classNames(
642
+ 'status-pill',
643
+ command.action === 'remove' && 'status-pill--danger',
644
+ command.action === 'rename' && 'status-pill--info',
645
+ command.action === 'skip' && 'status-pill--warning',
646
+ )}
647
+ >
648
+ {command.action}
649
+ </span>
650
+ </div>
651
+ <p class="app-muted">{command.summary}</p>
652
+ <div class="command-meta">
653
+ <span class="command-time">
654
+ {formatTimestamp(command.start)} -{' '}
655
+ {formatTimestamp(command.end)}
656
+ </span>
657
+ <button
658
+ class="button button--ghost"
659
+ type="button"
660
+ disabled={applied}
661
+ on={{ click: () => applyCommand(command) }}
662
+ >
663
+ {applied ? 'Applied' : 'Apply'}
664
+ </button>
665
+ </div>
666
+ </article>
667
+ )
668
+ })}
669
+ </div>
670
+ </section>
671
+ </div>
672
+
673
+ <section class="app-card app-card--full transcript-card">
674
+ <div class="transcript-header">
675
+ <div>
676
+ <h2>Transcript search</h2>
677
+ <p class="app-muted">
678
+ Search words and jump the playhead to review context.
679
+ </p>
680
+ </div>
681
+ <div class="transcript-preview">
682
+ <span class="summary-label">Playhead cue</span>
683
+ <span class="summary-value">
684
+ {currentWord ? formatTimestamp(currentWord.start) : '00:00.0'}
685
+ </span>
686
+ <span class="summary-subtext">{currentContext}</span>
687
+ </div>
688
+ </div>
689
+ <label class="input-label">
690
+ Search transcript
691
+ <input
692
+ class="text-input"
693
+ type="search"
694
+ placeholder="Search for a word or command marker"
695
+ value={searchQuery}
696
+ on={{
697
+ input: (event) => {
698
+ const target = event.currentTarget as HTMLInputElement
699
+ updateSearchQuery(target.value)
700
+ },
701
+ }}
702
+ />
703
+ </label>
704
+ {query.length === 0 ? (
705
+ <p class="app-muted transcript-empty">
706
+ Type to search the transcript words. Click a result to jump to it.
707
+ </p>
708
+ ) : (
709
+ <ul class="transcript-results">
710
+ {searchResults.map((word) => (
711
+ <li>
712
+ <button
713
+ class="transcript-result"
714
+ type="button"
715
+ on={{ click: () => setPlayhead(word.start) }}
716
+ >
717
+ <span class="transcript-time">
718
+ {formatTimestamp(word.start)}
719
+ </span>
720
+ <span class="transcript-snippet">
721
+ {buildContext(transcript, word.index, 3)}
722
+ </span>
723
+ </button>
724
+ </li>
725
+ ))}
726
+ </ul>
727
+ )}
728
+ </section>
729
+
730
+ <section class="app-card app-card--full">
731
+ <h2>CLI export preview</h2>
732
+ <p class="app-muted">
733
+ Use this command once you save your transcript edits.
734
+ </p>
735
+ <pre class="command-preview">{commandPreview}</pre>
736
+ </section>
737
+ </main>
738
+ )
739
+ }
740
+ }
741
+
742
+ function sortRanges(ranges: CutRange[]) {
743
+ return ranges.slice().sort((a, b) => a.start - b.start)
744
+ }
745
+
746
+ function mergeOverlappingRanges(ranges: CutRange[]) {
747
+ if (ranges.length === 0) return []
748
+ const sorted = sortRanges(ranges)
749
+ const merged: CutRange[] = [{ ...sorted[0] }]
750
+ for (let i = 1; i < sorted.length; i++) {
751
+ const current = sorted[i]
752
+ const last = merged[merged.length - 1]
753
+ if (current.start <= last.end) {
754
+ last.end = Math.max(last.end, current.end)
755
+ } else {
756
+ merged.push({ ...current })
757
+ }
758
+ }
759
+ return merged
760
+ }
761
+
762
+ function normalizeRange(range: CutRange, duration: number) {
763
+ const start = clamp(range.start, 0, Math.max(duration - MIN_CUT_LENGTH, 0))
764
+ const end = clamp(
765
+ range.end,
766
+ start + MIN_CUT_LENGTH,
767
+ Math.max(duration, start + MIN_CUT_LENGTH),
768
+ )
769
+ return { ...range, start, end }
770
+ }
771
+
772
+ function clamp(value: number, min: number, max: number) {
773
+ return Math.min(Math.max(value, min), max)
774
+ }
775
+
776
+ function formatTimestamp(value: number) {
777
+ const clamped = Math.max(value, 0)
778
+ const totalSeconds = Math.floor(clamped)
779
+ const minutes = Math.floor(totalSeconds / 60)
780
+ const seconds = totalSeconds % 60
781
+ const tenths = Math.floor((clamped - totalSeconds) * 10)
782
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${tenths}`
783
+ }
784
+
785
+ function formatSeconds(value: number) {
786
+ return `${value.toFixed(1)}s`
787
+ }
788
+
789
+ function classNames(...values: Array<string | false | null | undefined>) {
790
+ return values.filter(Boolean).join(' ')
791
+ }
792
+
793
+ function findWordAtTime(words: TranscriptWord[], time: number) {
794
+ let current: TranscriptWord | null = null
795
+ for (const word of words) {
796
+ if (word.start <= time) {
797
+ current = word
798
+ continue
799
+ }
800
+ break
801
+ }
802
+ return current
803
+ }
804
+
805
+ function buildContext(words: TranscriptWord[], index: number, radius: number) {
806
+ const start = Math.max(index - radius, 0)
807
+ const end = Math.min(index + radius + 1, words.length)
808
+ return words
809
+ .slice(start, end)
810
+ .map((word) => word.word)
811
+ .join(' ')
812
+ }
813
+
814
+ function buildTimelineTicks(duration: number, count: number) {
815
+ if (count <= 1) return [0]
816
+ const step = duration / (count - 1)
817
+ return Array.from({ length: count }, (_, index) =>
818
+ Number((index * step).toFixed(1)),
819
+ )
820
+ }
821
+
822
+ function buildCommandPreview(sourceName: string, chapters: ChapterPlan[]) {
823
+ const outputName =
824
+ chapters.find((chapter) => chapter.status !== 'skipped')?.outputName ??
825
+ 'edited-output.mp4'
826
+ return [
827
+ 'bun process-course/edits/cli.ts edit-video \\',
828
+ ` --input "${sourceName}" \\`,
829
+ ' --transcript "transcript.json" \\',
830
+ ' --edited "transcript.txt" \\',
831
+ ` --output "${outputName}"`,
832
+ ].join('\n')
833
+ }
834
+
835
+ function findPreviousCut(cutRanges: CutRange[], playhead: number) {
836
+ let previous: CutRange | null = null
837
+ for (const range of cutRanges) {
838
+ if (range.start < playhead) {
839
+ previous = range
840
+ continue
841
+ }
842
+ break
843
+ }
844
+ return previous
845
+ }
846
+
847
+ function isCommandApplied(
848
+ command: CommandWindow,
849
+ cutRanges: CutRange[],
850
+ chapters: ChapterPlan[],
851
+ ) {
852
+ if (command.action === 'remove') {
853
+ return cutRanges.some((range) => range.sourceId === command.id)
854
+ }
855
+ if (command.action === 'rename' && command.value) {
856
+ return chapters.some(
857
+ (chapter) =>
858
+ chapter.id === command.chapterId &&
859
+ chapter.outputName === command.value,
860
+ )
861
+ }
862
+ if (command.action === 'skip') {
863
+ return chapters.some(
864
+ (chapter) =>
865
+ chapter.id === command.chapterId && chapter.status === 'skipped',
866
+ )
867
+ }
868
+ return false
869
+ }