eprec 1.12.0 → 1.13.1

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.
@@ -0,0 +1,1276 @@
1
+ import type { Handle } from 'remix/component'
2
+ import {
3
+ buildFfmpegCommandPreview,
4
+ computeOutputDuration,
5
+ normalizeTrimRanges,
6
+ type TrimRange,
7
+ } from '../trim-commands.ts'
8
+
9
+ type AppConfig = {
10
+ initialVideoPath?: string
11
+ }
12
+
13
+ declare global {
14
+ interface Window {
15
+ __EPREC_APP__?: AppConfig
16
+ }
17
+ }
18
+
19
+ type TrimRangeWithId = TrimRange & { id: string }
20
+
21
+ const DEFAULT_TRIM_LENGTH = 2.5
22
+ const MIN_TRIM_LENGTH = 0.1
23
+ const PLAYHEAD_STEP = 0.1
24
+ const KEYBOARD_STEP = 0.1
25
+ const SHIFT_STEP = 1
26
+ const DEMO_VIDEO_PATH = 'fixtures/e2e-test.mp4'
27
+ const WAVEFORM_SAMPLES = 240
28
+
29
+ function readInitialVideoPath() {
30
+ if (typeof window === 'undefined') return ''
31
+ const raw = window.__EPREC_APP__?.initialVideoPath
32
+ if (typeof raw !== 'string') return ''
33
+ return raw.trim()
34
+ }
35
+
36
+ function buildVideoPreviewUrl(value: string) {
37
+ return `/api/video?path=${encodeURIComponent(value)}`
38
+ }
39
+
40
+ function buildOutputPath(value: string) {
41
+ const trimmed = value.trim()
42
+ if (!trimmed) return ''
43
+ const extensionMatch = trimmed.match(/(\.[^./\\]+)$/)
44
+ if (extensionMatch) {
45
+ return trimmed.replace(/(\.[^./\\]+)$/, '.trimmed$1')
46
+ }
47
+ return `${trimmed}.trimmed.mp4`
48
+ }
49
+
50
+ function clamp(value: number, min: number, max: number) {
51
+ return Math.min(Math.max(value, min), max)
52
+ }
53
+
54
+ function sortRanges(ranges: TrimRangeWithId[]) {
55
+ return ranges.slice().sort((a, b) => a.start - b.start)
56
+ }
57
+
58
+ function formatTimestamp(value: number) {
59
+ const clamped = Math.max(value, 0)
60
+ const totalSeconds = Math.floor(clamped)
61
+ const minutes = Math.floor(totalSeconds / 60)
62
+ const seconds = totalSeconds % 60
63
+ const hundredths = Math.floor((clamped - totalSeconds) * 100)
64
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(hundredths).padStart(2, '0')}`
65
+ }
66
+
67
+ function parseTimestampInput(value: string) {
68
+ const trimmed = value.trim()
69
+ if (!trimmed) return null
70
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
71
+ const seconds = Number.parseFloat(trimmed)
72
+ return Number.isFinite(seconds) ? seconds : null
73
+ }
74
+ const parts = trimmed.split(':').map((part) => part.trim())
75
+ if (parts.length !== 2 && parts.length !== 3) return null
76
+ const secondsPart = Number.parseFloat(parts[parts.length - 1] ?? '')
77
+ const minutesPart = Number.parseFloat(parts[parts.length - 2] ?? '')
78
+ const hoursPart = parts.length === 3 ? Number.parseFloat(parts[0] ?? '') : 0
79
+ if (
80
+ !Number.isFinite(secondsPart) ||
81
+ !Number.isFinite(minutesPart) ||
82
+ !Number.isFinite(hoursPart)
83
+ ) {
84
+ return null
85
+ }
86
+ if (secondsPart < 0 || minutesPart < 0 || hoursPart < 0) return null
87
+ return hoursPart * 3600 + minutesPart * 60 + secondsPart
88
+ }
89
+
90
+ function formatSeconds(value: number) {
91
+ return `${value.toFixed(1)}s`
92
+ }
93
+
94
+ function classNames(...values: Array<string | false | null | undefined>) {
95
+ return values.filter(Boolean).join(' ')
96
+ }
97
+
98
+ export function TrimPoints(handle: Handle) {
99
+ const initialVideoPath = readInitialVideoPath()
100
+ let videoPathInput = initialVideoPath
101
+ let outputPathInput = initialVideoPath
102
+ ? buildOutputPath(initialVideoPath)
103
+ : ''
104
+ let pathStatus: 'idle' | 'loading' | 'ready' | 'error' = initialVideoPath
105
+ ? 'loading'
106
+ : 'idle'
107
+ let pathError = ''
108
+ let previewUrl = ''
109
+ let previewError = ''
110
+ let previewDuration = 0
111
+ let previewReady = false
112
+ let previewNode: HTMLVideoElement | null = null
113
+ let trackNode: HTMLDivElement | null = null
114
+ let playhead = 0
115
+ let previewPlaying = false
116
+ let timeInputValue = formatTimestamp(playhead)
117
+ let isTimeEditing = false
118
+ let trimRanges: TrimRangeWithId[] = []
119
+ let selectedRangeId: string | null = null
120
+ let rangeCounter = 1
121
+ let activeDrag: {
122
+ rangeId: string
123
+ edge: 'start' | 'end'
124
+ pointerId: number
125
+ } | null = null
126
+ let runStatus: 'idle' | 'running' | 'success' | 'error' = 'idle'
127
+ let runProgress = 0
128
+ let runError = ''
129
+ let runLogs: string[] = []
130
+ let runController: AbortController | null = null
131
+ let initialLoadTriggered = false
132
+ let waveformSamples: number[] = []
133
+ let waveformStatus: 'idle' | 'loading' | 'ready' | 'error' = 'idle'
134
+ let waveformError = ''
135
+ let waveformSource = ''
136
+ let waveformNode: HTMLCanvasElement | null = null
137
+
138
+ // Cleanup ffmpeg operation on unmount
139
+ handle.signal.addEventListener('abort', () => {
140
+ if (runController) {
141
+ runController.abort()
142
+ }
143
+ })
144
+
145
+ const updateVideoPathInput = (value: string) => {
146
+ videoPathInput = value
147
+ if (pathError) pathError = ''
148
+ if (pathStatus === 'error') pathStatus = 'idle'
149
+ handle.update()
150
+ }
151
+
152
+ const updateOutputPathInput = (value: string) => {
153
+ outputPathInput = value
154
+ handle.update()
155
+ }
156
+
157
+ const resetPreviewState = () => {
158
+ previewReady = false
159
+ previewError = ''
160
+ previewDuration = 0
161
+ trimRanges = []
162
+ selectedRangeId = null
163
+ activeDrag = null
164
+ }
165
+
166
+ const syncVideoToTime = (
167
+ value: number,
168
+ options: { skipVideo?: boolean; updateInput?: boolean } = {},
169
+ ) => {
170
+ const maxDuration = previewDuration > 0 ? previewDuration : value
171
+ const nextTime = clamp(value, 0, Math.max(maxDuration, 0))
172
+ playhead = nextTime
173
+ if (!isTimeEditing || options.updateInput) {
174
+ timeInputValue = formatTimestamp(nextTime)
175
+ }
176
+ if (
177
+ previewNode &&
178
+ previewReady &&
179
+ !options.skipVideo &&
180
+ Math.abs(previewNode.currentTime - nextTime) > 0.02
181
+ ) {
182
+ previewNode.currentTime = nextTime
183
+ }
184
+ handle.update()
185
+ }
186
+
187
+ const updateTimeInput = (value: string) => {
188
+ timeInputValue = value
189
+ isTimeEditing = true
190
+ handle.update()
191
+ }
192
+
193
+ const commitTimeInput = () => {
194
+ const parsed = parseTimestampInput(timeInputValue)
195
+ isTimeEditing = false
196
+ if (parsed === null) {
197
+ timeInputValue = formatTimestamp(playhead)
198
+ handle.update()
199
+ return
200
+ }
201
+ syncVideoToTime(parsed, { updateInput: true })
202
+ }
203
+
204
+ const drawWaveform = () => {
205
+ if (!waveformNode) return
206
+ const ctx = waveformNode.getContext('2d')
207
+ if (!ctx) return
208
+ const width = waveformNode.clientWidth
209
+ const height = waveformNode.clientHeight
210
+ if (width <= 0 || height <= 0) return
211
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
212
+ waveformNode.width = Math.floor(width * dpr)
213
+ waveformNode.height = Math.floor(height * dpr)
214
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
215
+ ctx.clearRect(0, 0, width, height)
216
+ const color =
217
+ typeof window !== 'undefined'
218
+ ? window.getComputedStyle(waveformNode).color
219
+ : '#94a3b8'
220
+ ctx.strokeStyle = color
221
+ ctx.lineWidth = 1
222
+ if (waveformSamples.length === 0) {
223
+ ctx.beginPath()
224
+ ctx.moveTo(0, height / 2)
225
+ ctx.lineTo(width, height / 2)
226
+ ctx.stroke()
227
+ return
228
+ }
229
+ const mid = height / 2
230
+ const step = width / waveformSamples.length
231
+ ctx.beginPath()
232
+ waveformSamples.forEach((sample, index) => {
233
+ const x = index * step
234
+ const amplitude = sample * (mid - 2)
235
+ ctx.moveTo(x, mid - amplitude)
236
+ ctx.lineTo(x, mid + amplitude)
237
+ })
238
+ ctx.stroke()
239
+ }
240
+
241
+ const loadWaveform = async (url: string) => {
242
+ if (!url || waveformStatus === 'loading') return
243
+ if (waveformSource === url && waveformStatus === 'ready') return
244
+ waveformSource = url
245
+ waveformStatus = 'loading'
246
+ waveformError = ''
247
+ waveformSamples = []
248
+ drawWaveform()
249
+ handle.update()
250
+ const fetchedUrl = url
251
+ try {
252
+ if (typeof window === 'undefined' || !('AudioContext' in window)) {
253
+ throw new Error('AudioContext unavailable in this browser.')
254
+ }
255
+ const response = await fetch(url, {
256
+ cache: 'no-store',
257
+ signal: handle.signal,
258
+ })
259
+ if (!response.ok) {
260
+ throw new Error(`Waveform load failed (status ${response.status}).`)
261
+ }
262
+ const buffer = await response.arrayBuffer()
263
+ if (handle.signal.aborted) return
264
+ const audioContext = new AudioContext()
265
+ let audioBuffer: AudioBuffer
266
+ try {
267
+ audioBuffer = await audioContext.decodeAudioData(buffer.slice(0))
268
+ } finally {
269
+ void audioContext.close()
270
+ }
271
+ if (audioBuffer.numberOfChannels === 0) {
272
+ throw new Error('No audio track found in the video.')
273
+ }
274
+ const channelCount = audioBuffer.numberOfChannels
275
+ const channels = Array.from({ length: channelCount }, (_, index) =>
276
+ audioBuffer.getChannelData(index),
277
+ )
278
+ const totalSamples = audioBuffer.length
279
+ const sampleCount = Math.max(1, Math.min(WAVEFORM_SAMPLES, totalSamples))
280
+ const blockSize = Math.max(1, Math.floor(totalSamples / sampleCount))
281
+ const samples = new Array(sampleCount).fill(0)
282
+ let maxValue = 0
283
+ for (let i = 0; i < sampleCount; i++) {
284
+ const start = i * blockSize
285
+ const end = i === sampleCount - 1 ? totalSamples : start + blockSize
286
+ let peak = 0
287
+ for (let j = start; j < end; j++) {
288
+ let sum = 0
289
+ for (const channel of channels) {
290
+ sum += Math.abs(channel[j] ?? 0)
291
+ }
292
+ const avg = sum / channelCount
293
+ if (avg > peak) peak = avg
294
+ }
295
+ samples[i] = peak
296
+ if (peak > maxValue) maxValue = peak
297
+ }
298
+ const normalizedSamples =
299
+ maxValue > 0 ? samples.map((sample) => sample / maxValue) : samples
300
+ if (waveformSource !== fetchedUrl) return
301
+ waveformSamples = normalizedSamples
302
+ waveformStatus = 'ready'
303
+ handle.update()
304
+ drawWaveform()
305
+ } catch (error) {
306
+ if (handle.signal.aborted) return
307
+ if (waveformSource !== fetchedUrl) return
308
+ waveformStatus = 'error'
309
+ waveformError =
310
+ error instanceof Error ? error.message : 'Unable to render waveform.'
311
+ handle.update()
312
+ }
313
+ }
314
+
315
+ const applyPreviewSource = (url: string) => {
316
+ previewUrl = url
317
+ resetPreviewState()
318
+ handle.update()
319
+ }
320
+
321
+ const loadVideoFromPath = async (override?: string) => {
322
+ const candidate = (override ?? videoPathInput).trim()
323
+ if (!candidate) {
324
+ pathError = 'Enter a video file path to load.'
325
+ pathStatus = 'error'
326
+ handle.update()
327
+ return
328
+ }
329
+ videoPathInput = candidate
330
+ pathStatus = 'loading'
331
+ pathError = ''
332
+ previewError = ''
333
+ handle.update()
334
+ const preview = buildVideoPreviewUrl(candidate)
335
+ try {
336
+ const response = await fetch(preview, {
337
+ method: 'HEAD',
338
+ cache: 'no-store',
339
+ signal: handle.signal,
340
+ })
341
+ if (!response.ok) {
342
+ const message =
343
+ response.status === 404
344
+ ? 'Video file not found. Check the path.'
345
+ : `Unable to load the video (status ${response.status}).`
346
+ throw new Error(message)
347
+ }
348
+ if (handle.signal.aborted) return
349
+ pathStatus = 'ready'
350
+ outputPathInput = buildOutputPath(candidate)
351
+ applyPreviewSource(preview)
352
+ void loadWaveform(preview)
353
+ } catch (error) {
354
+ if (handle.signal.aborted) return
355
+ pathStatus = 'error'
356
+ pathError =
357
+ error instanceof Error ? error.message : 'Unable to load the video.'
358
+ handle.update()
359
+ }
360
+ }
361
+
362
+ const loadDemoVideo = () => {
363
+ videoPathInput = DEMO_VIDEO_PATH
364
+ outputPathInput = buildOutputPath(DEMO_VIDEO_PATH)
365
+ void loadVideoFromPath(DEMO_VIDEO_PATH)
366
+ }
367
+
368
+ if (initialVideoPath && !initialLoadTriggered) {
369
+ initialLoadTriggered = true
370
+ void loadVideoFromPath(initialVideoPath)
371
+ }
372
+
373
+ const setPlayhead = (value: number) => {
374
+ if (!previewReady || previewDuration <= 0) return
375
+ syncVideoToTime(value, { updateInput: true })
376
+ }
377
+
378
+ const addTrimRange = () => {
379
+ if (!previewReady || previewDuration <= MIN_TRIM_LENGTH) {
380
+ pathError = 'Load a video before adding trim ranges.'
381
+ pathStatus = 'error'
382
+ handle.update()
383
+ return
384
+ }
385
+ const start = clamp(playhead, 0, previewDuration - MIN_TRIM_LENGTH)
386
+ const end = clamp(
387
+ start + DEFAULT_TRIM_LENGTH,
388
+ start + MIN_TRIM_LENGTH,
389
+ previewDuration,
390
+ )
391
+ const newRange: TrimRangeWithId = {
392
+ id: `trim-${rangeCounter++}`,
393
+ start,
394
+ end,
395
+ }
396
+ trimRanges = sortRanges([...trimRanges, newRange])
397
+ selectedRangeId = newRange.id
398
+ syncVideoToTime(start, { updateInput: true })
399
+ }
400
+
401
+ const removeTrimRange = (rangeId: string) => {
402
+ trimRanges = trimRanges.filter((range) => range.id !== rangeId)
403
+ if (selectedRangeId === rangeId) {
404
+ selectedRangeId = trimRanges[0]?.id ?? null
405
+ }
406
+ handle.update()
407
+ }
408
+
409
+ const updateTrimRange = (
410
+ rangeId: string,
411
+ patch: Partial<TrimRange>,
412
+ edge?: 'start' | 'end',
413
+ ) => {
414
+ trimRanges = sortRanges(
415
+ trimRanges.map((range) => {
416
+ if (range.id !== rangeId) return range
417
+ let nextStart = Number.isFinite(patch.start) ? patch.start : range.start
418
+ let nextEnd = Number.isFinite(patch.end) ? patch.end : range.end
419
+ if (edge === 'start') {
420
+ nextStart = clamp(
421
+ nextStart,
422
+ 0,
423
+ Math.max(previewDuration - MIN_TRIM_LENGTH, 0),
424
+ )
425
+ nextEnd = clamp(nextEnd, nextStart + MIN_TRIM_LENGTH, previewDuration)
426
+ } else if (edge === 'end') {
427
+ nextEnd = clamp(nextEnd, MIN_TRIM_LENGTH, previewDuration)
428
+ nextStart = clamp(nextStart, 0, nextEnd - MIN_TRIM_LENGTH)
429
+ } else {
430
+ const minStart = clamp(
431
+ nextStart,
432
+ 0,
433
+ Math.max(previewDuration - MIN_TRIM_LENGTH, 0),
434
+ )
435
+ const minEnd = clamp(
436
+ nextEnd,
437
+ minStart + MIN_TRIM_LENGTH,
438
+ previewDuration,
439
+ )
440
+ nextStart = minStart
441
+ nextEnd = minEnd
442
+ }
443
+ return { ...range, start: nextStart, end: nextEnd }
444
+ }),
445
+ )
446
+ selectedRangeId = rangeId
447
+ handle.update()
448
+ }
449
+
450
+ const selectRange = (rangeId: string) => {
451
+ selectedRangeId = rangeId
452
+ const range = trimRanges.find((entry) => entry.id === rangeId)
453
+ if (range) {
454
+ syncVideoToTime(range.start, { updateInput: true })
455
+ return
456
+ }
457
+ handle.update()
458
+ }
459
+
460
+ const getTimeFromClientX = (clientX: number) => {
461
+ if (!trackNode || previewDuration <= 0) return 0
462
+ const rect = trackNode.getBoundingClientRect()
463
+ const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
464
+ return ratio * previewDuration
465
+ }
466
+
467
+ const startDrag = (
468
+ event: PointerEvent,
469
+ rangeId: string,
470
+ edge: 'start' | 'end',
471
+ ) => {
472
+ if (!trackNode || previewDuration <= 0) return
473
+ activeDrag = { rangeId, edge, pointerId: event.pointerId }
474
+ const target = event.currentTarget as HTMLElement
475
+ target.setPointerCapture(event.pointerId)
476
+ const nextTime = getTimeFromClientX(event.clientX)
477
+ updateTrimRange(rangeId, { [edge]: nextTime }, edge)
478
+ syncVideoToTime(nextTime, { updateInput: true })
479
+ }
480
+
481
+ const moveDrag = (event: PointerEvent) => {
482
+ if (!activeDrag || activeDrag.pointerId !== event.pointerId) return
483
+ const nextTime = getTimeFromClientX(event.clientX)
484
+ updateTrimRange(
485
+ activeDrag.rangeId,
486
+ { [activeDrag.edge]: nextTime },
487
+ activeDrag.edge,
488
+ )
489
+ syncVideoToTime(nextTime, { updateInput: true })
490
+ }
491
+
492
+ const endDrag = (event: PointerEvent) => {
493
+ if (!activeDrag || activeDrag.pointerId !== event.pointerId) return
494
+ activeDrag = null
495
+ }
496
+
497
+ const handleRangeKey = (
498
+ event: KeyboardEvent,
499
+ range: TrimRangeWithId,
500
+ edge: 'start' | 'end',
501
+ ) => {
502
+ const isForward = event.key === 'ArrowUp' || event.key === 'ArrowRight'
503
+ const isBackward = event.key === 'ArrowDown' || event.key === 'ArrowLeft'
504
+ if (!isForward && !isBackward) return
505
+ event.preventDefault()
506
+ const step = event.shiftKey ? SHIFT_STEP : KEYBOARD_STEP
507
+ const delta = isForward ? step : -step
508
+ const nextValue = edge === 'start' ? range.start + delta : range.end + delta
509
+ updateTrimRange(
510
+ range.id,
511
+ {
512
+ [edge]: nextValue,
513
+ },
514
+ edge,
515
+ )
516
+ syncVideoToTime(nextValue, { updateInput: true })
517
+ }
518
+
519
+ const handleNumberKey = (
520
+ event: KeyboardEvent,
521
+ range: TrimRangeWithId,
522
+ edge: 'start' | 'end',
523
+ ) => {
524
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
525
+ event.preventDefault()
526
+ const step = event.shiftKey ? SHIFT_STEP : KEYBOARD_STEP
527
+ const delta = event.key === 'ArrowUp' ? step : -step
528
+ const nextValue = edge === 'start' ? range.start + delta : range.end + delta
529
+ updateTrimRange(
530
+ range.id,
531
+ {
532
+ [edge]: nextValue,
533
+ },
534
+ edge,
535
+ )
536
+ syncVideoToTime(nextValue, { updateInput: true })
537
+ }
538
+
539
+ const runTrimCommand = async () => {
540
+ if (runStatus === 'running') return
541
+ const normalized = normalizeTrimRanges(
542
+ trimRanges,
543
+ previewDuration,
544
+ MIN_TRIM_LENGTH,
545
+ )
546
+ if (!videoPathInput.trim()) {
547
+ runStatus = 'error'
548
+ runError = 'Provide a video file path before running ffmpeg.'
549
+ handle.update()
550
+ return
551
+ }
552
+ if (!outputPathInput.trim()) {
553
+ runStatus = 'error'
554
+ runError = 'Provide an output path before running ffmpeg.'
555
+ handle.update()
556
+ return
557
+ }
558
+ if (!previewReady || previewDuration <= 0) {
559
+ runStatus = 'error'
560
+ runError = 'Load the video preview before running ffmpeg.'
561
+ handle.update()
562
+ return
563
+ }
564
+ if (normalized.length === 0) {
565
+ runStatus = 'error'
566
+ runError = 'Add at least one trim range to run ffmpeg.'
567
+ handle.update()
568
+ return
569
+ }
570
+ runStatus = 'running'
571
+ runProgress = 0
572
+ runError = ''
573
+ runLogs = []
574
+ runController = new AbortController()
575
+ handle.update()
576
+
577
+ try {
578
+ const response = await fetch('/api/trim', {
579
+ method: 'POST',
580
+ headers: { 'Content-Type': 'application/json' },
581
+ body: JSON.stringify({
582
+ inputPath: videoPathInput.trim(),
583
+ outputPath: outputPathInput.trim(),
584
+ duration: previewDuration,
585
+ ranges: normalized,
586
+ }),
587
+ signal: runController.signal,
588
+ })
589
+ if (!response.ok) {
590
+ runStatus = 'error'
591
+ runError = await response.text()
592
+ handle.update()
593
+ return
594
+ }
595
+ const reader = response.body
596
+ ?.pipeThrough(new TextDecoderStream())
597
+ .getReader()
598
+ if (!reader) {
599
+ runStatus = 'error'
600
+ runError = 'Streaming response not available.'
601
+ handle.update()
602
+ return
603
+ }
604
+ let buffer = ''
605
+ while (true) {
606
+ const { value, done } = await reader.read()
607
+ if (done) break
608
+ buffer += value
609
+ const lines = buffer.split('\n')
610
+ buffer = lines.pop() ?? ''
611
+ for (const line of lines) {
612
+ if (!line.trim()) continue
613
+ let payload: any = null
614
+ try {
615
+ payload = JSON.parse(line)
616
+ } catch {
617
+ runLogs = [...runLogs, line.trim()]
618
+ continue
619
+ }
620
+ if (payload?.type === 'log' && payload.message) {
621
+ runLogs = [...runLogs, payload.message]
622
+ }
623
+ if (payload?.type === 'progress') {
624
+ const nextProgress =
625
+ typeof payload.progress === 'number' ? payload.progress : 0
626
+ runProgress = clamp(nextProgress, 0, 1)
627
+ }
628
+ if (payload?.type === 'done') {
629
+ if (payload.success) {
630
+ runStatus = 'success'
631
+ runProgress = 1
632
+ } else {
633
+ runStatus = 'error'
634
+ runError = payload.error ?? 'ffmpeg failed.'
635
+ }
636
+ }
637
+ handle.update()
638
+ }
639
+ }
640
+ if (runStatus === 'running') {
641
+ runStatus = 'error'
642
+ runError = 'ffmpeg stream ended unexpectedly.'
643
+ handle.update()
644
+ }
645
+ } catch (error) {
646
+ if (runController === null) {
647
+ // Cancellation already set the error message, don't overwrite it
648
+ } else {
649
+ runStatus = 'error'
650
+ runError =
651
+ error instanceof Error ? error.message : 'Unable to run ffmpeg.'
652
+ }
653
+ handle.update()
654
+ } finally {
655
+ runController = null
656
+ }
657
+ }
658
+
659
+ const cancelRun = () => {
660
+ if (runController) {
661
+ runController.abort()
662
+ runController = null
663
+ runStatus = 'error'
664
+ runError = 'Run canceled.'
665
+ handle.update()
666
+ }
667
+ }
668
+
669
+ return () => {
670
+ const duration = previewDuration
671
+ const sortedRanges = sortRanges(trimRanges)
672
+ const normalizedRanges = normalizeTrimRanges(
673
+ trimRanges,
674
+ duration,
675
+ MIN_TRIM_LENGTH,
676
+ )
677
+ const totalRemoved = normalizedRanges.reduce(
678
+ (total, range) => total + (range.end - range.start),
679
+ 0,
680
+ )
681
+ const outputDuration = computeOutputDuration(
682
+ duration,
683
+ trimRanges,
684
+ MIN_TRIM_LENGTH,
685
+ )
686
+ const commandPreview =
687
+ videoPathInput.trim() &&
688
+ outputPathInput.trim() &&
689
+ normalizedRanges.length > 0
690
+ ? buildFfmpegCommandPreview({
691
+ inputPath: videoPathInput.trim(),
692
+ outputPath: outputPathInput.trim(),
693
+ ranges: normalizedRanges,
694
+ includeProgress: true,
695
+ })
696
+ : ''
697
+ const progressLabel =
698
+ runStatus === 'running'
699
+ ? `${Math.round(runProgress * 100)}%`
700
+ : runStatus === 'success'
701
+ ? 'Complete'
702
+ : runStatus === 'error'
703
+ ? 'Error'
704
+ : 'Idle'
705
+ const hintId = 'trim-keyboard-hint'
706
+ return (
707
+ <main class="app-shell trim-shell">
708
+ <header class="app-header">
709
+ <span class="app-kicker">Eprec Studio</span>
710
+ <h1 class="app-title">Trim points</h1>
711
+ <p class="app-subtitle">
712
+ Define ranges to remove, preview their timestamps on the timeline,
713
+ and run ffmpeg with live progress.
714
+ </p>
715
+ <nav class="app-nav">
716
+ <a class="app-link" href="/">
717
+ Editing workspace
718
+ </a>
719
+ </nav>
720
+ </header>
721
+
722
+ <section class="app-card app-card--full source-card">
723
+ <div class="source-header">
724
+ <div>
725
+ <h2>Video source</h2>
726
+ <p class="app-muted">
727
+ Load a local video file to calculate the trim timeline and
728
+ output command.
729
+ </p>
730
+ </div>
731
+ <span
732
+ class={classNames(
733
+ 'status-pill',
734
+ pathStatus === 'ready' && 'status-pill--success',
735
+ pathStatus === 'loading' && 'status-pill--warning',
736
+ pathStatus === 'error' && 'status-pill--danger',
737
+ pathStatus === 'idle' && 'status-pill--info',
738
+ )}
739
+ >
740
+ {pathStatus}
741
+ </span>
742
+ </div>
743
+ <div class="source-grid">
744
+ <div class="source-fields">
745
+ <label class="input-label">
746
+ Video file path
747
+ <input
748
+ class="text-input"
749
+ type="text"
750
+ placeholder="/path/to/video.mp4"
751
+ value={videoPathInput}
752
+ on={{
753
+ input: (event) => {
754
+ const target = event.currentTarget as HTMLInputElement
755
+ updateVideoPathInput(target.value)
756
+ },
757
+ }}
758
+ />
759
+ </label>
760
+ <label class="input-label">
761
+ Output file path
762
+ <input
763
+ class="text-input"
764
+ type="text"
765
+ placeholder="/path/to/video.trimmed.mp4"
766
+ value={outputPathInput}
767
+ on={{
768
+ input: (event) => {
769
+ const target = event.currentTarget as HTMLInputElement
770
+ updateOutputPathInput(target.value)
771
+ },
772
+ }}
773
+ />
774
+ </label>
775
+ <div class="source-actions">
776
+ <button
777
+ class="button button--primary"
778
+ type="button"
779
+ disabled={pathStatus === 'loading'}
780
+ on={{ click: () => void loadVideoFromPath() }}
781
+ >
782
+ {pathStatus === 'loading' ? 'Checking...' : 'Load video'}
783
+ </button>
784
+ <button
785
+ class="button button--ghost"
786
+ type="button"
787
+ on={{ click: loadDemoVideo }}
788
+ >
789
+ Use demo video
790
+ </button>
791
+ </div>
792
+ {pathStatus === 'error' && pathError ? (
793
+ <p class="status-note status-note--danger">{pathError}</p>
794
+ ) : null}
795
+ </div>
796
+ <div class="trim-preview">
797
+ <div class="panel-header">
798
+ <h3>Preview</h3>
799
+ <span class="summary-subtext">
800
+ {previewReady
801
+ ? `Duration ${formatTimestamp(previewDuration)}`
802
+ : 'Load a video to preview'}
803
+ </span>
804
+ </div>
805
+ <video
806
+ class="timeline-video-player"
807
+ src={previewUrl}
808
+ controls
809
+ preload="metadata"
810
+ connect={(node: HTMLVideoElement, signal) => {
811
+ previewNode = node
812
+ const handleLoaded = () => {
813
+ const nextDuration = Number(node.duration)
814
+ previewDuration = Number.isFinite(nextDuration)
815
+ ? nextDuration
816
+ : 0
817
+ previewReady = previewDuration > 0
818
+ previewError = ''
819
+ playhead = clamp(playhead, 0, previewDuration)
820
+ if (!isTimeEditing) {
821
+ timeInputValue = formatTimestamp(playhead)
822
+ }
823
+ if (
824
+ Math.abs(node.currentTime - playhead) > 0.02 &&
825
+ previewReady
826
+ ) {
827
+ node.currentTime = playhead
828
+ }
829
+ void loadWaveform(previewUrl)
830
+ handle.update()
831
+ }
832
+ const handleTimeUpdate = () => {
833
+ if (!previewReady || previewDuration <= 0) return
834
+ playhead = clamp(node.currentTime, 0, previewDuration)
835
+ if (!isTimeEditing) {
836
+ timeInputValue = formatTimestamp(playhead)
837
+ }
838
+ handle.update()
839
+ }
840
+ const handlePlay = () => {
841
+ previewPlaying = true
842
+ handle.update()
843
+ }
844
+ const handlePause = () => {
845
+ previewPlaying = false
846
+ handle.update()
847
+ }
848
+ const handleError = () => {
849
+ previewError = 'Unable to load the preview video.'
850
+ previewReady = false
851
+ handle.update()
852
+ }
853
+ node.addEventListener('loadedmetadata', handleLoaded)
854
+ node.addEventListener('timeupdate', handleTimeUpdate)
855
+ node.addEventListener('play', handlePlay)
856
+ node.addEventListener('pause', handlePause)
857
+ node.addEventListener('error', handleError)
858
+ signal.addEventListener('abort', () => {
859
+ node.removeEventListener('loadedmetadata', handleLoaded)
860
+ node.removeEventListener('timeupdate', handleTimeUpdate)
861
+ node.removeEventListener('play', handlePlay)
862
+ node.removeEventListener('pause', handlePause)
863
+ node.removeEventListener('error', handleError)
864
+ if (previewNode === node) {
865
+ previewNode = null
866
+ }
867
+ })
868
+ }}
869
+ />
870
+ {previewError ? (
871
+ <p class="status-note status-note--danger">{previewError}</p>
872
+ ) : null}
873
+ <div class="trim-time-row">
874
+ <label class="input-label">
875
+ Video time
876
+ <input
877
+ class="text-input text-input--compact"
878
+ type="text"
879
+ placeholder="00:00.00"
880
+ value={timeInputValue}
881
+ disabled={!previewReady}
882
+ on={{
883
+ focus: () => {
884
+ isTimeEditing = true
885
+ handle.update()
886
+ },
887
+ input: (event) => {
888
+ const target = event.currentTarget as HTMLInputElement
889
+ updateTimeInput(target.value)
890
+ },
891
+ blur: () => commitTimeInput(),
892
+ keydown: (event) => {
893
+ if (event.key === 'Enter') {
894
+ event.preventDefault()
895
+ commitTimeInput()
896
+ }
897
+ if (event.key === 'Escape') {
898
+ event.preventDefault()
899
+ isTimeEditing = false
900
+ timeInputValue = formatTimestamp(playhead)
901
+ handle.update()
902
+ }
903
+ },
904
+ }}
905
+ />
906
+ </label>
907
+ <span class="summary-subtext">
908
+ {previewPlaying ? 'Playing' : 'Paused'}
909
+ </span>
910
+ </div>
911
+ </div>
912
+ </div>
913
+ </section>
914
+
915
+ <section class="app-card app-card--full timeline-card">
916
+ <div class="timeline-header">
917
+ <div>
918
+ <h2>Trim timeline</h2>
919
+ <p class="app-muted">
920
+ Drag the trim handles or use arrow keys to fine-tune start and
921
+ end timestamps.
922
+ </p>
923
+ </div>
924
+ <button
925
+ class="button button--primary"
926
+ type="button"
927
+ disabled={!previewReady}
928
+ on={{ click: addTrimRange }}
929
+ >
930
+ Add trim range
931
+ </button>
932
+ </div>
933
+ <p class="app-muted trim-hint" id={hintId}>
934
+ Use arrow keys to nudge by {KEYBOARD_STEP}s. Hold Shift for{' '}
935
+ {SHIFT_STEP}
936
+ s.
937
+ </p>
938
+ <div
939
+ class={classNames(
940
+ 'trim-track',
941
+ !previewReady && 'trim-track--disabled',
942
+ )}
943
+ connect={(node: HTMLDivElement) => {
944
+ trackNode = node
945
+ }}
946
+ style={`--playhead:${duration > 0 ? (playhead / duration) * 100 : 0}%`}
947
+ >
948
+ <canvas
949
+ class="trim-waveform"
950
+ connect={(node: HTMLCanvasElement, signal) => {
951
+ waveformNode = node
952
+ drawWaveform()
953
+ if (typeof ResizeObserver === 'undefined') return
954
+ const observer = new ResizeObserver(() => drawWaveform())
955
+ observer.observe(node)
956
+ signal.addEventListener('abort', () => {
957
+ observer.disconnect()
958
+ if (waveformNode === node) {
959
+ waveformNode = null
960
+ }
961
+ })
962
+ }}
963
+ />
964
+ {sortedRanges.map((range) => (
965
+ <div
966
+ class={classNames(
967
+ 'trim-range',
968
+ range.id === selectedRangeId && 'is-selected',
969
+ )}
970
+ style={`--range-left:${duration > 0 ? (range.start / duration) * 100 : 0}%; --range-width:${duration > 0 ? ((range.end - range.start) / duration) * 100 : 0}%`}
971
+ on={{ click: () => selectRange(range.id) }}
972
+ role="group"
973
+ aria-label={`Trim range ${formatTimestamp(range.start)} to ${formatTimestamp(range.end)}`}
974
+ >
975
+ <span class="trim-range-label">
976
+ Remove {formatTimestamp(range.start)} -{' '}
977
+ {formatTimestamp(range.end)}
978
+ </span>
979
+ <span class="trim-handle-label trim-handle-label--start">
980
+ {formatTimestamp(range.start)}
981
+ </span>
982
+ <button
983
+ type="button"
984
+ class="trim-handle trim-handle--start"
985
+ role="slider"
986
+ aria-label="Trim start"
987
+ aria-valuemin={0}
988
+ aria-valuemax={duration}
989
+ aria-valuenow={range.start}
990
+ aria-valuetext={formatTimestamp(range.start)}
991
+ aria-describedby={hintId}
992
+ on={{
993
+ focus: () =>
994
+ syncVideoToTime(range.start, { updateInput: true }),
995
+ pointerdown: (event) => startDrag(event, range.id, 'start'),
996
+ pointermove: moveDrag,
997
+ pointerup: endDrag,
998
+ pointercancel: endDrag,
999
+ keydown: (event) => handleRangeKey(event, range, 'start'),
1000
+ }}
1001
+ />
1002
+ <span class="trim-handle-label trim-handle-label--end">
1003
+ {formatTimestamp(range.end)}
1004
+ </span>
1005
+ <button
1006
+ type="button"
1007
+ class="trim-handle trim-handle--end"
1008
+ role="slider"
1009
+ aria-label="Trim end"
1010
+ aria-valuemin={0}
1011
+ aria-valuemax={duration}
1012
+ aria-valuenow={range.end}
1013
+ aria-valuetext={formatTimestamp(range.end)}
1014
+ aria-describedby={hintId}
1015
+ on={{
1016
+ focus: () =>
1017
+ syncVideoToTime(range.end, { updateInput: true }),
1018
+ pointerdown: (event) => startDrag(event, range.id, 'end'),
1019
+ pointermove: moveDrag,
1020
+ pointerup: endDrag,
1021
+ pointercancel: endDrag,
1022
+ keydown: (event) => handleRangeKey(event, range, 'end'),
1023
+ }}
1024
+ />
1025
+ </div>
1026
+ ))}
1027
+ <span class="trim-playhead" />
1028
+ </div>
1029
+ <div class="trim-waveform-meta">
1030
+ {waveformStatus === 'loading' ? (
1031
+ <span class="summary-subtext">Rendering waveform...</span>
1032
+ ) : waveformStatus === 'error' ? (
1033
+ <span class="summary-subtext">{waveformError}</span>
1034
+ ) : (
1035
+ <span class="summary-subtext">
1036
+ Waveform {waveformSamples.length > 0 ? 'ready' : 'idle'}
1037
+ </span>
1038
+ )}
1039
+ </div>
1040
+ <div class="timeline-controls">
1041
+ <label class="control-label">
1042
+ Playhead
1043
+ <span class="control-value">{formatTimestamp(playhead)}</span>
1044
+ </label>
1045
+ <input
1046
+ class="timeline-slider"
1047
+ type="range"
1048
+ min="0"
1049
+ max={duration || 1}
1050
+ step={PLAYHEAD_STEP}
1051
+ value={playhead}
1052
+ disabled={!previewReady}
1053
+ on={{
1054
+ input: (event) => {
1055
+ const target = event.currentTarget as HTMLInputElement
1056
+ setPlayhead(Number(target.value))
1057
+ },
1058
+ }}
1059
+ />
1060
+ <button
1061
+ class="button button--ghost"
1062
+ type="button"
1063
+ disabled={!previewReady || sortedRanges.length === 0}
1064
+ on={{
1065
+ click: () => {
1066
+ const next = sortedRanges.find(
1067
+ (range) => range.start > playhead,
1068
+ )
1069
+ if (next) setPlayhead(next.start)
1070
+ },
1071
+ }}
1072
+ >
1073
+ Next trim
1074
+ </button>
1075
+ </div>
1076
+ </section>
1077
+
1078
+ <div class="app-grid app-grid--two trim-grid">
1079
+ <section class="app-card">
1080
+ <div class="panel-header">
1081
+ <h2>Trim ranges</h2>
1082
+ <span class="summary-subtext">{sortedRanges.length} total</span>
1083
+ </div>
1084
+ {sortedRanges.length === 0 ? (
1085
+ <p class="app-muted">
1086
+ Add a trim range to start removing segments.
1087
+ </p>
1088
+ ) : (
1089
+ <ul class="stacked-list trim-range-list">
1090
+ {sortedRanges.map((range) => (
1091
+ <li
1092
+ class={classNames(
1093
+ 'stacked-item',
1094
+ 'trim-range-row',
1095
+ range.id === selectedRangeId && 'is-selected',
1096
+ )}
1097
+ >
1098
+ <button
1099
+ class="trim-range-summary"
1100
+ type="button"
1101
+ on={{ click: () => selectRange(range.id) }}
1102
+ >
1103
+ <span class="trim-range-time">
1104
+ {formatTimestamp(range.start)} -{' '}
1105
+ {formatTimestamp(range.end)}
1106
+ </span>
1107
+ <span class="summary-subtext">
1108
+ Remove {formatSeconds(range.end - range.start)}
1109
+ </span>
1110
+ </button>
1111
+ <div class="trim-range-fields">
1112
+ <label class="input-label">
1113
+ Start
1114
+ <input
1115
+ class="text-input text-input--compact"
1116
+ type="number"
1117
+ min="0"
1118
+ max={duration}
1119
+ step={KEYBOARD_STEP}
1120
+ value={range.start.toFixed(2)}
1121
+ on={{
1122
+ focus: () =>
1123
+ syncVideoToTime(range.start, {
1124
+ updateInput: true,
1125
+ }),
1126
+ input: (event) => {
1127
+ const target =
1128
+ event.currentTarget as HTMLInputElement
1129
+ const nextValue = Number(target.value)
1130
+ if (!Number.isFinite(nextValue)) return
1131
+ updateTrimRange(
1132
+ range.id,
1133
+ { start: nextValue },
1134
+ 'start',
1135
+ )
1136
+ syncVideoToTime(nextValue, {
1137
+ updateInput: true,
1138
+ })
1139
+ },
1140
+ keydown: (event) =>
1141
+ handleNumberKey(event, range, 'start'),
1142
+ }}
1143
+ />
1144
+ </label>
1145
+ <label class="input-label">
1146
+ End
1147
+ <input
1148
+ class="text-input text-input--compact"
1149
+ type="number"
1150
+ min="0"
1151
+ max={duration}
1152
+ step={KEYBOARD_STEP}
1153
+ value={range.end.toFixed(2)}
1154
+ on={{
1155
+ focus: () =>
1156
+ syncVideoToTime(range.end, {
1157
+ updateInput: true,
1158
+ }),
1159
+ input: (event) => {
1160
+ const target =
1161
+ event.currentTarget as HTMLInputElement
1162
+ const nextValue = Number(target.value)
1163
+ if (!Number.isFinite(nextValue)) return
1164
+ updateTrimRange(
1165
+ range.id,
1166
+ { end: nextValue },
1167
+ 'end',
1168
+ )
1169
+ syncVideoToTime(nextValue, {
1170
+ updateInput: true,
1171
+ })
1172
+ },
1173
+ keydown: (event) =>
1174
+ handleNumberKey(event, range, 'end'),
1175
+ }}
1176
+ />
1177
+ </label>
1178
+ <button
1179
+ class="button button--ghost"
1180
+ type="button"
1181
+ on={{ click: () => removeTrimRange(range.id) }}
1182
+ >
1183
+ Remove
1184
+ </button>
1185
+ </div>
1186
+ </li>
1187
+ ))}
1188
+ </ul>
1189
+ )}
1190
+ </section>
1191
+
1192
+ <section class="app-card">
1193
+ <h2>Output summary</h2>
1194
+ <div class="summary-grid">
1195
+ <div class="summary-item">
1196
+ <span class="summary-label">Removed</span>
1197
+ <span class="summary-value">{formatSeconds(totalRemoved)}</span>
1198
+ <span class="summary-subtext">
1199
+ {normalizedRanges.length} normalized ranges
1200
+ </span>
1201
+ </div>
1202
+ <div class="summary-item">
1203
+ <span class="summary-label">Output length</span>
1204
+ <span class="summary-value">
1205
+ {previewReady ? formatTimestamp(outputDuration) : '--:--.--'}
1206
+ </span>
1207
+ <span class="summary-subtext">
1208
+ {previewReady && duration > 0
1209
+ ? `${Math.round((outputDuration / duration) * 100)}% kept`
1210
+ : 'Load a video to calculate'}
1211
+ </span>
1212
+ </div>
1213
+ <div class="summary-item">
1214
+ <span class="summary-label">Command status</span>
1215
+ <span class="summary-value">{progressLabel}</span>
1216
+ <span class="summary-subtext">
1217
+ {runStatus === 'running'
1218
+ ? 'ffmpeg in progress'
1219
+ : 'Ready to run'}
1220
+ </span>
1221
+ </div>
1222
+ </div>
1223
+ </section>
1224
+ </div>
1225
+
1226
+ <section class="app-card app-card--full trim-command-card">
1227
+ <div class="panel-header">
1228
+ <h2>ffmpeg command</h2>
1229
+ <div class="trim-command-actions">
1230
+ <button
1231
+ class="button button--primary"
1232
+ type="button"
1233
+ disabled={runStatus === 'running' || !commandPreview}
1234
+ on={{ click: runTrimCommand }}
1235
+ >
1236
+ {runStatus === 'running' ? 'Running...' : 'Run ffmpeg'}
1237
+ </button>
1238
+ <button
1239
+ class="button button--ghost"
1240
+ type="button"
1241
+ disabled={runStatus !== 'running'}
1242
+ on={{ click: cancelRun }}
1243
+ >
1244
+ Cancel
1245
+ </button>
1246
+ </div>
1247
+ </div>
1248
+ <p class="app-muted">
1249
+ Use this command in your terminal, or run it here to watch progress
1250
+ stream back into the UI.
1251
+ </p>
1252
+ {commandPreview ? (
1253
+ <pre class="command-preview">{commandPreview}</pre>
1254
+ ) : (
1255
+ <p class="status-note status-note--warning">
1256
+ Load a video and add at least one trim range to generate the
1257
+ command.
1258
+ </p>
1259
+ )}
1260
+ <div class="trim-progress">
1261
+ <progress max="1" value={runProgress} />
1262
+ <span class="summary-subtext">{progressLabel}</span>
1263
+ </div>
1264
+ {runError ? (
1265
+ <p class="status-note status-note--danger">{runError}</p>
1266
+ ) : null}
1267
+ <pre class="command-preview trim-output">
1268
+ {runLogs.length > 0
1269
+ ? runLogs.slice(-200).join('\n')
1270
+ : 'ffmpeg output will appear here.'}
1271
+ </pre>
1272
+ </section>
1273
+ </main>
1274
+ )
1275
+ }
1276
+ }