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