eprec 1.2.0 → 1.4.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
@@ -48,14 +48,14 @@ bun process-course-video.ts "/path/to/input.mp4" "/path/to/output" \
48
48
 
49
49
  ## Web UI (experimental)
50
50
 
51
- Start the Remix-powered UI shell with the CLI:
51
+ Start the Remix-powered UI shell (watch mode enabled):
52
52
 
53
53
  ```bash
54
- bun cli.ts app start
54
+ bun run app:start
55
55
  ```
56
56
 
57
- Then open `http://127.0.0.1:3000`. Use `--port` or `--host` to override the
58
- defaults.
57
+ Then open `http://localhost:3000`. Use `-- --port` or `-- --host` to override
58
+ the defaults.
59
59
 
60
60
  ## CLI Options
61
61
 
@@ -261,7 +261,9 @@ p {
261
261
  cursor: pointer;
262
262
  left: var(--range-left);
263
263
  width: var(--range-width);
264
- transition: transform 0.15s ease, box-shadow 0.15s ease;
264
+ transition:
265
+ transform 0.15s ease,
266
+ box-shadow 0.15s ease;
265
267
  }
266
268
 
267
269
  .timeline-range--manual {
@@ -370,7 +372,9 @@ p {
370
372
  font-size: 13px;
371
373
  font-weight: 600;
372
374
  cursor: pointer;
373
- transition: background 0.15s ease, color 0.15s ease;
375
+ transition:
376
+ background 0.15s ease,
377
+ color 0.15s ease;
374
378
  }
375
379
 
376
380
  .button--primary {
@@ -1,7 +1,5 @@
1
1
  import { EditingWorkspace } from './editing-workspace.tsx'
2
2
 
3
3
  export function App() {
4
- return () => (
5
- <EditingWorkspace />
6
- )
4
+ return () => <EditingWorkspace />
7
5
  }
@@ -27,6 +27,7 @@ export function EditingWorkspace(handle: Handle) {
27
27
  let previewPlaying = false
28
28
  let previewNode: HTMLVideoElement | null = null
29
29
  let lastSyncedPlayhead = playhead
30
+ let isScrubbing = false
30
31
 
31
32
  const setPlayhead = (value: number) => {
32
33
  playhead = clamp(value, 0, duration)
@@ -34,6 +35,16 @@ export function EditingWorkspace(handle: Handle) {
34
35
  handle.update()
35
36
  }
36
37
 
38
+ const startScrubbing = () => {
39
+ isScrubbing = true
40
+ }
41
+
42
+ const stopScrubbing = () => {
43
+ if (!isScrubbing) return
44
+ isScrubbing = false
45
+ syncVideoToPlayhead(playhead)
46
+ }
47
+
37
48
  const selectRange = (rangeId: string) => {
38
49
  selectedRangeId = rangeId
39
50
  handle.update()
@@ -41,7 +52,11 @@ export function EditingWorkspace(handle: Handle) {
41
52
 
42
53
  const addManualCut = () => {
43
54
  const start = clamp(playhead, 0, duration - MIN_CUT_LENGTH)
44
- const end = clamp(start + DEFAULT_CUT_LENGTH, start + MIN_CUT_LENGTH, duration)
55
+ const end = clamp(
56
+ start + DEFAULT_CUT_LENGTH,
57
+ start + MIN_CUT_LENGTH,
58
+ duration,
59
+ )
45
60
  const newRange: CutRange = {
46
61
  id: `manual-${manualCutId++}`,
47
62
  start,
@@ -130,10 +145,19 @@ export function EditingWorkspace(handle: Handle) {
130
145
  }
131
146
 
132
147
  const syncVideoToPlayhead = (value: number) => {
133
- if (!previewNode || !previewReady || duration <= 0 || previewDuration <= 0) {
148
+ if (
149
+ !previewNode ||
150
+ !previewReady ||
151
+ duration <= 0 ||
152
+ previewDuration <= 0
153
+ ) {
134
154
  return
135
155
  }
136
- const targetTime = clamp((value / duration) * previewDuration, 0, previewDuration)
156
+ const targetTime = clamp(
157
+ (value / duration) * previewDuration,
158
+ 0,
159
+ previewDuration,
160
+ )
137
161
  lastSyncedPlayhead = value
138
162
  if (Math.abs(previewNode.currentTime - targetTime) > 0.05) {
139
163
  previewNode.currentTime = targetTime
@@ -143,7 +167,7 @@ export function EditingWorkspace(handle: Handle) {
143
167
  return () => {
144
168
  const sortedCuts = sortRanges(cutRanges)
145
169
  const selectedRange = selectedRangeId
146
- ? sortedCuts.find((range) => range.id === selectedRangeId) ?? null
170
+ ? (sortedCuts.find((range) => range.id === selectedRangeId) ?? null)
147
171
  : null
148
172
  const mergedCuts = mergeOverlappingRanges(sortedCuts)
149
173
  const totalRemoved = mergedCuts.reduce(
@@ -161,7 +185,10 @@ export function EditingWorkspace(handle: Handle) {
161
185
  .filter((word) => word.word.toLowerCase().includes(query))
162
186
  .slice(0, 12)
163
187
  : []
164
- const commandPreview = buildCommandPreview(sampleEditSession.sourceName, chapters)
188
+ const commandPreview = buildCommandPreview(
189
+ sampleEditSession.sourceName,
190
+ chapters,
191
+ )
165
192
  const previewTime =
166
193
  previewReady && previewDuration > 0
167
194
  ? (playhead / duration) * previewDuration
@@ -190,29 +217,29 @@ export function EditingWorkspace(handle: Handle) {
190
217
  </div>
191
218
  <div class="summary-item">
192
219
  <span class="summary-label">Cuts</span>
193
- <span class="summary-value">
194
- {sortedCuts.length} ranges
195
- </span>
220
+ <span class="summary-value">{sortedCuts.length} ranges</span>
196
221
  <span class="summary-subtext">
197
222
  {formatSeconds(totalRemoved)} removed
198
223
  </span>
199
224
  </div>
200
225
  <div class="summary-item">
201
226
  <span class="summary-label">Output length</span>
202
- <span class="summary-value">{formatTimestamp(finalDuration)}</span>
227
+ <span class="summary-value">
228
+ {formatTimestamp(finalDuration)}
229
+ </span>
203
230
  <span class="summary-subtext">
204
231
  {Math.round((finalDuration / duration) * 100)}% retained
205
232
  </span>
206
233
  </div>
207
234
  <div class="summary-item">
208
235
  <span class="summary-label">Commands</span>
209
- <span class="summary-value">
210
- {commands.length} detected
211
- </span>
236
+ <span class="summary-value">{commands.length} detected</span>
212
237
  <span class="summary-subtext">
213
- {commands.filter((command) =>
214
- isCommandApplied(command, sortedCuts, chapters),
215
- ).length}{' '}
238
+ {
239
+ commands.filter((command) =>
240
+ isCommandApplied(command, sortedCuts, chapters),
241
+ ).length
242
+ }{' '}
216
243
  applied
217
244
  </span>
218
245
  </div>
@@ -228,7 +255,11 @@ export function EditingWorkspace(handle: Handle) {
228
255
  trims.
229
256
  </p>
230
257
  </div>
231
- <button class="button button--primary" type="button" on={{ click: addManualCut }}>
258
+ <button
259
+ class="button button--primary"
260
+ type="button"
261
+ on={{ click: addManualCut }}
262
+ >
232
263
  Add cut at playhead
233
264
  </button>
234
265
  </div>
@@ -260,11 +291,11 @@ export function EditingWorkspace(handle: Handle) {
260
291
  : 'Loading'}
261
292
  </span>
262
293
  </div>
263
- <video
264
- class="timeline-video-player"
265
- src="/e2e-test.mp4"
266
- controls
267
- preload="metadata"
294
+ <video
295
+ class="timeline-video-player"
296
+ src="/e2e-test.mp4"
297
+ controls
298
+ preload="metadata"
268
299
  connect={(node: HTMLVideoElement, signal) => {
269
300
  previewNode = node
270
301
  const handleLoadedMetadata = () => {
@@ -278,6 +309,8 @@ export function EditingWorkspace(handle: Handle) {
278
309
  }
279
310
  const handleTimeUpdate = () => {
280
311
  if (!previewReady || previewDuration <= 0) return
312
+ if (isScrubbing) return
313
+ if (!previewPlaying) return
281
314
  const mapped =
282
315
  (node.currentTime / previewDuration) * duration
283
316
  if (Math.abs(mapped - lastSyncedPlayhead) <= 0.05) {
@@ -294,7 +327,10 @@ export function EditingWorkspace(handle: Handle) {
294
327
  previewPlaying = false
295
328
  handle.update()
296
329
  }
297
- node.addEventListener('loadedmetadata', handleLoadedMetadata)
330
+ node.addEventListener(
331
+ 'loadedmetadata',
332
+ handleLoadedMetadata,
333
+ )
298
334
  node.addEventListener('timeupdate', handleTimeUpdate)
299
335
  node.addEventListener('play', handlePlay)
300
336
  node.addEventListener('pause', handlePause)
@@ -313,9 +349,7 @@ export function EditingWorkspace(handle: Handle) {
313
349
  }}
314
350
  />
315
351
  <div class="timeline-video-meta">
316
- <span>
317
- Preview {formatTimestamp(previewTime)}
318
- </span>
352
+ <span>Preview {formatTimestamp(previewTime)}</span>
319
353
  <span class="app-muted">
320
354
  Timeline {formatTimestamp(playhead)}
321
355
  </span>
@@ -350,9 +384,7 @@ export function EditingWorkspace(handle: Handle) {
350
384
  <div class="timeline-controls">
351
385
  <label class="control-label">
352
386
  Playhead
353
- <span class="control-value">
354
- {formatTimestamp(playhead)}
355
- </span>
387
+ <span class="control-value">{formatTimestamp(playhead)}</span>
356
388
  </label>
357
389
  <input
358
390
  class="timeline-slider"
@@ -364,8 +396,15 @@ export function EditingWorkspace(handle: Handle) {
364
396
  on={{
365
397
  input: (event) => {
366
398
  const target = event.currentTarget as HTMLInputElement
399
+ startScrubbing()
367
400
  setPlayhead(Number(target.value))
368
401
  },
402
+ pointerdown: startScrubbing,
403
+ pointerup: stopScrubbing,
404
+ pointercancel: stopScrubbing,
405
+ keydown: startScrubbing,
406
+ keyup: stopScrubbing,
407
+ blur: stopScrubbing,
369
408
  }}
370
409
  />
371
410
  <button
@@ -385,7 +424,9 @@ export function EditingWorkspace(handle: Handle) {
385
424
  type="button"
386
425
  on={{
387
426
  click: () => {
388
- const next = sortedCuts.find((range) => range.start > playhead)
427
+ const next = sortedCuts.find(
428
+ (range) => range.start > playhead,
429
+ )
389
430
  if (next) setPlayhead(next.start)
390
431
  },
391
432
  }}
@@ -460,7 +501,9 @@ export function EditingWorkspace(handle: Handle) {
460
501
  on={{
461
502
  input: (event) => {
462
503
  const target = event.currentTarget as HTMLInputElement
463
- updateCutRange(selectedRange.id, { reason: target.value })
504
+ updateCutRange(selectedRange.id, {
505
+ reason: target.value,
506
+ })
464
507
  },
465
508
  }}
466
509
  />
@@ -562,7 +605,8 @@ export function EditingWorkspace(handle: Handle) {
562
605
  value={chapter.status}
563
606
  on={{
564
607
  change: (event) => {
565
- const target = event.currentTarget as HTMLSelectElement
608
+ const target =
609
+ event.currentTarget as HTMLSelectElement
566
610
  updateChapterStatus(
567
611
  chapter.id,
568
612
  target.value as ChapterStatus,
package/app-server.ts CHANGED
@@ -10,6 +10,140 @@ type AppServerOptions = {
10
10
  port?: number
11
11
  }
12
12
 
13
+ const LOCALHOST_ALIASES = new Set(['127.0.0.1', '::1', 'localhost'])
14
+ const COLOR_ENABLED =
15
+ process.env.FORCE_COLOR === '1' ||
16
+ (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR)
17
+ const SHORTCUT_COLORS: Record<string, string> = {
18
+ o: '\u001b[36m',
19
+ r: '\u001b[33m',
20
+ q: '\u001b[31m',
21
+ h: '\u001b[35m',
22
+ }
23
+ const ANSI_RESET = '\u001b[0m'
24
+
25
+ function colorizeShortcut(key: string) {
26
+ if (!COLOR_ENABLED) {
27
+ return key
28
+ }
29
+ const color = SHORTCUT_COLORS[key.toLowerCase()]
30
+ return color ? `${color}${key}${ANSI_RESET}` : key
31
+ }
32
+
33
+ function formatHostnameForDisplay(hostname: string) {
34
+ if (LOCALHOST_ALIASES.has(hostname)) {
35
+ return 'localhost'
36
+ }
37
+ if (hostname.includes(':')) {
38
+ return `[${hostname}]`
39
+ }
40
+ return hostname
41
+ }
42
+
43
+ function formatServerUrl(hostname: string, port: number) {
44
+ return `http://${formatHostnameForDisplay(hostname)}:${port}`
45
+ }
46
+
47
+ function getShortcutLines(url: string) {
48
+ return [
49
+ '[app] shortcuts:',
50
+ ` ${colorizeShortcut('o')}: open ${url} in browser`,
51
+ ` ${colorizeShortcut('r')}: restart server`,
52
+ ` ${colorizeShortcut('q')}: quit server`,
53
+ ` ${colorizeShortcut('h')}: show shortcuts`,
54
+ ]
55
+ }
56
+
57
+ function logShortcuts(url: string) {
58
+ for (const line of getShortcutLines(url)) {
59
+ console.log(line)
60
+ }
61
+ }
62
+
63
+ function openBrowser(url: string) {
64
+ const platform = process.platform
65
+ const command =
66
+ platform === 'darwin'
67
+ ? ['open', url]
68
+ : platform === 'win32'
69
+ ? ['cmd', '/c', 'start', '', url]
70
+ : ['xdg-open', url]
71
+ try {
72
+ const subprocess = Bun.spawn({
73
+ cmd: command,
74
+ stdin: 'ignore',
75
+ stdout: 'ignore',
76
+ stderr: 'ignore',
77
+ })
78
+ void subprocess.exited.catch((error) => {
79
+ console.warn(
80
+ `[app] failed to open browser: ${
81
+ error instanceof Error ? error.message : String(error)
82
+ }`,
83
+ )
84
+ })
85
+ } catch (error) {
86
+ console.warn(
87
+ `[app] failed to open browser: ${
88
+ error instanceof Error ? error.message : String(error)
89
+ }`,
90
+ )
91
+ }
92
+ }
93
+
94
+ function setupShortcutHandling(options: {
95
+ getUrl: () => string
96
+ restart: () => void
97
+ stop: () => void
98
+ }) {
99
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
100
+ return () => {}
101
+ }
102
+
103
+ const stdin = process.stdin
104
+ const handleKey = (key: string) => {
105
+ if (key === '\u0003') {
106
+ options.stop()
107
+ return
108
+ }
109
+ const lower = key.toLowerCase()
110
+ if (lower === 'o') {
111
+ openBrowser(options.getUrl())
112
+ return
113
+ }
114
+ if (lower === 'r') {
115
+ options.restart()
116
+ return
117
+ }
118
+ if (lower === 'q') {
119
+ options.stop()
120
+ return
121
+ }
122
+ if (lower === 'h' || lower === '?') {
123
+ logShortcuts(options.getUrl())
124
+ }
125
+ }
126
+
127
+ const onData = (chunk: Buffer | string) => {
128
+ const input = chunk.toString()
129
+ for (const key of input) {
130
+ handleKey(key)
131
+ }
132
+ }
133
+
134
+ stdin.setRawMode(true)
135
+ stdin.resume()
136
+ stdin.on('data', onData)
137
+
138
+ return () => {
139
+ stdin.off('data', onData)
140
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
141
+ stdin.setRawMode(false)
142
+ }
143
+ stdin.pause()
144
+ }
145
+ }
146
+
13
147
  function startServer(port: number, hostname: string) {
14
148
  const router = createAppRouter(import.meta.dirname)
15
149
  return Bun.serve({
@@ -46,15 +180,48 @@ export async function startAppServer(options: AppServerOptions = {}) {
46
180
  const host = options.host ?? env.HOST
47
181
  const desiredPort = options.port ?? env.PORT
48
182
  const port = await getServerPort(env.NODE_ENV, desiredPort)
49
- const server = startServer(port, host)
50
- const hostname = server.hostname.includes(':')
51
- ? `[${server.hostname}]`
52
- : server.hostname
53
- const url = `http://${hostname}:${server.port}`
183
+ let server = startServer(port, host)
184
+ const getUrl = () => formatServerUrl(server.hostname, server.port)
185
+ let cleanupInput = () => {}
186
+ let isRestarting = false
187
+ const stopServer = () => {
188
+ console.log('[app] stopping server...')
189
+ cleanupInput()
190
+ server.stop()
191
+ process.exit(0)
192
+ }
193
+ const restartServer = async () => {
194
+ if (isRestarting) {
195
+ return
196
+ }
197
+ isRestarting = true
198
+ try {
199
+ console.log('[app] restarting server...')
200
+ await server.stop()
201
+ server = startServer(port, host)
202
+ console.log(`[app] running at ${getUrl()}`)
203
+ } finally {
204
+ isRestarting = false
205
+ }
206
+ }
207
+ cleanupInput = setupShortcutHandling({
208
+ getUrl,
209
+ restart: restartServer,
210
+ stop: stopServer,
211
+ })
212
+ const url = getUrl()
54
213
 
55
214
  console.log(`[app] running at ${url}`)
215
+ logShortcuts(url)
56
216
 
57
- return { server, url }
217
+ return {
218
+ get server() { return server },
219
+ url,
220
+ stop: () => {
221
+ cleanupInput()
222
+ server.stop()
223
+ }
224
+ }
58
225
  }
59
226
 
60
227
  if (import.meta.main) {