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 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
- Then open `http://localhost:3000`. Use `-- --port` or `-- --host` to override
58
- the defaults.
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
 
@@ -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
- sampleEditSession.sourceName,
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">{sampleEditSession.sourceName}</span>
566
+ <span class="summary-value">{sourceName}</span>
567
+ {sourcePath ? (
568
+ <span class="summary-subtext">{sourcePath}</span>
569
+ ) : (
570
+ <span class="summary-subtext">Demo fixture video</span>
571
+ )}
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 directly
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
- previewReady
656
- ? previewPlaying
657
- ? 'status-pill--info'
658
- : 'status-pill--success'
659
- : 'status-pill--warning',
859
+ previewStatus.className,
660
860
  )}
661
861
  >
662
- {previewReady
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="/e2e-test.mp4"
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(sourceName: string, chapters: ChapterPlan[]) {
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 "${sourceName}" \\`,
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
  : ''}
@@ -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">
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
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
- await startAppServer({ port, host })
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({ chapterIndex, chapterTitle }: ChapterProgressContext) {
111
+ createChapterProgress({
112
+ chapterIndex,
113
+ chapterTitle,
114
+ }: ChapterProgressContext) {
112
115
  let stepIndex = 0
113
116
  let stepCount = 1
114
117
  let stepLabel = 'Starting'