eprec 1.1.0 → 1.2.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/app/assets/styles.css +465 -2
- package/app/client/app.tsx +2 -32
- package/app/client/edit-session-data.ts +245 -0
- package/app/client/editing-workspace.tsx +825 -0
- package/app/components/layout.tsx +3 -5
- package/app/router.tsx +7 -3
- package/app/routes/index.tsx +22 -19
- package/app-server.ts +3 -1
- package/package.json +4 -2
- package/server/bundling.ts +61 -65
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import type { Handle } from 'remix/component'
|
|
2
|
+
import {
|
|
3
|
+
sampleEditSession,
|
|
4
|
+
type ChapterPlan,
|
|
5
|
+
type ChapterStatus,
|
|
6
|
+
type CommandWindow,
|
|
7
|
+
type CutRange,
|
|
8
|
+
type TranscriptWord,
|
|
9
|
+
} from './edit-session-data.ts'
|
|
10
|
+
|
|
11
|
+
const MIN_CUT_LENGTH = 0.2
|
|
12
|
+
const DEFAULT_CUT_LENGTH = 2.4
|
|
13
|
+
const PLAYHEAD_STEP = 0.1
|
|
14
|
+
|
|
15
|
+
export function EditingWorkspace(handle: Handle) {
|
|
16
|
+
const duration = sampleEditSession.duration
|
|
17
|
+
const transcript = sampleEditSession.transcript
|
|
18
|
+
const commands = sampleEditSession.commands
|
|
19
|
+
let cutRanges = sampleEditSession.cuts.map((range) => ({ ...range }))
|
|
20
|
+
let chapters = sampleEditSession.chapters.map((chapter) => ({ ...chapter }))
|
|
21
|
+
let playhead = 18.2
|
|
22
|
+
let selectedRangeId = cutRanges[0]?.id ?? null
|
|
23
|
+
let searchQuery = ''
|
|
24
|
+
let manualCutId = 1
|
|
25
|
+
let previewDuration = 0
|
|
26
|
+
let previewReady = false
|
|
27
|
+
let previewPlaying = false
|
|
28
|
+
let previewNode: HTMLVideoElement | null = null
|
|
29
|
+
let lastSyncedPlayhead = playhead
|
|
30
|
+
|
|
31
|
+
const setPlayhead = (value: number) => {
|
|
32
|
+
playhead = clamp(value, 0, duration)
|
|
33
|
+
syncVideoToPlayhead(playhead)
|
|
34
|
+
handle.update()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const selectRange = (rangeId: string) => {
|
|
38
|
+
selectedRangeId = rangeId
|
|
39
|
+
handle.update()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const addManualCut = () => {
|
|
43
|
+
const start = clamp(playhead, 0, duration - MIN_CUT_LENGTH)
|
|
44
|
+
const end = clamp(start + DEFAULT_CUT_LENGTH, start + MIN_CUT_LENGTH, duration)
|
|
45
|
+
const newRange: CutRange = {
|
|
46
|
+
id: `manual-${manualCutId++}`,
|
|
47
|
+
start,
|
|
48
|
+
end,
|
|
49
|
+
reason: 'Manual trim added from the timeline.',
|
|
50
|
+
source: 'manual',
|
|
51
|
+
}
|
|
52
|
+
cutRanges = sortRanges([...cutRanges, newRange])
|
|
53
|
+
selectedRangeId = newRange.id
|
|
54
|
+
handle.update()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const removeCut = (rangeId: string) => {
|
|
58
|
+
cutRanges = cutRanges.filter((range) => range.id !== rangeId)
|
|
59
|
+
if (selectedRangeId === rangeId) {
|
|
60
|
+
selectedRangeId = cutRanges[0]?.id ?? null
|
|
61
|
+
}
|
|
62
|
+
handle.update()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const updateCutRange = (rangeId: string, patch: Partial<CutRange>) => {
|
|
66
|
+
cutRanges = sortRanges(
|
|
67
|
+
cutRanges.map((range) => {
|
|
68
|
+
if (range.id !== rangeId) return range
|
|
69
|
+
return normalizeRange({ ...range, ...patch }, duration)
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
selectedRangeId = rangeId
|
|
73
|
+
handle.update()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const applyCommand = (command: CommandWindow) => {
|
|
77
|
+
if (command.action === 'remove') {
|
|
78
|
+
if (isCommandApplied(command, cutRanges, chapters)) return
|
|
79
|
+
const range: CutRange = {
|
|
80
|
+
id: `command-${command.id}`,
|
|
81
|
+
start: command.start,
|
|
82
|
+
end: command.end,
|
|
83
|
+
reason: `Jarvis command: ${command.label}.`,
|
|
84
|
+
source: 'command',
|
|
85
|
+
sourceId: command.id,
|
|
86
|
+
}
|
|
87
|
+
cutRanges = sortRanges([...cutRanges, normalizeRange(range, duration)])
|
|
88
|
+
selectedRangeId = range.id
|
|
89
|
+
handle.update()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (command.action === 'rename' && command.value) {
|
|
94
|
+
chapters = chapters.map((chapter) =>
|
|
95
|
+
chapter.id === command.chapterId
|
|
96
|
+
? { ...chapter, outputName: command.value, status: 'review' }
|
|
97
|
+
: chapter,
|
|
98
|
+
)
|
|
99
|
+
handle.update()
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (command.action === 'skip') {
|
|
104
|
+
chapters = chapters.map((chapter) =>
|
|
105
|
+
chapter.id === command.chapterId
|
|
106
|
+
? { ...chapter, status: 'skipped' }
|
|
107
|
+
: chapter,
|
|
108
|
+
)
|
|
109
|
+
handle.update()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const updateChapterStatus = (chapterId: string, status: ChapterStatus) => {
|
|
114
|
+
chapters = chapters.map((chapter) =>
|
|
115
|
+
chapter.id === chapterId ? { ...chapter, status } : chapter,
|
|
116
|
+
)
|
|
117
|
+
handle.update()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const updateChapterOutput = (chapterId: string, outputName: string) => {
|
|
121
|
+
chapters = chapters.map((chapter) =>
|
|
122
|
+
chapter.id === chapterId ? { ...chapter, outputName } : chapter,
|
|
123
|
+
)
|
|
124
|
+
handle.update()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const updateSearchQuery = (value: string) => {
|
|
128
|
+
searchQuery = value
|
|
129
|
+
handle.update()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const syncVideoToPlayhead = (value: number) => {
|
|
133
|
+
if (!previewNode || !previewReady || duration <= 0 || previewDuration <= 0) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const targetTime = clamp((value / duration) * previewDuration, 0, previewDuration)
|
|
137
|
+
lastSyncedPlayhead = value
|
|
138
|
+
if (Math.abs(previewNode.currentTime - targetTime) > 0.05) {
|
|
139
|
+
previewNode.currentTime = targetTime
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
const sortedCuts = sortRanges(cutRanges)
|
|
145
|
+
const selectedRange = selectedRangeId
|
|
146
|
+
? sortedCuts.find((range) => range.id === selectedRangeId) ?? null
|
|
147
|
+
: null
|
|
148
|
+
const mergedCuts = mergeOverlappingRanges(sortedCuts)
|
|
149
|
+
const totalRemoved = mergedCuts.reduce(
|
|
150
|
+
(total, range) => total + (range.end - range.start),
|
|
151
|
+
0,
|
|
152
|
+
)
|
|
153
|
+
const finalDuration = Math.max(duration - totalRemoved, 0)
|
|
154
|
+
const currentWord = findWordAtTime(transcript, playhead)
|
|
155
|
+
const currentContext = currentWord
|
|
156
|
+
? buildContext(transcript, currentWord.index, 4)
|
|
157
|
+
: 'No transcript cues found for the playhead.'
|
|
158
|
+
const query = searchQuery.trim().toLowerCase()
|
|
159
|
+
const searchResults = query
|
|
160
|
+
? transcript
|
|
161
|
+
.filter((word) => word.word.toLowerCase().includes(query))
|
|
162
|
+
.slice(0, 12)
|
|
163
|
+
: []
|
|
164
|
+
const commandPreview = buildCommandPreview(sampleEditSession.sourceName, chapters)
|
|
165
|
+
const previewTime =
|
|
166
|
+
previewReady && previewDuration > 0
|
|
167
|
+
? (playhead / duration) * previewDuration
|
|
168
|
+
: 0
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<main class="app-shell">
|
|
172
|
+
<header class="app-header">
|
|
173
|
+
<span class="app-kicker">Eprec Studio</span>
|
|
174
|
+
<h1 class="app-title">Editing workspace</h1>
|
|
175
|
+
<p class="app-subtitle">
|
|
176
|
+
Review transcript-based edits, refine command windows, and prepare
|
|
177
|
+
the final CLI export in one place.
|
|
178
|
+
</p>
|
|
179
|
+
</header>
|
|
180
|
+
|
|
181
|
+
<section class="app-card app-card--full">
|
|
182
|
+
<h2>Session summary</h2>
|
|
183
|
+
<div class="summary-grid">
|
|
184
|
+
<div class="summary-item">
|
|
185
|
+
<span class="summary-label">Source video</span>
|
|
186
|
+
<span class="summary-value">{sampleEditSession.sourceName}</span>
|
|
187
|
+
<span class="summary-subtext">
|
|
188
|
+
Duration {formatTimestamp(duration)}
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="summary-item">
|
|
192
|
+
<span class="summary-label">Cuts</span>
|
|
193
|
+
<span class="summary-value">
|
|
194
|
+
{sortedCuts.length} ranges
|
|
195
|
+
</span>
|
|
196
|
+
<span class="summary-subtext">
|
|
197
|
+
{formatSeconds(totalRemoved)} removed
|
|
198
|
+
</span>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="summary-item">
|
|
201
|
+
<span class="summary-label">Output length</span>
|
|
202
|
+
<span class="summary-value">{formatTimestamp(finalDuration)}</span>
|
|
203
|
+
<span class="summary-subtext">
|
|
204
|
+
{Math.round((finalDuration / duration) * 100)}% retained
|
|
205
|
+
</span>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="summary-item">
|
|
208
|
+
<span class="summary-label">Commands</span>
|
|
209
|
+
<span class="summary-value">
|
|
210
|
+
{commands.length} detected
|
|
211
|
+
</span>
|
|
212
|
+
<span class="summary-subtext">
|
|
213
|
+
{commands.filter((command) =>
|
|
214
|
+
isCommandApplied(command, sortedCuts, chapters),
|
|
215
|
+
).length}{' '}
|
|
216
|
+
applied
|
|
217
|
+
</span>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</section>
|
|
221
|
+
|
|
222
|
+
<section class="app-card app-card--full timeline-card">
|
|
223
|
+
<div class="timeline-header">
|
|
224
|
+
<div>
|
|
225
|
+
<h2>Timeline editor</h2>
|
|
226
|
+
<p class="app-muted">
|
|
227
|
+
Adjust cut ranges, jump between transcript cues, and add manual
|
|
228
|
+
trims.
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
<button class="button button--primary" type="button" on={{ click: addManualCut }}>
|
|
232
|
+
Add cut at playhead
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div class="timeline-layout">
|
|
237
|
+
<div class="timeline-preview">
|
|
238
|
+
<div class="timeline-video">
|
|
239
|
+
<div class="timeline-video-header">
|
|
240
|
+
<div>
|
|
241
|
+
<span class="summary-label">Preview video</span>
|
|
242
|
+
<span class="summary-subtext">
|
|
243
|
+
Scrub the timeline or play to sync the preview.
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
<span
|
|
247
|
+
class={classNames(
|
|
248
|
+
'status-pill',
|
|
249
|
+
previewReady
|
|
250
|
+
? previewPlaying
|
|
251
|
+
? 'status-pill--info'
|
|
252
|
+
: 'status-pill--success'
|
|
253
|
+
: 'status-pill--warning',
|
|
254
|
+
)}
|
|
255
|
+
>
|
|
256
|
+
{previewReady
|
|
257
|
+
? previewPlaying
|
|
258
|
+
? 'Playing'
|
|
259
|
+
: 'Ready'
|
|
260
|
+
: 'Loading'}
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
<video
|
|
264
|
+
class="timeline-video-player"
|
|
265
|
+
src="/e2e-test.mp4"
|
|
266
|
+
controls
|
|
267
|
+
preload="metadata"
|
|
268
|
+
connect={(node: HTMLVideoElement, signal) => {
|
|
269
|
+
previewNode = node
|
|
270
|
+
const handleLoadedMetadata = () => {
|
|
271
|
+
const nextDuration = Number(node.duration)
|
|
272
|
+
previewDuration = Number.isFinite(nextDuration)
|
|
273
|
+
? nextDuration
|
|
274
|
+
: 0
|
|
275
|
+
previewReady = previewDuration > 0
|
|
276
|
+
syncVideoToPlayhead(playhead)
|
|
277
|
+
handle.update()
|
|
278
|
+
}
|
|
279
|
+
const handleTimeUpdate = () => {
|
|
280
|
+
if (!previewReady || previewDuration <= 0) return
|
|
281
|
+
const mapped =
|
|
282
|
+
(node.currentTime / previewDuration) * duration
|
|
283
|
+
if (Math.abs(mapped - lastSyncedPlayhead) <= 0.05) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
playhead = clamp(mapped, 0, duration)
|
|
287
|
+
handle.update()
|
|
288
|
+
}
|
|
289
|
+
const handlePlay = () => {
|
|
290
|
+
previewPlaying = true
|
|
291
|
+
handle.update()
|
|
292
|
+
}
|
|
293
|
+
const handlePause = () => {
|
|
294
|
+
previewPlaying = false
|
|
295
|
+
handle.update()
|
|
296
|
+
}
|
|
297
|
+
node.addEventListener('loadedmetadata', handleLoadedMetadata)
|
|
298
|
+
node.addEventListener('timeupdate', handleTimeUpdate)
|
|
299
|
+
node.addEventListener('play', handlePlay)
|
|
300
|
+
node.addEventListener('pause', handlePause)
|
|
301
|
+
signal.addEventListener('abort', () => {
|
|
302
|
+
node.removeEventListener(
|
|
303
|
+
'loadedmetadata',
|
|
304
|
+
handleLoadedMetadata,
|
|
305
|
+
)
|
|
306
|
+
node.removeEventListener('timeupdate', handleTimeUpdate)
|
|
307
|
+
node.removeEventListener('play', handlePlay)
|
|
308
|
+
node.removeEventListener('pause', handlePause)
|
|
309
|
+
if (previewNode === node) {
|
|
310
|
+
previewNode = null
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}}
|
|
314
|
+
/>
|
|
315
|
+
<div class="timeline-video-meta">
|
|
316
|
+
<span>
|
|
317
|
+
Preview {formatTimestamp(previewTime)}
|
|
318
|
+
</span>
|
|
319
|
+
<span class="app-muted">
|
|
320
|
+
Timeline {formatTimestamp(playhead)}
|
|
321
|
+
</span>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<div
|
|
325
|
+
class="timeline-track"
|
|
326
|
+
style={`--playhead:${(playhead / duration) * 100}%`}
|
|
327
|
+
>
|
|
328
|
+
{sortedCuts.map((range) => (
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
class={classNames(
|
|
332
|
+
'timeline-range',
|
|
333
|
+
range.source === 'manual'
|
|
334
|
+
? 'timeline-range--manual'
|
|
335
|
+
: 'timeline-range--command',
|
|
336
|
+
range.id === selectedRangeId && 'is-selected',
|
|
337
|
+
)}
|
|
338
|
+
style={`--range-left:${(range.start / duration) * 100}%; --range-width:${((range.end - range.start) / duration) * 100}%`}
|
|
339
|
+
on={{ click: () => selectRange(range.id) }}
|
|
340
|
+
title={`${range.reason} (${formatTimestamp(range.start)} - ${formatTimestamp(range.end)})`}
|
|
341
|
+
/>
|
|
342
|
+
))}
|
|
343
|
+
<span class="timeline-playhead" />
|
|
344
|
+
</div>
|
|
345
|
+
<div class="timeline-scale">
|
|
346
|
+
{buildTimelineTicks(duration, 6).map((tick) => (
|
|
347
|
+
<span>{formatTimestamp(tick)}</span>
|
|
348
|
+
))}
|
|
349
|
+
</div>
|
|
350
|
+
<div class="timeline-controls">
|
|
351
|
+
<label class="control-label">
|
|
352
|
+
Playhead
|
|
353
|
+
<span class="control-value">
|
|
354
|
+
{formatTimestamp(playhead)}
|
|
355
|
+
</span>
|
|
356
|
+
</label>
|
|
357
|
+
<input
|
|
358
|
+
class="timeline-slider"
|
|
359
|
+
type="range"
|
|
360
|
+
min="0"
|
|
361
|
+
max={duration}
|
|
362
|
+
step={PLAYHEAD_STEP}
|
|
363
|
+
value={playhead}
|
|
364
|
+
on={{
|
|
365
|
+
input: (event) => {
|
|
366
|
+
const target = event.currentTarget as HTMLInputElement
|
|
367
|
+
setPlayhead(Number(target.value))
|
|
368
|
+
},
|
|
369
|
+
}}
|
|
370
|
+
/>
|
|
371
|
+
<button
|
|
372
|
+
class="button button--ghost"
|
|
373
|
+
type="button"
|
|
374
|
+
on={{
|
|
375
|
+
click: () => {
|
|
376
|
+
const previous = findPreviousCut(sortedCuts, playhead)
|
|
377
|
+
if (previous) setPlayhead(previous.start)
|
|
378
|
+
},
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
Prev cut
|
|
382
|
+
</button>
|
|
383
|
+
<button
|
|
384
|
+
class="button button--ghost"
|
|
385
|
+
type="button"
|
|
386
|
+
on={{
|
|
387
|
+
click: () => {
|
|
388
|
+
const next = sortedCuts.find((range) => range.start > playhead)
|
|
389
|
+
if (next) setPlayhead(next.start)
|
|
390
|
+
},
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
Next cut
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div class="timeline-editor">
|
|
399
|
+
<div class="panel-header">
|
|
400
|
+
<h3>Selected cut</h3>
|
|
401
|
+
{selectedRange ? (
|
|
402
|
+
<span
|
|
403
|
+
class={classNames(
|
|
404
|
+
'status-pill',
|
|
405
|
+
selectedRange.source === 'manual'
|
|
406
|
+
? 'status-pill--warning'
|
|
407
|
+
: 'status-pill--danger',
|
|
408
|
+
)}
|
|
409
|
+
>
|
|
410
|
+
{selectedRange.source === 'manual' ? 'Manual' : 'Command'}
|
|
411
|
+
</span>
|
|
412
|
+
) : null}
|
|
413
|
+
</div>
|
|
414
|
+
{selectedRange ? (
|
|
415
|
+
<div class="panel-grid">
|
|
416
|
+
<label class="input-label">
|
|
417
|
+
Start
|
|
418
|
+
<input
|
|
419
|
+
class="text-input text-input--compact"
|
|
420
|
+
type="number"
|
|
421
|
+
min="0"
|
|
422
|
+
max={duration}
|
|
423
|
+
step="0.1"
|
|
424
|
+
value={selectedRange.start.toFixed(2)}
|
|
425
|
+
on={{
|
|
426
|
+
input: (event) => {
|
|
427
|
+
const target = event.currentTarget as HTMLInputElement
|
|
428
|
+
updateCutRange(selectedRange.id, {
|
|
429
|
+
start: Number(target.value),
|
|
430
|
+
})
|
|
431
|
+
},
|
|
432
|
+
}}
|
|
433
|
+
/>
|
|
434
|
+
</label>
|
|
435
|
+
<label class="input-label">
|
|
436
|
+
End
|
|
437
|
+
<input
|
|
438
|
+
class="text-input text-input--compact"
|
|
439
|
+
type="number"
|
|
440
|
+
min="0"
|
|
441
|
+
max={duration}
|
|
442
|
+
step="0.1"
|
|
443
|
+
value={selectedRange.end.toFixed(2)}
|
|
444
|
+
on={{
|
|
445
|
+
input: (event) => {
|
|
446
|
+
const target = event.currentTarget as HTMLInputElement
|
|
447
|
+
updateCutRange(selectedRange.id, {
|
|
448
|
+
end: Number(target.value),
|
|
449
|
+
})
|
|
450
|
+
},
|
|
451
|
+
}}
|
|
452
|
+
/>
|
|
453
|
+
</label>
|
|
454
|
+
<label class="input-label input-label--full">
|
|
455
|
+
Reason
|
|
456
|
+
<input
|
|
457
|
+
class="text-input"
|
|
458
|
+
type="text"
|
|
459
|
+
value={selectedRange.reason}
|
|
460
|
+
on={{
|
|
461
|
+
input: (event) => {
|
|
462
|
+
const target = event.currentTarget as HTMLInputElement
|
|
463
|
+
updateCutRange(selectedRange.id, { reason: target.value })
|
|
464
|
+
},
|
|
465
|
+
}}
|
|
466
|
+
/>
|
|
467
|
+
</label>
|
|
468
|
+
<button
|
|
469
|
+
class="button button--danger"
|
|
470
|
+
type="button"
|
|
471
|
+
on={{ click: () => removeCut(selectedRange.id) }}
|
|
472
|
+
>
|
|
473
|
+
Remove cut
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
) : (
|
|
477
|
+
<p class="app-muted">
|
|
478
|
+
Select a cut range to inspect or adjust.
|
|
479
|
+
</p>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
<h3>Cut list</h3>
|
|
483
|
+
<ul class="cut-list">
|
|
484
|
+
{sortedCuts.map((range) => (
|
|
485
|
+
<li
|
|
486
|
+
class={classNames(
|
|
487
|
+
'cut-row',
|
|
488
|
+
range.id === selectedRangeId && 'is-selected',
|
|
489
|
+
)}
|
|
490
|
+
>
|
|
491
|
+
<button
|
|
492
|
+
class="cut-select"
|
|
493
|
+
type="button"
|
|
494
|
+
on={{ click: () => selectRange(range.id) }}
|
|
495
|
+
>
|
|
496
|
+
<span class="cut-time">
|
|
497
|
+
{formatTimestamp(range.start)} -{' '}
|
|
498
|
+
{formatTimestamp(range.end)}
|
|
499
|
+
</span>
|
|
500
|
+
<span class="cut-reason">{range.reason}</span>
|
|
501
|
+
</button>
|
|
502
|
+
<button
|
|
503
|
+
class="button button--ghost"
|
|
504
|
+
type="button"
|
|
505
|
+
on={{ click: () => removeCut(range.id) }}
|
|
506
|
+
>
|
|
507
|
+
Remove
|
|
508
|
+
</button>
|
|
509
|
+
</li>
|
|
510
|
+
))}
|
|
511
|
+
</ul>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
</section>
|
|
515
|
+
|
|
516
|
+
<div class="app-grid app-grid--two">
|
|
517
|
+
<section class="app-card">
|
|
518
|
+
<h2>Chapter plan</h2>
|
|
519
|
+
<p class="app-muted">
|
|
520
|
+
Update output names and mark chapters to skip before export.
|
|
521
|
+
</p>
|
|
522
|
+
<div class="chapter-list">
|
|
523
|
+
{chapters.map((chapter) => (
|
|
524
|
+
<article class="chapter-row">
|
|
525
|
+
<div class="chapter-header">
|
|
526
|
+
<div>
|
|
527
|
+
<h3>{chapter.title}</h3>
|
|
528
|
+
<span class="chapter-time">
|
|
529
|
+
{formatTimestamp(chapter.start)} -{' '}
|
|
530
|
+
{formatTimestamp(chapter.end)}
|
|
531
|
+
</span>
|
|
532
|
+
</div>
|
|
533
|
+
<span
|
|
534
|
+
class={classNames(
|
|
535
|
+
'status-pill',
|
|
536
|
+
chapter.status === 'ready' && 'status-pill--success',
|
|
537
|
+
chapter.status === 'review' && 'status-pill--warning',
|
|
538
|
+
chapter.status === 'skipped' && 'status-pill--danger',
|
|
539
|
+
)}
|
|
540
|
+
>
|
|
541
|
+
{chapter.status}
|
|
542
|
+
</span>
|
|
543
|
+
</div>
|
|
544
|
+
<label class="input-label">
|
|
545
|
+
Output file
|
|
546
|
+
<input
|
|
547
|
+
class="text-input"
|
|
548
|
+
type="text"
|
|
549
|
+
value={chapter.outputName}
|
|
550
|
+
on={{
|
|
551
|
+
input: (event) => {
|
|
552
|
+
const target = event.currentTarget as HTMLInputElement
|
|
553
|
+
updateChapterOutput(chapter.id, target.value)
|
|
554
|
+
},
|
|
555
|
+
}}
|
|
556
|
+
/>
|
|
557
|
+
</label>
|
|
558
|
+
<label class="input-label">
|
|
559
|
+
Status
|
|
560
|
+
<select
|
|
561
|
+
class="text-input"
|
|
562
|
+
value={chapter.status}
|
|
563
|
+
on={{
|
|
564
|
+
change: (event) => {
|
|
565
|
+
const target = event.currentTarget as HTMLSelectElement
|
|
566
|
+
updateChapterStatus(
|
|
567
|
+
chapter.id,
|
|
568
|
+
target.value as ChapterStatus,
|
|
569
|
+
)
|
|
570
|
+
},
|
|
571
|
+
}}
|
|
572
|
+
>
|
|
573
|
+
<option value="ready">ready</option>
|
|
574
|
+
<option value="review">review</option>
|
|
575
|
+
<option value="skipped">skipped</option>
|
|
576
|
+
</select>
|
|
577
|
+
</label>
|
|
578
|
+
<p class="app-muted">{chapter.notes}</p>
|
|
579
|
+
</article>
|
|
580
|
+
))}
|
|
581
|
+
</div>
|
|
582
|
+
</section>
|
|
583
|
+
|
|
584
|
+
<section class="app-card">
|
|
585
|
+
<h2>Command windows</h2>
|
|
586
|
+
<p class="app-muted">
|
|
587
|
+
Apply Jarvis commands to your cut list or chapter metadata.
|
|
588
|
+
</p>
|
|
589
|
+
<div class="command-list">
|
|
590
|
+
{commands.map((command) => {
|
|
591
|
+
const applied = isCommandApplied(command, sortedCuts, chapters)
|
|
592
|
+
return (
|
|
593
|
+
<article class="command-row">
|
|
594
|
+
<div class="command-header">
|
|
595
|
+
<h3>{command.label}</h3>
|
|
596
|
+
<span
|
|
597
|
+
class={classNames(
|
|
598
|
+
'status-pill',
|
|
599
|
+
command.action === 'remove' && 'status-pill--danger',
|
|
600
|
+
command.action === 'rename' && 'status-pill--info',
|
|
601
|
+
command.action === 'skip' && 'status-pill--warning',
|
|
602
|
+
)}
|
|
603
|
+
>
|
|
604
|
+
{command.action}
|
|
605
|
+
</span>
|
|
606
|
+
</div>
|
|
607
|
+
<p class="app-muted">{command.summary}</p>
|
|
608
|
+
<div class="command-meta">
|
|
609
|
+
<span class="command-time">
|
|
610
|
+
{formatTimestamp(command.start)} -{' '}
|
|
611
|
+
{formatTimestamp(command.end)}
|
|
612
|
+
</span>
|
|
613
|
+
<button
|
|
614
|
+
class="button button--ghost"
|
|
615
|
+
type="button"
|
|
616
|
+
disabled={applied}
|
|
617
|
+
on={{ click: () => applyCommand(command) }}
|
|
618
|
+
>
|
|
619
|
+
{applied ? 'Applied' : 'Apply'}
|
|
620
|
+
</button>
|
|
621
|
+
</div>
|
|
622
|
+
</article>
|
|
623
|
+
)
|
|
624
|
+
})}
|
|
625
|
+
</div>
|
|
626
|
+
</section>
|
|
627
|
+
</div>
|
|
628
|
+
|
|
629
|
+
<section class="app-card app-card--full transcript-card">
|
|
630
|
+
<div class="transcript-header">
|
|
631
|
+
<div>
|
|
632
|
+
<h2>Transcript search</h2>
|
|
633
|
+
<p class="app-muted">
|
|
634
|
+
Search words and jump the playhead to review context.
|
|
635
|
+
</p>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="transcript-preview">
|
|
638
|
+
<span class="summary-label">Playhead cue</span>
|
|
639
|
+
<span class="summary-value">
|
|
640
|
+
{currentWord ? formatTimestamp(currentWord.start) : '00:00.0'}
|
|
641
|
+
</span>
|
|
642
|
+
<span class="summary-subtext">{currentContext}</span>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
<label class="input-label">
|
|
646
|
+
Search transcript
|
|
647
|
+
<input
|
|
648
|
+
class="text-input"
|
|
649
|
+
type="search"
|
|
650
|
+
placeholder="Search for a word or command marker"
|
|
651
|
+
value={searchQuery}
|
|
652
|
+
on={{
|
|
653
|
+
input: (event) => {
|
|
654
|
+
const target = event.currentTarget as HTMLInputElement
|
|
655
|
+
updateSearchQuery(target.value)
|
|
656
|
+
},
|
|
657
|
+
}}
|
|
658
|
+
/>
|
|
659
|
+
</label>
|
|
660
|
+
{query.length === 0 ? (
|
|
661
|
+
<p class="app-muted transcript-empty">
|
|
662
|
+
Type to search the transcript words. Click a result to jump to it.
|
|
663
|
+
</p>
|
|
664
|
+
) : (
|
|
665
|
+
<ul class="transcript-results">
|
|
666
|
+
{searchResults.map((word) => (
|
|
667
|
+
<li>
|
|
668
|
+
<button
|
|
669
|
+
class="transcript-result"
|
|
670
|
+
type="button"
|
|
671
|
+
on={{ click: () => setPlayhead(word.start) }}
|
|
672
|
+
>
|
|
673
|
+
<span class="transcript-time">
|
|
674
|
+
{formatTimestamp(word.start)}
|
|
675
|
+
</span>
|
|
676
|
+
<span class="transcript-snippet">
|
|
677
|
+
{buildContext(transcript, word.index, 3)}
|
|
678
|
+
</span>
|
|
679
|
+
</button>
|
|
680
|
+
</li>
|
|
681
|
+
))}
|
|
682
|
+
</ul>
|
|
683
|
+
)}
|
|
684
|
+
</section>
|
|
685
|
+
|
|
686
|
+
<section class="app-card app-card--full">
|
|
687
|
+
<h2>CLI export preview</h2>
|
|
688
|
+
<p class="app-muted">
|
|
689
|
+
Use this command once you save your transcript edits.
|
|
690
|
+
</p>
|
|
691
|
+
<pre class="command-preview">{commandPreview}</pre>
|
|
692
|
+
</section>
|
|
693
|
+
</main>
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function sortRanges(ranges: CutRange[]) {
|
|
699
|
+
return ranges.slice().sort((a, b) => a.start - b.start)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function mergeOverlappingRanges(ranges: CutRange[]) {
|
|
703
|
+
if (ranges.length === 0) return []
|
|
704
|
+
const sorted = sortRanges(ranges)
|
|
705
|
+
const merged: CutRange[] = [{ ...sorted[0] }]
|
|
706
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
707
|
+
const current = sorted[i]
|
|
708
|
+
const last = merged[merged.length - 1]
|
|
709
|
+
if (current.start <= last.end) {
|
|
710
|
+
last.end = Math.max(last.end, current.end)
|
|
711
|
+
} else {
|
|
712
|
+
merged.push({ ...current })
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return merged
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function normalizeRange(range: CutRange, duration: number) {
|
|
719
|
+
const start = clamp(range.start, 0, Math.max(duration - MIN_CUT_LENGTH, 0))
|
|
720
|
+
const end = clamp(
|
|
721
|
+
range.end,
|
|
722
|
+
start + MIN_CUT_LENGTH,
|
|
723
|
+
Math.max(duration, start + MIN_CUT_LENGTH),
|
|
724
|
+
)
|
|
725
|
+
return { ...range, start, end }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function clamp(value: number, min: number, max: number) {
|
|
729
|
+
return Math.min(Math.max(value, min), max)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function formatTimestamp(value: number) {
|
|
733
|
+
const clamped = Math.max(value, 0)
|
|
734
|
+
const totalSeconds = Math.floor(clamped)
|
|
735
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
736
|
+
const seconds = totalSeconds % 60
|
|
737
|
+
const tenths = Math.floor((clamped - totalSeconds) * 10)
|
|
738
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${tenths}`
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function formatSeconds(value: number) {
|
|
742
|
+
return `${value.toFixed(1)}s`
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function classNames(...values: Array<string | false | null | undefined>) {
|
|
746
|
+
return values.filter(Boolean).join(' ')
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function findWordAtTime(words: TranscriptWord[], time: number) {
|
|
750
|
+
let current: TranscriptWord | null = null
|
|
751
|
+
for (const word of words) {
|
|
752
|
+
if (word.start <= time) {
|
|
753
|
+
current = word
|
|
754
|
+
continue
|
|
755
|
+
}
|
|
756
|
+
break
|
|
757
|
+
}
|
|
758
|
+
return current
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function buildContext(words: TranscriptWord[], index: number, radius: number) {
|
|
762
|
+
const start = Math.max(index - radius, 0)
|
|
763
|
+
const end = Math.min(index + radius + 1, words.length)
|
|
764
|
+
return words
|
|
765
|
+
.slice(start, end)
|
|
766
|
+
.map((word) => word.word)
|
|
767
|
+
.join(' ')
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function buildTimelineTicks(duration: number, count: number) {
|
|
771
|
+
if (count <= 1) return [0]
|
|
772
|
+
const step = duration / (count - 1)
|
|
773
|
+
return Array.from({ length: count }, (_, index) =>
|
|
774
|
+
Number((index * step).toFixed(1)),
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function buildCommandPreview(sourceName: string, chapters: ChapterPlan[]) {
|
|
779
|
+
const outputName =
|
|
780
|
+
chapters.find((chapter) => chapter.status !== 'skipped')?.outputName ??
|
|
781
|
+
'edited-output.mp4'
|
|
782
|
+
return [
|
|
783
|
+
'bun process-course/edits/cli.ts edit-video \\',
|
|
784
|
+
` --input "${sourceName}" \\`,
|
|
785
|
+
' --transcript "transcript.json" \\',
|
|
786
|
+
' --edited "transcript.txt" \\',
|
|
787
|
+
` --output "${outputName}"`,
|
|
788
|
+
].join('\n')
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function findPreviousCut(cutRanges: CutRange[], playhead: number) {
|
|
792
|
+
let previous: CutRange | null = null
|
|
793
|
+
for (const range of cutRanges) {
|
|
794
|
+
if (range.start < playhead) {
|
|
795
|
+
previous = range
|
|
796
|
+
continue
|
|
797
|
+
}
|
|
798
|
+
break
|
|
799
|
+
}
|
|
800
|
+
return previous
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function isCommandApplied(
|
|
804
|
+
command: CommandWindow,
|
|
805
|
+
cutRanges: CutRange[],
|
|
806
|
+
chapters: ChapterPlan[],
|
|
807
|
+
) {
|
|
808
|
+
if (command.action === 'remove') {
|
|
809
|
+
return cutRanges.some((range) => range.sourceId === command.id)
|
|
810
|
+
}
|
|
811
|
+
if (command.action === 'rename' && command.value) {
|
|
812
|
+
return chapters.some(
|
|
813
|
+
(chapter) =>
|
|
814
|
+
chapter.id === command.chapterId &&
|
|
815
|
+
chapter.outputName === command.value,
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
if (command.action === 'skip') {
|
|
819
|
+
return chapters.some(
|
|
820
|
+
(chapter) =>
|
|
821
|
+
chapter.id === command.chapterId && chapter.status === 'skipped',
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
return false
|
|
825
|
+
}
|