eprec 1.0.1 → 1.1.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
@@ -13,45 +13,115 @@
13
13
  [![MIT License][license-badge]][license]
14
14
  <!-- prettier-ignore-end -->
15
15
 
16
- ## Installation
16
+ ## Overview
17
+
18
+ A Bun-based CLI that processes recorded course videos by splitting chapter
19
+ markers into separate files, trimming silence at the start/end, and optionally
20
+ removing spoken "Jarvis" command windows via transcript timestamps refined with
21
+ audio-based silence detection.
22
+
23
+ ## Requirements
17
24
 
18
- To install dependencies:
25
+ - **Bun** - runtime and package manager
26
+ - **ffmpeg + ffprobe** - must be available on PATH
27
+ - **whisper-cli** _(optional)_ - from
28
+ [whisper.cpp](https://github.com/ggerganov/whisper.cpp), required for
29
+ transcription
30
+ - Pass `--whisper-binary-path` if not on PATH
31
+ - Model file auto-downloads to `.cache/whispercpp/ggml-small.en.bin`
32
+ - **Silero VAD model** - auto-downloads to `.cache/silero-vad.onnx` on first use
33
+
34
+ ## Installation
19
35
 
20
36
  ```bash
21
37
  bun install
22
38
  ```
23
39
 
24
- To run:
40
+ ## Quick Start
25
41
 
26
42
  ```bash
27
- bun run index.ts
43
+ bun process-course-video.ts "/path/to/input.mp4" "/path/to/output" \
44
+ --enable-transcription \
45
+ --keep-intermediates \
46
+ --write-logs
28
47
  ```
29
48
 
30
- ## Whisper.cpp transcription (optional)
49
+ ## Web UI (experimental)
31
50
 
32
- Install the local whisper.cpp CLI (Homebrew):
51
+ Start the Remix-powered UI shell with the CLI:
33
52
 
34
53
  ```bash
35
- brew install whisper-cpp
54
+ bun cli.ts app start
55
+ ```
56
+
57
+ Then open `http://127.0.0.1:3000`. Use `--port` or `--host` to override the
58
+ defaults.
59
+
60
+ ## CLI Options
61
+
62
+ | Option | Alias | Description | Default |
63
+ | ------------------------ | ----- | --------------------------------------- | ----------- |
64
+ | `input` | | Input video file (mp4/mkv) | _required_ |
65
+ | `outputDir` | | Output directory | `output` |
66
+ | `--min-chapter-seconds` | `-m` | Skip chapters shorter than this | `15` |
67
+ | `--dry-run` | `-d` | Don't write files or run ffmpeg | `false` |
68
+ | `--keep-intermediates` | `-k` | Keep `.tmp` files for debugging | `false` |
69
+ | `--write-logs` | `-l` | Write log files for skips/fallbacks | `false` |
70
+ | `--enable-transcription` | | Run whisper.cpp for command detection | `false` |
71
+ | `--whisper-model-path` | | Path to whisper.cpp model file | auto-cached |
72
+ | `--whisper-language` | | Language for whisper | `en` |
73
+ | `--whisper-binary-path` | | Path to `whisper-cli` binary | system PATH |
74
+ | `--chapter` | `-c` | Filter to specific chapters (see below) | all |
75
+
76
+ ## Chapter Selection
77
+
78
+ The `--chapter` flag supports flexible selection:
79
+
80
+ - Single: `--chapter 4`
81
+ - Range: `--chapter 4-6`
82
+ - Open range: `--chapter 4-*` (chapter 4 to end)
83
+ - Multiple: `--chapter 4,6,9-12`
84
+
85
+ Chapter numbers are 1-based by default.
86
+
87
+ ## Output Structure
88
+
89
+ Final files are written to the output directory with names like:
90
+
91
+ ```
92
+ chapter-01-intro.mp4
93
+ chapter-02-getting-started.mp4
94
+ chapter-03-custom-title.mp4
36
95
  ```
37
96
 
38
- The default small English model is downloaded on first use and cached at
39
- `.cache/whispercpp/ggml-small.en.bin`. Replace that file (or pass
40
- `--whisper-model-path`) to use a different model.
97
+ When `--keep-intermediates` is enabled, intermediate files go to `output/.tmp/`:
98
+
99
+ | File Pattern | Description |
100
+ | --------------------- | ------------------------------------------------ |
101
+ | `*-raw.mp4` | Raw chapter clip with initial padding removed |
102
+ | `*-normalized.mp4` | Audio normalized (highpass + denoise + loudnorm) |
103
+ | `*-transcribe.wav` | Audio extracted for whisper |
104
+ | `*-transcribe.json` | Whisper JSON output |
105
+ | `*-transcribe.txt` | Whisper text output |
106
+ | `*-splice-*.mp4` | Segments before/after command windows |
107
+ | `*-spliced.mp4` | Concatenated output after command removal |
108
+ | `*.log` | Per-chapter skip/fallback logs |
109
+ | `process-summary.log` | Overall processing summary |
110
+
111
+ ## Voice Commands
112
+
113
+ Commands are spoken in the format: `jarvis <command> ... thanks`
41
114
 
42
- Enable transcription with `--enable-transcription` when running
43
- `process-course-video.ts` to skip chapters that include "jarvis bad take" or
44
- "bad take jarvis". If the CLI isn't on your PATH, pass `--whisper-binary-path`
45
- with the full path to `whisper-cli`.
115
+ | Command | Effect |
116
+ | --------------------------------------- | ----------------------- |
117
+ | `jarvis bad take thanks` | Skip the entire chapter |
118
+ | `jarvis filename my-custom-name thanks` | Rename output file |
46
119
 
47
- Customize skip phrases by repeating `--whisper-skip-phrase` (do not use
48
- comma-separated values because phrases may include commas).
120
+ The command window (from "jarvis" to "thanks") is removed from the final video.
49
121
 
50
- Manual test checklist:
122
+ ## More Details
51
123
 
52
- - Run with `--enable-transcription` and confirm whisper.cpp runs locally.
53
- - Verify a chapter containing the phrase is skipped and logged.
54
- - Verify a normal chapter still renders and writes output.
124
+ Implementation notes and pipeline details live in `docs/README.md`.
55
125
 
56
126
  This project was created using `bun init` in bun v1.3.1. [Bun](https://bun.com)
57
127
  is a fast all-in-one JavaScript runtime.
@@ -0,0 +1,129 @@
1
+ :root {
2
+ color-scheme: light;
3
+ }
4
+
5
+ * {
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ body {
10
+ margin: 0;
11
+ font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
12
+ background: #f8fafc;
13
+ color: #0f172a;
14
+ }
15
+
16
+ h1,
17
+ h2,
18
+ h3,
19
+ p {
20
+ margin: 0;
21
+ }
22
+
23
+ .app-shell {
24
+ max-width: 960px;
25
+ margin: 0 auto;
26
+ padding: 48px 24px 72px;
27
+ }
28
+
29
+ .app-header {
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 12px;
33
+ margin-bottom: 32px;
34
+ }
35
+
36
+ .app-kicker {
37
+ font-size: 12px;
38
+ letter-spacing: 0.16em;
39
+ text-transform: uppercase;
40
+ color: #64748b;
41
+ font-weight: 600;
42
+ }
43
+
44
+ .app-title {
45
+ font-size: 32px;
46
+ font-weight: 700;
47
+ }
48
+
49
+ .app-subtitle {
50
+ font-size: 16px;
51
+ color: #475569;
52
+ line-height: 1.6;
53
+ max-width: 640px;
54
+ }
55
+
56
+ .app-grid {
57
+ display: grid;
58
+ gap: 16px;
59
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
60
+ }
61
+
62
+ .app-card {
63
+ background: #ffffff;
64
+ border-radius: 16px;
65
+ border: 1px solid #e2e8f0;
66
+ padding: 20px;
67
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 12px;
71
+ }
72
+
73
+ .app-list {
74
+ margin: 0;
75
+ padding-left: 18px;
76
+ color: #334155;
77
+ line-height: 1.6;
78
+ }
79
+
80
+ .app-muted {
81
+ color: #64748b;
82
+ line-height: 1.5;
83
+ }
84
+
85
+ .status-pill {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ gap: 6px;
89
+ padding: 4px 12px;
90
+ border-radius: 999px;
91
+ background: #e0f2fe;
92
+ color: #0369a1;
93
+ font-size: 12px;
94
+ font-weight: 600;
95
+ width: fit-content;
96
+ }
97
+
98
+ .counter-button {
99
+ display: inline-flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ gap: 12px;
103
+ padding: 10px 14px;
104
+ border: none;
105
+ border-radius: 10px;
106
+ background: #0ea5e9;
107
+ color: #ffffff;
108
+ font-size: 14px;
109
+ font-weight: 600;
110
+ cursor: pointer;
111
+ transition: transform 0.15s ease, background 0.15s ease;
112
+ }
113
+
114
+ .counter-button:hover {
115
+ background: #0284c7;
116
+ }
117
+
118
+ .counter-button:disabled {
119
+ cursor: not-allowed;
120
+ opacity: 0.6;
121
+ }
122
+
123
+ .counter-button:active {
124
+ transform: translateY(1px);
125
+ }
126
+
127
+ .counter-value {
128
+ font-weight: 700;
129
+ }
@@ -0,0 +1,37 @@
1
+ import { Counter } from './counter.tsx'
2
+
3
+ export function App() {
4
+ return () => (
5
+ <main class="app-shell">
6
+ <header class="app-header">
7
+ <span class="app-kicker">Eprec Studio</span>
8
+ <h1 class="app-title">Editing workspace</h1>
9
+ <p class="app-subtitle">
10
+ Prepare edits with the CLI, then review them here.
11
+ </p>
12
+ </header>
13
+ <div class="app-grid">
14
+ <section class="app-card">
15
+ <h2>Workflow</h2>
16
+ <ol class="app-list">
17
+ <li>Run a CLI edit command.</li>
18
+ <li>Open the workspace UI.</li>
19
+ <li>Review and refine the cut list.</li>
20
+ </ol>
21
+ </section>
22
+ <section class="app-card">
23
+ <h2>UI status</h2>
24
+ <p class="status-pill">Running locally</p>
25
+ <p class="app-muted">
26
+ Server-rendered shell with client-side edits.
27
+ </p>
28
+ </section>
29
+ <section class="app-card">
30
+ <h2>Interaction check</h2>
31
+ <p class="app-muted">Click the counter to verify interactivity.</p>
32
+ <Counter setup={{ initial: 0 }} />
33
+ </section>
34
+ </div>
35
+ </main>
36
+ )
37
+ }
@@ -0,0 +1,22 @@
1
+ import type { Handle } from 'remix/component'
2
+
3
+ type CounterSetup = { initial?: number }
4
+
5
+ export function Counter(handle: Handle, props: { setup?: CounterSetup } = {}) {
6
+ let count = props.setup?.initial ?? 0
7
+ return () => (
8
+ <button
9
+ type="button"
10
+ class="counter-button"
11
+ on={{
12
+ click: () => {
13
+ count += 1
14
+ handle.update()
15
+ },
16
+ }}
17
+ >
18
+ <span>Click count</span>
19
+ <span class="counter-value">{count}</span>
20
+ </button>
21
+ )
22
+ }
@@ -0,0 +1,8 @@
1
+ import { createRoot } from 'remix/component'
2
+ import { App } from './app.tsx'
3
+
4
+ const rootElement = document.getElementById('root')
5
+ if (rootElement) {
6
+ rootElement.innerHTML = ''
7
+ }
8
+ createRoot(rootElement ?? document.body).render(<App />)
@@ -0,0 +1,37 @@
1
+ import { html, type SafeHtml } from 'remix/html-template'
2
+ import { baseImportMap } from '../config/import-map.ts'
3
+
4
+ export function Layout({
5
+ children,
6
+ title = 'Eprec Studio',
7
+ entryScript = '/app/client/entry.tsx',
8
+ }: {
9
+ children?: SafeHtml
10
+ title?: string
11
+ entryScript?: string | false
12
+ }) {
13
+ const importmap = { imports: baseImportMap }
14
+ const importmapJson = JSON.stringify(importmap)
15
+ const importmapScript = html.raw`<script type="importmap">${importmapJson}</script>`
16
+ const modulePreloads = Object.values(baseImportMap).map((value) => {
17
+ return html`<link rel="modulepreload" href="${value}" />`
18
+ })
19
+
20
+ return html`<html lang="en">
21
+ <head>
22
+ <meta charset="utf-8" />
23
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
24
+ <title>${title}</title>
25
+ <link rel="stylesheet" href="/assets/styles.css" />
26
+ ${importmapScript} ${modulePreloads}
27
+ </head>
28
+ <body>
29
+ <div id="root">${children ?? ''}</div>
30
+ ${
31
+ entryScript
32
+ ? html`<script type="module" src="${entryScript}"></script>`
33
+ : ''
34
+ }
35
+ </body>
36
+ </html>`
37
+ }
@@ -0,0 +1,31 @@
1
+ type AppEnv = {
2
+ NODE_ENV: 'development' | 'production' | 'test'
3
+ PORT: number
4
+ HOST: string
5
+ }
6
+
7
+ const DEFAULT_PORT = 3000
8
+ const DEFAULT_HOST = '127.0.0.1'
9
+
10
+ function parseNodeEnv(value: string | undefined): AppEnv['NODE_ENV'] {
11
+ if (value === 'production' || value === 'test') {
12
+ return value
13
+ }
14
+ return 'development'
15
+ }
16
+
17
+ function parsePort(value: string | undefined): number {
18
+ const parsed = Number(value)
19
+ if (!Number.isFinite(parsed) || parsed <= 0) {
20
+ return DEFAULT_PORT
21
+ }
22
+ return Math.floor(parsed)
23
+ }
24
+
25
+ export function getEnv(): AppEnv {
26
+ return {
27
+ NODE_ENV: parseNodeEnv(process.env.NODE_ENV),
28
+ PORT: parsePort(process.env.PORT),
29
+ HOST: process.env.HOST?.trim() || DEFAULT_HOST,
30
+ }
31
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Base import map for client-side modules.
3
+ */
4
+ export const baseImportMap = {
5
+ 'remix/component': '/node_modules/remix/component',
6
+ 'remix/component/jsx-runtime': '/node_modules/remix/component/jsx-runtime',
7
+ 'remix/component/jsx-dev-runtime':
8
+ '/node_modules/remix/component/jsx-dev-runtime',
9
+ } as const
@@ -0,0 +1,3 @@
1
+ if (!process.env.NODE_ENV) {
2
+ process.env.NODE_ENV = 'development'
3
+ }
@@ -0,0 +1,5 @@
1
+ import { route } from 'remix/fetch-router'
2
+
3
+ export default route({
4
+ index: '/',
5
+ })
@@ -0,0 +1,6 @@
1
+ import type { SafeHtml } from 'remix/html-template'
2
+ import { createHtmlResponse } from 'remix/response/html'
3
+
4
+ export function render(body: string | SafeHtml, init?: ResponseInit) {
5
+ return createHtmlResponse(body, init)
6
+ }
package/app/router.tsx ADDED
@@ -0,0 +1,102 @@
1
+ import path from 'node:path'
2
+ import { createRouter, type Middleware } from 'remix/fetch-router'
3
+ import { html } from 'remix/html-template'
4
+ import { Layout } from './components/layout.tsx'
5
+ import routes from './config/routes.ts'
6
+ import { render } from './helpers/render.ts'
7
+ import indexHandlers from './routes/index.tsx'
8
+
9
+ const STATIC_CORS_HEADERS = {
10
+ 'Access-Control-Allow-Origin': '*',
11
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
12
+ 'Access-Control-Allow-Headers': 'Accept, Content-Type',
13
+ } as const
14
+
15
+ function bunStaticFiles(
16
+ root: string,
17
+ options: { filter?: (pathname: string) => boolean; cacheControl?: string },
18
+ ): Middleware {
19
+ const absoluteRoot = path.resolve(root)
20
+ return async (context, next) => {
21
+ if (context.method === 'OPTIONS') {
22
+ const relativePath = context.url.pathname.replace(/^\/+/, '')
23
+ if (options.filter && !options.filter(relativePath)) {
24
+ return next()
25
+ }
26
+ const filePath = path.join(absoluteRoot, relativePath)
27
+ if (!filePath.startsWith(absoluteRoot + path.sep)) {
28
+ return next()
29
+ }
30
+ const file = Bun.file(filePath)
31
+ if (!(await file.exists())) {
32
+ return next()
33
+ }
34
+ return new Response(null, {
35
+ status: 204,
36
+ headers: {
37
+ ...STATIC_CORS_HEADERS,
38
+ 'Access-Control-Max-Age': '86400',
39
+ },
40
+ })
41
+ }
42
+
43
+ if (context.method !== 'GET' && context.method !== 'HEAD') {
44
+ return next()
45
+ }
46
+ const relativePath = context.url.pathname.replace(/^\/+/, '')
47
+ if (options.filter && !options.filter(relativePath)) {
48
+ return next()
49
+ }
50
+ const filePath = path.join(absoluteRoot, relativePath)
51
+ if (!filePath.startsWith(absoluteRoot + path.sep)) {
52
+ return next()
53
+ }
54
+ const file = Bun.file(filePath)
55
+ if (!(await file.exists())) {
56
+ return next()
57
+ }
58
+ return new Response(context.method === 'HEAD' ? null : file, {
59
+ headers: {
60
+ 'Content-Type': file.type,
61
+ 'Content-Length': String(file.size),
62
+ ...(options.cacheControl
63
+ ? { 'Cache-Control': options.cacheControl }
64
+ : {}),
65
+ ...STATIC_CORS_HEADERS,
66
+ },
67
+ })
68
+ }
69
+ }
70
+
71
+ const cacheControl =
72
+ process.env.NODE_ENV === 'production'
73
+ ? 'public, max-age=31536000, immutable'
74
+ : 'no-cache'
75
+
76
+ export function createAppRouter(rootDir: string) {
77
+ const router = createRouter({
78
+ middleware: [
79
+ bunStaticFiles(path.join(rootDir, 'public'), { cacheControl }),
80
+ bunStaticFiles(path.join(rootDir, 'app'), {
81
+ filter: (pathname) => pathname.startsWith('assets/'),
82
+ cacheControl,
83
+ }),
84
+ ],
85
+ defaultHandler() {
86
+ return render(
87
+ Layout({
88
+ title: 'Not Found',
89
+ entryScript: false,
90
+ children: html`<main class="app-shell">
91
+ <h1 class="app-title">404 - Not Found</h1>
92
+ </main>`,
93
+ }),
94
+ { status: 404 },
95
+ )
96
+ },
97
+ })
98
+
99
+ router.map(routes.index, indexHandlers)
100
+
101
+ return router
102
+ }
@@ -0,0 +1,50 @@
1
+ import { html } from 'remix/html-template'
2
+ import { Layout } from '../components/layout.tsx'
3
+ import { render } from '../helpers/render.ts'
4
+
5
+ const indexHandler = {
6
+ middleware: [],
7
+ loader() {
8
+ return render(
9
+ Layout({
10
+ title: 'Eprec Studio',
11
+ children: html`<main class="app-shell">
12
+ <header class="app-header">
13
+ <span class="app-kicker">Eprec Studio</span>
14
+ <h1 class="app-title">Editing workspace</h1>
15
+ <p class="app-subtitle">
16
+ Prepare edits with the CLI, then review them here.
17
+ </p>
18
+ </header>
19
+ <div class="app-grid">
20
+ <section class="app-card">
21
+ <h2>Workflow</h2>
22
+ <ol class="app-list">
23
+ <li>Run a CLI edit command.</li>
24
+ <li>Open the workspace UI.</li>
25
+ <li>Review and refine the cut list.</li>
26
+ </ol>
27
+ </section>
28
+ <section class="app-card">
29
+ <h2>UI status</h2>
30
+ <p class="status-pill">UI booted</p>
31
+ <p class="app-muted">
32
+ Client-side components load after the first paint.
33
+ </p>
34
+ </section>
35
+ <section class="app-card">
36
+ <h2>Interaction check</h2>
37
+ <p class="app-muted">Client bundle loads after this page.</p>
38
+ <button class="counter-button" type="button" disabled>
39
+ <span>Click count</span>
40
+ <span class="counter-value">0</span>
41
+ </button>
42
+ </section>
43
+ </div>
44
+ </main>`,
45
+ }),
46
+ )
47
+ },
48
+ }
49
+
50
+ export default indexHandler
package/app-server.ts ADDED
@@ -0,0 +1,60 @@
1
+ import './app/config/init-env.ts'
2
+
3
+ import getPort from 'get-port'
4
+ import { getEnv } from './app/config/env.ts'
5
+ import { createAppRouter } from './app/router.tsx'
6
+ import { createBundlingRoutes } from './server/bundling.ts'
7
+
8
+ type AppServerOptions = {
9
+ host?: string
10
+ port?: number
11
+ }
12
+
13
+ function startServer(port: number, hostname: string) {
14
+ const router = createAppRouter(import.meta.dirname)
15
+ return Bun.serve({
16
+ port,
17
+ hostname,
18
+ idleTimeout: 30,
19
+ routes: createBundlingRoutes(import.meta.dirname),
20
+ async fetch(request) {
21
+ try {
22
+ return await router.fetch(request)
23
+ } catch (error) {
24
+ console.error(error)
25
+ return new Response('Internal Server Error', { status: 500 })
26
+ }
27
+ },
28
+ })
29
+ }
30
+
31
+ async function getServerPort(nodeEnv: string, desiredPort: number) {
32
+ if (nodeEnv === 'production') {
33
+ return desiredPort
34
+ }
35
+ const port = await getPort({ port: desiredPort })
36
+ if (port !== desiredPort) {
37
+ console.warn(`⚠️ Port ${desiredPort} was taken, using port ${port} instead`)
38
+ }
39
+ return port
40
+ }
41
+
42
+ export async function startAppServer(options: AppServerOptions = {}) {
43
+ const env = getEnv()
44
+ const host = options.host ?? env.HOST
45
+ const desiredPort = options.port ?? env.PORT
46
+ const port = await getServerPort(env.NODE_ENV, desiredPort)
47
+ const server = startServer(port, host)
48
+ const hostname = server.hostname.includes(':')
49
+ ? `[${server.hostname}]`
50
+ : server.hostname
51
+ const url = `http://${hostname}:${server.port}`
52
+
53
+ console.log(`[app] running at ${url}`)
54
+
55
+ return { server, url }
56
+ }
57
+
58
+ if (import.meta.main) {
59
+ await startAppServer()
60
+ }
package/cli.ts CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import type { CommandBuilder, CommandHandler } from 'yargs'
4
4
  import yargs from 'yargs/yargs'
5
5
  import { hideBin } from 'yargs/helpers'
6
+ import { startAppServer } from './app-server'
6
7
  import { ensureFfmpegAvailable } from './process-course/ffmpeg'
7
8
  import {
8
9
  normalizeProcessArgs,
@@ -53,6 +54,28 @@ async function main() {
53
54
  configureCombineVideosCommand as CommandBuilder,
54
55
  handleCombineVideosCommand as CommandHandler,
55
56
  )
57
+ .command(
58
+ 'app start',
59
+ 'Start the web UI server',
60
+ (command) =>
61
+ command
62
+ .option('port', {
63
+ type: 'number',
64
+ describe: 'Port for the app server',
65
+ })
66
+ .option('host', {
67
+ type: 'string',
68
+ describe: 'Host to bind for the app server',
69
+ }),
70
+ async (argv) => {
71
+ const port =
72
+ typeof argv.port === 'number' && Number.isFinite(argv.port)
73
+ ? argv.port
74
+ : undefined
75
+ const host = resolveOptionalString(argv.host)
76
+ await startAppServer({ port, host })
77
+ },
78
+ )
56
79
  .command(
57
80
  'transcribe <input>',
58
81
  'Transcribe a single audio/video file',
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.0.1",
4
+ "version": "1.1.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/epicweb-dev/eprec"
9
9
  },
10
10
  "scripts": {
11
+ "app:start": "bun ./app-server.ts",
11
12
  "format": "prettier --write .",
12
13
  "test": "bun test process-course utils.test.ts",
13
14
  "test:e2e": "bun test e2e",
@@ -18,9 +19,13 @@
18
19
  "eprec": "./cli.ts"
19
20
  },
20
21
  "files": [
22
+ "app/**",
23
+ "app-server.ts",
21
24
  "cli.ts",
22
25
  "process-course/**",
23
26
  "process-course-video.ts",
27
+ "public/**",
28
+ "server/**",
24
29
  "speech-detection.ts",
25
30
  "utils.ts",
26
31
  "whispercpp-transcribe.ts"
@@ -36,7 +41,9 @@
36
41
  "typescript": "^5"
37
42
  },
38
43
  "dependencies": {
44
+ "get-port": "^7.1.0",
39
45
  "onnxruntime-node": "^1.23.2",
46
+ "remix": "3.0.0-alpha.0",
40
47
  "yargs": "^18.0.0"
41
48
  }
42
49
  }
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Disallow:
@@ -0,0 +1,210 @@
1
+ import path from 'node:path'
2
+
3
+ type PackageJson = {
4
+ exports?: Record<string, { default?: string; types?: string } | string>
5
+ module?: string
6
+ main?: string
7
+ }
8
+
9
+ async function resolvePackageExport(
10
+ specifier: string,
11
+ rootDir: string,
12
+ ): Promise<string | null> {
13
+ const parts = specifier.split('/')
14
+ let packageName: string
15
+ let subpathParts: string[]
16
+
17
+ if (specifier.startsWith('@')) {
18
+ if (parts.length < 2) return null
19
+ packageName = `${parts[0]}/${parts[1]}`
20
+ subpathParts = parts.slice(2)
21
+ } else {
22
+ if (parts.length === 0 || !parts[0]) return null
23
+ packageName = parts[0]
24
+ subpathParts = parts.slice(1)
25
+ }
26
+
27
+ const subpath = subpathParts.length > 0 ? `./${subpathParts.join('/')}` : '.'
28
+ const packageDir = path.join(rootDir, 'node_modules', packageName)
29
+ const packageJsonPath = path.join(packageDir, 'package.json')
30
+ const packageJsonFile = Bun.file(packageJsonPath)
31
+
32
+ if (!(await packageJsonFile.exists())) return null
33
+
34
+ const packageJson = JSON.parse(
35
+ await packageJsonFile.text(),
36
+ ) as PackageJson
37
+
38
+ if (!packageJson.exports) {
39
+ const entryFile = packageJson.module || packageJson.main
40
+ if (entryFile) {
41
+ const entryPath = path.join(packageDir, entryFile)
42
+ if (await Bun.file(entryPath).exists()) return entryPath
43
+ }
44
+ const indexPath = path.join(packageDir, 'index.js')
45
+ return (await Bun.file(indexPath).exists()) ? indexPath : null
46
+ }
47
+
48
+ const exportEntry = packageJson.exports[subpath]
49
+ if (!exportEntry) return null
50
+
51
+ const exportPath =
52
+ typeof exportEntry === 'string'
53
+ ? exportEntry
54
+ : exportEntry.default
55
+
56
+ if (!exportPath) return null
57
+
58
+ const resolvedPath = path.join(packageDir, exportPath)
59
+ return (await Bun.file(resolvedPath).exists()) ? resolvedPath : null
60
+ }
61
+
62
+ const BUNDLING_CORS_HEADERS = {
63
+ 'Access-Control-Allow-Origin': '*',
64
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
65
+ 'Access-Control-Allow-Headers': 'Accept, Content-Type',
66
+ } as const
67
+
68
+ export function createBundlingRoutes(rootDir: string) {
69
+ const clientDir = path.resolve(rootDir, 'app', 'client')
70
+
71
+ return {
72
+ '/app/client/*': async (request: Request) => {
73
+ if (request.method === 'OPTIONS') {
74
+ return new Response(null, {
75
+ status: 204,
76
+ headers: {
77
+ ...BUNDLING_CORS_HEADERS,
78
+ 'Access-Control-Max-Age': '86400',
79
+ },
80
+ })
81
+ }
82
+
83
+ const url = new URL(request.url)
84
+ const reqPath = path.posix.normalize(url.pathname.replace(/^\/+/, ''))
85
+ const resolved = path.resolve(rootDir, reqPath)
86
+
87
+ if (!resolved.startsWith(clientDir + path.sep)) {
88
+ return new Response('Forbidden', {
89
+ status: 403,
90
+ headers: BUNDLING_CORS_HEADERS,
91
+ })
92
+ }
93
+
94
+ if (!resolved.endsWith('.ts') && !resolved.endsWith('.tsx')) {
95
+ return new Response('Not Found', {
96
+ status: 404,
97
+ headers: BUNDLING_CORS_HEADERS,
98
+ })
99
+ }
100
+
101
+ const entryFile = Bun.file(resolved)
102
+ if (!(await entryFile.exists())) {
103
+ return new Response('Not Found', {
104
+ status: 404,
105
+ headers: BUNDLING_CORS_HEADERS,
106
+ })
107
+ }
108
+
109
+ const buildResult = await Bun.build({
110
+ entrypoints: [resolved],
111
+ target: 'browser',
112
+ minify: Bun.env.NODE_ENV === 'production',
113
+ splitting: false,
114
+ format: 'esm',
115
+ sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
116
+ jsx: { importSource: 'remix/component' },
117
+ })
118
+
119
+ if (!buildResult.success) {
120
+ const errorMessage = buildResult.logs
121
+ .map((log) => log.message)
122
+ .join('\n')
123
+ return new Response(errorMessage || 'Build failed', {
124
+ status: 500,
125
+ headers: {
126
+ 'Content-Type': 'text/plain',
127
+ ...BUNDLING_CORS_HEADERS,
128
+ },
129
+ })
130
+ }
131
+
132
+ const output = buildResult.outputs[0]
133
+ return new Response(output, {
134
+ headers: {
135
+ 'Content-Type': 'application/javascript',
136
+ 'Cache-Control':
137
+ Bun.env.NODE_ENV === 'production'
138
+ ? 'public, max-age=31536000, immutable'
139
+ : 'no-cache',
140
+ ...BUNDLING_CORS_HEADERS,
141
+ },
142
+ })
143
+ },
144
+
145
+ '/node_modules/*': async (request: Request) => {
146
+ if (request.method === 'OPTIONS') {
147
+ return new Response(null, {
148
+ status: 204,
149
+ headers: {
150
+ ...BUNDLING_CORS_HEADERS,
151
+ 'Access-Control-Max-Age': '86400',
152
+ },
153
+ })
154
+ }
155
+
156
+ const url = new URL(request.url)
157
+ const specifier = url.pathname.replace('/node_modules/', '')
158
+ const filepath = await resolvePackageExport(specifier, rootDir)
159
+
160
+ if (!filepath) {
161
+ return new Response('Package not found', {
162
+ status: 404,
163
+ headers: BUNDLING_CORS_HEADERS,
164
+ })
165
+ }
166
+
167
+ const nodeModulesDir = path.resolve(rootDir, 'node_modules')
168
+ if (!filepath.startsWith(nodeModulesDir + path.sep)) {
169
+ return new Response('Forbidden', {
170
+ status: 403,
171
+ headers: BUNDLING_CORS_HEADERS,
172
+ })
173
+ }
174
+
175
+ const buildResult = await Bun.build({
176
+ entrypoints: [filepath],
177
+ target: 'browser',
178
+ minify: Bun.env.NODE_ENV === 'production',
179
+ splitting: false,
180
+ format: 'esm',
181
+ sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
182
+ })
183
+
184
+ if (!buildResult.success) {
185
+ const errorMessage = buildResult.logs
186
+ .map((log) => log.message)
187
+ .join('\n')
188
+ return new Response(errorMessage || 'Build failed', {
189
+ status: 500,
190
+ headers: {
191
+ 'Content-Type': 'text/plain',
192
+ ...BUNDLING_CORS_HEADERS,
193
+ },
194
+ })
195
+ }
196
+
197
+ const output = buildResult.outputs[0]
198
+ return new Response(output, {
199
+ headers: {
200
+ 'Content-Type': 'application/javascript',
201
+ 'Cache-Control':
202
+ Bun.env.NODE_ENV === 'production'
203
+ ? 'public, max-age=31536000, immutable'
204
+ : 'no-cache',
205
+ ...BUNDLING_CORS_HEADERS,
206
+ },
207
+ })
208
+ },
209
+ }
210
+ }
@@ -113,9 +113,7 @@ async function readTranscriptText(transcriptPath: string, fallback: string) {
113
113
  throw new Error('Whisper.cpp transcript output was empty.')
114
114
  }
115
115
 
116
- async function readTranscriptSegments(
117
- transcriptPath: string,
118
- ): Promise<{
116
+ async function readTranscriptSegments(transcriptPath: string): Promise<{
119
117
  segments: TranscriptSegment[]
120
118
  source: TranscriptionResult['segmentsSource']
121
119
  }> {