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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchSorter } from 'match-sorter'
|
|
1
2
|
import type { Handle } from 'remix/component'
|
|
2
3
|
import {
|
|
3
4
|
sampleEditSession,
|
|
@@ -8,19 +9,78 @@ import {
|
|
|
8
9
|
type TranscriptWord,
|
|
9
10
|
} from './edit-session-data.ts'
|
|
10
11
|
|
|
12
|
+
type AppConfig = {
|
|
13
|
+
initialVideoPath?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare global {
|
|
17
|
+
interface Window {
|
|
18
|
+
__EPREC_APP__?: AppConfig
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
const MIN_CUT_LENGTH = 0.2
|
|
12
23
|
const DEFAULT_CUT_LENGTH = 2.4
|
|
13
24
|
const PLAYHEAD_STEP = 0.1
|
|
25
|
+
const DEFAULT_PREVIEW_URL = '/e2e-test.mp4'
|
|
26
|
+
|
|
27
|
+
function readInitialVideoPath() {
|
|
28
|
+
if (typeof window === 'undefined') return ''
|
|
29
|
+
const raw = window.__EPREC_APP__?.initialVideoPath
|
|
30
|
+
if (typeof raw !== 'string') return ''
|
|
31
|
+
return raw.trim()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildVideoPreviewUrl(value: string) {
|
|
35
|
+
return `/api/video?path=${encodeURIComponent(value)}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractVideoName(value: string) {
|
|
39
|
+
const normalized = value.replace(/\\/g, '/')
|
|
40
|
+
const parts = normalized.split('/')
|
|
41
|
+
const last = parts[parts.length - 1]
|
|
42
|
+
return last && last.length > 0 ? last : value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ProcessingStatus = 'queued' | 'running' | 'done'
|
|
46
|
+
type ProcessingCategory = 'chapter' | 'transcript' | 'export'
|
|
47
|
+
type ProcessingTask = {
|
|
48
|
+
id: string
|
|
49
|
+
title: string
|
|
50
|
+
detail: string
|
|
51
|
+
status: ProcessingStatus
|
|
52
|
+
category: ProcessingCategory
|
|
53
|
+
}
|
|
14
54
|
|
|
15
55
|
export function EditingWorkspace(handle: Handle) {
|
|
16
56
|
const duration = sampleEditSession.duration
|
|
17
57
|
const transcript = sampleEditSession.transcript
|
|
18
58
|
const commands = sampleEditSession.commands
|
|
59
|
+
const transcriptIndex = transcript.map((word) => ({
|
|
60
|
+
...word,
|
|
61
|
+
context: buildContext(transcript, word.index, 3),
|
|
62
|
+
}))
|
|
63
|
+
const initialVideoPath = readInitialVideoPath()
|
|
64
|
+
let sourceName = sampleEditSession.sourceName
|
|
65
|
+
let sourcePath = ''
|
|
66
|
+
let previewUrl = DEFAULT_PREVIEW_URL
|
|
67
|
+
let previewSource: 'demo' | 'path' = 'demo'
|
|
68
|
+
let videoPathInput = initialVideoPath
|
|
69
|
+
let pathStatus: 'idle' | 'loading' | 'ready' | 'error' = initialVideoPath
|
|
70
|
+
? 'loading'
|
|
71
|
+
: 'idle'
|
|
72
|
+
let pathError = ''
|
|
73
|
+
let previewError = ''
|
|
19
74
|
let cutRanges = sampleEditSession.cuts.map((range) => ({ ...range }))
|
|
20
75
|
let chapters = sampleEditSession.chapters.map((chapter) => ({ ...chapter }))
|
|
21
76
|
let playhead = 18.2
|
|
22
77
|
let selectedRangeId = cutRanges[0]?.id ?? null
|
|
23
78
|
let searchQuery = ''
|
|
79
|
+
let primaryChapterId = chapters[0]?.id ?? ''
|
|
80
|
+
let secondaryChapterId = chapters[1]?.id ?? chapters[0]?.id ?? ''
|
|
81
|
+
let processingQueue: ProcessingTask[] = []
|
|
82
|
+
let activeTaskId: string | null = null
|
|
83
|
+
let processingCount = 1
|
|
24
84
|
let manualCutId = 1
|
|
25
85
|
let previewDuration = 0
|
|
26
86
|
let previewReady = false
|
|
@@ -29,6 +89,94 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
29
89
|
let lastSyncedPlayhead = playhead
|
|
30
90
|
let isScrubbing = false
|
|
31
91
|
|
|
92
|
+
const resetPreviewState = () => {
|
|
93
|
+
previewReady = false
|
|
94
|
+
previewPlaying = false
|
|
95
|
+
previewDuration = 0
|
|
96
|
+
previewError = ''
|
|
97
|
+
lastSyncedPlayhead = playhead
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const updateVideoPathInput = (value: string) => {
|
|
101
|
+
videoPathInput = value
|
|
102
|
+
if (pathError) pathError = ''
|
|
103
|
+
if (pathStatus === 'error') pathStatus = 'idle'
|
|
104
|
+
handle.update()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const applyPreviewSource = (options: {
|
|
108
|
+
url: string
|
|
109
|
+
name: string
|
|
110
|
+
source: 'demo' | 'path'
|
|
111
|
+
path?: string
|
|
112
|
+
}) => {
|
|
113
|
+
previewUrl = options.url
|
|
114
|
+
sourceName = options.name
|
|
115
|
+
sourcePath = options.path ?? ''
|
|
116
|
+
previewSource = options.source
|
|
117
|
+
resetPreviewState()
|
|
118
|
+
handle.update()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const loadVideoFromPath = async (override?: string) => {
|
|
122
|
+
const candidate = (override ?? videoPathInput).trim()
|
|
123
|
+
if (!candidate) {
|
|
124
|
+
pathError = 'Enter a video file path to load.'
|
|
125
|
+
pathStatus = 'error'
|
|
126
|
+
handle.update()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
videoPathInput = candidate
|
|
130
|
+
pathStatus = 'loading'
|
|
131
|
+
pathError = ''
|
|
132
|
+
previewError = ''
|
|
133
|
+
handle.update()
|
|
134
|
+
const preview = buildVideoPreviewUrl(candidate)
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(preview, {
|
|
137
|
+
method: 'HEAD',
|
|
138
|
+
cache: 'no-store',
|
|
139
|
+
signal: handle.signal,
|
|
140
|
+
})
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const message =
|
|
143
|
+
response.status === 404
|
|
144
|
+
? 'Video file not found. Check the path.'
|
|
145
|
+
: `Unable to load the video (status ${response.status}).`
|
|
146
|
+
throw new Error(message)
|
|
147
|
+
}
|
|
148
|
+
if (handle.signal.aborted) return
|
|
149
|
+
pathStatus = 'ready'
|
|
150
|
+
applyPreviewSource({
|
|
151
|
+
url: preview,
|
|
152
|
+
name: extractVideoName(candidate),
|
|
153
|
+
source: 'path',
|
|
154
|
+
path: candidate,
|
|
155
|
+
})
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (handle.signal.aborted) return
|
|
158
|
+
pathStatus = 'error'
|
|
159
|
+
pathError =
|
|
160
|
+
error instanceof Error ? error.message : 'Unable to load the video.'
|
|
161
|
+
handle.update()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const resetToDemo = () => {
|
|
166
|
+
pathStatus = 'idle'
|
|
167
|
+
pathError = ''
|
|
168
|
+
videoPathInput = ''
|
|
169
|
+
applyPreviewSource({
|
|
170
|
+
url: DEFAULT_PREVIEW_URL,
|
|
171
|
+
name: sampleEditSession.sourceName,
|
|
172
|
+
source: 'demo',
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (initialVideoPath) {
|
|
177
|
+
void loadVideoFromPath(initialVideoPath)
|
|
178
|
+
}
|
|
179
|
+
|
|
32
180
|
const setPlayhead = (value: number) => {
|
|
33
181
|
playhead = clamp(value, 0, duration)
|
|
34
182
|
syncVideoToPlayhead(playhead)
|
|
@@ -144,6 +292,127 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
144
292
|
handle.update()
|
|
145
293
|
}
|
|
146
294
|
|
|
295
|
+
const findChapter = (chapterId: string) =>
|
|
296
|
+
chapters.find((chapter) => chapter.id === chapterId) ?? null
|
|
297
|
+
|
|
298
|
+
const updatePrimaryChapter = (chapterId: string) => {
|
|
299
|
+
primaryChapterId = chapterId
|
|
300
|
+
if (secondaryChapterId === chapterId) {
|
|
301
|
+
secondaryChapterId =
|
|
302
|
+
chapters.find((chapter) => chapter.id !== chapterId)?.id ?? chapterId
|
|
303
|
+
}
|
|
304
|
+
handle.update()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const updateSecondaryChapter = (chapterId: string) => {
|
|
308
|
+
secondaryChapterId = chapterId
|
|
309
|
+
handle.update()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const queueTask = (
|
|
313
|
+
title: string,
|
|
314
|
+
detail: string,
|
|
315
|
+
category: ProcessingCategory,
|
|
316
|
+
) => {
|
|
317
|
+
const task: ProcessingTask = {
|
|
318
|
+
id: `task-${processingCount++}`,
|
|
319
|
+
title,
|
|
320
|
+
detail,
|
|
321
|
+
status: 'queued',
|
|
322
|
+
category,
|
|
323
|
+
}
|
|
324
|
+
processingQueue = [...processingQueue, task]
|
|
325
|
+
handle.update()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const queueChapterEdit = () => {
|
|
329
|
+
const chapter = findChapter(primaryChapterId)
|
|
330
|
+
if (!chapter) return
|
|
331
|
+
queueTask(
|
|
332
|
+
`Edit ${chapter.title}`,
|
|
333
|
+
`Review trims for ${formatTimestamp(chapter.start)} - ${formatTimestamp(
|
|
334
|
+
chapter.end,
|
|
335
|
+
)}.`,
|
|
336
|
+
'chapter',
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const queueCombineChapters = () => {
|
|
341
|
+
const primary = findChapter(primaryChapterId)
|
|
342
|
+
const secondary = findChapter(secondaryChapterId)
|
|
343
|
+
if (!primary || !secondary || primary.id === secondary.id) return
|
|
344
|
+
queueTask(
|
|
345
|
+
`Combine ${primary.title} + ${secondary.title}`,
|
|
346
|
+
'Merge both chapters into a single preview export.',
|
|
347
|
+
'chapter',
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const queueTranscriptRegeneration = () => {
|
|
352
|
+
queueTask(
|
|
353
|
+
'Regenerate transcript',
|
|
354
|
+
'Run Whisper alignment and refresh search cues.',
|
|
355
|
+
'transcript',
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const queueCommandScan = () => {
|
|
360
|
+
queueTask(
|
|
361
|
+
'Detect command windows',
|
|
362
|
+
'Scan for Jarvis commands and update cut ranges.',
|
|
363
|
+
'transcript',
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const queuePreviewRender = () => {
|
|
368
|
+
queueTask(
|
|
369
|
+
'Render preview clip',
|
|
370
|
+
'Bake a short MP4 with current edits applied.',
|
|
371
|
+
'export',
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const queueFinalExport = () => {
|
|
376
|
+
queueTask(
|
|
377
|
+
'Export edited chapters',
|
|
378
|
+
'Render final chapters and write the export package.',
|
|
379
|
+
'export',
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const startNextTask = () => {
|
|
384
|
+
if (activeTaskId) return
|
|
385
|
+
const next = processingQueue.find((task) => task.status === 'queued')
|
|
386
|
+
if (!next) return
|
|
387
|
+
activeTaskId = next.id
|
|
388
|
+
processingQueue = processingQueue.map((task) =>
|
|
389
|
+
task.id === next.id ? { ...task, status: 'running' } : task,
|
|
390
|
+
)
|
|
391
|
+
handle.update()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const markActiveDone = () => {
|
|
395
|
+
if (!activeTaskId) return
|
|
396
|
+
processingQueue = processingQueue.map((task) =>
|
|
397
|
+
task.id === activeTaskId ? { ...task, status: 'done' } : task,
|
|
398
|
+
)
|
|
399
|
+
activeTaskId = null
|
|
400
|
+
handle.update()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const clearCompletedTasks = () => {
|
|
404
|
+
processingQueue = processingQueue.filter((task) => task.status !== 'done')
|
|
405
|
+
handle.update()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const removeTask = (taskId: string) => {
|
|
409
|
+
processingQueue = processingQueue.filter((task) => task.id !== taskId)
|
|
410
|
+
if (activeTaskId === taskId) {
|
|
411
|
+
activeTaskId = null
|
|
412
|
+
}
|
|
413
|
+
handle.update()
|
|
414
|
+
}
|
|
415
|
+
|
|
147
416
|
const syncVideoToPlayhead = (value: number) => {
|
|
148
417
|
if (
|
|
149
418
|
!previewNode ||
|
|
@@ -179,20 +448,44 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
179
448
|
const currentContext = currentWord
|
|
180
449
|
? buildContext(transcript, currentWord.index, 4)
|
|
181
450
|
: 'No transcript cues found for the playhead.'
|
|
182
|
-
const query = searchQuery.trim()
|
|
451
|
+
const query = searchQuery.trim()
|
|
183
452
|
const searchResults = query
|
|
184
|
-
?
|
|
185
|
-
|
|
186
|
-
|
|
453
|
+
? matchSorter(transcriptIndex, query, {
|
|
454
|
+
keys: ['word'],
|
|
455
|
+
}).slice(0, 12)
|
|
187
456
|
: []
|
|
457
|
+
const queuedCount = processingQueue.filter(
|
|
458
|
+
(task) => task.status === 'queued',
|
|
459
|
+
).length
|
|
460
|
+
const completedCount = processingQueue.filter(
|
|
461
|
+
(task) => task.status === 'done',
|
|
462
|
+
).length
|
|
463
|
+
const runningTask =
|
|
464
|
+
processingQueue.find((task) => task.status === 'running') ?? null
|
|
465
|
+
const canCombineChapters =
|
|
466
|
+
primaryChapterId.length > 0 &&
|
|
467
|
+
secondaryChapterId.length > 0 &&
|
|
468
|
+
primaryChapterId !== secondaryChapterId
|
|
188
469
|
const commandPreview = buildCommandPreview(
|
|
189
|
-
|
|
470
|
+
sourceName,
|
|
190
471
|
chapters,
|
|
472
|
+
sourcePath,
|
|
191
473
|
)
|
|
192
474
|
const previewTime =
|
|
193
475
|
previewReady && previewDuration > 0
|
|
194
476
|
? (playhead / duration) * previewDuration
|
|
195
477
|
: 0
|
|
478
|
+
const previewStatus = previewError
|
|
479
|
+
? { label: 'Error', className: 'status-pill--danger' }
|
|
480
|
+
: previewReady
|
|
481
|
+
? previewPlaying
|
|
482
|
+
? { label: 'Playing', className: 'status-pill--info' }
|
|
483
|
+
: { label: 'Ready', className: 'status-pill--success' }
|
|
484
|
+
: { label: 'Loading', className: 'status-pill--warning' }
|
|
485
|
+
const sourceStatus =
|
|
486
|
+
previewSource === 'path'
|
|
487
|
+
? { label: 'Path', className: 'status-pill--success' }
|
|
488
|
+
: { label: 'Demo', className: 'status-pill--info' }
|
|
196
489
|
|
|
197
490
|
return (
|
|
198
491
|
<main class="app-shell">
|
|
@@ -205,12 +498,77 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
205
498
|
</p>
|
|
206
499
|
</header>
|
|
207
500
|
|
|
501
|
+
<section class="app-card app-card--full source-card">
|
|
502
|
+
<div class="source-header">
|
|
503
|
+
<div>
|
|
504
|
+
<h2>Source video</h2>
|
|
505
|
+
<p class="app-muted">
|
|
506
|
+
Paste a full video path to preview it and update the CLI export.
|
|
507
|
+
</p>
|
|
508
|
+
</div>
|
|
509
|
+
<span
|
|
510
|
+
class={classNames('status-pill', sourceStatus.className)}
|
|
511
|
+
title={`Preview source: ${sourceStatus.label}`}
|
|
512
|
+
>
|
|
513
|
+
{sourceStatus.label}
|
|
514
|
+
</span>
|
|
515
|
+
</div>
|
|
516
|
+
<div class="source-grid">
|
|
517
|
+
<div class="source-fields">
|
|
518
|
+
<label class="input-label">
|
|
519
|
+
Video file path
|
|
520
|
+
<input
|
|
521
|
+
class="text-input"
|
|
522
|
+
type="text"
|
|
523
|
+
placeholder="/path/to/video.mp4"
|
|
524
|
+
value={videoPathInput}
|
|
525
|
+
on={{
|
|
526
|
+
input: (event) => {
|
|
527
|
+
const target =
|
|
528
|
+
event.currentTarget as HTMLInputElement
|
|
529
|
+
updateVideoPathInput(target.value)
|
|
530
|
+
},
|
|
531
|
+
}}
|
|
532
|
+
/>
|
|
533
|
+
</label>
|
|
534
|
+
<div class="source-actions">
|
|
535
|
+
<button
|
|
536
|
+
class="button button--primary"
|
|
537
|
+
type="button"
|
|
538
|
+
disabled={
|
|
539
|
+
pathStatus === 'loading' ||
|
|
540
|
+
videoPathInput.trim().length === 0
|
|
541
|
+
}
|
|
542
|
+
on={{ click: () => void loadVideoFromPath() }}
|
|
543
|
+
>
|
|
544
|
+
{pathStatus === 'loading' ? 'Checking...' : 'Load from path'}
|
|
545
|
+
</button>
|
|
546
|
+
<button
|
|
547
|
+
class="button button--ghost"
|
|
548
|
+
type="button"
|
|
549
|
+
on={{ click: resetToDemo }}
|
|
550
|
+
>
|
|
551
|
+
Use demo video
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
{pathStatus === 'error' && pathError ? (
|
|
555
|
+
<p class="status-note status-note--danger">{pathError}</p>
|
|
556
|
+
) : null}
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
</section>
|
|
560
|
+
|
|
208
561
|
<section class="app-card app-card--full">
|
|
209
562
|
<h2>Session summary</h2>
|
|
210
563
|
<div class="summary-grid">
|
|
211
564
|
<div class="summary-item">
|
|
212
565
|
<span class="summary-label">Source video</span>
|
|
213
|
-
<span class="summary-value">{
|
|
566
|
+
<span class="summary-value">{sourceName}</span>
|
|
567
|
+
{sourcePath ? (
|
|
568
|
+
<span class="summary-subtext">{sourcePath}</span>
|
|
569
|
+
) : (
|
|
570
|
+
<span class="summary-subtext">Demo fixture video</span>
|
|
571
|
+
)}
|
|
214
572
|
<span class="summary-subtext">
|
|
215
573
|
Duration {formatTimestamp(duration)}
|
|
216
574
|
</span>
|
|
@@ -246,6 +604,227 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
246
604
|
</div>
|
|
247
605
|
</section>
|
|
248
606
|
|
|
607
|
+
<section class="app-card app-card--full actions-card">
|
|
608
|
+
<div class="actions-header">
|
|
609
|
+
<div>
|
|
610
|
+
<h2>Processing actions</h2>
|
|
611
|
+
<p class="app-muted">
|
|
612
|
+
Queue chapter edits, transcript cleanup, and export jobs
|
|
613
|
+
directly from the workspace.
|
|
614
|
+
</p>
|
|
615
|
+
</div>
|
|
616
|
+
<div class="actions-meta">
|
|
617
|
+
<div class="summary-item">
|
|
618
|
+
<span class="summary-label">Queue</span>
|
|
619
|
+
<span class="summary-value">{queuedCount} queued</span>
|
|
620
|
+
<span class="summary-subtext">
|
|
621
|
+
{runningTask ? `Running: ${runningTask.title}` : 'Idle'}
|
|
622
|
+
</span>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="actions-buttons">
|
|
626
|
+
<button
|
|
627
|
+
class="button button--primary"
|
|
628
|
+
type="button"
|
|
629
|
+
disabled={queuedCount === 0 || Boolean(runningTask)}
|
|
630
|
+
on={{ click: startNextTask }}
|
|
631
|
+
>
|
|
632
|
+
Run next
|
|
633
|
+
</button>
|
|
634
|
+
<button
|
|
635
|
+
class="button button--ghost"
|
|
636
|
+
type="button"
|
|
637
|
+
disabled={!runningTask}
|
|
638
|
+
on={{ click: markActiveDone }}
|
|
639
|
+
>
|
|
640
|
+
Mark running done
|
|
641
|
+
</button>
|
|
642
|
+
<button
|
|
643
|
+
class="button button--ghost"
|
|
644
|
+
type="button"
|
|
645
|
+
disabled={completedCount === 0}
|
|
646
|
+
on={{ click: clearCompletedTasks }}
|
|
647
|
+
>
|
|
648
|
+
Clear completed
|
|
649
|
+
</button>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
|
|
653
|
+
<div class="actions-grid">
|
|
654
|
+
<article class="actions-panel">
|
|
655
|
+
<div class="panel-header">
|
|
656
|
+
<h3>Chapter processing</h3>
|
|
657
|
+
<span class="status-pill status-pill--info">Chapter</span>
|
|
658
|
+
</div>
|
|
659
|
+
<label class="input-label">
|
|
660
|
+
Primary chapter
|
|
661
|
+
<select
|
|
662
|
+
class="text-input"
|
|
663
|
+
value={primaryChapterId}
|
|
664
|
+
on={{
|
|
665
|
+
change: (event) => {
|
|
666
|
+
const target = event.currentTarget as HTMLSelectElement
|
|
667
|
+
updatePrimaryChapter(target.value)
|
|
668
|
+
},
|
|
669
|
+
}}
|
|
670
|
+
>
|
|
671
|
+
{chapters.map((chapter) => (
|
|
672
|
+
<option value={chapter.id}>{chapter.title}</option>
|
|
673
|
+
))}
|
|
674
|
+
</select>
|
|
675
|
+
</label>
|
|
676
|
+
<label class="input-label">
|
|
677
|
+
Secondary chapter
|
|
678
|
+
<select
|
|
679
|
+
class="text-input"
|
|
680
|
+
value={secondaryChapterId}
|
|
681
|
+
on={{
|
|
682
|
+
change: (event) => {
|
|
683
|
+
const target = event.currentTarget as HTMLSelectElement
|
|
684
|
+
updateSecondaryChapter(target.value)
|
|
685
|
+
},
|
|
686
|
+
}}
|
|
687
|
+
>
|
|
688
|
+
{chapters.map((chapter) => (
|
|
689
|
+
<option value={chapter.id}>{chapter.title}</option>
|
|
690
|
+
))}
|
|
691
|
+
</select>
|
|
692
|
+
</label>
|
|
693
|
+
<div class="actions-button-row">
|
|
694
|
+
<button
|
|
695
|
+
class="button button--primary"
|
|
696
|
+
type="button"
|
|
697
|
+
disabled={!primaryChapterId}
|
|
698
|
+
on={{ click: queueChapterEdit }}
|
|
699
|
+
>
|
|
700
|
+
Edit chapter
|
|
701
|
+
</button>
|
|
702
|
+
<button
|
|
703
|
+
class="button button--ghost"
|
|
704
|
+
type="button"
|
|
705
|
+
disabled={!canCombineChapters}
|
|
706
|
+
on={{ click: queueCombineChapters }}
|
|
707
|
+
>
|
|
708
|
+
Combine chapters
|
|
709
|
+
</button>
|
|
710
|
+
</div>
|
|
711
|
+
<p class="app-muted">
|
|
712
|
+
Stage edits or merge two chapters without leaving this view.
|
|
713
|
+
</p>
|
|
714
|
+
</article>
|
|
715
|
+
|
|
716
|
+
<article class="actions-panel">
|
|
717
|
+
<div class="panel-header">
|
|
718
|
+
<h3>Transcript utilities</h3>
|
|
719
|
+
<span class="status-pill status-pill--warning">Transcript</span>
|
|
720
|
+
</div>
|
|
721
|
+
<div class="actions-button-row">
|
|
722
|
+
<button
|
|
723
|
+
class="button button--ghost"
|
|
724
|
+
type="button"
|
|
725
|
+
on={{ click: queueTranscriptRegeneration }}
|
|
726
|
+
>
|
|
727
|
+
Regenerate transcript
|
|
728
|
+
</button>
|
|
729
|
+
<button
|
|
730
|
+
class="button button--ghost"
|
|
731
|
+
type="button"
|
|
732
|
+
on={{ click: queueCommandScan }}
|
|
733
|
+
>
|
|
734
|
+
Detect command windows
|
|
735
|
+
</button>
|
|
736
|
+
</div>
|
|
737
|
+
<p class="app-muted">
|
|
738
|
+
Refresh the transcript or scan for command markers on demand.
|
|
739
|
+
</p>
|
|
740
|
+
</article>
|
|
741
|
+
|
|
742
|
+
<article class="actions-panel">
|
|
743
|
+
<div class="panel-header">
|
|
744
|
+
<h3>Exports</h3>
|
|
745
|
+
<span class="status-pill status-pill--success">Export</span>
|
|
746
|
+
</div>
|
|
747
|
+
<div class="actions-button-row">
|
|
748
|
+
<button
|
|
749
|
+
class="button button--ghost"
|
|
750
|
+
type="button"
|
|
751
|
+
on={{ click: queuePreviewRender }}
|
|
752
|
+
>
|
|
753
|
+
Render preview clip
|
|
754
|
+
</button>
|
|
755
|
+
<button
|
|
756
|
+
class="button button--ghost"
|
|
757
|
+
type="button"
|
|
758
|
+
on={{ click: queueFinalExport }}
|
|
759
|
+
>
|
|
760
|
+
Export edited chapters
|
|
761
|
+
</button>
|
|
762
|
+
</div>
|
|
763
|
+
<p class="app-muted">
|
|
764
|
+
Trigger preview renders or finalize exports for the pipeline.
|
|
765
|
+
</p>
|
|
766
|
+
</article>
|
|
767
|
+
</div>
|
|
768
|
+
|
|
769
|
+
<div class="actions-queue">
|
|
770
|
+
<div class="panel-header">
|
|
771
|
+
<h3>Processing queue</h3>
|
|
772
|
+
<span class="summary-subtext">
|
|
773
|
+
{processingQueue.length} total
|
|
774
|
+
</span>
|
|
775
|
+
</div>
|
|
776
|
+
{processingQueue.length === 0 ? (
|
|
777
|
+
<p class="app-muted">
|
|
778
|
+
No actions queued yet. Use the buttons above to stage work.
|
|
779
|
+
</p>
|
|
780
|
+
) : (
|
|
781
|
+
<ul class="stacked-list processing-list">
|
|
782
|
+
{processingQueue.map((task) => (
|
|
783
|
+
<li
|
|
784
|
+
class={classNames(
|
|
785
|
+
'stacked-item',
|
|
786
|
+
'processing-row',
|
|
787
|
+
task.status === 'running' && 'is-running',
|
|
788
|
+
task.status === 'done' && 'is-complete',
|
|
789
|
+
)}
|
|
790
|
+
>
|
|
791
|
+
<div class="processing-row-header">
|
|
792
|
+
<div>
|
|
793
|
+
<h4>{task.title}</h4>
|
|
794
|
+
<p class="app-muted">{task.detail}</p>
|
|
795
|
+
</div>
|
|
796
|
+
<span
|
|
797
|
+
class={classNames(
|
|
798
|
+
'status-pill',
|
|
799
|
+
task.status === 'queued' && 'status-pill--info',
|
|
800
|
+
task.status === 'running' && 'status-pill--warning',
|
|
801
|
+
task.status === 'done' && 'status-pill--success',
|
|
802
|
+
)}
|
|
803
|
+
>
|
|
804
|
+
{task.status}
|
|
805
|
+
</span>
|
|
806
|
+
</div>
|
|
807
|
+
<div class="processing-row-meta">
|
|
808
|
+
<span class="summary-subtext">
|
|
809
|
+
{formatProcessingCategory(task.category)}
|
|
810
|
+
</span>
|
|
811
|
+
{task.status === 'queued' ? (
|
|
812
|
+
<button
|
|
813
|
+
class="button button--ghost"
|
|
814
|
+
type="button"
|
|
815
|
+
on={{ click: () => removeTask(task.id) }}
|
|
816
|
+
>
|
|
817
|
+
Remove
|
|
818
|
+
</button>
|
|
819
|
+
) : null}
|
|
820
|
+
</div>
|
|
821
|
+
</li>
|
|
822
|
+
))}
|
|
823
|
+
</ul>
|
|
824
|
+
)}
|
|
825
|
+
</div>
|
|
826
|
+
</section>
|
|
827
|
+
|
|
249
828
|
<section class="app-card app-card--full timeline-card">
|
|
250
829
|
<div class="timeline-header">
|
|
251
830
|
<div>
|
|
@@ -277,23 +856,15 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
277
856
|
<span
|
|
278
857
|
class={classNames(
|
|
279
858
|
'status-pill',
|
|
280
|
-
|
|
281
|
-
? previewPlaying
|
|
282
|
-
? 'status-pill--info'
|
|
283
|
-
: 'status-pill--success'
|
|
284
|
-
: 'status-pill--warning',
|
|
859
|
+
previewStatus.className,
|
|
285
860
|
)}
|
|
286
861
|
>
|
|
287
|
-
{
|
|
288
|
-
? previewPlaying
|
|
289
|
-
? 'Playing'
|
|
290
|
-
: 'Ready'
|
|
291
|
-
: 'Loading'}
|
|
862
|
+
{previewStatus.label}
|
|
292
863
|
</span>
|
|
293
864
|
</div>
|
|
294
865
|
<video
|
|
295
866
|
class="timeline-video-player"
|
|
296
|
-
src=
|
|
867
|
+
src={previewUrl}
|
|
297
868
|
controls
|
|
298
869
|
preload="metadata"
|
|
299
870
|
connect={(node: HTMLVideoElement, signal) => {
|
|
@@ -304,6 +875,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
304
875
|
? nextDuration
|
|
305
876
|
: 0
|
|
306
877
|
previewReady = previewDuration > 0
|
|
878
|
+
previewError = ''
|
|
307
879
|
syncVideoToPlayhead(playhead)
|
|
308
880
|
handle.update()
|
|
309
881
|
}
|
|
@@ -327,6 +899,12 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
327
899
|
previewPlaying = false
|
|
328
900
|
handle.update()
|
|
329
901
|
}
|
|
902
|
+
const handleError = () => {
|
|
903
|
+
previewError = 'Unable to load the preview video.'
|
|
904
|
+
previewReady = false
|
|
905
|
+
previewPlaying = false
|
|
906
|
+
handle.update()
|
|
907
|
+
}
|
|
330
908
|
node.addEventListener(
|
|
331
909
|
'loadedmetadata',
|
|
332
910
|
handleLoadedMetadata,
|
|
@@ -334,6 +912,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
334
912
|
node.addEventListener('timeupdate', handleTimeUpdate)
|
|
335
913
|
node.addEventListener('play', handlePlay)
|
|
336
914
|
node.addEventListener('pause', handlePause)
|
|
915
|
+
node.addEventListener('error', handleError)
|
|
337
916
|
signal.addEventListener('abort', () => {
|
|
338
917
|
node.removeEventListener(
|
|
339
918
|
'loadedmetadata',
|
|
@@ -342,6 +921,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
342
921
|
node.removeEventListener('timeupdate', handleTimeUpdate)
|
|
343
922
|
node.removeEventListener('play', handlePlay)
|
|
344
923
|
node.removeEventListener('pause', handlePause)
|
|
924
|
+
node.removeEventListener('error', handleError)
|
|
345
925
|
if (previewNode === node) {
|
|
346
926
|
previewNode = null
|
|
347
927
|
}
|
|
@@ -354,6 +934,9 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
354
934
|
Timeline {formatTimestamp(playhead)}
|
|
355
935
|
</span>
|
|
356
936
|
</div>
|
|
937
|
+
{previewError ? (
|
|
938
|
+
<p class="status-note status-note--danger">{previewError}</p>
|
|
939
|
+
) : null}
|
|
357
940
|
</div>
|
|
358
941
|
<div
|
|
359
942
|
class="timeline-track"
|
|
@@ -523,10 +1106,11 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
523
1106
|
)}
|
|
524
1107
|
|
|
525
1108
|
<h3>Cut list</h3>
|
|
526
|
-
<ul class="cut-list">
|
|
1109
|
+
<ul class="cut-list stacked-list">
|
|
527
1110
|
{sortedCuts.map((range) => (
|
|
528
1111
|
<li
|
|
529
1112
|
class={classNames(
|
|
1113
|
+
'stacked-item',
|
|
530
1114
|
'cut-row',
|
|
531
1115
|
range.id === selectedRangeId && 'is-selected',
|
|
532
1116
|
)}
|
|
@@ -562,9 +1146,9 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
562
1146
|
<p class="app-muted">
|
|
563
1147
|
Update output names and mark chapters to skip before export.
|
|
564
1148
|
</p>
|
|
565
|
-
<div class="chapter-list">
|
|
1149
|
+
<div class="chapter-list stacked-list">
|
|
566
1150
|
{chapters.map((chapter) => (
|
|
567
|
-
<article class="chapter-row">
|
|
1151
|
+
<article class="chapter-row stacked-item">
|
|
568
1152
|
<div class="chapter-header">
|
|
569
1153
|
<div>
|
|
570
1154
|
<h3>{chapter.title}</h3>
|
|
@@ -630,11 +1214,11 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
630
1214
|
<p class="app-muted">
|
|
631
1215
|
Apply Jarvis commands to your cut list or chapter metadata.
|
|
632
1216
|
</p>
|
|
633
|
-
<div class="command-list">
|
|
1217
|
+
<div class="command-list stacked-list">
|
|
634
1218
|
{commands.map((command) => {
|
|
635
1219
|
const applied = isCommandApplied(command, sortedCuts, chapters)
|
|
636
1220
|
return (
|
|
637
|
-
<article class="command-row">
|
|
1221
|
+
<article class="command-row stacked-item">
|
|
638
1222
|
<div class="command-header">
|
|
639
1223
|
<h3>{command.label}</h3>
|
|
640
1224
|
<span
|
|
@@ -705,10 +1289,14 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
705
1289
|
<p class="app-muted transcript-empty">
|
|
706
1290
|
Type to search the transcript words. Click a result to jump to it.
|
|
707
1291
|
</p>
|
|
1292
|
+
) : searchResults.length === 0 ? (
|
|
1293
|
+
<p class="app-muted transcript-empty">
|
|
1294
|
+
No results found for "{query}".
|
|
1295
|
+
</p>
|
|
708
1296
|
) : (
|
|
709
|
-
<ul class="transcript-results">
|
|
1297
|
+
<ul class="transcript-results stacked-list">
|
|
710
1298
|
{searchResults.map((word) => (
|
|
711
|
-
<li>
|
|
1299
|
+
<li class="stacked-item">
|
|
712
1300
|
<button
|
|
713
1301
|
class="transcript-result"
|
|
714
1302
|
type="button"
|
|
@@ -717,9 +1305,7 @@ export function EditingWorkspace(handle: Handle) {
|
|
|
717
1305
|
<span class="transcript-time">
|
|
718
1306
|
{formatTimestamp(word.start)}
|
|
719
1307
|
</span>
|
|
720
|
-
<span class="transcript-snippet">
|
|
721
|
-
{buildContext(transcript, word.index, 3)}
|
|
722
|
-
</span>
|
|
1308
|
+
<span class="transcript-snippet">{word.context}</span>
|
|
723
1309
|
</button>
|
|
724
1310
|
</li>
|
|
725
1311
|
))}
|
|
@@ -786,6 +1372,12 @@ function formatSeconds(value: number) {
|
|
|
786
1372
|
return `${value.toFixed(1)}s`
|
|
787
1373
|
}
|
|
788
1374
|
|
|
1375
|
+
function formatProcessingCategory(category: ProcessingCategory) {
|
|
1376
|
+
if (category === 'chapter') return 'Chapter task'
|
|
1377
|
+
if (category === 'transcript') return 'Transcript task'
|
|
1378
|
+
return 'Export task'
|
|
1379
|
+
}
|
|
1380
|
+
|
|
789
1381
|
function classNames(...values: Array<string | false | null | undefined>) {
|
|
790
1382
|
return values.filter(Boolean).join(' ')
|
|
791
1383
|
}
|
|
@@ -819,13 +1411,21 @@ function buildTimelineTicks(duration: number, count: number) {
|
|
|
819
1411
|
)
|
|
820
1412
|
}
|
|
821
1413
|
|
|
822
|
-
function buildCommandPreview(
|
|
1414
|
+
function buildCommandPreview(
|
|
1415
|
+
sourceName: string,
|
|
1416
|
+
chapters: ChapterPlan[],
|
|
1417
|
+
sourcePath?: string,
|
|
1418
|
+
) {
|
|
823
1419
|
const outputName =
|
|
824
1420
|
chapters.find((chapter) => chapter.status !== 'skipped')?.outputName ??
|
|
825
1421
|
'edited-output.mp4'
|
|
1422
|
+
const inputPath =
|
|
1423
|
+
typeof sourcePath === 'string' && sourcePath.trim().length > 0
|
|
1424
|
+
? sourcePath
|
|
1425
|
+
: sourceName
|
|
826
1426
|
return [
|
|
827
1427
|
'bun process-course/edits/cli.ts edit-video \\',
|
|
828
|
-
` --input "${
|
|
1428
|
+
` --input "${inputPath}" \\`,
|
|
829
1429
|
' --transcript "transcript.json" \\',
|
|
830
1430
|
' --edited "transcript.txt" \\',
|
|
831
1431
|
` --output "${outputName}"`,
|