From 0cb091cb5331a62f490b87d273ea8ae52a582e9c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 7 Jun 2026 00:44:45 -0700 Subject: [PATCH] Reduce relay playback overhead --- AGENTS.md | 8 +++++--- README.md | 6 +++--- docker-compose-example.yml | 6 ++++-- public/app.js | 4 ++-- server/index.js | 30 ++++++++++++++++++++++++------ 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d92a661..057d7da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ The regression history matters: Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams. -Finite-duration sessions are treated as recorded video and seekable. When the configured mode is `relay`, recorded sessions switch to the seek-capable `split` path once duration metadata is known; live/unknown-duration streams stay in relay mode. +Finite-duration sessions are treated as recorded video and seekable when metadata probing is enabled. Metadata probing is enabled by default except in `relay`, where it defaults off to preserve the one-upstream-connection behavior for IPTV-style streams. When the configured mode is `relay` and duration metadata is known, recorded sessions switch to the seek-capable `split` path; live/unknown-duration streams stay in relay mode. ## ffmpeg Pipelines @@ -178,6 +178,8 @@ Runtime: - `FFMPEG_HTTP_RECONNECT_DELAY_MAX`: max ffmpeg reconnect delay, default `2`. - `FFMPEG_HTTP_RECONNECT_MAX_RETRIES`: max ffmpeg reconnect retries, default `4`. - `FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR`: HTTP status list for reconnect, default `5xx`. +- `METADATA_PROBE_ENABLED`: probe session duration with ffprobe, default `1` except in `relay`, where default is `0`. +- `METADATA_PROBE_TIMEOUT_MS`: ffprobe duration timeout, default `4000`. - `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`. - `RECENT_URLS_PATH`: recent URL JSON path. - `RECENT_URL_LIMIT`: recent URL count, default `12`. @@ -190,8 +192,8 @@ Runtime: - `DEFAULT_AUDIO_CHANNELS`: default MP3 audio channels, fallback `1`, clamped `1..2`. - `DEFAULT_AUDIO_SAMPLE_RATE`: default MP3 audio sample rate, fallback `44100`, clamped `22050..48000`. - `MAX_WS_BUFFER_BYTES`: server-side WebSocket JPEG frame backlog cap, default `524288`. -- `MAX_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `16777216`. -- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`. +- `MAX_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `4194304`. +- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `8388608`. Session playback options are accepted by `POST /api/session` even though the UI hides them: diff --git a/README.md b/README.md index 0a16026..4ae1a71 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream input JPEG frames are dropped when the browser WebSocket falls behind instead of letting stale frames queue indefinitely. Tune the server-side backlog cap with `MAX_WS_BUFFER_BYTES`; the default is `524288`. -In single mode, audio output from `ffmpeg` is buffered before it is written to the browser so short HTTP backpressure pauses are less likely to stall frame generation. Tune the cap with `MAX_AUDIO_QUEUE_BYTES`; the default is `16777216`. +In single mode, audio output from `ffmpeg` is buffered before it is written to the browser so short HTTP backpressure pauses are less likely to stall frame generation. Tune the cap with `MAX_AUDIO_QUEUE_BYTES`; the default is `4194304`. Playback uses `PLAYBACK_CONNECTION_MODE=split` by default. The Docker Compose example sets `PLAYBACK_CONNECTION_MODE=relay` so IPTV-style streams can be tested with one upstream connection. @@ -61,9 +61,9 @@ Available playback modes: - `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections. - `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other. -Finite-duration streams are treated as recorded video and become seekable once metadata is available. If the app is configured for `relay`, recorded streams switch to the seek-capable `split` path; live or unknown-duration streams stay in relay mode. +Finite-duration streams are treated as recorded video and become seekable once metadata is available. Metadata probing is enabled by default except in `relay`, where `METADATA_PROBE_ENABLED=0` avoids extra upstream connections. Set `METADATA_PROBE_ENABLED=1` if you want recorded relay sessions to become seekable and switch to the seek-capable `split` path. -Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `16777216`. +Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `8388608`. ## Tuning diff --git a/docker-compose-example.yml b/docker-compose-example.yml index 2a5d632..ce0093d 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -19,6 +19,8 @@ services: FFMPEG_HTTP_RECONNECT_DELAY_MAX: "2" FFMPEG_HTTP_RECONNECT_MAX_RETRIES: "4" FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR: "5xx" + METADATA_PROBE_ENABLED: "0" + METADATA_PROBE_TIMEOUT_MS: "4000" DEFAULT_FPS: "8" DEFAULT_FRAME_WIDTH: "480" JPEG_QUALITY: "12" @@ -26,8 +28,8 @@ services: DEFAULT_AUDIO_CHANNELS: "1" DEFAULT_AUDIO_SAMPLE_RATE: "44100" MAX_WS_BUFFER_BYTES: "524288" - MAX_AUDIO_QUEUE_BYTES: "16777216" - MAX_RELAY_BRANCH_QUEUE_BYTES: "16777216" + MAX_AUDIO_QUEUE_BYTES: "4194304" + MAX_RELAY_BRANCH_QUEUE_BYTES: "8388608" RECENT_URLS_PATH: /app/data/recent-urls.json FAVORITES_PATH: /app/data/favorites.json extra_hosts: diff --git a/public/app.js b/public/app.js index 5525781..8a6e243 100644 --- a/public/app.js +++ b/public/app.js @@ -34,7 +34,7 @@ const elements = { mute: document.querySelector('#mute'), }; -const context = elements.canvas.getContext('2d', { alpha: false }); +const context = elements.canvas.getContext('2d', { alpha: false, desynchronized: true }); const FRAME_LATE_GRACE_SECONDS = 0.25; const MAX_PENDING_FRAME_QUEUE_SECONDS = 1.5; const MAX_DECODED_FRAME_QUEUE_SECONDS = 1.5; @@ -492,7 +492,7 @@ function handleFramePacket(packet, streamGeneration) { } state.lastFramePacketAt = Date.now(); - state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration }); + state.pendingFrames.push({ timestamp, jpeg: new Uint8Array(packet, 8), streamGeneration }); trimPendingFrameQueue(); void pumpFrameDecodeQueue(); } diff --git a/server/index.js b/server/index.js index cb35923..348dfba 100644 --- a/server/index.js +++ b/server/index.js @@ -26,16 +26,17 @@ const FFMPEG_HTTP_RECONNECT_DELAY_MAX = clampInteger(process.env.FFMPEG_HTTP_REC const FFMPEG_HTTP_RECONNECT_MAX_RETRIES = clampInteger(process.env.FFMPEG_HTTP_RECONNECT_MAX_RETRIES, 4, 0, 20); const FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR = process.env.FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR ?? '5xx'; const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE); +const METADATA_PROBE_ENABLED = parseBoolean(process.env.METADATA_PROBE_ENABLED, PLAYBACK_CONNECTION_MODE !== 'relay'); +const METADATA_PROBE_TIMEOUT_MS = clampInteger(process.env.METADATA_PROBE_TIMEOUT_MS, 4 * 1000, 1000, 30 * 1000); 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 FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json'); const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200); 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 = clampInteger(process.env.MAX_WS_BUFFER_BYTES, 512 * 1024, 128 * 1024, 64 * 1024 * 1024); -const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 16 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024); -const MAX_RELAY_BRANCH_QUEUE_BYTES = clampInteger(process.env.MAX_RELAY_BRANCH_QUEUE_BYTES, 16 * 1024 * 1024, 512 * 1024, 256 * 1024 * 1024); +const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 4 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024); +const MAX_RELAY_BRANCH_QUEUE_BYTES = clampInteger(process.env.MAX_RELAY_BRANCH_QUEUE_BYTES, 8 * 1024 * 1024, 512 * 1024, 256 * 1024 * 1024); const RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2); const JPEG_SOI = Buffer.from([0xff, 0xd8]); const JPEG_EOI = Buffer.from([0xff, 0xd9]); @@ -116,18 +117,23 @@ app.post('/api/session', async (request, response) => { const options = parsePlaybackOptions(request.body); const id = randomUUID(); + const metadataStatus = METADATA_PROBE_ENABLED ? 'pending' : 'disabled'; + sessions.set(id, { id, url, options, duration: null, - metadataStatus: 'pending', + metadataStatus, seekSeconds: 0, seekGeneration: 0, createdAt: Date.now(), lastUsedAt: Date.now(), }); - startSessionMetadataProbe(sessions.get(id)); + + if (METADATA_PROBE_ENABLED) { + startSessionMetadataProbe(sessions.get(id)); + } try { await addRecentUrl(url); @@ -1126,7 +1132,7 @@ function createRelayPlayback(session) { return; } - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const buffer = toBufferView(chunk); sourceBytes += buffer.length; if (!audioInput.write(buffer) || !frameInput.write(buffer)) { @@ -1704,6 +1710,18 @@ function copyJpegFrame(jpeg, packet, offset) { } } +function toBufferView(chunk) { + if (Buffer.isBuffer(chunk)) { + return chunk; + } + + if (chunk instanceof ArrayBuffer) { + return Buffer.from(chunk); + } + + return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); +} + function createSourceInput(sessionId, kind) { const token = randomUUID(); sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });