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 +4 -4
- package/app/assets/styles.css +6 -2
- package/app/client/app.tsx +1 -3
- package/app/client/editing-workspace.tsx +75 -31
- package/app-server.ts +173 -6
- package/cli.ts +285 -45
- package/package.json +5 -3
- package/process-course/cli.ts +11 -10
- package/process-course/edits/cli-prompts.test.ts +108 -0
- package/process-course/edits/cli.ts +260 -48
- package/process-course/logging.ts +28 -3
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
|
|
51
|
+
Start the Remix-powered UI shell (watch mode enabled):
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
bun
|
|
54
|
+
bun run app:start
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Then open `http://
|
|
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
|
|
package/app/assets/styles.css
CHANGED
|
@@ -261,7 +261,9 @@ p {
|
|
|
261
261
|
cursor: pointer;
|
|
262
262
|
left: var(--range-left);
|
|
263
263
|
width: var(--range-width);
|
|
264
|
-
transition:
|
|
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:
|
|
375
|
+
transition:
|
|
376
|
+
background 0.15s ease,
|
|
377
|
+
color 0.15s ease;
|
|
374
378
|
}
|
|
375
379
|
|
|
376
380
|
.button--primary {
|
package/app/client/app.tsx
CHANGED
|
@@ -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(
|
|
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 (
|
|
148
|
+
if (
|
|
149
|
+
!previewNode ||
|
|
150
|
+
!previewReady ||
|
|
151
|
+
duration <= 0 ||
|
|
152
|
+
previewDuration <= 0
|
|
153
|
+
) {
|
|
134
154
|
return
|
|
135
155
|
}
|
|
136
|
-
const targetTime = clamp(
|
|
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(
|
|
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">
|
|
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
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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(
|
|
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(
|
|
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, {
|
|
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 =
|
|
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
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
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 {
|
|
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) {
|