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.
- package/README.md +8 -2
- package/app/assets/styles.css +193 -10
- package/app/client/editing-workspace.tsx +629 -29
- package/app/components/layout.tsx +10 -0
- package/app/config/import-map.ts +1 -0
- package/app/routes/index.tsx +20 -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
|
@@ -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/config/import-map.ts
CHANGED
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,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">
|
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'
|