eprec 1.7.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.
@@ -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
  : ''}
@@ -6,4 +6,5 @@ export const baseImportMap = {
6
6
  'remix/component/jsx-runtime': '/node_modules/remix/component/jsx-runtime',
7
7
  'remix/component/jsx-dev-runtime':
8
8
  '/node_modules/remix/component/jsx-dev-runtime',
9
+ 'match-sorter': '/node_modules/match-sorter',
9
10
  } as const
@@ -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,24 @@ 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>
28
+ <section class="app-card app-card--full">
29
+ <h2>Processing actions</h2>
30
+ <p class="app-muted">
31
+ Queue chapter edits, transcript cleanup, and export jobs once the
32
+ client loads.
33
+ </p>
34
+ <ul class="app-list">
35
+ <li>Edit a chapter with the latest cut list.</li>
36
+ <li>Combine two chapters into a merged preview.</li>
37
+ <li>Regenerate the transcript or detect command windows.</li>
38
+ </ul>
39
+ </section>
20
40
  <section class="app-card app-card--full">
21
41
  <h2>Timeline editor</h2>
22
42
  <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.7.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'