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.
|
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
|
## ffmpeg Pipelines
|
||||||
|
|
||||||
@@ -178,6 +178,8 @@ Runtime:
|
|||||||
- `FFMPEG_HTTP_RECONNECT_DELAY_MAX`: max ffmpeg reconnect delay, default `2`.
|
- `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_MAX_RETRIES`: max ffmpeg reconnect retries, default `4`.
|
||||||
- `FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR`: HTTP status list for reconnect, default `5xx`.
|
- `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`.
|
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
|
||||||
- `RECENT_URLS_PATH`: recent URL JSON path.
|
- `RECENT_URLS_PATH`: recent URL JSON path.
|
||||||
- `RECENT_URL_LIMIT`: recent URL count, default `12`.
|
- `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_CHANNELS`: default MP3 audio channels, fallback `1`, clamped `1..2`.
|
||||||
- `DEFAULT_AUDIO_SAMPLE_RATE`: default MP3 audio sample rate, fallback `44100`, clamped `22050..48000`.
|
- `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_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_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `4194304`.
|
||||||
- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`.
|
- `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:
|
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`.
|
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.
|
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.
|
- `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.
|
- `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
|
## Tuning
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ services:
|
|||||||
FFMPEG_HTTP_RECONNECT_DELAY_MAX: "2"
|
FFMPEG_HTTP_RECONNECT_DELAY_MAX: "2"
|
||||||
FFMPEG_HTTP_RECONNECT_MAX_RETRIES: "4"
|
FFMPEG_HTTP_RECONNECT_MAX_RETRIES: "4"
|
||||||
FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR: "5xx"
|
FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR: "5xx"
|
||||||
|
METADATA_PROBE_ENABLED: "0"
|
||||||
|
METADATA_PROBE_TIMEOUT_MS: "4000"
|
||||||
DEFAULT_FPS: "8"
|
DEFAULT_FPS: "8"
|
||||||
DEFAULT_FRAME_WIDTH: "480"
|
DEFAULT_FRAME_WIDTH: "480"
|
||||||
JPEG_QUALITY: "12"
|
JPEG_QUALITY: "12"
|
||||||
@@ -26,8 +28,8 @@ services:
|
|||||||
DEFAULT_AUDIO_CHANNELS: "1"
|
DEFAULT_AUDIO_CHANNELS: "1"
|
||||||
DEFAULT_AUDIO_SAMPLE_RATE: "44100"
|
DEFAULT_AUDIO_SAMPLE_RATE: "44100"
|
||||||
MAX_WS_BUFFER_BYTES: "524288"
|
MAX_WS_BUFFER_BYTES: "524288"
|
||||||
MAX_AUDIO_QUEUE_BYTES: "16777216"
|
MAX_AUDIO_QUEUE_BYTES: "4194304"
|
||||||
MAX_RELAY_BRANCH_QUEUE_BYTES: "16777216"
|
MAX_RELAY_BRANCH_QUEUE_BYTES: "8388608"
|
||||||
RECENT_URLS_PATH: /app/data/recent-urls.json
|
RECENT_URLS_PATH: /app/data/recent-urls.json
|
||||||
FAVORITES_PATH: /app/data/favorites.json
|
FAVORITES_PATH: /app/data/favorites.json
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const elements = {
|
|||||||
mute: document.querySelector('#mute'),
|
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 FRAME_LATE_GRACE_SECONDS = 0.25;
|
||||||
const MAX_PENDING_FRAME_QUEUE_SECONDS = 1.5;
|
const MAX_PENDING_FRAME_QUEUE_SECONDS = 1.5;
|
||||||
const MAX_DECODED_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.lastFramePacketAt = Date.now();
|
||||||
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration });
|
state.pendingFrames.push({ timestamp, jpeg: new Uint8Array(packet, 8), streamGeneration });
|
||||||
trimPendingFrameQueue();
|
trimPendingFrameQueue();
|
||||||
void pumpFrameDecodeQueue();
|
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_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 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 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_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 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_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
|
||||||
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
|
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
|
||||||
const SESSION_TTL_MS = 60 * 60 * 1000;
|
const SESSION_TTL_MS = 60 * 60 * 1000;
|
||||||
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
|
|
||||||
const PLAYBACK_READY_TIMEOUT_MS = 15 * 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_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_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, 16 * 1024 * 1024, 512 * 1024, 256 * 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 RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2);
|
||||||
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
||||||
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
||||||
@@ -116,18 +117,23 @@ app.post('/api/session', async (request, response) => {
|
|||||||
const options = parsePlaybackOptions(request.body);
|
const options = parsePlaybackOptions(request.body);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
|
||||||
|
const metadataStatus = METADATA_PROBE_ENABLED ? 'pending' : 'disabled';
|
||||||
|
|
||||||
sessions.set(id, {
|
sessions.set(id, {
|
||||||
id,
|
id,
|
||||||
url,
|
url,
|
||||||
options,
|
options,
|
||||||
duration: null,
|
duration: null,
|
||||||
metadataStatus: 'pending',
|
metadataStatus,
|
||||||
seekSeconds: 0,
|
seekSeconds: 0,
|
||||||
seekGeneration: 0,
|
seekGeneration: 0,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastUsedAt: Date.now(),
|
lastUsedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (METADATA_PROBE_ENABLED) {
|
||||||
startSessionMetadataProbe(sessions.get(id));
|
startSessionMetadataProbe(sessions.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addRecentUrl(url);
|
await addRecentUrl(url);
|
||||||
@@ -1126,7 +1132,7 @@ function createRelayPlayback(session) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
const buffer = toBufferView(chunk);
|
||||||
sourceBytes += buffer.length;
|
sourceBytes += buffer.length;
|
||||||
|
|
||||||
if (!audioInput.write(buffer) || !frameInput.write(buffer)) {
|
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) {
|
function createSourceInput(sessionId, kind) {
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });
|
sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });
|
||||||
|
|||||||
Reference in New Issue
Block a user