diff --git a/CHANGELOG.md b/CHANGELOG.md index acfdaa7..235ac95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,5 @@ # Changelog -## Unreleased - -### Added - -- **Chromium auto-reload** — per-stream toggle to reload the browser page on a configurable interval via Chrome DevTools Protocol. Implemented as a dedicated Supervisord process (`autoreload-{id}`) using a raw WebSocket CDP connection (no external dependencies). Responds cleanly to `supervisorctl stop` via SIGTERM trap. Interval is set in minutes from the card's 3-dot menu and persisted server-side. Toggle applies via `POST /api/streams/{id}/autoreload` without restarting the stream. - ---- - ## Decap Stream v1.0.0 Turn any web page into an RTMP/HLS stream. Chromium renders the page in a virtual display, ffmpeg captures it, and MediaMTX publishes it, all managed through a web UI. @@ -19,9 +11,9 @@ Turn any web page into an RTMP/HLS stream. Chromium renders the page in a virtua - **Scalable card sizes** — mini / sm / md / lg with proportional scaling across all elements - **Inline VNC** — inspect any stream's virtual display without leaving the UI - **Autologin with CDP detection** — skips login if the session is still alive on container restart -- **Built-in HLS player** — with controls; static standalone page optimized for TV browsers (`/player.html?id=`) -- **Per-card Pure mode** — open streams as a raw `.m3u8` link or a zero-dependency `.html` page, usable in VLC or any HLS-capable player -- **Per-card new-tab toggle** — open any action button in a new tab; settings are per-card and saved in the browser +- **Built-in HLS player** — with controls; static standalone page optimized for TV browsers (`/player/.html`) +- **Pure mode** — global toggle in Settings to open streams as a raw `.m3u8` link or a zero-dependency `.html` page, usable in VLC or any HLS-capable player +- **Open in new tab** — global toggle in Settings to open any action button in a new tab; saved in the browser - **Optional UI authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI - **Persistent desired state** — streams restore automatically on container restart diff --git a/README.md b/README.md index d871150..841f781 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,10 @@ All processes are managed by Supervisord. The web UI is a Next.js app that contr - **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream - **GPU acceleration** — optional per-stream Chromium GPU flag (disabled by default for container compatibility) - **Built-in HLS player** — watch any stream in the browser via a standalone HTML page optimized for TVs (Back + Mute buttons, reconnect on stall, direct MediaMTX connection when available) -- **Per-card Pure mode** — toggle in the card menu to open Play Stream as a raw `.m3u8` link or Run HTML as a minimal `.html` page with no UI; works with native players and TV browsers -- **Per-card new tab** — toggle to open any button in a new tab instead of navigating in place; both settings are per-card and saved in the browser -- **Chromium auto-reload** — per-card toggle to reload the browser page on a configurable interval; uses Chrome DevTools Protocol (no xdotool focus tricks); configured from the card menu and persisted on the server +- **Pure mode** — global toggle in Settings to open Play Stream as a raw `.m3u8` link or Run HTML as a minimal `.html` page with no UI; works with native players and TV browsers +- **Open in new tab** — global toggle in Settings to open any button in a new tab instead of navigating in place; saved in the browser +- **Chromium auto-reload** — per-stream toggle to reload the Chromium page on a configurable interval via Chrome DevTools Protocol; configured from the card menu and persisted on the server +- **Player client-side auto-reload** — global toggle in Settings to reload the HLS player itself on a configurable interval (in minutes) ## Platform Support @@ -61,20 +62,26 @@ services: image: ghcr.io/riguettodev/decap-stream:latest container_name: decap-stream restart: unless-stopped - shm_size: "2gb" + shm_size: "1gb" security_opt: - - seccomp:unconfined # required for Chromium syscalls + - seccomp:unconfined + # gpus: all # Uncomment for NVIDIA (nvenc) — requires nvidia-container-toolkit on host + # devices: + # - /dev/dri:/dev/dri # Uncomment for Intel/AMD (vaapi or qsv) environment: - # TZ: America/Sao_Paulo - # AUTH_USER: admin # optional: enables login if both are set + TZ: America/Sao_Paulo + # FFMPEG_HWACCEL: nvenc # GPU encoding: nvenc (NVIDIA), vaapi (Intel/AMD), qsv (Intel QSV) / Requires: nvenc → gpus: all | vaapi/qsv → devices: /dev/dri + # LD_LIBRARY_PATH: /usr/lib/wsl/lib # WSL2 + nvenc only: injects NVENC libs not auto-mounted by Docker + # AUTH_USER: admin # Se definido (junto com AUTH_PASS), habilita login # AUTH_PASS: secure_password ports: - "3000:3000" # Web UI — main entry point - - "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN - # - "1935:1935" # RTMP — expose only for external ingest (e.g. OBS) - # - "8888:8888" # HLS — internal only; proxied through the UI at /api/hls/ + - "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN + # - "1935:1935" # RTMP — internal only; expose only for external ingest (e.g. OBS) + # - "8888:8888" # HLS — internal only; proxied through Next.js at /api/hls/ volumes: - streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs + # - /usr/lib/wsl/lib:/usr/lib/wsl/lib:ro # WSL2 + nvenc: exposes libnvidia-encode.so.1 # - logs:/app/data/logs # Optional volumes: @@ -109,7 +116,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): | RTMP ingest | `rtmp://:1935/live/` | | HLS manifest (proxied) | `http://:3000/api/hls/live//index.m3u8` | | HLS manifest (direct) | `http://:8888/live//index.m3u8` — requires port 8888 exposed | -| HTML player | `http://:3000/player.html?id=` — static minimal page, no UI chrome | +| HTML player | `http://:3000/player/.html` — static minimal page, no UI chrome | | VNC (inline) | `http://:3000/vnc/` | > **Pure mode** (toggle per card): Play Stream opens the proxied HLS `.m3u8` directly; Run HTML opens the `.html` player. Both can be pasted into VLC or any HLS-capable player, or loaded natively on TV browsers that support HLS. diff --git a/docker/Dockerfile b/docker/Dockerfile index 8d0ddb0..1c6bafe 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,6 +27,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ && apt-get install -y --no-install-recommends \ xvfb x11vnc novnc websockify \ ffmpeg supervisor xdotool tzdata \ + mesa-va-drivers intel-media-va-driver \ chromium \ curl gnupg \ && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ @@ -70,7 +71,6 @@ COPY docker/server.mjs /opt/server.mjs COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /opt/scripts/*.sh /entrypoint.sh -VOLUME ["/app/data"] EXPOSE 3000 1935 8888 6080 CMD ["/entrypoint.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3ddc3d3..29da25b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -6,8 +6,13 @@ services: shm_size: "1gb" security_opt: - seccomp:unconfined + # gpus: all # Uncomment for NVIDIA (nvenc) — requires nvidia-container-toolkit on host + # devices: + # - /dev/dri:/dev/dri # Uncomment for Intel/AMD (vaapi or qsv) environment: TZ: America/Sao_Paulo + # FFMPEG_HWACCEL: nvenc # GPU encoding: nvenc (NVIDIA), vaapi (Intel/AMD), qsv (Intel QSV) / Requires: nvenc → gpus: all | vaapi/qsv → devices: /dev/dri + # LD_LIBRARY_PATH: /usr/lib/wsl/lib # WSL2 + nvenc only: injects NVENC libs not auto-mounted by Docker # AUTH_USER: admin # Se definido (junto com AUTH_PASS), habilita login # AUTH_PASS: secure_password ports: @@ -17,6 +22,7 @@ services: # - "8888:8888" # HLS — internal only; proxied through Next.js at /api/hls/ volumes: - streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs + # - /usr/lib/wsl/lib:/usr/lib/wsl/lib:ro # WSL2 + nvenc: exposes libnvidia-encode.so.1 # - logs:/app/data/logs # Optional volumes: diff --git a/scripts/reprovision.mjs b/scripts/reprovision.mjs index fb1698d..dbb1b1e 100644 --- a/scripts/reprovision.mjs +++ b/scripts/reprovision.mjs @@ -23,6 +23,70 @@ function render(tpl, vars) { return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? '')) } +const NVENC_PRESET = { + ultrafast: 'p1', superfast: 'p1', veryfast: 'p2', + faster: 'p3', fast: 'p3', medium: 'p4', + slow: 'p5', slower: 'p6', veryslow: 'p7', +} + +function buildEncoderFlags(stream) { + const { preset, tune, gop, bitrate, bufsize } = stream + const hwaccel = (process.env.FFMPEG_HWACCEL ?? '').toLowerCase().trim() + const lines = [] + const ln = (s) => lines.push(` ${s} \\`) + + if (hwaccel === 'nvenc') { + ln(`-c:v h264_nvenc`) + ln(`-preset ${NVENC_PRESET[preset] ?? 'p4'}`) + ln(`-tune ${tune === 'zerolatency' ? 'll' : 'hq'}`) + ln(`-profile:v high`) + ln(`-pix_fmt yuv420p`) + ln(`-rc cbr`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } else if (hwaccel === 'vaapi') { + ln(`-vaapi_device /dev/dri/renderD128`) + ln(`-vf 'format=nv12,hwupload'`) + ln(`-c:v h264_vaapi`) + ln(`-profile:v baseline`) + ln(`-level 3.1`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } else if (hwaccel === 'qsv') { + ln(`-c:v h264_qsv`) + ln(`-preset veryfast`) + ln(`-profile:v baseline`) + ln(`-level 3.1`) + ln(`-pix_fmt nv12`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } else { + ln(`-c:v libx264`) + ln(`-preset ${preset}`) + ln(`-tune ${tune}`) + ln(`-profile:v baseline`) + ln(`-level 3.1`) + ln(`-pix_fmt yuv420p`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-sc_threshold 0`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } + + return lines.join('\n') +} + for (const stream of streams) { const dir = path.join(STREAMS_DIR, stream.id) fs.mkdirSync(path.join(dir, 'chrome-profile'), { recursive: true }) @@ -49,6 +113,7 @@ for (const stream of streams) { USER: stream.user ?? '', PASS: stream.pass ?? '', GPU_FLAGS: stream.gpu ? '' : ' --disable-gpu \\\n', + ENCODER_FLAGS: buildEncoderFlags(stream), AUTO_RELOAD: stream.autoReload ? 'true' : 'false', AUTO_RELOAD_INTERVAL: stream.autoReloadInterval ?? 3600, } diff --git a/scripts/stream.template.conf b/scripts/stream.template.conf index 8e5969f..3284aa4 100644 --- a/scripts/stream.template.conf +++ b/scripts/stream.template.conf @@ -73,18 +73,7 @@ command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \ -i {{DISPLAY}} \ -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ -shortest \ - -c:v libx264 \ - -preset {{PRESET}} \ - -tune {{TUNE}} \ - -profile:v baseline \ - -level 3.1 \ - -pix_fmt yuv420p \ - -g {{GOP}} \ - -keyint_min {{GOP}} \ - -sc_threshold 0 \ - -b:v {{BITRATE}} \ - -maxrate {{BITRATE}} \ - -bufsize {{BUFSIZE}} \ +{{ENCODER_FLAGS}} -c:a aac \ -b:a 128k \ -ar 44100 \ diff --git a/src/lib/supervisor.ts b/src/lib/supervisor.ts index f20305e..65f871f 100644 --- a/src/lib/supervisor.ts +++ b/src/lib/supervisor.ts @@ -29,6 +29,70 @@ function supervisorctl(cmd: string) { } } +const NVENC_PRESET: Record = { + ultrafast: "p1", superfast: "p1", veryfast: "p2", + faster: "p3", fast: "p3", medium: "p4", + slow: "p5", slower: "p6", veryslow: "p7", +} + +function buildEncoderFlags(stream: Stream): string { + const { preset, tune, gop, bitrate, bufsize } = stream + const hwaccel = (process.env.FFMPEG_HWACCEL ?? "").toLowerCase().trim() + const lines: string[] = [] + const ln = (s: string) => lines.push(` ${s} \\`) + + if (hwaccel === "nvenc") { + ln(`-c:v h264_nvenc`) + ln(`-preset ${NVENC_PRESET[preset] ?? "p4"}`) + ln(`-tune ${tune === "zerolatency" ? "ll" : "hq"}`) + ln(`-profile:v high`) + ln(`-pix_fmt yuv420p`) + ln(`-rc cbr`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } else if (hwaccel === "vaapi") { + ln(`-vaapi_device /dev/dri/renderD128`) + ln(`-vf 'format=nv12,hwupload'`) + ln(`-c:v h264_vaapi`) + ln(`-profile:v baseline`) + ln(`-level 3.1`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } else if (hwaccel === "qsv") { + ln(`-c:v h264_qsv`) + ln(`-preset veryfast`) + ln(`-profile:v baseline`) + ln(`-level 3.1`) + ln(`-pix_fmt nv12`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } else { + ln(`-c:v libx264`) + ln(`-preset ${preset}`) + ln(`-tune ${tune}`) + ln(`-profile:v baseline`) + ln(`-level 3.1`) + ln(`-pix_fmt yuv420p`) + ln(`-g ${gop}`) + ln(`-keyint_min ${gop}`) + ln(`-sc_threshold 0`) + ln(`-b:v ${bitrate}`) + ln(`-maxrate ${bitrate}`) + ln(`-bufsize ${bufsize}`) + } + + return lines.join("\n") +} + // converts "1920x1080" → "1920,1080" for Chrome --window-size flag function resolutionToChrome(res: string): string { return res.replace("x", ",") @@ -64,6 +128,7 @@ export function provisionStream(stream: Stream): void { USER: stream.user ?? "", PASS: stream.pass ?? "", GPU_FLAGS: stream.gpu ? "" : " --disable-gpu \\\n", + ENCODER_FLAGS: buildEncoderFlags(stream), AUTO_RELOAD: stream.autoReload ? "true" : "false", AUTO_RELOAD_INTERVAL: stream.autoReloadInterval ?? 3600, } @@ -153,20 +218,21 @@ function fetchAllStatuses(): Record> { const result: Record> = {} try { // One call for all programs — avoid N×5 blocking execSync calls per poll cycle + // supervisorctl exits 3 when any process is EXITED/STOPPED — || true keeps execSync from throwing const out = execSync( - `supervisorctl -c /etc/supervisor/supervisord.conf status`, - { stdio: "pipe" } + `supervisorctl -c /etc/supervisor/supervisord.conf status || true`, + { stdio: "pipe", shell: "/bin/sh" } ).toString() for (const line of out.split("\n")) { // e.g. "ffmpeg-abc123 RUNNING pid 42, uptime 0:01:00" - const m = line.match(/^(\S+)-(\S+)\s+(RUNNING|STOPPED|FATAL|STARTING)/) + const m = line.match(/^(xvfb|chromium|autologin|autoreload|x11vnc|ffmpeg)-(\S+)\s+(RUNNING|STOPPED|FATAL|STARTING)/) if (!m) continue const [, program, id, status] = m if (!result[id]) result[id] = {} result[id][program] = status as ProgramStatus } } catch { - // supervisorctl can exit non-zero; return whatever was parsed + // fallback: supervisorctl completely unavailable } _statusCache = result