From 9cea5b46b60bfe26a161c1930dc1204668a96ee8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Jan 2026 01:16:33 +0000 Subject: [PATCH] feat(trim-points): add timeline zoom controls Co-authored-by: me --- app/assets/styles.css | 71 ++++- app/client/trim-points.tsx | 567 ++++++++++++++++++++++++++++++++----- 2 files changed, 572 insertions(+), 66 deletions(-) diff --git a/app/assets/styles.css b/app/assets/styles.css index cfdb777..a1cdd21 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -578,6 +578,10 @@ p { pointer-events: none; } +.trim-track--interactive { + cursor: pointer; +} + .trim-track--skeleton { min-height: 96px; background: linear-gradient( @@ -614,6 +618,30 @@ p { color-mix(in srgb, var(--color-primary) 55%, transparent); } +.trim-range--clipped-start::before, +.trim-range--clipped-end::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 20px; + pointer-events: none; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-warning-border) 60%, transparent), + transparent + ); +} + +.trim-range--clipped-start::before { + left: 0; +} + +.trim-range--clipped-end::after { + right: 0; + transform: scaleX(-1); +} + .trim-range-label { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); @@ -681,6 +709,10 @@ p { z-index: 3; } +.trim-playhead--clipped { + opacity: 0.4; +} + .trim-range-list { gap: var(--spacing-sm); } @@ -718,7 +750,10 @@ p { .trim-waveform-meta { display: flex; - justify-content: flex-end; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md); + flex-wrap: wrap; } .trim-time-row { @@ -884,6 +919,10 @@ p { color: var(--color-text-muted); } +.trim-timeline-scale { + margin-top: var(--spacing-sm); +} + .timeline-controls { display: grid; grid-template-columns: auto 1fr auto auto; @@ -891,6 +930,29 @@ p { align-items: center; } +.timeline-nav, +.timeline-zoom, +.timeline-window { + display: grid; + grid-template-columns: auto 1fr auto; + gap: var(--spacing-md); + align-items: center; +} + +.timeline-action-group { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + align-items: center; +} + +.timeline-zoom-controls { + display: grid; + grid-template-columns: auto 1fr auto; + gap: var(--spacing-sm); + align-items: center; +} + .timeline-slider { width: 100%; } @@ -1196,6 +1258,13 @@ p { grid-template-columns: 1fr; } + .timeline-nav, + .timeline-zoom, + .timeline-window, + .timeline-zoom-controls { + grid-template-columns: 1fr; + } + .trim-range-fields { grid-template-columns: 1fr; } diff --git a/app/client/trim-points.tsx b/app/client/trim-points.tsx index bbe86f7..db26194 100644 --- a/app/client/trim-points.tsx +++ b/app/client/trim-points.tsx @@ -23,6 +23,11 @@ const MIN_TRIM_LENGTH = 0.1 const PLAYHEAD_STEP = 0.1 const KEYBOARD_STEP = 0.1 const SHIFT_STEP = 1 +const MIN_ZOOM = 1 +const MAX_ZOOM = 8 +const ZOOM_STEP = 0.5 +const NAVIGATION_STEP = 1 +const VIEW_PAN_RATIO = 0.8 const DEMO_VIDEO_PATH = 'fixtures/e2e-test.mp4' const WAVEFORM_SAMPLES = 240 @@ -91,6 +96,12 @@ function formatSeconds(value: number) { return `${value.toFixed(1)}s` } +function formatZoom(value: number) { + if (!Number.isFinite(value) || value <= 0) return '1x' + const rounded = Math.round(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}x` +} + function classNames(...values: Array) { return values.filter(Boolean).join(' ') } @@ -134,6 +145,8 @@ export function TrimPoints(handle: Handle) { let waveformError = '' let waveformSource = '' let waveformNode: HTMLCanvasElement | null = null + let zoomLevel = MIN_ZOOM + let zoomWindowStart = 0 // Cleanup ffmpeg operation on unmount handle.signal.addEventListener('abort', () => { @@ -161,6 +174,122 @@ export function TrimPoints(handle: Handle) { trimRanges = [] selectedRangeId = null activeDrag = null + zoomLevel = MIN_ZOOM + zoomWindowStart = 0 + } + + const getWindowState = () => { + if (previewDuration <= 0) { + return { + zoom: clamp(zoomLevel, MIN_ZOOM, MAX_ZOOM), + windowDuration: 0, + windowStart: 0, + windowEnd: 0, + maxStart: 0, + } + } + const zoom = clamp(zoomLevel, MIN_ZOOM, MAX_ZOOM) + const windowDuration = previewDuration / zoom + const maxStart = Math.max(previewDuration - windowDuration, 0) + const windowStart = clamp(zoomWindowStart, 0, maxStart) + return { + zoom, + windowDuration, + windowStart, + windowEnd: windowStart + windowDuration, + maxStart, + } + } + + const normalizeZoomState = (options: { focusTime?: number } = {}) => { + if (previewDuration <= 0) { + zoomLevel = MIN_ZOOM + zoomWindowStart = 0 + return + } + zoomLevel = clamp(zoomLevel, MIN_ZOOM, MAX_ZOOM) + const windowDuration = previewDuration / zoomLevel + const maxStart = Math.max(previewDuration - windowDuration, 0) + if (typeof options.focusTime === 'number') { + const focus = clamp(options.focusTime, 0, previewDuration) + zoomWindowStart = clamp(focus - windowDuration / 2, 0, maxStart) + return + } + zoomWindowStart = clamp(zoomWindowStart, 0, maxStart) + } + + const ensurePlayheadVisible = () => { + if (previewDuration <= 0) return + const { windowDuration, windowStart, windowEnd, maxStart } = getWindowState() + if (windowDuration <= 0) return + let nextStart = windowStart + if (playhead < windowStart) { + nextStart = clamp(playhead - windowDuration * 0.1, 0, maxStart) + } else if (playhead > windowEnd) { + nextStart = clamp(playhead - windowDuration * 0.9, 0, maxStart) + } + if (nextStart !== zoomWindowStart) { + zoomWindowStart = nextStart + drawWaveform() + } + } + + const setZoom = (value: number, focusTime = playhead) => { + zoomLevel = clamp(value, MIN_ZOOM, MAX_ZOOM) + if (previewDuration > 0) { + const windowDuration = previewDuration / zoomLevel + const maxStart = Math.max(previewDuration - windowDuration, 0) + zoomWindowStart = clamp(focusTime - windowDuration / 2, 0, maxStart) + } else { + zoomWindowStart = 0 + } + drawWaveform() + handle.update() + } + + const setWindowStart = (value: number) => { + if (previewDuration <= 0) return + const windowDuration = previewDuration / clamp(zoomLevel, MIN_ZOOM, MAX_ZOOM) + const maxStart = Math.max(previewDuration - windowDuration, 0) + zoomWindowStart = clamp(value, 0, maxStart) + drawWaveform() + handle.update() + } + + const centerWindowOnTime = (value: number) => { + if (previewDuration <= 0) return + const windowDuration = previewDuration / clamp(zoomLevel, MIN_ZOOM, MAX_ZOOM) + const maxStart = Math.max(previewDuration - windowDuration, 0) + const focus = clamp(value, 0, previewDuration) + zoomWindowStart = clamp(focus - windowDuration / 2, 0, maxStart) + drawWaveform() + handle.update() + } + + const panWindow = (direction: number) => { + const { windowDuration } = getWindowState() + if (windowDuration <= 0) return + const shift = windowDuration * VIEW_PAN_RATIO * direction + setWindowStart(zoomWindowStart + shift) + } + + const zoomToRange = (range: TrimRangeWithId) => { + if (previewDuration <= 0) return + const rangeLength = Math.max(range.end - range.start, MIN_TRIM_LENGTH) + const padding = Math.max(rangeLength * 0.5, MIN_TRIM_LENGTH * 2) + const targetWindow = clamp(rangeLength + padding, MIN_TRIM_LENGTH, previewDuration) + const targetZoom = clamp(previewDuration / targetWindow, MIN_ZOOM, MAX_ZOOM) + zoomLevel = targetZoom + const windowDuration = previewDuration / zoomLevel + const maxStart = Math.max(previewDuration - windowDuration, 0) + const nextStart = clamp( + range.start - (windowDuration - rangeLength) / 2, + 0, + maxStart, + ) + zoomWindowStart = nextStart + drawWaveform() + handle.update() } const syncVideoToTime = ( @@ -181,6 +310,7 @@ export function TrimPoints(handle: Handle) { ) { previewNode.currentTime = nextTime } + ensurePlayheadVisible() handle.update() } @@ -213,13 +343,23 @@ export function TrimPoints(handle: Handle) { waveformNode.height = Math.floor(height * dpr) ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, width, height) + let samples = waveformSamples + if (previewDuration > 0 && waveformSamples.length > 0 && zoomLevel > 1) { + const { windowStart, windowEnd } = getWindowState() + const totalSamples = waveformSamples.length + const startIndex = Math.floor((windowStart / previewDuration) * totalSamples) + const endIndex = Math.ceil((windowEnd / previewDuration) * totalSamples) + const safeStart = clamp(startIndex, 0, Math.max(totalSamples - 1, 0)) + const safeEnd = clamp(endIndex, safeStart + 1, totalSamples) + samples = waveformSamples.slice(safeStart, safeEnd) + } const color = typeof window !== 'undefined' ? window.getComputedStyle(waveformNode).color : '#94a3b8' ctx.strokeStyle = color ctx.lineWidth = 1 - if (waveformSamples.length === 0) { + if (samples.length === 0) { ctx.beginPath() ctx.moveTo(0, height / 2) ctx.lineTo(width, height / 2) @@ -227,9 +367,9 @@ export function TrimPoints(handle: Handle) { return } const mid = height / 2 - const step = width / waveformSamples.length + const step = width / samples.length ctx.beginPath() - waveformSamples.forEach((sample, index) => { + samples.forEach((sample, index) => { const x = index * step const amplitude = sample * (mid - 2) ctx.moveTo(x, mid - amplitude) @@ -375,6 +515,38 @@ export function TrimPoints(handle: Handle) { syncVideoToTime(value, { updateInput: true }) } + const nudgePlayhead = (delta: number) => { + if (!previewReady || previewDuration <= 0) return + setPlayhead(playhead + delta) + } + + const jumpToStart = () => setPlayhead(0) + + const jumpToEnd = () => { + if (!previewReady || previewDuration <= 0) return + setPlayhead(previewDuration) + } + + const jumpToPrevTrim = () => { + const previousRanges = sortRanges(trimRanges).filter( + (range) => range.start < playhead, + ) + const previous = previousRanges[previousRanges.length - 1] + if (previous) setPlayhead(previous.start) + } + + const jumpToNextTrim = () => { + const next = sortRanges(trimRanges).find((range) => range.start > playhead) + if (next) setPlayhead(next.start) + } + + const zoomToSelectedRange = () => { + if (!selectedRangeId) return + const range = trimRanges.find((entry) => entry.id === selectedRangeId) + if (!range) return + zoomToRange(range) + } + const addTrimRange = () => { if (!previewReady || previewDuration <= MIN_TRIM_LENGTH) { pathError = 'Load a video before adding trim ranges.' @@ -461,7 +633,10 @@ export function TrimPoints(handle: Handle) { if (!trackNode || previewDuration <= 0) return 0 const rect = trackNode.getBoundingClientRect() const ratio = clamp((clientX - rect.left) / rect.width, 0, 1) - return ratio * previewDuration + const { windowDuration, windowStart } = getWindowState() + if (windowDuration <= 0) return 0 + const nextTime = windowStart + ratio * windowDuration + return clamp(nextTime, 0, previewDuration) } const startDrag = ( @@ -702,6 +877,61 @@ export function TrimPoints(handle: Handle) { : runStatus === 'error' ? 'Error' : 'Idle' + const windowState = getWindowState() + const windowDuration = windowState.windowDuration + const windowStart = windowState.windowStart + const windowEnd = windowState.windowEnd + const windowLabel = + previewReady && windowDuration > 0 + ? `${formatTimestamp(windowStart)} - ${formatTimestamp(windowEnd)}` + : '--:--.--' + const zoomLabel = formatZoom(windowState.zoom) + const windowStep = + previewReady && windowDuration > 0 + ? Math.max(windowDuration / 20, PLAYHEAD_STEP) + : PLAYHEAD_STEP + const maxWindowStart = Math.max(duration - windowDuration, 0) + const playheadPercent = + previewReady && windowDuration > 0 + ? clamp((playhead - windowStart) / windowDuration, 0, 1) * 100 + : 0 + const playheadInView = + previewReady && playhead >= windowStart && playhead <= windowEnd + const visibleRanges = + windowDuration > 0 + ? sortedRanges + .map((range) => { + const clippedStart = range.start < windowStart + const clippedEnd = range.end > windowEnd + const start = clippedStart ? windowStart : range.start + const end = clippedEnd ? windowEnd : range.end + if (end <= start) return null + const left = ((start - windowStart) / windowDuration) * 100 + const width = ((end - start) / windowDuration) * 100 + return { + range, + left, + width, + clippedStart, + clippedEnd, + } + }) + .filter( + ( + entry, + ): entry is { + range: TrimRangeWithId + left: number + width: number + clippedStart: boolean + clippedEnd: boolean + } => Boolean(entry), + ) + : [] + const timelineTicks = + previewReady && windowDuration > 0 + ? buildTimelineTicks(windowStart, windowDuration, 6) + : buildTimelineTicks(0, Math.max(duration, 0), 6) const hintId = 'trim-keyboard-hint' return (
@@ -817,6 +1047,7 @@ export function TrimPoints(handle: Handle) { previewReady = previewDuration > 0 previewError = '' playhead = clamp(playhead, 0, previewDuration) + normalizeZoomState({ focusTime: playhead }) if (!isTimeEditing) { timeInputValue = formatTimestamp(playhead) } @@ -835,6 +1066,7 @@ export function TrimPoints(handle: Handle) { if (!isTimeEditing) { timeInputValue = formatTimestamp(playhead) } + ensurePlayheadVisible() handle.update() } const handlePlay = () => { @@ -932,18 +1164,26 @@ export function TrimPoints(handle: Handle) {

Use arrow keys to nudge by {KEYBOARD_STEP}s. Hold Shift for{' '} - {SHIFT_STEP} - s. + {SHIFT_STEP}s. Click the timeline to move the playhead and use zoom + controls to focus on edits.

{ trackNode = node }} - style={`--playhead:${duration > 0 ? (playhead / duration) * 100 : 0}%`} + style={`--playhead:${playheadPercent}%`} + on={{ + pointerdown: (event) => { + if (event.currentTarget !== event.target) return + const nextTime = getTimeFromClientX(event.clientX) + setPlayhead(nextTime) + }, + }} > - {sortedRanges.map((range) => ( + {visibleRanges.map((entry) => (
0 ? (range.start / duration) * 100 : 0}%; --range-width:${duration > 0 ? ((range.end - range.start) / duration) * 100 : 0}%`} - on={{ click: () => selectRange(range.id) }} + style={`--range-left:${entry.left}%; --range-width:${entry.width}%`} + on={{ click: () => selectRange(entry.range.id) }} role="group" - aria-label={`Trim range ${formatTimestamp(range.start)} to ${formatTimestamp(range.end)}`} + aria-label={`Trim range ${formatTimestamp(entry.range.start)} to ${formatTimestamp(entry.range.end)}`} > - Remove {formatTimestamp(range.start)} -{' '} - {formatTimestamp(range.end)} - - - {formatTimestamp(range.start)} + Remove {formatTimestamp(entry.range.start)} -{' '} + {formatTimestamp(entry.range.end)} -
))} - + +
+
+ {timelineTicks.map((tick) => ( + {formatTimestamp(tick)} + ))}
+ + {previewReady && windowDuration > 0 + ? `View ${windowLabel} ยท Zoom ${zoomLabel}` + : 'Load a video to unlock timeline zoom.'} + {waveformStatus === 'loading' ? ( Rendering waveform... ) : waveformStatus === 'error' ? ( @@ -1063,16 +1336,171 @@ export function TrimPoints(handle: Handle) { disabled={!previewReady || sortedRanges.length === 0} on={{ click: () => { - const next = sortedRanges.find( - (range) => range.start > playhead, - ) - if (next) setPlayhead(next.start) + jumpToPrevTrim() + }, + }} + > + Prev trim + +
+
+ +
+ + + + +
+
+ + +
+
+
+ +
+ + { + const target = event.currentTarget as HTMLInputElement + setZoom(Number(target.value)) + }, + }} + /> + +
+
+ +
+
+
+ + { + const target = event.currentTarget as HTMLInputElement + setWindowStart(Number(target.value)) + }, + }} + /> +
+ + +
+
@@ -1274,3 +1702,12 @@ export function TrimPoints(handle: Handle) { ) } } + +function buildTimelineTicks(start: number, windowDuration: number, count: number) { + if (count <= 1) return [start] + if (windowDuration <= 0) return [start] + const step = windowDuration / (count - 1) + return Array.from({ length: count }, (_, index) => + Number((start + index * step).toFixed(2)), + ) +}