eprec 1.8.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.
- package/README.md +8 -2
- package/app/assets/styles.css +58 -0
- package/app/client/editing-workspace.tsx +235 -19
- package/app/components/layout.tsx +10 -0
- package/app/routes/index.tsx +8 -0
- package/app/video-api.ts +161 -0
- package/package.json +1 -1
- package/src/app-server.ts +9 -0
- 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
|
@@ -188,6 +188,7 @@ p {
|
|
|
188
188
|
grid-template-columns: repeat(auto-fit, minmax(var(--card-min-width), 1fr));
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
|
|
191
192
|
.app-card {
|
|
192
193
|
background: var(--color-surface);
|
|
193
194
|
border-radius: var(--radius-xl);
|
|
@@ -227,6 +228,63 @@ p {
|
|
|
227
228
|
box-shadow: inset 0 0 0 1px var(--color-border-accent);
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
.source-card {
|
|
232
|
+
gap: var(--spacing-lg);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.source-header {
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: flex-start;
|
|
238
|
+
justify-content: space-between;
|
|
239
|
+
gap: var(--spacing-lg);
|
|
240
|
+
flex-wrap: wrap;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.source-grid {
|
|
244
|
+
display: flex;
|
|
245
|
+
flex-direction: column;
|
|
246
|
+
gap: var(--spacing-lg);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.source-fields {
|
|
250
|
+
display: flex;
|
|
251
|
+
flex-direction: column;
|
|
252
|
+
gap: var(--spacing-md);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.source-actions {
|
|
256
|
+
display: flex;
|
|
257
|
+
flex-wrap: wrap;
|
|
258
|
+
gap: var(--spacing-sm);
|
|
259
|
+
}
|
|
260
|
+
.status-note {
|
|
261
|
+
margin: 0;
|
|
262
|
+
padding: var(--spacing-sm) var(--spacing-md);
|
|
263
|
+
border-radius: var(--radius-md);
|
|
264
|
+
font-size: var(--font-size-xs);
|
|
265
|
+
background: var(--color-surface-muted);
|
|
266
|
+
color: var(--color-text-subtle);
|
|
267
|
+
border: 1px solid var(--color-border);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.status-note--success {
|
|
271
|
+
background: var(--color-success-surface);
|
|
272
|
+
color: var(--color-success-text);
|
|
273
|
+
border-color: color-mix(in srgb, var(--color-success-text) 20%, transparent);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.status-note--warning {
|
|
277
|
+
background: var(--color-warning-surface);
|
|
278
|
+
color: var(--color-warning-text);
|
|
279
|
+
border-color: var(--color-warning-border);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.status-note--danger {
|
|
283
|
+
background: var(--color-danger-surface);
|
|
284
|
+
color: var(--color-danger-text);
|
|
285
|
+
border-color: var(--color-danger-border);
|
|
286
|
+
}
|
|
287
|
+
|
|
230
288
|
.app-list {
|
|
231
289
|
margin: 0;
|
|
232
290
|
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
|
}
|
|
@@ -340,13 +467,25 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
340
467
|
secondaryChapterId.length > 0 &&
|
|
341
468
|
primaryChapterId !== secondaryChapterId
|
|
342
469
|
const commandPreview = buildCommandPreview(
|
|
343
|
-
|
|
470
|
+
sourceName,
|
|
344
471
|
chapters,
|
|
472
|
+
sourcePath,
|
|
345
473
|
)
|
|
346
474
|
const previewTime =
|
|
347
475
|
previewReady && previewDuration > 0
|
|
348
476
|
? (playhead / duration) * previewDuration
|
|
349
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' }
|
|
350
489
|
|
|
351
490
|
return (
|
|
352
491
|
<main class="app-shell">
|
|
@@ -359,12 +498,77 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
359
498
|
</p>
|
|
360
499
|
</header>
|
|
361
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
|
+
|
|
362
561
|
<section class="app-card app-card--full">
|
|
363
562
|
<h2>Session summary</h2>
|
|
364
563
|
<div class="summary-grid">
|
|
365
564
|
<div class="summary-item">
|
|
366
565
|
<span class="summary-label">Source video</span>
|
|
367
|
-
<span class="summary-value">{
|
|
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
|
+
)}
|
|
368
572
|
<span class="summary-subtext">
|
|
369
573
|
Duration {formatTimestamp(duration)}
|
|
370
574
|
</span>
|
|
@@ -405,8 +609,8 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
405
609
|
<div>
|
|
406
610
|
<h2>Processing actions</h2>
|
|
407
611
|
<p class="app-muted">
|
|
408
|
-
Queue chapter edits, transcript cleanup, and export jobs
|
|
409
|
-
from the workspace.
|
|
612
|
+
Queue chapter edits, transcript cleanup, and export jobs
|
|
613
|
+
directly from the workspace.
|
|
410
614
|
</p>
|
|
411
615
|
</div>
|
|
412
616
|
<div class="actions-meta">
|
|
@@ -652,23 +856,15 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
652
856
|
<span
|
|
653
857
|
class={classNames(
|
|
654
858
|
'status-pill',
|
|
655
|
-
|
|
656
|
-
? previewPlaying
|
|
657
|
-
? 'status-pill--info'
|
|
658
|
-
: 'status-pill--success'
|
|
659
|
-
: 'status-pill--warning',
|
|
859
|
+
previewStatus.className,
|
|
660
860
|
)}
|
|
661
861
|
>
|
|
662
|
-
{
|
|
663
|
-
? previewPlaying
|
|
664
|
-
? 'Playing'
|
|
665
|
-
: 'Ready'
|
|
666
|
-
: 'Loading'}
|
|
862
|
+
{previewStatus.label}
|
|
667
863
|
</span>
|
|
668
864
|
</div>
|
|
669
865
|
<video
|
|
670
866
|
class="timeline-video-player"
|
|
671
|
-
src=
|
|
867
|
+
src={previewUrl}
|
|
672
868
|
controls
|
|
673
869
|
preload="metadata"
|
|
674
870
|
connect={(node: HTMLVideoElement, signal) => {
|
|
@@ -679,6 +875,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
679
875
|
? nextDuration
|
|
680
876
|
: 0
|
|
681
877
|
previewReady = previewDuration > 0
|
|
878
|
+
previewError = ''
|
|
682
879
|
syncVideoToPlayhead(playhead)
|
|
683
880
|
handle.update()
|
|
684
881
|
}
|
|
@@ -702,6 +899,12 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
702
899
|
previewPlaying = false
|
|
703
900
|
handle.update()
|
|
704
901
|
}
|
|
902
|
+
const handleError = () => {
|
|
903
|
+
previewError = 'Unable to load the preview video.'
|
|
904
|
+
previewReady = false
|
|
905
|
+
previewPlaying = false
|
|
906
|
+
handle.update()
|
|
907
|
+
}
|
|
705
908
|
node.addEventListener(
|
|
706
909
|
'loadedmetadata',
|
|
707
910
|
handleLoadedMetadata,
|
|
@@ -709,6 +912,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
709
912
|
node.addEventListener('timeupdate', handleTimeUpdate)
|
|
710
913
|
node.addEventListener('play', handlePlay)
|
|
711
914
|
node.addEventListener('pause', handlePause)
|
|
915
|
+
node.addEventListener('error', handleError)
|
|
712
916
|
signal.addEventListener('abort', () => {
|
|
713
917
|
node.removeEventListener(
|
|
714
918
|
'loadedmetadata',
|
|
@@ -717,6 +921,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
717
921
|
node.removeEventListener('timeupdate', handleTimeUpdate)
|
|
718
922
|
node.removeEventListener('play', handlePlay)
|
|
719
923
|
node.removeEventListener('pause', handlePause)
|
|
924
|
+
node.removeEventListener('error', handleError)
|
|
720
925
|
if (previewNode === node) {
|
|
721
926
|
previewNode = null
|
|
722
927
|
}
|
|
@@ -729,6 +934,9 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
729
934
|
Timeline {formatTimestamp(playhead)}
|
|
730
935
|
</span>
|
|
731
936
|
</div>
|
|
937
|
+
{previewError ? (
|
|
938
|
+
<p class="status-note status-note--danger">{previewError}</p>
|
|
939
|
+
) : null}
|
|
732
940
|
</div>
|
|
733
941
|
<div
|
|
734
942
|
class="timeline-track"
|
|
@@ -1203,13 +1411,21 @@ function buildTimelineTicks(duration: number, count: number) {
|
|
|
1203
1411
|
)
|
|
1204
1412
|
}
|
|
1205
1413
|
|
|
1206
|
-
function buildCommandPreview(
|
|
1414
|
+
function buildCommandPreview(
|
|
1415
|
+
sourceName: string,
|
|
1416
|
+
chapters: ChapterPlan[],
|
|
1417
|
+
sourcePath?: string,
|
|
1418
|
+
) {
|
|
1207
1419
|
const outputName =
|
|
1208
1420
|
chapters.find((chapter) => chapter.status !== 'skipped')?.outputName ??
|
|
1209
1421
|
'edited-output.mp4'
|
|
1422
|
+
const inputPath =
|
|
1423
|
+
typeof sourcePath === 'string' && sourcePath.trim().length > 0
|
|
1424
|
+
? sourcePath
|
|
1425
|
+
: sourceName
|
|
1210
1426
|
return [
|
|
1211
1427
|
'bun process-course/edits/cli.ts edit-video \\',
|
|
1212
|
-
` --input "${
|
|
1428
|
+
` --input "${inputPath}" \\`,
|
|
1213
1429
|
' --transcript "transcript.json" \\',
|
|
1214
1430
|
' --edited "transcript.txt" \\',
|
|
1215
1431
|
` --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,12 @@ 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">
|
|
25
|
+
Paste a video file path once the UI loads.
|
|
26
|
+
</p>
|
|
27
|
+
</section>
|
|
20
28
|
<section class="app-card app-card--full">
|
|
21
29
|
<h2>Processing actions</h2>
|
|
22
30
|
<p class="app-muted">
|
package/app/video-api.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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 hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getVideoCorsHeaders(origin: string | null) {
|
|
18
|
+
if (!isLocalhostOrigin(origin)) {
|
|
19
|
+
return {}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
'Access-Control-Allow-Origin': origin,
|
|
23
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
24
|
+
'Access-Control-Allow-Headers': 'Accept, Content-Type, Range',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ByteRange = { start: number; end: number }
|
|
29
|
+
|
|
30
|
+
function expandHomePath(value: string) {
|
|
31
|
+
if (!value.startsWith('~/') && !value.startsWith('~\\')) {
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
const home = process.env.HOME?.trim()
|
|
35
|
+
if (!home) return value
|
|
36
|
+
return path.join(home, value.slice(2))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveVideoPath(rawPath: string): string | null {
|
|
40
|
+
const trimmed = rawPath.trim()
|
|
41
|
+
if (!trimmed) return null
|
|
42
|
+
if (trimmed.startsWith('file://')) {
|
|
43
|
+
try {
|
|
44
|
+
return fileURLToPath(trimmed)
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return path.resolve(expandHomePath(trimmed))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseRangeHeader(
|
|
53
|
+
header: string | null,
|
|
54
|
+
size: number,
|
|
55
|
+
): ByteRange | null {
|
|
56
|
+
if (!header) return null
|
|
57
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
|
58
|
+
if (!match) return null
|
|
59
|
+
const startRaw = match[1]
|
|
60
|
+
const endRaw = match[2]
|
|
61
|
+
const start = startRaw ? Number(startRaw) : null
|
|
62
|
+
const end = endRaw ? Number(endRaw) : null
|
|
63
|
+
if (start === null && end === null) return null
|
|
64
|
+
if (start !== null && (!Number.isFinite(start) || start < 0)) return null
|
|
65
|
+
if (end !== null && (!Number.isFinite(end) || end < 0)) return null
|
|
66
|
+
|
|
67
|
+
if (start === null) {
|
|
68
|
+
const suffix = end ?? 0
|
|
69
|
+
if (suffix <= 0) return null
|
|
70
|
+
const rangeStart = Math.max(size - suffix, 0)
|
|
71
|
+
return { start: rangeStart, end: size - 1 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rangeEnd =
|
|
75
|
+
end === null || end >= size ? Math.max(size - 1, 0) : end
|
|
76
|
+
if (start > rangeEnd) return null
|
|
77
|
+
return { start, end: rangeEnd }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildVideoHeaders(contentType: string, length: number, origin: string | null) {
|
|
81
|
+
return {
|
|
82
|
+
'Content-Type': contentType,
|
|
83
|
+
'Content-Length': String(length),
|
|
84
|
+
'Accept-Ranges': 'bytes',
|
|
85
|
+
'Cache-Control': 'no-cache',
|
|
86
|
+
...getVideoCorsHeaders(origin),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function handleVideoRequest(request: Request): Promise<Response> {
|
|
91
|
+
const url = new URL(request.url)
|
|
92
|
+
if (url.pathname !== VIDEO_ROUTE) {
|
|
93
|
+
return new Response('Not Found', { status: 404 })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const origin = request.headers.get('origin')
|
|
97
|
+
const corsHeaders = getVideoCorsHeaders(origin)
|
|
98
|
+
|
|
99
|
+
if (request.method === 'OPTIONS') {
|
|
100
|
+
return new Response(null, {
|
|
101
|
+
status: 204,
|
|
102
|
+
headers: {
|
|
103
|
+
...corsHeaders,
|
|
104
|
+
'Access-Control-Max-Age': '86400',
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
110
|
+
return new Response('Method Not Allowed', {
|
|
111
|
+
status: 405,
|
|
112
|
+
headers: corsHeaders,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rawPath = url.searchParams.get('path')
|
|
117
|
+
const filePath = rawPath ? resolveVideoPath(rawPath) : null
|
|
118
|
+
if (!filePath) {
|
|
119
|
+
return new Response('Missing or invalid path query.', {
|
|
120
|
+
status: 400,
|
|
121
|
+
headers: corsHeaders,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const file = Bun.file(filePath)
|
|
126
|
+
if (!(await file.exists())) {
|
|
127
|
+
return new Response('Video file not found.', {
|
|
128
|
+
status: 404,
|
|
129
|
+
headers: corsHeaders,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const contentType = file.type || 'application/octet-stream'
|
|
134
|
+
const size = file.size
|
|
135
|
+
const rangeHeader = request.headers.get('range')
|
|
136
|
+
if (rangeHeader) {
|
|
137
|
+
const range = parseRangeHeader(rangeHeader, size)
|
|
138
|
+
if (!range) {
|
|
139
|
+
return new Response(null, {
|
|
140
|
+
status: 416,
|
|
141
|
+
headers: {
|
|
142
|
+
'Content-Range': `bytes */${size}`,
|
|
143
|
+
...corsHeaders,
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
const chunk = file.slice(range.start, range.end + 1)
|
|
148
|
+
const length = range.end - range.start + 1
|
|
149
|
+
return new Response(request.method === 'HEAD' ? null : chunk, {
|
|
150
|
+
status: 206,
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Range': `bytes ${range.start}-${range.end}/${size}`,
|
|
153
|
+
...buildVideoHeaders(contentType, length, origin),
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new Response(request.method === 'HEAD' ? null : file, {
|
|
159
|
+
headers: buildVideoHeaders(contentType, size, origin),
|
|
160
|
+
})
|
|
161
|
+
}
|
package/package.json
CHANGED
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'])
|
|
@@ -155,6 +157,10 @@ function startServer(port: number, hostname: string) {
|
|
|
155
157
|
routes: createBundlingRoutes(APP_ROOT),
|
|
156
158
|
async fetch(request) {
|
|
157
159
|
try {
|
|
160
|
+
const url = new URL(request.url)
|
|
161
|
+
if (url.pathname === '/api/video') {
|
|
162
|
+
return await handleVideoRequest(request)
|
|
163
|
+
}
|
|
158
164
|
return await router.fetch(request)
|
|
159
165
|
} catch (error) {
|
|
160
166
|
console.error(error)
|
|
@@ -178,6 +184,9 @@ async function getServerPort(nodeEnv: string, desiredPort: number) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
export async function startAppServer(options: AppServerOptions = {}) {
|
|
187
|
+
if (options.videoPath) {
|
|
188
|
+
process.env.EPREC_APP_VIDEO_PATH = options.videoPath.trim()
|
|
189
|
+
}
|
|
181
190
|
const env = getEnv()
|
|
182
191
|
const host = options.host ?? env.HOST
|
|
183
192
|
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'
|