eprec 1.8.0 → 1.10.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.
- package/README.md +8 -2
- package/app/assets/styles.css +57 -0
- package/app/client/editing-workspace.tsx +233 -25
- package/app/components/layout.tsx +10 -0
- package/app/routes/index.tsx +6 -0
- package/app/video-api.ts +168 -0
- package/package.json +1 -1
- package/src/app-server.test.ts +70 -0
- package/src/app-server.ts +62 -18
- package/src/cli.ts +6 -1
- package/src/process-course-video.ts +4 -1
package/README.md
CHANGED
|
@@ -54,8 +54,14 @@ Start the Remix-powered UI shell (watch mode enabled):
|
|
|
54
54
|
bun run app:start
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
To preload a local video path for the UI:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bun run app:start -- --video-path "/path/to/video.mp4"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then open `http://localhost:3000`. Use `-- --port`, `-- --host`, or
|
|
64
|
+
`-- --video-path` to override the defaults.
|
|
59
65
|
|
|
60
66
|
## CLI Options
|
|
61
67
|
|
package/app/assets/styles.css
CHANGED
|
@@ -227,6 +227,63 @@ p {
|
|
|
227
227
|
box-shadow: inset 0 0 0 1px var(--color-border-accent);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
.source-card {
|
|
231
|
+
gap: var(--spacing-lg);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.source-header {
|
|
235
|
+
display: flex;
|
|
236
|
+
align-items: flex-start;
|
|
237
|
+
justify-content: space-between;
|
|
238
|
+
gap: var(--spacing-lg);
|
|
239
|
+
flex-wrap: wrap;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.source-grid {
|
|
243
|
+
display: flex;
|
|
244
|
+
flex-direction: column;
|
|
245
|
+
gap: var(--spacing-lg);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.source-fields {
|
|
249
|
+
display: flex;
|
|
250
|
+
flex-direction: column;
|
|
251
|
+
gap: var(--spacing-md);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.source-actions {
|
|
255
|
+
display: flex;
|
|
256
|
+
flex-wrap: wrap;
|
|
257
|
+
gap: var(--spacing-sm);
|
|
258
|
+
}
|
|
259
|
+
.status-note {
|
|
260
|
+
margin: 0;
|
|
261
|
+
padding: var(--spacing-sm) var(--spacing-md);
|
|
262
|
+
border-radius: var(--radius-md);
|
|
263
|
+
font-size: var(--font-size-xs);
|
|
264
|
+
background: var(--color-surface-muted);
|
|
265
|
+
color: var(--color-text-subtle);
|
|
266
|
+
border: 1px solid var(--color-border);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.status-note--success {
|
|
270
|
+
background: var(--color-success-surface);
|
|
271
|
+
color: var(--color-success-text);
|
|
272
|
+
border-color: color-mix(in srgb, var(--color-success-text) 20%, transparent);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.status-note--warning {
|
|
276
|
+
background: var(--color-warning-surface);
|
|
277
|
+
color: var(--color-warning-text);
|
|
278
|
+
border-color: var(--color-warning-border);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.status-note--danger {
|
|
282
|
+
background: var(--color-danger-surface);
|
|
283
|
+
color: var(--color-danger-text);
|
|
284
|
+
border-color: var(--color-danger-border);
|
|
285
|
+
}
|
|
286
|
+
|
|
230
287
|
.app-list {
|
|
231
288
|
margin: 0;
|
|
232
289
|
padding-left: var(--spacing-lg);
|
|
@@ -9,9 +9,38 @@ import {
|
|
|
9
9
|
type TranscriptWord,
|
|
10
10
|
} from './edit-session-data.ts'
|
|
11
11
|
|
|
12
|
+
type AppConfig = {
|
|
13
|
+
initialVideoPath?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare global {
|
|
17
|
+
interface Window {
|
|
18
|
+
__EPREC_APP__?: AppConfig
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
const MIN_CUT_LENGTH = 0.2
|
|
13
23
|
const DEFAULT_CUT_LENGTH = 2.4
|
|
14
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
|
+
}
|
|
15
44
|
|
|
16
45
|
type ProcessingStatus = 'queued' | 'running' | 'done'
|
|
17
46
|
type ProcessingCategory = 'chapter' | 'transcript' | 'export'
|
|
@@ -31,6 +60,17 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
31
60
|
...word,
|
|
32
61
|
context: buildContext(transcript, word.index, 3),
|
|
33
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 = ''
|
|
34
74
|
let cutRanges = sampleEditSession.cuts.map((range) => ({ ...range }))
|
|
35
75
|
let chapters = sampleEditSession.chapters.map((chapter) => ({ ...chapter }))
|
|
36
76
|
let playhead = 18.2
|
|
@@ -49,6 +89,94 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
49
89
|
let lastSyncedPlayhead = playhead
|
|
50
90
|
let isScrubbing = false
|
|
51
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
|
+
|
|
52
180
|
const setPlayhead = (value: number) => {
|
|
53
181
|
playhead = clamp(value, 0, duration)
|
|
54
182
|
syncVideoToPlayhead(playhead)
|
|
@@ -171,8 +299,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
171
299
|
primaryChapterId = chapterId
|
|
172
300
|
if (secondaryChapterId === chapterId) {
|
|
173
301
|
secondaryChapterId =
|
|
174
|
-
chapters.find((chapter) => chapter.id !== chapterId)?.id ??
|
|
175
|
-
chapterId
|
|
302
|
+
chapters.find((chapter) => chapter.id !== chapterId)?.id ?? chapterId
|
|
176
303
|
}
|
|
177
304
|
handle.update()
|
|
178
305
|
}
|
|
@@ -339,14 +466,22 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
339
466
|
primaryChapterId.length > 0 &&
|
|
340
467
|
secondaryChapterId.length > 0 &&
|
|
341
468
|
primaryChapterId !== secondaryChapterId
|
|
342
|
-
const commandPreview = buildCommandPreview(
|
|
343
|
-
sampleEditSession.sourceName,
|
|
344
|
-
chapters,
|
|
345
|
-
)
|
|
469
|
+
const commandPreview = buildCommandPreview(sourceName, chapters, sourcePath)
|
|
346
470
|
const previewTime =
|
|
347
471
|
previewReady && previewDuration > 0
|
|
348
472
|
? (playhead / duration) * previewDuration
|
|
349
473
|
: 0
|
|
474
|
+
const previewStatus = previewError
|
|
475
|
+
? { label: 'Error', className: 'status-pill--danger' }
|
|
476
|
+
: previewReady
|
|
477
|
+
? previewPlaying
|
|
478
|
+
? { label: 'Playing', className: 'status-pill--info' }
|
|
479
|
+
: { label: 'Ready', className: 'status-pill--success' }
|
|
480
|
+
: { label: 'Loading', className: 'status-pill--warning' }
|
|
481
|
+
const sourceStatus =
|
|
482
|
+
previewSource === 'path'
|
|
483
|
+
? { label: 'Path', className: 'status-pill--success' }
|
|
484
|
+
: { label: 'Demo', className: 'status-pill--info' }
|
|
350
485
|
|
|
351
486
|
return (
|
|
352
487
|
<main class="app-shell">
|
|
@@ -359,12 +494,76 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
359
494
|
</p>
|
|
360
495
|
</header>
|
|
361
496
|
|
|
497
|
+
<section class="app-card app-card--full source-card">
|
|
498
|
+
<div class="source-header">
|
|
499
|
+
<div>
|
|
500
|
+
<h2>Source video</h2>
|
|
501
|
+
<p class="app-muted">
|
|
502
|
+
Paste a full video path to preview it and update the CLI export.
|
|
503
|
+
</p>
|
|
504
|
+
</div>
|
|
505
|
+
<span
|
|
506
|
+
class={classNames('status-pill', sourceStatus.className)}
|
|
507
|
+
title={`Preview source: ${sourceStatus.label}`}
|
|
508
|
+
>
|
|
509
|
+
{sourceStatus.label}
|
|
510
|
+
</span>
|
|
511
|
+
</div>
|
|
512
|
+
<div class="source-grid">
|
|
513
|
+
<div class="source-fields">
|
|
514
|
+
<label class="input-label">
|
|
515
|
+
Video file path
|
|
516
|
+
<input
|
|
517
|
+
class="text-input"
|
|
518
|
+
type="text"
|
|
519
|
+
placeholder="/path/to/video.mp4"
|
|
520
|
+
value={videoPathInput}
|
|
521
|
+
on={{
|
|
522
|
+
input: (event) => {
|
|
523
|
+
const target = event.currentTarget as HTMLInputElement
|
|
524
|
+
updateVideoPathInput(target.value)
|
|
525
|
+
},
|
|
526
|
+
}}
|
|
527
|
+
/>
|
|
528
|
+
</label>
|
|
529
|
+
<div class="source-actions">
|
|
530
|
+
<button
|
|
531
|
+
class="button button--primary"
|
|
532
|
+
type="button"
|
|
533
|
+
disabled={
|
|
534
|
+
pathStatus === 'loading' ||
|
|
535
|
+
videoPathInput.trim().length === 0
|
|
536
|
+
}
|
|
537
|
+
on={{ click: () => void loadVideoFromPath() }}
|
|
538
|
+
>
|
|
539
|
+
{pathStatus === 'loading' ? 'Checking...' : 'Load from path'}
|
|
540
|
+
</button>
|
|
541
|
+
<button
|
|
542
|
+
class="button button--ghost"
|
|
543
|
+
type="button"
|
|
544
|
+
on={{ click: resetToDemo }}
|
|
545
|
+
>
|
|
546
|
+
Use demo video
|
|
547
|
+
</button>
|
|
548
|
+
</div>
|
|
549
|
+
{pathStatus === 'error' && pathError ? (
|
|
550
|
+
<p class="status-note status-note--danger">{pathError}</p>
|
|
551
|
+
) : null}
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
</section>
|
|
555
|
+
|
|
362
556
|
<section class="app-card app-card--full">
|
|
363
557
|
<h2>Session summary</h2>
|
|
364
558
|
<div class="summary-grid">
|
|
365
559
|
<div class="summary-item">
|
|
366
560
|
<span class="summary-label">Source video</span>
|
|
367
|
-
<span class="summary-value">{
|
|
561
|
+
<span class="summary-value">{sourceName}</span>
|
|
562
|
+
{sourcePath ? (
|
|
563
|
+
<span class="summary-subtext">{sourcePath}</span>
|
|
564
|
+
) : (
|
|
565
|
+
<span class="summary-subtext">Demo fixture video</span>
|
|
566
|
+
)}
|
|
368
567
|
<span class="summary-subtext">
|
|
369
568
|
Duration {formatTimestamp(duration)}
|
|
370
569
|
</span>
|
|
@@ -405,8 +604,8 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
405
604
|
<div>
|
|
406
605
|
<h2>Processing actions</h2>
|
|
407
606
|
<p class="app-muted">
|
|
408
|
-
Queue chapter edits, transcript cleanup, and export jobs
|
|
409
|
-
from the workspace.
|
|
607
|
+
Queue chapter edits, transcript cleanup, and export jobs
|
|
608
|
+
directly from the workspace.
|
|
410
609
|
</p>
|
|
411
610
|
</div>
|
|
412
611
|
<div class="actions-meta">
|
|
@@ -650,25 +849,14 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
650
849
|
</span>
|
|
651
850
|
</div>
|
|
652
851
|
<span
|
|
653
|
-
class={classNames(
|
|
654
|
-
'status-pill',
|
|
655
|
-
previewReady
|
|
656
|
-
? previewPlaying
|
|
657
|
-
? 'status-pill--info'
|
|
658
|
-
: 'status-pill--success'
|
|
659
|
-
: 'status-pill--warning',
|
|
660
|
-
)}
|
|
852
|
+
class={classNames('status-pill', previewStatus.className)}
|
|
661
853
|
>
|
|
662
|
-
{
|
|
663
|
-
? previewPlaying
|
|
664
|
-
? 'Playing'
|
|
665
|
-
: 'Ready'
|
|
666
|
-
: 'Loading'}
|
|
854
|
+
{previewStatus.label}
|
|
667
855
|
</span>
|
|
668
856
|
</div>
|
|
669
857
|
<video
|
|
670
858
|
class="timeline-video-player"
|
|
671
|
-
src=
|
|
859
|
+
src={previewUrl}
|
|
672
860
|
controls
|
|
673
861
|
preload="metadata"
|
|
674
862
|
connect={(node: HTMLVideoElement, signal) => {
|
|
@@ -679,6 +867,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
679
867
|
? nextDuration
|
|
680
868
|
: 0
|
|
681
869
|
previewReady = previewDuration > 0
|
|
870
|
+
previewError = ''
|
|
682
871
|
syncVideoToPlayhead(playhead)
|
|
683
872
|
handle.update()
|
|
684
873
|
}
|
|
@@ -702,6 +891,12 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
702
891
|
previewPlaying = false
|
|
703
892
|
handle.update()
|
|
704
893
|
}
|
|
894
|
+
const handleError = () => {
|
|
895
|
+
previewError = 'Unable to load the preview video.'
|
|
896
|
+
previewReady = false
|
|
897
|
+
previewPlaying = false
|
|
898
|
+
handle.update()
|
|
899
|
+
}
|
|
705
900
|
node.addEventListener(
|
|
706
901
|
'loadedmetadata',
|
|
707
902
|
handleLoadedMetadata,
|
|
@@ -709,6 +904,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
709
904
|
node.addEventListener('timeupdate', handleTimeUpdate)
|
|
710
905
|
node.addEventListener('play', handlePlay)
|
|
711
906
|
node.addEventListener('pause', handlePause)
|
|
907
|
+
node.addEventListener('error', handleError)
|
|
712
908
|
signal.addEventListener('abort', () => {
|
|
713
909
|
node.removeEventListener(
|
|
714
910
|
'loadedmetadata',
|
|
@@ -717,6 +913,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
717
913
|
node.removeEventListener('timeupdate', handleTimeUpdate)
|
|
718
914
|
node.removeEventListener('play', handlePlay)
|
|
719
915
|
node.removeEventListener('pause', handlePause)
|
|
916
|
+
node.removeEventListener('error', handleError)
|
|
720
917
|
if (previewNode === node) {
|
|
721
918
|
previewNode = null
|
|
722
919
|
}
|
|
@@ -729,6 +926,9 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
729
926
|
Timeline {formatTimestamp(playhead)}
|
|
730
927
|
</span>
|
|
731
928
|
</div>
|
|
929
|
+
{previewError ? (
|
|
930
|
+
<p class="status-note status-note--danger">{previewError}</p>
|
|
931
|
+
) : null}
|
|
732
932
|
</div>
|
|
733
933
|
<div
|
|
734
934
|
class="timeline-track"
|
|
@@ -1203,13 +1403,21 @@ function buildTimelineTicks(duration: number, count: number) {
|
|
|
1203
1403
|
)
|
|
1204
1404
|
}
|
|
1205
1405
|
|
|
1206
|
-
function buildCommandPreview(
|
|
1406
|
+
function buildCommandPreview(
|
|
1407
|
+
sourceName: string,
|
|
1408
|
+
chapters: ChapterPlan[],
|
|
1409
|
+
sourcePath?: string,
|
|
1410
|
+
) {
|
|
1207
1411
|
const outputName =
|
|
1208
1412
|
chapters.find((chapter) => chapter.status !== 'skipped')?.outputName ??
|
|
1209
1413
|
'edited-output.mp4'
|
|
1414
|
+
const inputPath =
|
|
1415
|
+
typeof sourcePath === 'string' && sourcePath.trim().length > 0
|
|
1416
|
+
? sourcePath
|
|
1417
|
+
: sourceName
|
|
1210
1418
|
return [
|
|
1211
1419
|
'bun process-course/edits/cli.ts edit-video \\',
|
|
1212
|
-
` --input "${
|
|
1420
|
+
` --input "${inputPath}" \\`,
|
|
1213
1421
|
' --transcript "transcript.json" \\',
|
|
1214
1422
|
' --edited "transcript.txt" \\',
|
|
1215
1423
|
` --output "${outputName}"`,
|
|
@@ -5,10 +5,12 @@ export function Layout({
|
|
|
5
5
|
children,
|
|
6
6
|
title = 'Eprec Studio',
|
|
7
7
|
entryScript = '/app/client/entry.tsx',
|
|
8
|
+
appConfig,
|
|
8
9
|
}: {
|
|
9
10
|
children?: SafeHtml
|
|
10
11
|
title?: string
|
|
11
12
|
entryScript?: string | false
|
|
13
|
+
appConfig?: Record<string, unknown>
|
|
12
14
|
}) {
|
|
13
15
|
const importmap = { imports: baseImportMap }
|
|
14
16
|
const importmapJson = JSON.stringify(importmap)
|
|
@@ -16,6 +18,13 @@ export function Layout({
|
|
|
16
18
|
const modulePreloads = Object.values(baseImportMap).map((value) => {
|
|
17
19
|
return html`<link rel="modulepreload" href="${value}" />`
|
|
18
20
|
})
|
|
21
|
+
const appConfigJson = appConfig ? JSON.stringify(appConfig) : null
|
|
22
|
+
const appConfigScript = appConfigJson
|
|
23
|
+
? html.raw`<script>window.__EPREC_APP__=${appConfigJson.replace(
|
|
24
|
+
/</g,
|
|
25
|
+
'\\u003c',
|
|
26
|
+
)}</script>`
|
|
27
|
+
: ''
|
|
19
28
|
|
|
20
29
|
return html`<html lang="en">
|
|
21
30
|
<head>
|
|
@@ -27,6 +36,7 @@ export function Layout({
|
|
|
27
36
|
</head>
|
|
28
37
|
<body>
|
|
29
38
|
<div id="root">${children ?? ''}</div>
|
|
39
|
+
${appConfigScript}
|
|
30
40
|
${entryScript
|
|
31
41
|
? html`<script type="module" src="${entryScript}"></script>`
|
|
32
42
|
: ''}
|
package/app/routes/index.tsx
CHANGED
|
@@ -5,9 +5,11 @@ import { render } from '../helpers/render.ts'
|
|
|
5
5
|
const indexHandler = {
|
|
6
6
|
middleware: [],
|
|
7
7
|
loader() {
|
|
8
|
+
const initialVideoPath = process.env.EPREC_APP_VIDEO_PATH?.trim()
|
|
8
9
|
return render(
|
|
9
10
|
Layout({
|
|
10
11
|
title: 'Eprec Studio',
|
|
12
|
+
appConfig: initialVideoPath ? { initialVideoPath } : undefined,
|
|
11
13
|
children: html`<main class="app-shell">
|
|
12
14
|
<header class="app-header">
|
|
13
15
|
<span class="app-kicker">Eprec Studio</span>
|
|
@@ -17,6 +19,10 @@ const indexHandler = {
|
|
|
17
19
|
exports.
|
|
18
20
|
</p>
|
|
19
21
|
</header>
|
|
22
|
+
<section class="app-card app-card--full">
|
|
23
|
+
<h2>Source video</h2>
|
|
24
|
+
<p class="app-muted">Paste a video file path once the UI loads.</p>
|
|
25
|
+
</section>
|
|
20
26
|
<section class="app-card app-card--full">
|
|
21
27
|
<h2>Processing actions</h2>
|
|
22
28
|
<p class="app-muted">
|
package/app/video-api.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
const VIDEO_ROUTE = '/api/video'
|
|
5
|
+
|
|
6
|
+
function isLocalhostOrigin(origin: string | null): boolean {
|
|
7
|
+
if (!origin) return false
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(origin)
|
|
10
|
+
const hostname = url.hostname.toLowerCase()
|
|
11
|
+
return (
|
|
12
|
+
hostname === 'localhost' ||
|
|
13
|
+
hostname === '127.0.0.1' ||
|
|
14
|
+
hostname === '[::1]'
|
|
15
|
+
)
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getVideoCorsHeaders(origin: string | null) {
|
|
22
|
+
if (!isLocalhostOrigin(origin)) {
|
|
23
|
+
return {}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
'Access-Control-Allow-Origin': origin,
|
|
27
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
28
|
+
'Access-Control-Allow-Headers': 'Accept, Content-Type, Range',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ByteRange = { start: number; end: number }
|
|
33
|
+
|
|
34
|
+
function expandHomePath(value: string) {
|
|
35
|
+
if (!value.startsWith('~/') && !value.startsWith('~\\')) {
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
const home = process.env.HOME?.trim()
|
|
39
|
+
if (!home) return value
|
|
40
|
+
return path.join(home, value.slice(2))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveVideoPath(rawPath: string): string | null {
|
|
44
|
+
const trimmed = rawPath.trim()
|
|
45
|
+
if (!trimmed) return null
|
|
46
|
+
if (trimmed.startsWith('file://')) {
|
|
47
|
+
try {
|
|
48
|
+
return fileURLToPath(trimmed)
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return path.resolve(expandHomePath(trimmed))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseRangeHeader(
|
|
57
|
+
header: string | null,
|
|
58
|
+
size: number,
|
|
59
|
+
): ByteRange | null {
|
|
60
|
+
if (!header) return null
|
|
61
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
|
62
|
+
if (!match) return null
|
|
63
|
+
const startRaw = match[1]
|
|
64
|
+
const endRaw = match[2]
|
|
65
|
+
const start = startRaw ? Number(startRaw) : null
|
|
66
|
+
const end = endRaw ? Number(endRaw) : null
|
|
67
|
+
if (start === null && end === null) return null
|
|
68
|
+
if (start !== null && (!Number.isFinite(start) || start < 0)) return null
|
|
69
|
+
if (end !== null && (!Number.isFinite(end) || end < 0)) return null
|
|
70
|
+
|
|
71
|
+
if (start === null) {
|
|
72
|
+
const suffix = end ?? 0
|
|
73
|
+
if (suffix <= 0) return null
|
|
74
|
+
const rangeStart = Math.max(size - suffix, 0)
|
|
75
|
+
return { start: rangeStart, end: size - 1 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rangeEnd = end === null || end >= size ? Math.max(size - 1, 0) : end
|
|
79
|
+
if (start > rangeEnd) return null
|
|
80
|
+
return { start, end: rangeEnd }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildVideoHeaders(
|
|
84
|
+
contentType: string,
|
|
85
|
+
length: number,
|
|
86
|
+
origin: string | null,
|
|
87
|
+
) {
|
|
88
|
+
return {
|
|
89
|
+
'Content-Type': contentType,
|
|
90
|
+
'Content-Length': String(length),
|
|
91
|
+
'Accept-Ranges': 'bytes',
|
|
92
|
+
'Cache-Control': 'no-cache',
|
|
93
|
+
...getVideoCorsHeaders(origin),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function handleVideoRequest(request: Request): Promise<Response> {
|
|
98
|
+
const url = new URL(request.url)
|
|
99
|
+
if (url.pathname !== VIDEO_ROUTE) {
|
|
100
|
+
return new Response('Not Found', { status: 404 })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const origin = request.headers.get('origin')
|
|
104
|
+
const corsHeaders = getVideoCorsHeaders(origin)
|
|
105
|
+
|
|
106
|
+
if (request.method === 'OPTIONS') {
|
|
107
|
+
return new Response(null, {
|
|
108
|
+
status: 204,
|
|
109
|
+
headers: {
|
|
110
|
+
...corsHeaders,
|
|
111
|
+
'Access-Control-Max-Age': '86400',
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
117
|
+
return new Response('Method Not Allowed', {
|
|
118
|
+
status: 405,
|
|
119
|
+
headers: corsHeaders,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rawPath = url.searchParams.get('path')
|
|
124
|
+
const filePath = rawPath ? resolveVideoPath(rawPath) : null
|
|
125
|
+
if (!filePath) {
|
|
126
|
+
return new Response('Missing or invalid path query.', {
|
|
127
|
+
status: 400,
|
|
128
|
+
headers: corsHeaders,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const file = Bun.file(filePath)
|
|
133
|
+
if (!(await file.exists())) {
|
|
134
|
+
return new Response('Video file not found.', {
|
|
135
|
+
status: 404,
|
|
136
|
+
headers: corsHeaders,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const contentType = file.type || 'application/octet-stream'
|
|
141
|
+
const size = file.size
|
|
142
|
+
const rangeHeader = request.headers.get('range')
|
|
143
|
+
if (rangeHeader) {
|
|
144
|
+
const range = parseRangeHeader(rangeHeader, size)
|
|
145
|
+
if (!range) {
|
|
146
|
+
return new Response(null, {
|
|
147
|
+
status: 416,
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Range': `bytes */${size}`,
|
|
150
|
+
...corsHeaders,
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
const chunk = file.slice(range.start, range.end + 1)
|
|
155
|
+
const length = range.end - range.start + 1
|
|
156
|
+
return new Response(request.method === 'HEAD' ? null : chunk, {
|
|
157
|
+
status: 206,
|
|
158
|
+
headers: {
|
|
159
|
+
'Content-Range': `bytes ${range.start}-${range.end}/${size}`,
|
|
160
|
+
...buildVideoHeaders(contentType, length, origin),
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return new Response(request.method === 'HEAD' ? null : file, {
|
|
166
|
+
headers: buildVideoHeaders(contentType, size, origin),
|
|
167
|
+
})
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { createShortcutInputHandler } from './app-server'
|
|
3
|
+
|
|
4
|
+
type ShortcutCounts = {
|
|
5
|
+
open: number
|
|
6
|
+
restart: number
|
|
7
|
+
stop: number
|
|
8
|
+
help: number
|
|
9
|
+
spacing: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createShortcutCounts(): ShortcutCounts {
|
|
13
|
+
return {
|
|
14
|
+
open: 0,
|
|
15
|
+
restart: 0,
|
|
16
|
+
stop: 0,
|
|
17
|
+
help: 0,
|
|
18
|
+
spacing: 0,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createShortcutHandler() {
|
|
23
|
+
const counts = createShortcutCounts()
|
|
24
|
+
const handler = createShortcutInputHandler({
|
|
25
|
+
open: () => {
|
|
26
|
+
counts.open += 1
|
|
27
|
+
},
|
|
28
|
+
restart: () => {
|
|
29
|
+
counts.restart += 1
|
|
30
|
+
},
|
|
31
|
+
stop: () => {
|
|
32
|
+
counts.stop += 1
|
|
33
|
+
},
|
|
34
|
+
help: () => {
|
|
35
|
+
counts.help += 1
|
|
36
|
+
},
|
|
37
|
+
spacing: () => {
|
|
38
|
+
counts.spacing += 1
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return { counts, handler }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('createShortcutInputHandler adds spacing for enter key', () => {
|
|
46
|
+
const { counts, handler } = createShortcutHandler()
|
|
47
|
+
|
|
48
|
+
handler.handleInput('\r')
|
|
49
|
+
|
|
50
|
+
expect(counts.spacing).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('createShortcutInputHandler ignores linefeed after carriage return', () => {
|
|
54
|
+
const { counts, handler } = createShortcutHandler()
|
|
55
|
+
|
|
56
|
+
handler.handleInput('\r\n')
|
|
57
|
+
|
|
58
|
+
expect(counts.spacing).toBe(1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('createShortcutInputHandler maps shortcuts case-insensitively', () => {
|
|
62
|
+
const { counts, handler } = createShortcutHandler()
|
|
63
|
+
|
|
64
|
+
handler.handleInput('OrQh?')
|
|
65
|
+
|
|
66
|
+
expect(counts.open).toBe(1)
|
|
67
|
+
expect(counts.restart).toBe(1)
|
|
68
|
+
expect(counts.stop).toBe(1)
|
|
69
|
+
expect(counts.help).toBe(2)
|
|
70
|
+
})
|
package/src/app-server.ts
CHANGED
|
@@ -4,11 +4,13 @@ import '../app/config/init-env.ts'
|
|
|
4
4
|
import getPort from 'get-port'
|
|
5
5
|
import { getEnv } from '../app/config/env.ts'
|
|
6
6
|
import { createAppRouter } from '../app/router.tsx'
|
|
7
|
+
import { handleVideoRequest } from '../app/video-api.ts'
|
|
7
8
|
import { createBundlingRoutes } from '../server/bundling.ts'
|
|
8
9
|
|
|
9
10
|
type AppServerOptions = {
|
|
10
11
|
host?: string
|
|
11
12
|
port?: number
|
|
13
|
+
videoPath?: string
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const LOCALHOST_ALIASES = new Set(['127.0.0.1', '::1', 'localhost'])
|
|
@@ -24,6 +26,14 @@ const SHORTCUT_COLORS: Record<string, string> = {
|
|
|
24
26
|
const ANSI_RESET = '\u001b[0m'
|
|
25
27
|
const APP_ROOT = path.resolve(import.meta.dirname, '..')
|
|
26
28
|
|
|
29
|
+
type ShortcutActions = {
|
|
30
|
+
open: () => void
|
|
31
|
+
restart: () => void
|
|
32
|
+
stop: () => void
|
|
33
|
+
help: () => void
|
|
34
|
+
spacing: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
function colorizeShortcut(key: string) {
|
|
28
38
|
if (!COLOR_ENABLED) {
|
|
29
39
|
return key
|
|
@@ -53,6 +63,7 @@ function getShortcutLines(url: string) {
|
|
|
53
63
|
` ${colorizeShortcut('r')}: restart server`,
|
|
54
64
|
` ${colorizeShortcut('q')}: quit server`,
|
|
55
65
|
` ${colorizeShortcut('h')}: show shortcuts`,
|
|
66
|
+
` ${colorizeShortcut('enter')}: add log spacing`,
|
|
56
67
|
]
|
|
57
68
|
}
|
|
58
69
|
|
|
@@ -93,44 +104,70 @@ function openBrowser(url: string) {
|
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
restart: () => void
|
|
99
|
-
stop: () => void
|
|
100
|
-
}) {
|
|
101
|
-
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
102
|
-
return () => {}
|
|
103
|
-
}
|
|
107
|
+
export function createShortcutInputHandler(actions: ShortcutActions) {
|
|
108
|
+
let lastKey: string | null = null
|
|
104
109
|
|
|
105
|
-
const stdin = process.stdin
|
|
106
110
|
const handleKey = (key: string) => {
|
|
107
111
|
if (key === '\u0003') {
|
|
108
|
-
|
|
112
|
+
actions.stop()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (key === '\r' || key === '\n') {
|
|
116
|
+
actions.spacing()
|
|
109
117
|
return
|
|
110
118
|
}
|
|
111
119
|
const lower = key.toLowerCase()
|
|
112
120
|
if (lower === 'o') {
|
|
113
|
-
|
|
121
|
+
actions.open()
|
|
114
122
|
return
|
|
115
123
|
}
|
|
116
124
|
if (lower === 'r') {
|
|
117
|
-
|
|
125
|
+
actions.restart()
|
|
118
126
|
return
|
|
119
127
|
}
|
|
120
128
|
if (lower === 'q') {
|
|
121
|
-
|
|
129
|
+
actions.stop()
|
|
122
130
|
return
|
|
123
131
|
}
|
|
124
132
|
if (lower === 'h' || lower === '?') {
|
|
125
|
-
|
|
133
|
+
actions.help()
|
|
126
134
|
}
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
return {
|
|
138
|
+
handleInput: (input: string) => {
|
|
139
|
+
for (const key of input) {
|
|
140
|
+
if (key === '\n' && lastKey === '\r') {
|
|
141
|
+
lastKey = key
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
handleKey(key)
|
|
145
|
+
lastKey = key
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setupShortcutHandling(options: {
|
|
152
|
+
getUrl: () => string
|
|
153
|
+
restart: () => void
|
|
154
|
+
stop: () => void
|
|
155
|
+
}) {
|
|
156
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
157
|
+
return () => {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const stdin = process.stdin
|
|
161
|
+
const shortcutHandler = createShortcutInputHandler({
|
|
162
|
+
open: () => openBrowser(options.getUrl()),
|
|
163
|
+
restart: options.restart,
|
|
164
|
+
stop: options.stop,
|
|
165
|
+
help: () => logShortcuts(options.getUrl()),
|
|
166
|
+
spacing: () => console.log(''),
|
|
167
|
+
})
|
|
168
|
+
|
|
129
169
|
const onData = (chunk: Buffer | string) => {
|
|
130
|
-
|
|
131
|
-
for (const key of input) {
|
|
132
|
-
handleKey(key)
|
|
133
|
-
}
|
|
170
|
+
shortcutHandler.handleInput(chunk.toString())
|
|
134
171
|
}
|
|
135
172
|
|
|
136
173
|
stdin.setRawMode(true)
|
|
@@ -155,6 +192,10 @@ function startServer(port: number, hostname: string) {
|
|
|
155
192
|
routes: createBundlingRoutes(APP_ROOT),
|
|
156
193
|
async fetch(request) {
|
|
157
194
|
try {
|
|
195
|
+
const url = new URL(request.url)
|
|
196
|
+
if (url.pathname === '/api/video') {
|
|
197
|
+
return await handleVideoRequest(request)
|
|
198
|
+
}
|
|
158
199
|
return await router.fetch(request)
|
|
159
200
|
} catch (error) {
|
|
160
201
|
console.error(error)
|
|
@@ -178,6 +219,9 @@ async function getServerPort(nodeEnv: string, desiredPort: number) {
|
|
|
178
219
|
}
|
|
179
220
|
|
|
180
221
|
export async function startAppServer(options: AppServerOptions = {}) {
|
|
222
|
+
if (options.videoPath) {
|
|
223
|
+
process.env.EPREC_APP_VIDEO_PATH = options.videoPath.trim()
|
|
224
|
+
}
|
|
181
225
|
const env = getEnv()
|
|
182
226
|
const host = options.host ?? env.HOST
|
|
183
227
|
const desiredPort = options.port ?? env.PORT
|
package/src/cli.ts
CHANGED
|
@@ -108,6 +108,10 @@ async function main(rawArgs = hideBin(process.argv)) {
|
|
|
108
108
|
.option('host', {
|
|
109
109
|
type: 'string',
|
|
110
110
|
describe: 'Host to bind for the app server',
|
|
111
|
+
})
|
|
112
|
+
.option('video-path', {
|
|
113
|
+
type: 'string',
|
|
114
|
+
describe: 'Default input video path for the app UI',
|
|
111
115
|
}),
|
|
112
116
|
async (argv) => {
|
|
113
117
|
const port =
|
|
@@ -115,7 +119,8 @@ async function main(rawArgs = hideBin(process.argv)) {
|
|
|
115
119
|
? argv.port
|
|
116
120
|
: undefined
|
|
117
121
|
const host = resolveOptionalString(argv.host)
|
|
118
|
-
|
|
122
|
+
const videoPath = resolveOptionalString(argv['video-path'])
|
|
123
|
+
await startAppServer({ port, host, videoPath })
|
|
119
124
|
},
|
|
120
125
|
)
|
|
121
126
|
.command(
|
|
@@ -108,7 +108,10 @@ function buildProgressText(params: {
|
|
|
108
108
|
function createSpinnerProgressReporter(context: SpinnerProgressContext) {
|
|
109
109
|
const chapterCount = Math.max(1, context.chapterCount)
|
|
110
110
|
return {
|
|
111
|
-
createChapterProgress({
|
|
111
|
+
createChapterProgress({
|
|
112
|
+
chapterIndex,
|
|
113
|
+
chapterTitle,
|
|
114
|
+
}: ChapterProgressContext) {
|
|
112
115
|
let stepIndex = 0
|
|
113
116
|
let stepCount = 1
|
|
114
117
|
let stepLabel = 'Starting'
|