From a3429dee851ded36e2f51bdb0b92f12b4216b524 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 18:53:35 -0700 Subject: [PATCH] adds seeking support --- public/app.js | 280 +++++++++++++++++++++++++++++++++++++++++++++- public/index.html | 5 + public/styles.css | 110 ++++++++++++++++++ server/index.js | 225 +++++++++++++++++++++++++++++++++++-- 4 files changed, 604 insertions(+), 16 deletions(-) diff --git a/public/app.js b/public/app.js index 346eb74..caa8629 100644 --- a/public/app.js +++ b/public/app.js @@ -13,6 +13,10 @@ const elements = { loader: document.querySelector('#loader'), playerMessage: document.querySelector('#player-message'), controls: document.querySelector('#controls'), + playhead: document.querySelector('#playhead'), + seek: document.querySelector('#seek'), + currentTime: document.querySelector('#current-time'), + totalTime: document.querySelector('#total-time'), back: document.querySelector('#back'), playPause: document.querySelector('#play-pause'), mute: document.querySelector('#mute'), @@ -31,6 +35,11 @@ const state = { controlsVisible: true, hideControlsTimer: 0, recentUrls: [], + playbackOffset: 0, + duration: null, + seekable: false, + isSeeking: false, + seekPreviewTime: 0, }; void loadRecentUrls(); @@ -109,15 +118,46 @@ elements.mute.addEventListener('click', () => { syncControlLabels(); }); +elements.seek.addEventListener('pointerdown', () => { + if (!state.seekable) { + return; + } + + state.isSeeking = true; + state.seekPreviewTime = Number(elements.seek.value); + setControlsVisible(true); + syncPlayhead(); +}); + +elements.seek.addEventListener('input', () => { + if (!state.seekable) { + return; + } + + state.isSeeking = true; + state.seekPreviewTime = Number(elements.seek.value); + syncPlayhead(); +}); + +elements.seek.addEventListener('change', () => { + if (!state.seekable) { + return; + } + + void seekTo(Number(elements.seek.value)); +}); + elements.audio.addEventListener('play', () => { startRenderLoop(); syncControlLabels(); scheduleControlsHide(); + syncPlayhead(); }); elements.audio.addEventListener('pause', () => { syncControlLabels(); setControlsVisible(true); + syncPlayhead(); }); elements.audio.addEventListener('playing', () => { @@ -132,6 +172,15 @@ elements.audio.addEventListener('waiting', () => { } }); +elements.audio.addEventListener('timeupdate', () => { + syncPlayhead(); +}); + +elements.audio.addEventListener('ended', () => { + syncPlayhead(); + setControlsVisible(true); +}); + elements.audio.addEventListener('error', () => { if (state.session) { showPlayerMessage('Audio failed'); @@ -140,17 +189,39 @@ elements.audio.addEventListener('error', () => { }); function startSession(session) { - const generation = state.generation; state.session = session; + state.playbackOffset = Number(session.seekSeconds) || 0; + state.duration = normalizeDuration(session.duration); + state.seekable = Boolean(session.seekable); + state.isSeeking = false; + state.seekPreviewTime = state.playbackOffset; state.frameCount = 0; clearFrameQueue(); syncControlLabels(); + syncPlayhead(); setControlsVisible(true); elements.loader.hidden = false; clearPlayerMessage(); + connectPlaybackStreams(); + void refreshSessionMetadata(session.id); +} + +function connectPlaybackStreams() { + if (!state.session) { + return; + } + + const generation = state.generation; + const session = state.session; + + if (state.websocket) { + state.websocket.close(1000, 'client reconnecting'); + state.websocket = null; + } + const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}`; + const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}?g=${session.seekGeneration ?? 0}`; const websocket = new WebSocket(websocketUrl); websocket.binaryType = 'arraybuffer'; state.websocket = websocket; @@ -169,20 +240,21 @@ function startSession(session) { }); websocket.addEventListener('close', () => { - if (state.session?.id === session.id) { + if (generation === state.generation && state.session?.id === session.id) { showPlayerMessage('Stream ended'); setControlsVisible(true); } }); websocket.addEventListener('error', () => { - if (state.session?.id === session.id) { + if (generation === state.generation && state.session?.id === session.id) { showPlayerMessage('Stream failed'); setControlsVisible(true); } }); - elements.audio.src = `/audio/${session.id}`; + elements.audio.pause(); + elements.audio.src = `/audio/${session.id}?g=${session.seekGeneration ?? 0}`; elements.audio.load(); void playAudio(); startRenderLoop(); @@ -231,6 +303,10 @@ function handleControlMessage(rawMessage) { setControlsVisible(true); } + if (message.type === 'ready') { + updateSessionMetadata(message); + } + if (message.type === 'end') { showPlayerMessage('Stream ended'); setControlsVisible(true); @@ -244,6 +320,7 @@ function startRenderLoop() { const render = () => { drawReadyFrames(); + syncPlayhead(); state.raf = requestAnimationFrame(render); }; @@ -309,6 +386,11 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) { state.generation += 1; state.session = null; state.frameCount = 0; + state.playbackOffset = 0; + state.duration = null; + state.seekable = false; + state.isSeeking = false; + state.seekPreviewTime = 0; clearHideControlsTimer(); if (state.websocket) { @@ -331,6 +413,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) { clearPlayerMessage(); setControlsVisible(true); syncControlLabels(); + syncPlayhead(); if (shouldShowEntry) { showEntry(); @@ -374,6 +457,191 @@ function releaseImage(image) { } } +async function seekTo(value) { + if (!state.session || !state.seekable) { + state.isSeeking = false; + syncPlayhead(); + return; + } + + const duration = state.duration; + + if (!Number.isFinite(duration) || duration <= 0) { + state.isSeeking = false; + syncPlayhead(); + return; + } + + const generation = state.generation + 1; + const targetTime = clampNumber(value, 0, duration); + const sessionId = state.session.id; + + state.generation = generation; + state.playbackOffset = targetTime; + state.seekPreviewTime = targetTime; + state.isSeeking = false; + state.frameCount = 0; + elements.loader.hidden = false; + clearPlayerMessage(); + clearFrameQueue(); + context.clearRect(0, 0, elements.canvas.width, elements.canvas.height); + syncPlayhead(); + + if (state.websocket) { + state.websocket.close(1000, 'client seeking'); + state.websocket = null; + } + + elements.audio.pause(); + elements.audio.removeAttribute('src'); + elements.audio.load(); + + try { + const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/seek`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ time: targetTime }), + }); + const payload = await response.json(); + + if (!response.ok) { + throw new Error(payload.error ?? 'Seek failed.'); + } + + if (generation !== state.generation || state.session?.id !== sessionId) { + return; + } + + state.session = payload; + updateSessionMetadata(payload); + connectPlaybackStreams(); + } catch (error) { + if (generation !== state.generation || state.session?.id !== sessionId) { + return; + } + + showPlayerMessage(error.message); + setControlsVisible(true); + syncPlayhead(); + } +} + +async function refreshSessionMetadata(sessionId) { + const generation = state.generation; + + for (let attempt = 0; attempt < 10; attempt += 1) { + if (generation !== state.generation || state.session?.id !== sessionId) { + return; + } + + try { + const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { cache: 'no-store' }); + + if (!response.ok) { + return; + } + + const payload = await response.json(); + + if (generation !== state.generation || state.session?.id !== sessionId) { + return; + } + + state.session = { ...state.session, ...payload }; + updateSessionMetadata(payload); + + if (payload.metadataStatus !== 'pending') { + return; + } + } catch { + return; + } + + await delay(650); + } +} + +function updateSessionMetadata(payload) { + if ('duration' in payload) { + state.duration = normalizeDuration(payload.duration); + } + + if ('seekable' in payload) { + state.seekable = Boolean(payload.seekable); + } + + if (Number.isFinite(payload.seekSeconds)) { + state.playbackOffset = payload.seekSeconds; + } + + syncPlayhead(); +} + +function syncPlayhead() { + const currentTime = getVisiblePlaybackTime(); + const duration = state.duration; + const hasDuration = Number.isFinite(duration) && duration > 0; + const max = hasDuration ? duration : 1; + const value = hasDuration ? clampNumber(currentTime, 0, max) : 0; + const progress = hasDuration && max > 0 ? (value / max) * 100 : 0; + + elements.currentTime.textContent = formatTime(currentTime); + elements.totalTime.textContent = formatTime(duration); + elements.seek.max = String(max); + elements.seek.value = String(value); + elements.seek.disabled = !state.seekable; + elements.seek.setAttribute('aria-valuemin', '0'); + elements.seek.setAttribute('aria-valuemax', String(Math.round(max))); + elements.seek.setAttribute('aria-valuenow', String(Math.round(value))); + elements.seek.style.setProperty('--progress', `${progress}%`); +} + +function getVisiblePlaybackTime() { + if (state.isSeeking) { + return Number.isFinite(state.seekPreviewTime) ? state.seekPreviewTime : 0; + } + + const currentTime = state.playbackOffset + (Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0); + + if (Number.isFinite(state.duration)) { + return clampNumber(currentTime, 0, state.duration); + } + + return Math.max(0, currentTime); +} + +function normalizeDuration(value) { + const duration = Number(value); + return Number.isFinite(duration) && duration > 0 ? duration : null; +} + +function formatTime(value) { + if (!Number.isFinite(value)) { + return '--:--'; + } + + const totalSeconds = Math.max(0, Math.floor(value)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function clampNumber(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function delay(ms) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + function setControlsVisible(visible) { state.controlsVisible = visible; elements.stage.classList.toggle('controls-hidden', !visible); @@ -389,7 +657,7 @@ function setControlsVisible(visible) { function scheduleControlsHide() { clearHideControlsTimer(); state.hideControlsTimer = window.setTimeout(() => { - if (state.session && !elements.audio.paused) { + if (state.session && !elements.audio.paused && !state.isSeeking) { setControlsVisible(false); } }, 2400); diff --git a/public/index.html b/public/index.html index 628f447..276f94b 100644 --- a/public/index.html +++ b/public/index.html @@ -36,6 +36,11 @@
+
+ 0:00 + + --:-- +
diff --git a/public/styles.css b/public/styles.css index 0cc0346..2cf6af5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -237,6 +237,111 @@ canvas { transition: opacity 180ms ease, transform 180ms ease; } +.playhead { + position: absolute; + top: -1.75rem; + right: 1.1rem; + left: 1.1rem; + height: 2.2rem; +} + +.time-badge { + position: absolute; + top: 0; + min-width: 3.6rem; + padding: 0.22rem 0.45rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--fg); + font-size: 0.76rem; + font-weight: 800; + line-height: 1; + text-align: center; + font-variant-numeric: tabular-nums; + background: var(--glass-strong); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38); + backdrop-filter: blur(14px); +} + +.current-time { + left: 0; +} + +.total-time { + right: 0; +} + +.seek-slider { + position: absolute; + right: 0; + bottom: 0.18rem; + left: 0; + width: 100%; + height: 1rem; + margin: 0; + appearance: none; + background: transparent; + cursor: pointer; + --progress: 0%; +} + +.seek-slider:disabled { + cursor: default; +} + +.seek-slider::-webkit-slider-runnable-track { + height: 0.28rem; + border-radius: 999px; + background: + linear-gradient( + to right, + var(--accent) 0%, + var(--accent) var(--progress), + rgba(246, 241, 232, 0.28) var(--progress), + rgba(246, 241, 232, 0.28) 100% + ); +} + +.seek-slider::-moz-range-track { + height: 0.28rem; + border-radius: 999px; + background: rgba(246, 241, 232, 0.28); +} + +.seek-slider::-moz-range-progress { + height: 0.28rem; + border-radius: 999px; + background: var(--accent); +} + +.seek-slider::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.36rem; + appearance: none; + border: 2px solid rgba(7, 7, 7, 0.75); + border-radius: 50%; + background: var(--fg); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48); +} + +.seek-slider::-moz-range-thumb { + width: 0.86rem; + height: 0.86rem; + border: 2px solid rgba(7, 7, 7, 0.75); + border-radius: 50%; + background: var(--fg); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48); +} + +.seek-slider:disabled::-webkit-slider-thumb { + opacity: 0; +} + +.seek-slider:disabled::-moz-range-thumb { + opacity: 0; +} + .video-stage.controls-hidden .controls { pointer-events: none; opacity: 0; @@ -288,6 +393,11 @@ audio { gap: 0.5rem; } + .playhead { + right: 0.75rem; + left: 0.75rem; + } + .control-button { min-width: 0; flex: 1; diff --git a/server/index.js b/server/index.js index f445dfe..2c0c4b0 100644 --- a/server/index.js +++ b/server/index.js @@ -18,12 +18,14 @@ const wss = new WebSocketServer({ noServer: true }); const PORT = Number(process.env.PORT ?? 3000); const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg'; +const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe'; const FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning'; const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0'; const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE); const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json'); const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50); const SESSION_TTL_MS = 60 * 60 * 1000; +const METADATA_PROBE_TIMEOUT_MS = 8 * 1000; const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000; const MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024; const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 16 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024); @@ -80,9 +82,14 @@ app.post('/api/session', async (request, response) => { id, url, options, + duration: null, + metadataStatus: 'pending', + seekSeconds: 0, + seekGeneration: 0, createdAt: Date.now(), lastUsedAt: Date.now(), }); + startSessionMetadataProbe(sessions.get(id)); try { await addRecentUrl(url); @@ -90,7 +97,56 @@ app.post('/api/session', async (request, response) => { console.warn(`Failed to store recent URL: ${error.message}`); } - response.status(201).json({ id, options }); + response.status(201).json(formatSessionPayload(sessions.get(id))); +}); + +app.get('/api/session/:sessionId', (request, response) => { + const session = getSession(request.params.sessionId); + + if (!session) { + response.status(404).json({ error: 'Unknown or expired session.' }); + return; + } + + response.json(formatSessionPayload(session)); +}); + +app.post('/api/session/:sessionId/seek', (request, response) => { + const session = getSession(request.params.sessionId); + + if (!session) { + response.status(404).json({ error: 'Unknown or expired session.' }); + return; + } + + if (!isSessionSeekable(session)) { + response.status(409).json({ error: 'This stream is not seekable.' }); + return; + } + + const requestedSeconds = Number(request.body?.time); + + if (!Number.isFinite(requestedSeconds)) { + response.status(400).json({ error: 'A numeric seek time is required.' }); + return; + } + + const seekSeconds = clampNumber(requestedSeconds, 0, session.duration); + const playback = playbacks.get(session.id); + + session.seekSeconds = seekSeconds; + session.seekGeneration += 1; + session.lastUsedAt = Date.now(); + + if (playback && !playback.closed) { + playback.stop('client_seek'); + if (playbacks.get(session.id) === playback) { + playbacks.delete(session.id); + } + } + + logInfo(`session seeked id=${shortId(session.id)} time=${seekSeconds.toFixed(3)} duration=${session.duration.toFixed(3)}`); + response.json(formatSessionPayload(session)); }); app.all('/_source/:token', async (request, response) => { @@ -297,6 +353,10 @@ function clampInteger(value, fallback, min, max) { return Math.min(max, Math.max(min, Math.round(parsed))); } +function clampNumber(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + function parseAudioBitrate(value) { if (typeof value !== 'string') { return defaults.audioBitrate; @@ -305,6 +365,124 @@ function parseAudioBitrate(value) { return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate; } +function formatSessionPayload(session) { + return { + id: session.id, + options: session.options, + duration: Number.isFinite(session.duration) ? session.duration : null, + metadataStatus: session.metadataStatus, + seekable: isSessionSeekable(session), + seekSeconds: session.seekSeconds, + seekGeneration: session.seekGeneration, + }; +} + +function isSessionSeekable(session) { + return PLAYBACK_CONNECTION_MODE !== 'relay' && Number.isFinite(session.duration) && session.duration > 0; +} + +function startSessionMetadataProbe(session) { + void probeSessionDuration(session).then((duration) => { + const current = sessions.get(session.id); + + if (current !== session) { + return; + } + + session.duration = duration; + session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable'; + }).catch((error) => { + const current = sessions.get(session.id); + + if (current !== session) { + return; + } + + session.duration = null; + session.metadataStatus = 'unavailable'; + logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`); + }); +} + +async function probeSessionDuration(session) { + const source = createSourceInput(session.id, 'probe'); + + try { + const output = await runFfprobe([ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'json', + source.url, + ]); + const payload = JSON.parse(output); + const duration = Number(payload?.format?.duration); + + return Number.isFinite(duration) && duration > 0 ? duration : null; + } finally { + source.release(); + } +} + +function runFfprobe(args) { + return new Promise((resolve, reject) => { + const ffprobe = spawn(FFPROBE_PATH, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + let settled = false; + + const finish = (callback) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + callback(); + }; + + const timer = setTimeout(() => { + timedOut = true; + stopProcess(ffprobe); + }, METADATA_PROBE_TIMEOUT_MS); + timer.unref(); + + ffprobe.stdout.on('data', (chunk) => { + stdout = appendTail(stdout, chunk); + }); + + ffprobe.stderr.on('data', (chunk) => { + stderr = appendTail(stderr, chunk); + }); + + ffprobe.on('error', (error) => { + finish(() => reject(error)); + }); + + ffprobe.on('close', (code, signal) => { + finish(() => { + if (timedOut) { + reject(new Error('ffprobe timed out.')); + return; + } + + if (code === 0) { + resolve(stdout); + return; + } + + const detail = redactSecrets(stderr).trim(); + reject(new Error(detail ? `ffprobe exited with code ${code}: ${detail}` : `ffprobe exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`)); + }); + }); + }); +} + async function loadRecentUrls() { try { const raw = await fs.readFile(RECENT_URLS_PATH, 'utf8'); @@ -443,6 +621,9 @@ function streamSplitFrames(websocket, session) { fps, quality, width, + duration: session.duration, + seekable: isSessionSeekable(session), + seekSeconds: session.seekSeconds, }); ffmpeg.stdout.on('data', handleFrameData); @@ -576,6 +757,9 @@ function createRelayPlayback(session) { get closed() { return closed; }, + stop(reason = 'playback_stopped') { + stop(reason); + }, attachAudio(request, response) { if (closed) { response.status(410).end(); @@ -624,6 +808,9 @@ function createRelayPlayback(session) { fps, quality, width, + duration: session.duration, + seekable: isSessionSeekable(session), + seekSeconds: session.seekSeconds, }); websocket.on('close', () => { @@ -888,7 +1075,9 @@ function createRelayPlayback(session) { sourceController?.abort(); audioInput?.destroy(); frameInput?.destroy(); - playbacks.delete(session.id); + if (playbacks.get(session.id) === playback) { + playbacks.delete(session.id); + } if (audioResponse && !audioResponse.writableEnded) { audioResponse.end(); @@ -939,6 +1128,9 @@ function createPlayback(session) { get closed() { return closed; }, + stop(reason = 'playback_stopped') { + stop(reason); + }, attachAudio(request, response) { if (closed) { response.status(410).end(); @@ -987,6 +1179,9 @@ function createPlayback(session) { fps, quality, width, + duration: session.duration, + seekable: isSessionSeekable(session), + seekSeconds: session.seekSeconds, }); websocket.on('close', () => { @@ -1184,7 +1379,9 @@ function createPlayback(session) { closed = true; clearReadyTimer(); releaseSource(); - playbacks.delete(session.id); + if (playbacks.get(session.id) === playback) { + playbacks.delete(session.id); + } audioQueue = []; audioQueueBytes = 0; @@ -1252,16 +1449,16 @@ function createFrameWorker(session) { } function buildRelayAudioArgs(session) { - return buildAudioArgs(session, 'pipe:0', { seekable: false }); + return buildAudioArgs(session, 'pipe:0', { seekable: false, startTime: 0 }); } function buildRelayFrameArgs(session) { - return buildFrameArgs(session, 'pipe:0', { seekable: false }); + return buildFrameArgs(session, 'pipe:0', { seekable: false, startTime: 0 }); } function buildPlaybackArgs(session, inputUrl) { return [ - ...buildInputArgs(inputUrl), + ...buildInputArgs(inputUrl, { startTime: session.seekSeconds }), '-map', '0:a:0?', '-vn', @@ -1284,7 +1481,7 @@ function buildPlaybackArgs(session, inputUrl) { function buildAudioArgs(session, inputUrl, inputOptions) { return [ - ...buildInputArgs(inputUrl, inputOptions), + ...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }), '-map', '0:a:0?', '-vn', @@ -1304,14 +1501,14 @@ function buildAudioArgs(session, inputUrl, inputOptions) { function buildFrameArgs(session, inputUrl, inputOptions) { return [ - ...buildInputArgs(inputUrl, inputOptions), + ...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }), '-map', '0:v:0', ...buildFrameOutputArgs(session, 'pipe:1'), ]; } -function buildInputArgs(inputUrl, { seekable = true } = {}) { +function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) { const args = [ '-hide_banner', '-nostdin', @@ -1321,13 +1518,21 @@ function buildInputArgs(inputUrl, { seekable = true } = {}) { ]; if (seekable) { - args.push('-seekable', FFMPEG_INPUT_SEEKABLE); + args.push('-seekable', startTime > 0 ? '1' : FFMPEG_INPUT_SEEKABLE); + } + + if (startTime > 0) { + args.push('-ss', formatFfmpegSeconds(startTime)); } args.push('-re', '-i', inputUrl); return args; } +function formatFfmpegSeconds(seconds) { + return clampNumber(seconds, 0, Number.MAX_SAFE_INTEGER).toFixed(3); +} + function buildFrameOutputArgs(session, outputUrl) { const { fps, quality, width } = session.options; const videoFilter = `fps=${fps},scale=w='min(${width},iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p`;