diff --git a/AGENTS.md b/AGENTS.md index 23f92b0..d92a661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ Common HTTP input args: - `-loglevel ${FFMPEG_LOG_LEVEL}` - `-nostats` - `-seekable ${FFMPEG_INPUT_SEEKABLE}` +- HTTP reconnect options when `FFMPEG_HTTP_RECONNECT=1` and the input is HTTP(S) - `-re` - `-i ` @@ -97,15 +98,17 @@ Audio output: - Maps `0:a:0?` - Disables video with `-vn` -- Converts to stereo 48 kHz MP3 with `libmp3lame` -- Uses `session.options.audioBitrate`, default `160k` +- Converts to MP3 with `libmp3lame` +- Uses `session.options.audioBitrate`, default `64k` +- Uses `session.options.audioChannels`, default `1` +- Uses `session.options.audioSampleRate`, default `44100` - Outputs to `pipe:1` Frame output: - Maps `0:v:0` - Disables audio with `-an` -- Applies `fps=,scale=w='min(,iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p` +- Applies `fps=,scale=w='min(,iw)':h=-2:flags=fast_bilinear:out_range=pc,format=yuvj420p` - Encodes `mjpeg` - Uses `-pix_fmt yuvj420p`, `-color_range pc`, `-q:v ` - Outputs `image2pipe` to either `pipe:1` or `pipe:3` @@ -171,22 +174,33 @@ Runtime: - `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`. - `FFMPEG_LOG_LEVEL`: ffmpeg log level, default `warning`. - `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`. +- `FFMPEG_HTTP_RECONNECT`: enable ffmpeg HTTP reconnect options for HTTP inputs, default `1`. +- `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`. - `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`. - `RECENT_URLS_PATH`: recent URL JSON path. - `RECENT_URL_LIMIT`: recent URL count, default `12`. - `FAVORITES_PATH`: favorites JSON path. - `FAVORITES_LIMIT`: favorites count, default `50`. -- `JPEG_QUALITY`: default JPEG quality, fallback `7`, clamped `2..18`; lower is better for ffmpeg `-q:v`. -- `MAX_WS_BUFFER_BYTES`: server-side WebSocket JPEG frame backlog cap, default `2097152`. +- `DEFAULT_FPS`: default frame rate, fallback `8`, clamped `1..30`. +- `DEFAULT_FRAME_WIDTH`: default maximum frame width, fallback `480`, clamped `160..1920`. +- `JPEG_QUALITY`: default JPEG quality, fallback `12`, clamped `2..18`; lower is better for ffmpeg `-q:v`. +- `DEFAULT_AUDIO_BITRATE`: default MP3 audio bitrate, fallback `64k`. +- `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`. Session playback options are accepted by `POST /api/session` even though the UI hides them: -- `fps`: default `24`, clamped `1..30`. -- `width`: default `960`, clamped `160..1920`. +- `fps`: default `8`, clamped `1..30`. +- `width`: default `480`, clamped `160..1920`. - `quality`: defaults to `JPEG_QUALITY`, clamped `2..18`; lower is better for ffmpeg `-q:v`. -- `audioBitrate`: default `160k`, accepts two or three digits followed by `k`. +- `audioBitrate`: default `64k`, accepts two or three digits followed by `k`. +- `audioChannels`: default `1`, clamped `1..2`. +- `audioSampleRate`: default `44100`, clamped `22050..48000`. ## Docker Notes diff --git a/README.md b/README.md index 65b58e9..0a16026 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Recently played URLs and favorites are stored globally by the backend. In Docker docker logs -f frame-stream-player ``` -The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior. +The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior. For ffmpeg-owned HTTP inputs, reconnect handling is enabled by default with `FFMPEG_HTTP_RECONNECT=1`. -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 `2097152`. +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`. @@ -69,10 +69,10 @@ Relay mode uses bounded per-worker input queues so one branch can briefly lag wi The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`. -- Frame rate defaults to `24fps`. Lower it if the client cannot keep up. -- Max width defaults to `960px`. Lower it first if bandwidth or image decode is the bottleneck. -- JPEG quality uses ffmpeg's `-q:v` scale, where lower is better. Set the default with `JPEG_QUALITY`; `7` is the fallback, `2` is high quality, and `18` is rough but lighter. -- Audio defaults to MP3 at `160k`. +- Frame rate defaults to `8fps`. Set `DEFAULT_FPS` higher only when clients and links can keep up. +- Max width defaults to `480px`. Set `DEFAULT_FRAME_WIDTH` higher for better detail at the cost of bandwidth and browser image decode work. +- JPEG quality uses ffmpeg's `-q:v` scale, where lower is better. Set the default with `JPEG_QUALITY`; `12` is the low-bandwidth fallback, `2` is high quality, and `18` is rough but lighter. +- Audio defaults to mono MP3 at `64k`. Tune with `DEFAULT_AUDIO_BITRATE`, `DEFAULT_AUDIO_CHANNELS`, and `DEFAULT_AUDIO_SAMPLE_RATE`. ## Tradeoffs diff --git a/docker-compose-example.yml b/docker-compose-example.yml index dfd3218..2a5d632 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -15,8 +15,17 @@ services: PLAYBACK_CONNECTION_MODE: relay FFMPEG_LOG_LEVEL: warning FFMPEG_INPUT_SEEKABLE: "0" - JPEG_QUALITY: "7" - MAX_WS_BUFFER_BYTES: "2097152" + FFMPEG_HTTP_RECONNECT: "1" + FFMPEG_HTTP_RECONNECT_DELAY_MAX: "2" + FFMPEG_HTTP_RECONNECT_MAX_RETRIES: "4" + FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR: "5xx" + DEFAULT_FPS: "8" + DEFAULT_FRAME_WIDTH: "480" + JPEG_QUALITY: "12" + DEFAULT_AUDIO_BITRATE: "64k" + 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" RECENT_URLS_PATH: /app/data/recent-urls.json diff --git a/public/app.js b/public/app.js index 9cd1d74..e4bbf7b 100644 --- a/public/app.js +++ b/public/app.js @@ -36,10 +36,10 @@ const elements = { const context = elements.canvas.getContext('2d', { alpha: false }); const FRAME_LATE_GRACE_SECONDS = 0.25; -const MAX_PENDING_FRAME_QUEUE_SECONDS = 2; -const MAX_DECODED_FRAME_QUEUE_SECONDS = 3; -const MIN_PENDING_FRAME_QUEUE = 12; -const MIN_DECODED_FRAME_QUEUE = 24; +const MAX_PENDING_FRAME_QUEUE_SECONDS = 1.5; +const MAX_DECODED_FRAME_QUEUE_SECONDS = 1.5; +const MIN_PENDING_FRAME_QUEUE = 6; +const MIN_DECODED_FRAME_QUEUE = 8; const IMAGE_DECODE_TIMEOUT_MS = 3000; const FRAME_STALL_CHECK_MS = 1000; const FRAME_STARTUP_STALL_RESET_MS = 10000; diff --git a/server/index.js b/server/index.js index 411e2f8..cb35923 100644 --- a/server/index.js +++ b/server/index.js @@ -14,13 +14,17 @@ const publicDir = path.join(__dirname, '..', 'public'); const app = express(); const server = createServer(app); -const wss = new WebSocketServer({ noServer: true }); +const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false }); 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 FFMPEG_HTTP_RECONNECT = parseBoolean(process.env.FFMPEG_HTTP_RECONNECT, true); +const FFMPEG_HTTP_RECONNECT_DELAY_MAX = clampInteger(process.env.FFMPEG_HTTP_RECONNECT_DELAY_MAX, 2, 0, 30); +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 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); @@ -29,7 +33,7 @@ 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, 2 * 1024 * 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_RELAY_BRANCH_QUEUE_BYTES = clampInteger(process.env.MAX_RELAY_BRANCH_QUEUE_BYTES, 16 * 1024 * 1024, 512 * 1024, 256 * 1024 * 1024); const RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2); @@ -37,10 +41,12 @@ const JPEG_SOI = Buffer.from([0xff, 0xd8]); const JPEG_EOI = Buffer.from([0xff, 0xd9]); const defaults = { - fps: 24, - width: 960, - quality: clampInteger(process.env.JPEG_QUALITY, 7, 2, 18), - audioBitrate: '160k', + fps: clampInteger(process.env.DEFAULT_FPS, 8, 1, 30), + width: clampInteger(process.env.DEFAULT_FRAME_WIDTH, 480, 160, 1920), + quality: clampInteger(process.env.JPEG_QUALITY, 12, 2, 18), + audioBitrate: parseAudioBitrate(process.env.DEFAULT_AUDIO_BITRATE, '64k'), + audioChannels: clampInteger(process.env.DEFAULT_AUDIO_CHANNELS, 1, 1, 2), + audioSampleRate: clampInteger(process.env.DEFAULT_AUDIO_SAMPLE_RATE, 44100, 22050, 48000), }; const sessions = new Map(); @@ -363,7 +369,9 @@ function parsePlaybackOptions(body) { fps: clampInteger(body?.fps, defaults.fps, 1, 30), width: clampInteger(body?.width, defaults.width, 160, 1920), quality: clampInteger(body?.quality, defaults.quality, 2, 18), - audioBitrate: parseAudioBitrate(body?.audioBitrate), + audioBitrate: parseAudioBitrate(body?.audioBitrate, defaults.audioBitrate), + audioChannels: clampInteger(body?.audioChannels, defaults.audioChannels, 1, 2), + audioSampleRate: clampInteger(body?.audioSampleRate, defaults.audioSampleRate, 22050, 48000), }; } @@ -394,16 +402,32 @@ function clampInteger(value, fallback, min, max) { return Math.min(max, Math.max(min, Math.round(parsed))); } +function parseBoolean(value, fallback) { + if (value === undefined || value === '') { + return fallback; + } + + if (['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase())) { + return true; + } + + if (['0', 'false', 'no', 'off'].includes(String(value).toLowerCase())) { + return false; + } + + return fallback; +} + function clampNumber(value, min, max) { return Math.min(max, Math.max(min, value)); } -function parseAudioBitrate(value) { +function parseAudioBitrate(value, fallback = '64k') { if (typeof value !== 'string') { - return defaults.audioBitrate; + return fallback; } - return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate; + return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : fallback; } function formatSessionPayload(session) { @@ -1654,7 +1678,7 @@ function sendFramePacket(websocket, timestamp, jpeg, onError) { const packet = Buffer.allocUnsafe(packetBytes); packet.writeDoubleLE(timestamp, 0); copyJpegFrame(jpeg, packet, 8); - websocket.send(packet, { binary: true }, (error) => { + websocket.send(packet, { binary: true, compress: false }, (error) => { if (error) { onError(error); } @@ -1728,9 +1752,9 @@ function buildPlaybackArgs(session, inputUrl) { '0:a:0?', '-vn', '-ac', - '2', + String(session.options.audioChannels), '-ar', - '48000', + String(session.options.audioSampleRate), '-codec:a', 'libmp3lame', '-b:a', @@ -1751,9 +1775,9 @@ function buildAudioArgs(session, inputUrl, inputOptions) { '0:a:0?', '-vn', '-ac', - '2', + String(session.options.audioChannels), '-ar', - '48000', + String(session.options.audioSampleRate), '-codec:a', 'libmp3lame', '-b:a', @@ -1790,17 +1814,40 @@ function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) { args.push('-ss', formatFfmpegSeconds(startTime)); } + if (FFMPEG_HTTP_RECONNECT && isHttpInputUrl(inputUrl)) { + args.push( + '-reconnect', + '1', + '-reconnect_on_network_error', + '1', + '-reconnect_streamed', + '1', + '-reconnect_delay_max', + String(FFMPEG_HTTP_RECONNECT_DELAY_MAX), + '-reconnect_max_retries', + String(FFMPEG_HTTP_RECONNECT_MAX_RETRIES), + ); + + if (FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR) { + args.push('-reconnect_on_http_error', FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR); + } + } + args.push('-re', '-i', inputUrl); return args; } +function isHttpInputUrl(inputUrl) { + return inputUrl.startsWith('http://') || inputUrl.startsWith('https://'); +} + 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`; + const videoFilter = `fps=${fps},scale=w='min(${width},iw)':h=-2:flags=fast_bilinear:out_range=pc,format=yuvj420p`; return [ '-an',