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 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
 
@@ -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">{sampleEditSession.sourceName}</span>
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 directly
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
- {previewReady
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="/e2e-test.mp4"
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(sourceName: string, chapters: ChapterPlan[]) {
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 "${sourceName}" \\`,
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
  : ''}
@@ -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">
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.8.0",
4
+ "version": "1.10.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -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 setupShortcutHandling(options: {
97
- getUrl: () => string
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
- options.stop()
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
- openBrowser(options.getUrl())
121
+ actions.open()
114
122
  return
115
123
  }
116
124
  if (lower === 'r') {
117
- options.restart()
125
+ actions.restart()
118
126
  return
119
127
  }
120
128
  if (lower === 'q') {
121
- options.stop()
129
+ actions.stop()
122
130
  return
123
131
  }
124
132
  if (lower === 'h' || lower === '?') {
125
- logShortcuts(options.getUrl())
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
- const input = chunk.toString()
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
- 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'