Reduce relay playback overhead
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user