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 @@
+
+
+
+
+
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`;