From 1a80b0ffc0003a1d339c84d4f7438d09c1c2cad0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Jan 2026 21:14:35 +0000 Subject: [PATCH 1/7] feat(app): add trim points page Co-authored-by: me --- app/assets/styles.css | 218 +++++++ app/client/app.tsx | 13 +- app/client/editing-workspace.tsx | 5 + app/client/trim-points.tsx | 959 +++++++++++++++++++++++++++++++ app/config/routes.ts | 1 + app/router.tsx | 6 + app/routes/index.tsx | 3 + app/routes/trim-points.tsx | 51 ++ app/trim-api.ts | 261 +++++++++ app/trim-commands.ts | 154 +++++ src/app-server.ts | 4 + 11 files changed, 1673 insertions(+), 2 deletions(-) create mode 100644 app/client/trim-points.tsx create mode 100644 app/routes/trim-points.tsx create mode 100644 app/trim-api.ts create mode 100644 app/trim-commands.ts diff --git a/app/assets/styles.css b/app/assets/styles.css index 1241fd9..65368d4 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -182,6 +182,23 @@ p { max-width: 640px; } +.app-nav { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); +} + +.app-link { + color: var(--color-primary); + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); + text-decoration: none; +} + +.app-link:hover { + text-decoration: underline; +} + .app-grid { display: grid; gap: var(--spacing-lg); @@ -515,6 +532,203 @@ p { gap: var(--spacing-xl); } +.trim-grid { + align-items: start; +} + +.trim-preview { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.trim-hint { + margin: 0; +} + +.trim-track { + position: relative; + height: 96px; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border); + background: linear-gradient( + 90deg, + var(--color-border-muted) 0%, + var(--color-border-muted) 2%, + transparent 2%, + transparent 20% + ); + background-size: 20% 100%; + overflow: hidden; +} + +.trim-track--disabled { + opacity: 0.6; + pointer-events: none; +} + +.trim-track--skeleton { + min-height: 96px; + background: linear-gradient( + 90deg, + var(--color-background) 0%, + var(--color-border) 45%, + var(--color-background) 90% + ); + background-size: 200% 100%; + animation: shimmer 1.8s infinite; +} + +.trim-range { + position: absolute; + top: 18px; + height: 60px; + border-radius: var(--radius-md); + border: 1px solid var(--color-warning-border); + background: var(--color-warning-surface); + left: var(--range-left); + width: var(--range-width); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: 0 var(--spacing-lg); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--color-warning-border) 40%, transparent); +} + +.trim-range.is-selected { + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--color-primary) 55%, transparent); +} + +.trim-range-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); +} + +.trim-handle { + position: absolute; + top: 50%; + width: 14px; + height: 52px; + border-radius: var(--radius-sm); + background: var(--color-primary); + border: 1px solid var(--color-primary-active); + box-shadow: var(--shadow-sm); + cursor: ew-resize; + transform: translate(-50%, -50%); +} + +.trim-handle--start { + left: 0; +} + +.trim-handle--end { + right: 0; + transform: translate(50%, -50%); +} + +.trim-handle:focus-visible { + outline: 2px solid var(--color-border-accent); + outline-offset: 2px; +} + +.trim-handle-label { + position: absolute; + top: -18px; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-inverse); + background: var(--color-surface-inverse); + padding: 2px 6px; + border-radius: var(--radius-pill); + white-space: nowrap; +} + +.trim-handle-label--start { + left: 0; + transform: translateX(-50%); +} + +.trim-handle-label--end { + right: 0; + transform: translateX(50%); +} + +.trim-playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + left: var(--playhead); + background: var(--color-primary); + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--color-primary) 40%, transparent); +} + +.trim-range-list { + gap: var(--spacing-sm); +} + +.trim-range-row { + border: 1px solid var(--color-border); + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + background: var(--color-surface); +} + +.trim-range-summary { + border: none; + padding: 0; + background: transparent; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + text-align: left; + cursor: pointer; +} + +.trim-range-time { + font-weight: var(--font-weight-semibold); +} + +.trim-range-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: var(--spacing-sm); + align-items: end; +} + +.trim-progress { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.trim-progress progress { + flex: 1; + height: 10px; +} + +.trim-command-card { + gap: var(--spacing-lg); +} + +.trim-command-actions { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.trim-output { + max-height: 240px; + overflow-y: auto; +} + .timeline-header { display: flex; align-items: flex-start; @@ -955,4 +1169,8 @@ p { .timeline-controls { grid-template-columns: 1fr; } + + .trim-range-fields { + grid-template-columns: 1fr; + } } diff --git a/app/client/app.tsx b/app/client/app.tsx index 60b3247..6f4b62d 100644 --- a/app/client/app.tsx +++ b/app/client/app.tsx @@ -1,5 +1,14 @@ +import type { Handle } from 'remix/component' import { EditingWorkspace } from './editing-workspace.tsx' +import { TrimPoints } from './trim-points.tsx' -export function App() { - return () => +export function App(handle: Handle) { + return () => { + const pathname = + typeof window === 'undefined' ? '/' : window.location.pathname + if (pathname.startsWith('/trim-points')) { + return + } + return + } } diff --git a/app/client/editing-workspace.tsx b/app/client/editing-workspace.tsx index 3bb1589..2f44e60 100644 --- a/app/client/editing-workspace.tsx +++ b/app/client/editing-workspace.tsx @@ -601,6 +601,11 @@ export function EditingWorkspace(handle: Handle) { Review transcript-based edits, refine command windows, and prepare the final CLI export in one place.

+
diff --git a/app/client/trim-points.tsx b/app/client/trim-points.tsx new file mode 100644 index 0000000..442567e --- /dev/null +++ b/app/client/trim-points.tsx @@ -0,0 +1,959 @@ +import type { Handle } from 'remix/component' +import { + buildFfmpegCommandPreview, + computeOutputDuration, + normalizeTrimRanges, + type TrimRange, +} from '../trim-commands.ts' + +type AppConfig = { + initialVideoPath?: string +} + +declare global { + interface Window { + __EPREC_APP__?: AppConfig + } +} + +type TrimRangeWithId = TrimRange & { id: string } + +const DEFAULT_TRIM_LENGTH = 2.5 +const MIN_TRIM_LENGTH = 0.1 +const PLAYHEAD_STEP = 0.1 +const KEYBOARD_STEP = 0.1 +const SHIFT_STEP = 1 +const DEMO_VIDEO_PATH = 'fixtures/e2e-test.mp4' + +function readInitialVideoPath() { + if (typeof window === 'undefined') return '' + const raw = window.__EPREC_APP__?.initialVideoPath + if (typeof raw !== 'string') return '' + return raw.trim() +} + +function buildVideoPreviewUrl(value: string) { + return `/api/video?path=${encodeURIComponent(value)}` +} + +function buildOutputPath(value: string) { + const trimmed = value.trim() + if (!trimmed) return '' + const extensionMatch = trimmed.match(/(\.[^./\\]+)$/) + if (extensionMatch) { + return trimmed.replace(/(\.[^./\\]+)$/, '.trimmed$1') + } + return `${trimmed}.trimmed.mp4` +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function sortRanges(ranges: TrimRangeWithId[]) { + return ranges.slice().sort((a, b) => a.start - b.start) +} + +function formatTimestamp(value: number) { + const clamped = Math.max(value, 0) + const totalSeconds = Math.floor(clamped) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + const hundredths = Math.floor((clamped - totalSeconds) * 100) + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(hundredths).padStart(2, '0')}` +} + +function formatSeconds(value: number) { + return `${value.toFixed(1)}s` +} + +function classNames(...values: Array) { + return values.filter(Boolean).join(' ') +} + +export function TrimPoints(handle: Handle) { + const initialVideoPath = readInitialVideoPath() + let videoPathInput = initialVideoPath + let outputPathInput = initialVideoPath ? buildOutputPath(initialVideoPath) : '' + let pathStatus: 'idle' | 'loading' | 'ready' | 'error' = initialVideoPath + ? 'loading' + : 'idle' + let pathError = '' + let previewUrl = '' + let previewError = '' + let previewDuration = 0 + let previewReady = false + let previewNode: HTMLVideoElement | null = null + let trackNode: HTMLDivElement | null = null + let playhead = 0 + let trimRanges: TrimRangeWithId[] = [] + let selectedRangeId: string | null = null + let rangeCounter = 1 + let activeDrag: + | { rangeId: string; edge: 'start' | 'end'; pointerId: number } + | null = null + let runStatus: 'idle' | 'running' | 'success' | 'error' = 'idle' + let runProgress = 0 + let runError = '' + let runLogs: string[] = [] + let runController: AbortController | null = null + let initialLoadTriggered = false + + const updateVideoPathInput = (value: string) => { + videoPathInput = value + if (pathError) pathError = '' + if (pathStatus === 'error') pathStatus = 'idle' + handle.update() + } + + const updateOutputPathInput = (value: string) => { + outputPathInput = value + handle.update() + } + + const resetPreviewState = () => { + previewReady = false + previewError = '' + previewDuration = 0 + } + + const applyPreviewSource = (url: string) => { + previewUrl = url + resetPreviewState() + handle.update() + } + + const loadVideoFromPath = async (override?: string) => { + const candidate = (override ?? videoPathInput).trim() + if (!candidate) { + pathError = 'Enter a video file path to load.' + pathStatus = 'error' + handle.update() + return + } + videoPathInput = candidate + pathStatus = 'loading' + pathError = '' + previewError = '' + handle.update() + const preview = buildVideoPreviewUrl(candidate) + try { + const response = await fetch(preview, { + method: 'HEAD', + cache: 'no-store', + signal: handle.signal, + }) + if (!response.ok) { + const message = + response.status === 404 + ? 'Video file not found. Check the path.' + : `Unable to load the video (status ${response.status}).` + throw new Error(message) + } + if (handle.signal.aborted) return + pathStatus = 'ready' + outputPathInput = buildOutputPath(candidate) + applyPreviewSource(preview) + } catch (error) { + if (handle.signal.aborted) return + pathStatus = 'error' + pathError = + error instanceof Error ? error.message : 'Unable to load the video.' + handle.update() + } + } + + const loadDemoVideo = () => { + videoPathInput = DEMO_VIDEO_PATH + outputPathInput = buildOutputPath(DEMO_VIDEO_PATH) + void loadVideoFromPath(DEMO_VIDEO_PATH) + } + + if (initialVideoPath && !initialLoadTriggered) { + initialLoadTriggered = true + void loadVideoFromPath(initialVideoPath) + } + + const setPlayhead = (value: number) => { + if (!previewReady || previewDuration <= 0) return + playhead = clamp(value, 0, previewDuration) + handle.update() + } + + const addTrimRange = () => { + if (!previewReady || previewDuration <= 0) { + pathError = 'Load a video before adding trim ranges.' + pathStatus = 'error' + handle.update() + return + } + const start = clamp(playhead, 0, previewDuration - MIN_TRIM_LENGTH) + const end = clamp( + start + DEFAULT_TRIM_LENGTH, + start + MIN_TRIM_LENGTH, + previewDuration, + ) + const newRange: TrimRangeWithId = { + id: `trim-${rangeCounter++}`, + start, + end, + } + trimRanges = sortRanges([...trimRanges, newRange]) + selectedRangeId = newRange.id + handle.update() + } + + const removeTrimRange = (rangeId: string) => { + trimRanges = trimRanges.filter((range) => range.id !== rangeId) + if (selectedRangeId === rangeId) { + selectedRangeId = trimRanges[0]?.id ?? null + } + handle.update() + } + + const updateTrimRange = ( + rangeId: string, + patch: Partial, + edge?: 'start' | 'end', + ) => { + trimRanges = sortRanges( + trimRanges.map((range) => { + if (range.id !== rangeId) return range + let nextStart = Number.isFinite(patch.start) + ? patch.start + : range.start + let nextEnd = Number.isFinite(patch.end) ? patch.end : range.end + if (edge === 'start') { + nextStart = clamp( + nextStart, + 0, + Math.max(previewDuration - MIN_TRIM_LENGTH, 0), + ) + nextEnd = clamp( + nextEnd, + nextStart + MIN_TRIM_LENGTH, + previewDuration, + ) + } else if (edge === 'end') { + nextEnd = clamp(nextEnd, MIN_TRIM_LENGTH, previewDuration) + nextStart = clamp(nextStart, 0, nextEnd - MIN_TRIM_LENGTH) + } else { + const minStart = clamp( + nextStart, + 0, + Math.max(previewDuration - MIN_TRIM_LENGTH, 0), + ) + const minEnd = clamp( + nextEnd, + minStart + MIN_TRIM_LENGTH, + previewDuration, + ) + nextStart = minStart + nextEnd = minEnd + } + return { ...range, start: nextStart, end: nextEnd } + }), + ) + selectedRangeId = rangeId + handle.update() + } + + const selectRange = (rangeId: string) => { + selectedRangeId = rangeId + handle.update() + } + + const getTimeFromClientX = (clientX: number) => { + if (!trackNode || previewDuration <= 0) return 0 + const rect = trackNode.getBoundingClientRect() + const ratio = clamp((clientX - rect.left) / rect.width, 0, 1) + return ratio * previewDuration + } + + const startDrag = ( + event: PointerEvent, + rangeId: string, + edge: 'start' | 'end', + ) => { + if (!trackNode || previewDuration <= 0) return + activeDrag = { rangeId, edge, pointerId: event.pointerId } + const target = event.currentTarget as HTMLElement + target.setPointerCapture(event.pointerId) + updateTrimRange(rangeId, { [edge]: getTimeFromClientX(event.clientX) }, edge) + } + + const moveDrag = (event: PointerEvent) => { + if (!activeDrag || activeDrag.pointerId !== event.pointerId) return + updateTrimRange( + activeDrag.rangeId, + { [activeDrag.edge]: getTimeFromClientX(event.clientX) }, + activeDrag.edge, + ) + } + + const endDrag = (event: PointerEvent) => { + if (!activeDrag || activeDrag.pointerId !== event.pointerId) return + activeDrag = null + } + + const handleRangeKey = ( + event: KeyboardEvent, + range: TrimRangeWithId, + edge: 'start' | 'end', + ) => { + const isForward = + event.key === 'ArrowUp' || event.key === 'ArrowRight' + const isBackward = + event.key === 'ArrowDown' || event.key === 'ArrowLeft' + if (!isForward && !isBackward) return + event.preventDefault() + const step = event.shiftKey ? SHIFT_STEP : KEYBOARD_STEP + const delta = isForward ? step : -step + updateTrimRange( + range.id, + { + [edge]: edge === 'start' ? range.start + delta : range.end + delta, + }, + edge, + ) + } + + const handleNumberKey = ( + event: KeyboardEvent, + range: TrimRangeWithId, + edge: 'start' | 'end', + ) => { + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return + event.preventDefault() + const step = event.shiftKey ? SHIFT_STEP : KEYBOARD_STEP + const delta = event.key === 'ArrowUp' ? step : -step + updateTrimRange( + range.id, + { + [edge]: edge === 'start' ? range.start + delta : range.end + delta, + }, + edge, + ) + } + + const runTrimCommand = async () => { + if (runStatus === 'running') return + const normalized = normalizeTrimRanges( + trimRanges, + previewDuration, + MIN_TRIM_LENGTH, + ) + if (!videoPathInput.trim()) { + runStatus = 'error' + runError = 'Provide a video file path before running ffmpeg.' + handle.update() + return + } + if (!outputPathInput.trim()) { + runStatus = 'error' + runError = 'Provide an output path before running ffmpeg.' + handle.update() + return + } + if (!previewReady || previewDuration <= 0) { + runStatus = 'error' + runError = 'Load the video preview before running ffmpeg.' + handle.update() + return + } + if (normalized.length === 0) { + runStatus = 'error' + runError = 'Add at least one trim range to run ffmpeg.' + handle.update() + return + } + runStatus = 'running' + runProgress = 0 + runError = '' + runLogs = [] + runController = new AbortController() + handle.update() + + try { + const response = await fetch('/api/trim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + inputPath: videoPathInput.trim(), + outputPath: outputPathInput.trim(), + duration: previewDuration, + ranges: normalized, + }), + signal: runController.signal, + }) + if (!response.ok) { + runStatus = 'error' + runError = await response.text() + handle.update() + return + } + const reader = response.body + ?.pipeThrough(new TextDecoderStream()) + .getReader() + if (!reader) { + runStatus = 'error' + runError = 'Streaming response not available.' + handle.update() + return + } + let buffer = '' + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += value + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + let payload: any = null + try { + payload = JSON.parse(line) + } catch { + runLogs = [...runLogs, line.trim()] + continue + } + if (payload?.type === 'log' && payload.message) { + runLogs = [...runLogs, payload.message] + } + if (payload?.type === 'progress') { + const nextProgress = + typeof payload.progress === 'number' ? payload.progress : 0 + runProgress = clamp(nextProgress, 0, 1) + } + if (payload?.type === 'done') { + if (payload.success) { + runStatus = 'success' + runProgress = 1 + } else { + runStatus = 'error' + runError = payload.error ?? 'ffmpeg failed.' + } + } + handle.update() + } + } + if (runStatus === 'running') { + runStatus = 'error' + runError = 'ffmpeg stream ended unexpectedly.' + handle.update() + } + } catch (error) { + runStatus = 'error' + runError = + error instanceof Error ? error.message : 'Unable to run ffmpeg.' + handle.update() + } finally { + runController = null + } + } + + const cancelRun = () => { + if (runController) { + runController.abort() + runController = null + } + runStatus = 'error' + runError = 'Run canceled.' + handle.update() + } + + return () => { + const duration = previewDuration + const sortedRanges = sortRanges(trimRanges) + const normalizedRanges = normalizeTrimRanges( + trimRanges, + duration, + MIN_TRIM_LENGTH, + ) + const totalRemoved = normalizedRanges.reduce( + (total, range) => total + (range.end - range.start), + 0, + ) + const outputDuration = computeOutputDuration( + duration, + trimRanges, + MIN_TRIM_LENGTH, + ) + const commandPreview = + videoPathInput.trim() && outputPathInput.trim() && normalizedRanges.length > 0 + ? buildFfmpegCommandPreview({ + inputPath: videoPathInput.trim(), + outputPath: outputPathInput.trim(), + ranges: normalizedRanges, + includeProgress: true, + }) + : '' + const progressLabel = + runStatus === 'running' + ? `${Math.round(runProgress * 100)}%` + : runStatus === 'success' + ? 'Complete' + : runStatus === 'error' + ? 'Error' + : 'Idle' + const hintId = 'trim-keyboard-hint' + return ( +
+
+ Eprec Studio +

Trim points

+

+ Define ranges to remove, preview their timestamps on the timeline, + and run ffmpeg with live progress. +

+ +
+ +
+
+
+

Video source

+

+ Load a local video file to calculate the trim timeline and output + command. +

+
+ + {pathStatus} + +
+
+
+ + +
+ + +
+ {pathStatus === 'error' && pathError ? ( +

{pathError}

+ ) : null} +
+
+
+

Preview

+ + {previewReady + ? `Duration ${formatTimestamp(previewDuration)}` + : 'Load a video to preview'} + +
+
+
+
+ +
+
+
+

Trim timeline

+

+ Drag the trim handles or use arrow keys to fine-tune start and + end timestamps. +

+
+ +
+

+ Use arrow keys to nudge by {KEYBOARD_STEP}s. Hold Shift for {SHIFT_STEP} + s. +

+
{ + trackNode = node + }} + style={`--playhead:${duration > 0 ? (playhead / duration) * 100 : 0}%`} + > + {sortedRanges.map((range) => ( +
0 ? (range.start / duration) * 100 : 0}%; --range-width:${duration > 0 ? ((range.end - range.start) / duration) * 100 : 0}%`} + on={{ click: () => selectRange(range.id) }} + role="group" + aria-label={`Trim range ${formatTimestamp(range.start)} to ${formatTimestamp(range.end)}`} + > + + Remove {formatTimestamp(range.start)} -{' '} + {formatTimestamp(range.end)} + + + {formatTimestamp(range.start)} + +
+ ))} + +
+
+ + { + const target = event.currentTarget as HTMLInputElement + setPlayhead(Number(target.value)) + }, + }} + /> + +
+
+ +
+
+
+

Trim ranges

+ + {sortedRanges.length} total + +
+ {sortedRanges.length === 0 ? ( +

+ Add a trim range to start removing segments. +

+ ) : ( +
    + {sortedRanges.map((range) => ( +
  • + +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+

Output summary

+
+
+ Removed + {formatSeconds(totalRemoved)} + + {normalizedRanges.length} normalized ranges + +
+
+ Output length + + {previewReady + ? formatTimestamp(outputDuration) + : '--:--.--'} + + + {previewReady && duration > 0 + ? `${Math.round((outputDuration / duration) * 100)}% kept` + : 'Load a video to calculate'} + +
+
+ Command status + {progressLabel} + + {runStatus === 'running' + ? 'ffmpeg in progress' + : 'Ready to run'} + +
+
+
+
+ +
+
+

ffmpeg command

+
+ + +
+
+

+ Use this command in your terminal, or run it here to watch progress + stream back into the UI. +

+ {commandPreview ? ( +
{commandPreview}
+ ) : ( +

+ Load a video and add at least one trim range to generate the + command. +

+ )} +
+ + {progressLabel} +
+ {runError ? ( +

{runError}

+ ) : null} +
+						{runLogs.length > 0
+							? runLogs.slice(-200).join('\n')
+							: 'ffmpeg output will appear here.'}
+					
+
+
+ ) + } +} diff --git a/app/config/routes.ts b/app/config/routes.ts index 5f719f0..6c3446f 100644 --- a/app/config/routes.ts +++ b/app/config/routes.ts @@ -2,4 +2,5 @@ import { route } from 'remix/fetch-router' export default route({ index: '/', + trimPoints: '/trim-points', }) diff --git a/app/router.tsx b/app/router.tsx index 254e08a..b5a512d 100644 --- a/app/router.tsx +++ b/app/router.tsx @@ -5,6 +5,7 @@ import { Layout } from './components/layout.tsx' import routes from './config/routes.ts' import { render } from './helpers/render.ts' import indexHandlers from './routes/index.tsx' +import trimPointsHandlers from './routes/trim-points.tsx' const STATIC_CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', @@ -102,5 +103,10 @@ export function createAppRouter(rootDir: string) { action: indexHandlers.loader, }) + router.map(routes.trimPoints, { + middleware: trimPointsHandlers.middleware, + action: trimPointsHandlers.loader, + }) + return router } diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 030178d..470e071 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -18,6 +18,9 @@ const indexHandler = { Review transcript-based edits, refine cut ranges, and prepare exports.

+

Source video

diff --git a/app/routes/trim-points.tsx b/app/routes/trim-points.tsx new file mode 100644 index 0000000..8913bd5 --- /dev/null +++ b/app/routes/trim-points.tsx @@ -0,0 +1,51 @@ +import { html } from 'remix/html-template' +import { Layout } from '../components/layout.tsx' +import { render } from '../helpers/render.ts' + +const trimPointsHandler = { + middleware: [], + loader() { + const initialVideoPath = process.env.EPREC_APP_VIDEO_PATH?.trim() + return render( + Layout({ + title: 'Trim points - Eprec Studio', + appConfig: initialVideoPath ? { initialVideoPath } : undefined, + children: html`
+
+ Eprec Studio +

Trim points

+

+ Add start and stop points, generate an ffmpeg trim command, and run + it with live progress. +

+ +
+
+

Video source

+

+ Enter a video file path once the interactive UI loads. +

+
+
+

Timeline

+

+ Add trim ranges and drag their handles to fine-tune timestamps. +

+
+
+
+

ffmpeg command

+

+ Command output and progress details appear after you load a video. +

+
Loading trim preview…
+
+
`, + }), + ) + }, +} + +export default trimPointsHandler diff --git a/app/trim-api.ts b/app/trim-api.ts new file mode 100644 index 0000000..2db050b --- /dev/null +++ b/app/trim-api.ts @@ -0,0 +1,261 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdir } from 'node:fs/promises' +import { + buildFfmpegArgs, + computeOutputDuration, + normalizeTrimRanges, + type TrimRange, +} from './trim-commands.ts' + +const TRIM_ROUTE = '/api/trim' + +type TrimRequestPayload = { + inputPath?: string + outputPath?: string + duration?: number + ranges?: TrimRange[] +} + +function expandHomePath(value: string) { + if (!value.startsWith('~/') && !value.startsWith('~\\')) { + return value + } + const home = process.env.HOME?.trim() + if (!home) return value + return path.join(home, value.slice(2)) +} + +function resolveMediaPath(rawPath: string): string | null { + const trimmed = rawPath.trim() + if (!trimmed) return null + if (trimmed.startsWith('file://')) { + try { + return fileURLToPath(trimmed) + } catch { + return null + } + } + return path.resolve(expandHomePath(trimmed)) +} + +function parseRanges(ranges: unknown): TrimRange[] { + if (!Array.isArray(ranges)) return [] + return ranges + .map((entry) => { + if (!entry || typeof entry !== 'object') return null + const candidate = entry as TrimRange + if (!Number.isFinite(candidate.start) || !Number.isFinite(candidate.end)) { + return null + } + return { start: candidate.start, end: candidate.end } + }) + .filter((range): range is TrimRange => Boolean(range)) +} + +function parseOutTimeValue(value: string) { + const parts = value.trim().split(':') + if (parts.length !== 3) return null + const [hours, minutes, seconds] = parts + const h = Number.parseFloat(hours) + const m = Number.parseFloat(minutes) + const s = Number.parseFloat(seconds) + if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(s)) { + return null + } + return h * 3600 + m * 60 + s +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +async function readLines( + stream: ReadableStream | null, + onLine: (line: string) => void, +) { + if (!stream) return + const reader = stream.pipeThrough(new TextDecoderStream()).getReader() + let buffer = '' + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += value + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (trimmed) onLine(trimmed) + } + } + const trailing = buffer.trim() + if (trailing) onLine(trailing) +} + +export async function handleTrimRequest(request: Request): Promise { + const url = new URL(request.url) + if (url.pathname !== TRIM_ROUTE) { + return new Response('Not Found', { status: 404 }) + } + + if (request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }) + } + + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }) + } + + let payload: TrimRequestPayload + try { + payload = (await request.json()) as TrimRequestPayload + } catch { + return new Response('Invalid JSON payload.', { status: 400 }) + } + + const inputRaw = payload.inputPath ?? '' + const outputRaw = payload.outputPath ?? '' + const duration = Number(payload.duration ?? 0) + if (!Number.isFinite(duration) || duration <= 0) { + return new Response('Invalid or missing duration.', { status: 400 }) + } + + const inputPath = resolveMediaPath(inputRaw) + const outputPath = resolveMediaPath(outputRaw) + if (!inputPath || !outputPath) { + return new Response('Input and output paths are required.', { + status: 400, + }) + } + + const ranges = normalizeTrimRanges(parseRanges(payload.ranges), duration) + if (ranges.length === 0) { + return new Response('No valid trim ranges provided.', { status: 400 }) + } + + const outputDuration = computeOutputDuration(duration, ranges) + if (outputDuration <= 0) { + return new Response('Trim ranges remove the full video.', { status: 400 }) + } + + const resolvedInput = path.resolve(inputPath) + const resolvedOutput = path.resolve(outputPath) + if (resolvedInput === resolvedOutput) { + return new Response('Output path must be different from input.', { + status: 400, + }) + } + + const inputFile = Bun.file(resolvedInput) + if (!(await inputFile.exists())) { + return new Response('Input file not found.', { status: 404 }) + } + + await mkdir(path.dirname(resolvedOutput), { recursive: true }) + + const args = buildFfmpegArgs({ + inputPath: resolvedInput, + outputPath: resolvedOutput, + ranges, + withProgress: true, + }) + if (args.length === 0) { + return new Response('Unable to build ffmpeg command.', { status: 400 }) + } + + const outputDurationSeconds = outputDuration + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + start(controller) { + let outTimeSeconds = 0 + const send = (payload: Record) => { + try { + controller.enqueue( + encoder.encode(`${JSON.stringify(payload)}\n`), + ) + } catch { + // stream closed + } + } + const process = Bun.spawn({ + cmd: args, + stdout: 'pipe', + stderr: 'pipe', + }) + + request.signal.addEventListener('abort', () => { + try { + process.kill() + } catch { + // ignore + } + }) + + void readLines(process.stdout, (line) => { + const [key, rawValue] = line.split('=') + const value = rawValue ?? '' + if (key === 'out_time_ms') { + const next = Number.parseFloat(value) + if (Number.isFinite(next)) outTimeSeconds = next / 1000 + } + if (key === 'out_time_us') { + const next = Number.parseFloat(value) + if (Number.isFinite(next)) outTimeSeconds = next / 1000000 + } + if (key === 'out_time') { + const parsed = parseOutTimeValue(value) + if (parsed !== null) outTimeSeconds = parsed + } + if (key === 'progress') { + const progress = + outputDurationSeconds > 0 + ? clamp(outTimeSeconds / outputDurationSeconds, 0, 1) + : 0 + send({ type: 'progress', progress }) + if (value === 'end') { + send({ type: 'progress', progress: 1 }) + } + } + }) + + void readLines(process.stderr, (line) => { + send({ type: 'log', message: line }) + }) + + process.exited + .then((exitCode) => { + send({ + type: 'done', + success: exitCode === 0, + exitCode, + }) + }) + .catch((error) => { + send({ + type: 'done', + success: false, + error: error instanceof Error ? error.message : String(error), + }) + }) + .finally(() => { + controller.close() + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }, + }) +} diff --git a/app/trim-commands.ts b/app/trim-commands.ts new file mode 100644 index 0000000..aa18190 --- /dev/null +++ b/app/trim-commands.ts @@ -0,0 +1,154 @@ +export type TrimRange = { + start: number + end: number +} + +const DEFAULT_MIN_RANGE = 0.05 + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function formatSecondsForCommand(value: number) { + return value.toFixed(3) +} + +export function normalizeTrimRanges( + ranges: TrimRange[], + duration: number, + minLength: number = DEFAULT_MIN_RANGE, +) { + if (!Number.isFinite(duration) || duration <= 0) return [] + const normalized = ranges + .map((range) => { + const startRaw = Number.isFinite(range.start) ? range.start : 0 + const endRaw = Number.isFinite(range.end) ? range.end : 0 + const start = clamp(Math.min(startRaw, endRaw), 0, duration) + const end = clamp(Math.max(startRaw, endRaw), 0, duration) + if (end - start < minLength) return null + return { start, end } + }) + .filter((range): range is TrimRange => Boolean(range)) + .sort((a, b) => a.start - b.start) + + const merged: TrimRange[] = [] + for (const range of normalized) { + const last = merged[merged.length - 1] + if (last && range.start <= last.end + minLength) { + last.end = Math.max(last.end, range.end) + } else { + merged.push({ ...range }) + } + } + return merged +} + +export function computeOutputDuration( + duration: number, + ranges: TrimRange[], + minLength: number = DEFAULT_MIN_RANGE, +) { + const normalized = normalizeTrimRanges(ranges, duration, minLength) + const removed = normalized.reduce( + (total, range) => total + (range.end - range.start), + 0, + ) + return Math.max(duration - removed, 0) +} + +export function buildTrimExpression(ranges: TrimRange[]) { + if (ranges.length === 0) return '' + const expressions = ranges.map( + (range) => + `between(t,${formatSecondsForCommand(range.start)},${formatSecondsForCommand(range.end)})`, + ) + return `not(${expressions.join('+')})` +} + +export function buildTrimFilters(ranges: TrimRange[]) { + const expression = buildTrimExpression(ranges) + if (!expression) { + return { + expression: '', + videoFilter: '', + audioFilter: '', + } + } + return { + expression, + videoFilter: `select='${expression}',setpts=N/FRAME_RATE/TB`, + audioFilter: `aselect='${expression}',asetpts=N/SR/TB`, + } +} + +export function buildFfmpegArgs(options: { + inputPath: string + outputPath: string + ranges: TrimRange[] + withProgress?: boolean +}) { + const filters = buildTrimFilters(options.ranges) + if (!filters.expression) return [] + const args = [ + 'ffmpeg', + '-hide_banner', + '-y', + '-i', + options.inputPath, + '-vf', + filters.videoFilter, + '-af', + filters.audioFilter, + '-map', + '0:v', + '-map', + '0:a?', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '18', + '-c:a', + 'aac', + '-b:a', + '192k', + '-movflags', + '+faststart', + ] + if (options.withProgress) { + args.push('-progress', 'pipe:1', '-nostats') + } + args.push(options.outputPath) + return args +} + +function quoteShellArgument(value: string) { + const escaped = value.replace(/(["\\$`])/g, '\\$1') + return `"${escaped}"` +} + +export function buildFfmpegCommandPreview(options: { + inputPath: string + outputPath: string + ranges: TrimRange[] + includeProgress?: boolean +}) { + const filters = buildTrimFilters(options.ranges) + if (!filters.expression) return '' + const lines = [ + 'ffmpeg -hide_banner -y \\', + ` -i ${quoteShellArgument(options.inputPath)} \\`, + ` -vf ${quoteShellArgument(filters.videoFilter)} \\`, + ` -af ${quoteShellArgument(filters.audioFilter)} \\`, + ' -map 0:v -map 0:a? \\', + ' -c:v libx264 -preset veryfast -crf 18 \\', + ' -c:a aac -b:a 192k \\', + ' -movflags +faststart \\', + ] + if (options.includeProgress) { + lines.push(' -progress pipe:1 -nostats \\') + } + lines.push(` ${quoteShellArgument(options.outputPath)}`) + return lines.join('\n') +} diff --git a/src/app-server.ts b/src/app-server.ts index 5db6a0d..157d1ae 100644 --- a/src/app-server.ts +++ b/src/app-server.ts @@ -4,6 +4,7 @@ import '../app/config/init-env.ts' import getPort from 'get-port' import { getEnv } from '../app/config/env.ts' import { createAppRouter } from '../app/router.tsx' +import { handleTrimRequest } from '../app/trim-api.ts' import { handleVideoRequest } from '../app/video-api.ts' import { createBundlingRoutes } from '../server/bundling.ts' import { handleProcessingQueueRequest } from '../server/processing-queue.ts' @@ -197,6 +198,9 @@ function startServer(port: number, hostname: string) { if (url.pathname === '/api/video') { return await handleVideoRequest(request) } + if (url.pathname === '/api/trim') { + return await handleTrimRequest(request) + } if (url.pathname.startsWith('/api/processing-queue')) { return await handleProcessingQueueRequest(request) } From 887b832ecb97435a49bee8df6aa52ea9bb962638 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Jan 2026 21:32:18 +0000 Subject: [PATCH 2/7] chore(app): use ascii ellipsis Co-authored-by: me --- app/routes/trim-points.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/trim-points.tsx b/app/routes/trim-points.tsx index 8913bd5..a3d71e9 100644 --- a/app/routes/trim-points.tsx +++ b/app/routes/trim-points.tsx @@ -40,7 +40,7 @@ const trimPointsHandler = {

Command output and progress details appear after you load a video.

-
Loading trim preview…
+
Loading trim preview...
`, }), From ef1f3b8e1db23450aa6fcdec1eaad980392b28ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Jan 2026 21:43:59 +0000 Subject: [PATCH 3/7] fix: resolve race condition and cancel message bugs - Wait for stdout/stderr readers to complete before closing stream - Preserve user-friendly cancel message when abort occurs --- app/client/trim-points.tsx | 10 ++-- app/trim-api.ts | 102 ++++++++++++++++++------------------- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/app/client/trim-points.tsx b/app/client/trim-points.tsx index 442567e..4c1bb64 100644 --- a/app/client/trim-points.tsx +++ b/app/client/trim-points.tsx @@ -443,9 +443,13 @@ export function TrimPoints(handle: Handle) { handle.update() } } catch (error) { - runStatus = 'error' - runError = - error instanceof Error ? error.message : 'Unable to run ffmpeg.' + if (runController === null) { + // Cancellation already set the error message, don't overwrite it + } else { + runStatus = 'error' + runError = + error instanceof Error ? error.message : 'Unable to run ffmpeg.' + } handle.update() } finally { runController = null diff --git a/app/trim-api.ts b/app/trim-api.ts index 2db050b..22940e8 100644 --- a/app/trim-api.ts +++ b/app/trim-api.ts @@ -191,63 +191,63 @@ export async function handleTrimRequest(request: Request): Promise { stderr: 'pipe', }) - request.signal.addEventListener('abort', () => { - try { - process.kill() - } catch { - // ignore - } - }) + request.signal.addEventListener('abort', () => { + try { + process.kill() + } catch { + // ignore + } + }) - void readLines(process.stdout, (line) => { - const [key, rawValue] = line.split('=') - const value = rawValue ?? '' - if (key === 'out_time_ms') { - const next = Number.parseFloat(value) - if (Number.isFinite(next)) outTimeSeconds = next / 1000 - } - if (key === 'out_time_us') { - const next = Number.parseFloat(value) - if (Number.isFinite(next)) outTimeSeconds = next / 1000000 - } - if (key === 'out_time') { - const parsed = parseOutTimeValue(value) - if (parsed !== null) outTimeSeconds = parsed - } - if (key === 'progress') { - const progress = - outputDurationSeconds > 0 - ? clamp(outTimeSeconds / outputDurationSeconds, 0, 1) - : 0 - send({ type: 'progress', progress }) - if (value === 'end') { - send({ type: 'progress', progress: 1 }) - } + const stdoutReader = readLines(process.stdout, (line) => { + const [key, rawValue] = line.split('=') + const value = rawValue ?? '' + if (key === 'out_time_ms') { + const next = Number.parseFloat(value) + if (Number.isFinite(next)) outTimeSeconds = next / 1000 + } + if (key === 'out_time_us') { + const next = Number.parseFloat(value) + if (Number.isFinite(next)) outTimeSeconds = next / 1000000 + } + if (key === 'out_time') { + const parsed = parseOutTimeValue(value) + if (parsed !== null) outTimeSeconds = parsed + } + if (key === 'progress') { + const progress = + outputDurationSeconds > 0 + ? clamp(outTimeSeconds / outputDurationSeconds, 0, 1) + : 0 + send({ type: 'progress', progress }) + if (value === 'end') { + send({ type: 'progress', progress: 1 }) } - }) + } + }) - void readLines(process.stderr, (line) => { - send({ type: 'log', message: line }) - }) + const stderrReader = readLines(process.stderr, (line) => { + send({ type: 'log', message: line }) + }) - process.exited - .then((exitCode) => { - send({ - type: 'done', - success: exitCode === 0, - exitCode, - }) - }) - .catch((error) => { - send({ - type: 'done', - success: false, - error: error instanceof Error ? error.message : String(error), - }) + Promise.all([stdoutReader, stderrReader, process.exited]) + .then(([, , exitCode]) => { + send({ + type: 'done', + success: exitCode === 0, + exitCode, }) - .finally(() => { - controller.close() + }) + .catch((error) => { + send({ + type: 'done', + success: false, + error: error instanceof Error ? error.message : String(error), }) + }) + .finally(() => { + controller.close() + }) }, }) From a2fd531234768fb5a3ed403c2b5d3ff8d655aae3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Jan 2026 21:45:34 +0000 Subject: [PATCH 4/7] feat(app): sync trim playback Co-authored-by: me --- app/assets/styles.css | 26 +++ app/client/trim-points.tsx | 328 ++++++++++++++++++++++++++++++++++++- 2 files changed, 347 insertions(+), 7 deletions(-) diff --git a/app/assets/styles.css b/app/assets/styles.css index 65368d4..cfdb777 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -562,6 +562,17 @@ p { overflow: hidden; } +.trim-waveform { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + color: var(--color-text-faint); + opacity: 0.7; + z-index: 1; +} + .trim-track--disabled { opacity: 0.6; pointer-events: none; @@ -595,6 +606,7 @@ p { padding: 0 var(--spacing-lg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning-border) 40%, transparent); + z-index: 2; } .trim-range.is-selected { @@ -666,6 +678,7 @@ p { background: var(--color-primary); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 40%, transparent); + z-index: 3; } .trim-range-list { @@ -703,6 +716,19 @@ p { align-items: end; } +.trim-waveform-meta { + display: flex; + justify-content: flex-end; +} + +.trim-time-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + flex-wrap: wrap; +} + .trim-progress { display: flex; align-items: center; diff --git a/app/client/trim-points.tsx b/app/client/trim-points.tsx index 4c1bb64..c25b80b 100644 --- a/app/client/trim-points.tsx +++ b/app/client/trim-points.tsx @@ -24,6 +24,7 @@ const PLAYHEAD_STEP = 0.1 const KEYBOARD_STEP = 0.1 const SHIFT_STEP = 1 const DEMO_VIDEO_PATH = 'fixtures/e2e-test.mp4' +const WAVEFORM_SAMPLES = 240 function readInitialVideoPath() { if (typeof window === 'undefined') return '' @@ -63,6 +64,30 @@ function formatTimestamp(value: number) { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(hundredths).padStart(2, '0')}` } +function parseTimestampInput(value: string) { + const trimmed = value.trim() + if (!trimmed) return null + if (/^\d+(\.\d+)?$/.test(trimmed)) { + const seconds = Number.parseFloat(trimmed) + return Number.isFinite(seconds) ? seconds : null + } + const parts = trimmed.split(':').map((part) => part.trim()) + if (parts.length !== 2 && parts.length !== 3) return null + const secondsPart = Number.parseFloat(parts[parts.length - 1] ?? '') + const minutesPart = Number.parseFloat(parts[parts.length - 2] ?? '') + const hoursPart = + parts.length === 3 ? Number.parseFloat(parts[0] ?? '') : 0 + if ( + !Number.isFinite(secondsPart) || + !Number.isFinite(minutesPart) || + !Number.isFinite(hoursPart) + ) { + return null + } + if (secondsPart < 0 || minutesPart < 0 || hoursPart < 0) return null + return hoursPart * 3600 + minutesPart * 60 + secondsPart +} + function formatSeconds(value: number) { return `${value.toFixed(1)}s` } @@ -86,6 +111,9 @@ export function TrimPoints(handle: Handle) { let previewNode: HTMLVideoElement | null = null let trackNode: HTMLDivElement | null = null let playhead = 0 + let previewPlaying = false + let timeInputValue = formatTimestamp(playhead) + let isTimeEditing = false let trimRanges: TrimRangeWithId[] = [] let selectedRangeId: string | null = null let rangeCounter = 1 @@ -98,6 +126,11 @@ export function TrimPoints(handle: Handle) { let runLogs: string[] = [] let runController: AbortController | null = null let initialLoadTriggered = false + let waveformSamples: number[] = [] + let waveformStatus: 'idle' | 'loading' | 'ready' | 'error' = 'idle' + let waveformError = '' + let waveformSource = '' + let waveformNode: HTMLCanvasElement | null = null const updateVideoPathInput = (value: string) => { videoPathInput = value @@ -117,6 +150,159 @@ export function TrimPoints(handle: Handle) { previewDuration = 0 } + const syncVideoToTime = ( + value: number, + options: { skipVideo?: boolean; updateInput?: boolean } = {}, + ) => { + const maxDuration = previewDuration > 0 ? previewDuration : value + const nextTime = clamp(value, 0, Math.max(maxDuration, 0)) + playhead = nextTime + if (!isTimeEditing || options.updateInput) { + timeInputValue = formatTimestamp(nextTime) + } + if ( + previewNode && + previewReady && + !options.skipVideo && + Math.abs(previewNode.currentTime - nextTime) > 0.02 + ) { + previewNode.currentTime = nextTime + } + handle.update() + } + + const updateTimeInput = (value: string) => { + timeInputValue = value + isTimeEditing = true + handle.update() + } + + const commitTimeInput = () => { + const parsed = parseTimestampInput(timeInputValue) + isTimeEditing = false + if (parsed === null) { + timeInputValue = formatTimestamp(playhead) + handle.update() + return + } + syncVideoToTime(parsed, { updateInput: true }) + } + + const drawWaveform = () => { + if (!waveformNode) return + const ctx = waveformNode.getContext('2d') + if (!ctx) return + const width = waveformNode.clientWidth + const height = waveformNode.clientHeight + if (width <= 0 || height <= 0) return + const dpr = + typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1 + waveformNode.width = Math.floor(width * dpr) + waveformNode.height = Math.floor(height * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + const color = + typeof window !== 'undefined' + ? window.getComputedStyle(waveformNode).color + : '#94a3b8' + ctx.strokeStyle = color + ctx.lineWidth = 1 + if (waveformSamples.length === 0) { + ctx.beginPath() + ctx.moveTo(0, height / 2) + ctx.lineTo(width, height / 2) + ctx.stroke() + return + } + const mid = height / 2 + const step = width / waveformSamples.length + ctx.beginPath() + waveformSamples.forEach((sample, index) => { + const x = index * step + const amplitude = sample * (mid - 2) + ctx.moveTo(x, mid - amplitude) + ctx.lineTo(x, mid + amplitude) + }) + ctx.stroke() + } + + const loadWaveform = async (url: string) => { + if (!url || waveformStatus === 'loading') return + if (waveformSource === url && waveformStatus === 'ready') return + waveformSource = url + waveformStatus = 'loading' + waveformError = '' + waveformSamples = [] + drawWaveform() + handle.update() + try { + if (typeof window === 'undefined' || !('AudioContext' in window)) { + throw new Error('AudioContext unavailable in this browser.') + } + const response = await fetch(url, { + cache: 'no-store', + signal: handle.signal, + }) + if (!response.ok) { + throw new Error(`Waveform load failed (status ${response.status}).`) + } + const buffer = await response.arrayBuffer() + if (handle.signal.aborted) return + const audioContext = new AudioContext() + let audioBuffer: AudioBuffer + try { + audioBuffer = await audioContext.decodeAudioData(buffer.slice(0)) + } finally { + void audioContext.close() + } + if (audioBuffer.numberOfChannels === 0) { + throw new Error('No audio track found in the video.') + } + const channelCount = audioBuffer.numberOfChannels + const channels = Array.from({ length: channelCount }, (_, index) => + audioBuffer.getChannelData(index), + ) + const totalSamples = audioBuffer.length + const sampleCount = Math.max( + 1, + Math.min(WAVEFORM_SAMPLES, totalSamples), + ) + const blockSize = Math.max(1, Math.floor(totalSamples / sampleCount)) + const samples = new Array(sampleCount).fill(0) + let maxValue = 0 + for (let i = 0; i < sampleCount; i++) { + const start = i * blockSize + const end = + i === sampleCount - 1 ? totalSamples : start + blockSize + let peak = 0 + for (let j = start; j < end; j++) { + let sum = 0 + for (const channel of channels) { + sum += Math.abs(channel[j] ?? 0) + } + const avg = sum / channelCount + if (avg > peak) peak = avg + } + samples[i] = peak + if (peak > maxValue) maxValue = peak + } + const normalizedSamples = + maxValue > 0 ? samples.map((sample) => sample / maxValue) : samples + waveformSamples = normalizedSamples + waveformStatus = 'ready' + handle.update() + drawWaveform() + } catch (error) { + if (handle.signal.aborted) return + waveformStatus = 'error' + waveformError = + error instanceof Error + ? error.message + : 'Unable to render waveform.' + handle.update() + } + } + const applyPreviewSource = (url: string) => { previewUrl = url resetPreviewState() @@ -154,6 +340,7 @@ export function TrimPoints(handle: Handle) { pathStatus = 'ready' outputPathInput = buildOutputPath(candidate) applyPreviewSource(preview) + void loadWaveform(preview) } catch (error) { if (handle.signal.aborted) return pathStatus = 'error' @@ -176,8 +363,7 @@ export function TrimPoints(handle: Handle) { const setPlayhead = (value: number) => { if (!previewReady || previewDuration <= 0) return - playhead = clamp(value, 0, previewDuration) - handle.update() + syncVideoToTime(value, { updateInput: true }) } const addTrimRange = () => { @@ -200,7 +386,7 @@ export function TrimPoints(handle: Handle) { } trimRanges = sortRanges([...trimRanges, newRange]) selectedRangeId = newRange.id - handle.update() + syncVideoToTime(start, { updateInput: true }) } const removeTrimRange = (rangeId: string) => { @@ -260,6 +446,11 @@ export function TrimPoints(handle: Handle) { const selectRange = (rangeId: string) => { selectedRangeId = rangeId + const range = trimRanges.find((entry) => entry.id === rangeId) + if (range) { + syncVideoToTime(range.start, { updateInput: true }) + return + } handle.update() } @@ -279,16 +470,20 @@ export function TrimPoints(handle: Handle) { activeDrag = { rangeId, edge, pointerId: event.pointerId } const target = event.currentTarget as HTMLElement target.setPointerCapture(event.pointerId) - updateTrimRange(rangeId, { [edge]: getTimeFromClientX(event.clientX) }, edge) + const nextTime = getTimeFromClientX(event.clientX) + updateTrimRange(rangeId, { [edge]: nextTime }, edge) + syncVideoToTime(nextTime, { updateInput: true }) } const moveDrag = (event: PointerEvent) => { if (!activeDrag || activeDrag.pointerId !== event.pointerId) return + const nextTime = getTimeFromClientX(event.clientX) updateTrimRange( activeDrag.rangeId, - { [activeDrag.edge]: getTimeFromClientX(event.clientX) }, + { [activeDrag.edge]: nextTime }, activeDrag.edge, ) + syncVideoToTime(nextTime, { updateInput: true }) } const endDrag = (event: PointerEvent) => { @@ -309,13 +504,15 @@ export function TrimPoints(handle: Handle) { event.preventDefault() const step = event.shiftKey ? SHIFT_STEP : KEYBOARD_STEP const delta = isForward ? step : -step + const nextValue = edge === 'start' ? range.start + delta : range.end + delta updateTrimRange( range.id, { - [edge]: edge === 'start' ? range.start + delta : range.end + delta, + [edge]: nextValue, }, edge, ) + syncVideoToTime(nextValue, { updateInput: true }) } const handleNumberKey = ( @@ -327,13 +524,15 @@ export function TrimPoints(handle: Handle) { event.preventDefault() const step = event.shiftKey ? SHIFT_STEP : KEYBOARD_STEP const delta = event.key === 'ArrowUp' ? step : -step + const nextValue = edge === 'start' ? range.start + delta : range.end + delta updateTrimRange( range.id, { - [edge]: edge === 'start' ? range.start + delta : range.end + delta, + [edge]: nextValue, }, edge, ) + syncVideoToTime(nextValue, { updateInput: true }) } const runTrimCommand = async () => { @@ -615,6 +814,32 @@ export function TrimPoints(handle: Handle) { previewReady = previewDuration > 0 previewError = '' playhead = clamp(playhead, 0, previewDuration) + if (!isTimeEditing) { + timeInputValue = formatTimestamp(playhead) + } + if ( + Math.abs(node.currentTime - playhead) > 0.02 && + previewReady + ) { + node.currentTime = playhead + } + void loadWaveform(previewUrl) + handle.update() + } + const handleTimeUpdate = () => { + if (!previewReady || previewDuration <= 0) return + playhead = clamp(node.currentTime, 0, previewDuration) + if (!isTimeEditing) { + timeInputValue = formatTimestamp(playhead) + } + handle.update() + } + const handlePlay = () => { + previewPlaying = true + handle.update() + } + const handlePause = () => { + previewPlaying = false handle.update() } const handleError = () => { @@ -623,9 +848,15 @@ export function TrimPoints(handle: Handle) { handle.update() } node.addEventListener('loadedmetadata', handleLoaded) + node.addEventListener('timeupdate', handleTimeUpdate) + node.addEventListener('play', handlePlay) + node.addEventListener('pause', handlePause) node.addEventListener('error', handleError) signal.addEventListener('abort', () => { node.removeEventListener('loadedmetadata', handleLoaded) + node.removeEventListener('timeupdate', handleTimeUpdate) + node.removeEventListener('play', handlePlay) + node.removeEventListener('pause', handlePause) node.removeEventListener('error', handleError) if (previewNode === node) { previewNode = null @@ -636,6 +867,44 @@ export function TrimPoints(handle: Handle) { {previewError ? (

{previewError}

) : null} +
+ + + {previewPlaying ? 'Playing' : 'Paused'} + +
@@ -672,6 +941,22 @@ export function TrimPoints(handle: Handle) { }} style={`--playhead:${duration > 0 ? (playhead / duration) * 100 : 0}%`} > + { + waveformNode = node + drawWaveform() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(() => drawWaveform()) + observer.observe(node) + signal.addEventListener('abort', () => { + observer.disconnect() + if (waveformNode === node) { + waveformNode = null + } + }) + }} + /> {sortedRanges.map((range) => (
+ syncVideoToTime(range.start, { updateInput: true }), pointerdown: (event) => startDrag(event, range.id, 'start'), pointermove: moveDrag, @@ -723,6 +1010,8 @@ export function TrimPoints(handle: Handle) { aria-valuetext={formatTimestamp(range.end)} aria-describedby={hintId} on={{ + focus: () => + syncVideoToTime(range.end, { updateInput: true }), pointerdown: (event) => startDrag(event, range.id, 'end'), pointermove: moveDrag, pointerup: endDrag, @@ -734,6 +1023,17 @@ export function TrimPoints(handle: Handle) { ))}
+
+ {waveformStatus === 'loading' ? ( + Rendering waveform... + ) : waveformStatus === 'error' ? ( + {waveformError} + ) : ( + + Waveform {waveformSamples.length > 0 ? 'ready' : 'idle'} + + )} +