From 8e90371d3844cc20fab355f8ee0c21663d7ea514 Mon Sep 17 00:00:00 2001 From: Michael Bino Date: Thu, 30 Apr 2026 11:33:16 -0400 Subject: [PATCH] Add Linux virtual camera output via v4l2loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --virtual-camera (and --virtual-camera-pix-fmt) so streams can be written to a local v4l2loopback device and consumed by host apps — browsers, video conferencing, OBS, etc. — as a webcam source. Works for both browser/x11grab and --video-file inputs; ffmpeg switches to raw v4l2 output (no libx264, no audio) when the flag is set. --ingest is optional when --virtual-camera is provided. Linux-only; refuses to run elsewhere. Includes scripts/setup-virtual-camera.sh helper to load/unload the kernel module with sensible defaults, README docs, and tests covering CLI parsing, ffmpeg arg construction for both input modes, and platform/device guards. --- README.md | 72 +++++++++++- scripts/setup-virtual-camera.sh | 111 ++++++++++++++++++ src/index.ts | 198 +++++++++++++++++++++----------- tests/virtual-camera.test.ts | 197 +++++++++++++++++++++++++++++++ 4 files changed, 511 insertions(+), 67 deletions(-) create mode 100755 scripts/setup-virtual-camera.sh create mode 100644 tests/virtual-camera.test.ts diff --git a/README.md b/README.md index 427bd5c..a77f8a4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Page Stream loads a supplied URL or local HTML in Playwright-controlled Chromium - Compositor for multi-source, collage-style layouts. See [`COMPOSITOR-ARCHITECTURE.md`](COMPOSITOR-ARCHITECTURE.md) and [`TESTING-STABLE-COMPOSITOR.md`](TESTING-STABLE-COMPOSITOR.md). - Scales for production operations. See [`OPERATIONAL-NOTES.md`](OPERATIONAL-NOTES.md) for restart guidance and troubleshooting. - Primary support for SRT, secondary support for RTMP, extensible to other outputs (its just `ffmpeg`!). +- **Virtual camera output (Linux)** so any client app on the host (browsers, video conferencing, OBS, etc.) can pick the stream as a webcam source. - **Direct video file streaming** for looping pre-recorded content without browser overhead. - noVNC viewer to interact with the Chromium session (disabled by default). - Optimized for and tested on Apple Silicon. @@ -169,10 +170,12 @@ docker run --rm \ ## CLI Options ``` -page-stream --ingest [options] +page-stream (--ingest | --virtual-camera ) [options] -Required: - -i, --ingest Ingest URI (SRT/RTMP/etc) +Output target (one of): + -i, --ingest Ingest URI (SRT/RTMP/etc) + --virtual-camera Linux v4l2loopback device path (e.g. /dev/video10) + --virtual-camera-pix-fmt Pixel format for the v4l2 device (default yuv420p) Optional: -u, --url Page URL or local file (default: demo) @@ -376,6 +379,69 @@ If you encounter permission issues reading video files on macOS: 2. Video files should be readable: `chmod 644 ./videos/*.mp4` 3. If using Colima/Docker Desktop, the directory must be within a shared/mounted path +## Virtual Camera Output (Linux) + +Stream the rendered page (or a video file) to a local virtual camera so any client app on the host — browsers, video conferencing apps, OBS, GStreamer pipelines, etc. — can pick the stream as a webcam source. This is **Linux-only** because it relies on the [`v4l2loopback`](https://github.com/v4l2loopback/v4l2loopback) kernel module. macOS support is on the roadmap but architecturally significantly heavier (it requires a CoreMediaIO DAL plugin or third-party tools like OBS's virtual camera). + +### One-time setup + +Install the kernel module: + +```bash +# Debian / Ubuntu +sudo apt install v4l2loopback-dkms + +# Fedora +sudo dnf install v4l2loopback + +# Arch +sudo pacman -S v4l2loopback-dkms +``` + +Then load it. The repo ships a helper that loads a single device at `/dev/video10` with the label `PageStream`: + +```bash +sudo ./scripts/setup-virtual-camera.sh +# or with a specific device number / label: +sudo ./scripts/setup-virtual-camera.sh --device 12 --label "Lobby Sign" +sudo ./scripts/setup-virtual-camera.sh --status +sudo ./scripts/setup-virtual-camera.sh --teardown +``` + +### Run page-stream against it + +`--virtual-camera` replaces `--ingest`; you don't need both. Audio is dropped because v4l2 devices are video-only. + +```bash +# Page → /dev/video10 +node dist/index.js \ + --virtual-camera /dev/video10 \ + --url demo/index.html \ + --width 1280 --height 720 --fps 30 + +# Looping video file → /dev/video10 +node dist/index.js \ + --virtual-camera /dev/video10 \ + --video-file ./videos/loop.mp4 --video-loop +``` + +To verify the device is producing frames, point any v4l2 consumer at it: + +```bash +ffplay -f v4l2 /dev/video10 +# or +vlc v4l2:///dev/video10 +``` + +In Chromium / Firefox / Zoom / OBS / Google Meet the device appears in the camera picker under the label set when the module was loaded (default `PageStream`). + +### Notes & limitations + +- The Docker workflow defaults to network ingests; if you want to write to a host v4l2 device from inside a container you must pass `--device /dev/video10:/dev/video10` and run the container with sufficient privileges. Most users will run virtual camera mode directly on the host instead. +- Choose a `--virtual-camera-pix-fmt` your consumers support. `yuv420p` is the broadest. Some apps prefer `yuyv422`. +- Width and height should be even; v4l2 + `yuv420p` does not accept odd dimensions. +- The reconnect/backoff machinery applies if the v4l2 device write briefly fails (e.g. a consumer reopens the device); use `--reconnect-attempts 0` for indefinite retries. + ## Optional noVNC Viewer Set `ENABLE_NOVNC=1` to start a lightweight VNC server (`x11vnc`) bound to localhost plus a WebSocket bridge (`websockify`) serving the noVNC client on port `6080` (container). diff --git a/scripts/setup-virtual-camera.sh b/scripts/setup-virtual-camera.sh new file mode 100755 index 0000000..9796c03 --- /dev/null +++ b/scripts/setup-virtual-camera.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env zsh +# Linux-only helper: load v4l2loopback so page-stream can write to a virtual camera. +# +# Usage: +# sudo ./scripts/setup-virtual-camera.sh # load with defaults +# sudo ./scripts/setup-virtual-camera.sh --device 10 --label PageStream +# sudo ./scripts/setup-virtual-camera.sh --status # show currently loaded devices +# sudo ./scripts/setup-virtual-camera.sh --teardown # unload the module +# +# Defaults match what page-stream advertises in the README: a single virtual +# camera at /dev/video10 with the label "PageStream". + +set -euo pipefail + +DEVICE_NR="10" +LABEL="PageStream" +ACTION="load" + +while [[ $# -gt 0 ]]; do + case "$1" in + --device) + DEVICE_NR="$2" + shift 2 + ;; + --label) + LABEL="$2" + shift 2 + ;; + --status) + ACTION="status" + shift + ;; + --teardown|--unload) + ACTION="teardown" + shift + ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: virtual camera support is Linux-only (requires the v4l2loopback kernel module)." >&2 + exit 1 +fi + +require_root() { + if [[ "$(id -u)" != "0" ]]; then + echo "ERROR: $1 requires root. Re-run with sudo." >&2 + exit 1 + fi +} + +case "$ACTION" in + status) + if lsmod | grep -q '^v4l2loopback'; then + echo "v4l2loopback is loaded." + v4l2loopback_devices=$(ls /sys/devices/virtual/video4linux 2>/dev/null || true) + if [[ -n "$v4l2loopback_devices" ]]; then + for dev in $v4l2loopback_devices; do + name_file="/sys/devices/virtual/video4linux/$dev/name" + if [[ -f "$name_file" ]]; then + printf " /dev/%s -> %s\n" "$dev" "$(cat "$name_file")" + else + printf " /dev/%s\n" "$dev" + fi + done + fi + else + echo "v4l2loopback is NOT loaded." + fi + ;; + teardown) + require_root "--teardown" + if lsmod | grep -q '^v4l2loopback'; then + modprobe -r v4l2loopback + echo "v4l2loopback unloaded." + else + echo "v4l2loopback was not loaded; nothing to do." + fi + ;; + load) + require_root "loading v4l2loopback" + if ! modinfo v4l2loopback >/dev/null 2>&1; then + echo "ERROR: v4l2loopback kernel module is not installed." >&2 + echo " Debian/Ubuntu: sudo apt install v4l2loopback-dkms" >&2 + echo " Fedora: sudo dnf install v4l2loopback" >&2 + echo " Arch: sudo pacman -S v4l2loopback-dkms" >&2 + exit 1 + fi + if lsmod | grep -q '^v4l2loopback'; then + echo "v4l2loopback is already loaded; reloading to apply the requested device + label." + modprobe -r v4l2loopback + fi + modprobe v4l2loopback \ + devices=1 \ + video_nr="$DEVICE_NR" \ + card_label="$LABEL" \ + exclusive_caps=1 + echo "Loaded v4l2loopback: /dev/video$DEVICE_NR (label: $LABEL)" + echo + echo "Use it with page-stream:" + echo " node dist/index.js --virtual-camera /dev/video$DEVICE_NR --url demo/index.html" + ;; +esac diff --git a/src/index.ts b/src/index.ts index 7c02734..e8ca8c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,8 @@ interface StreamOptions { injectJs?: string; // path to JS file to inject into the page videoFile?: string; // path to video file for direct streaming (bypasses browser) videoLoop: boolean; // loop video file continuously + virtualCamera?: string; // Linux v4l2loopback device path (e.g. /dev/video10) — output to virtual camera instead of network ingest + virtualCameraPixFmt?: string; // output pixel format for v4l2 device (default yuv420p) } const __filename = fileURLToPath(import.meta.url); @@ -65,6 +67,21 @@ export class PageStreamer { constructor(private opts: StreamOptions) {} async start() { + // Virtual camera output: validate platform + device once before launching ffmpeg + if (this.opts.virtualCamera) { + if (process.platform !== 'linux') { + throw new Error( + `Virtual camera output is Linux-only (requires v4l2loopback). Detected platform: ${process.platform}.` + ); + } + if (!fs.existsSync(this.opts.virtualCamera)) { + throw new Error( + `Virtual camera device not found: ${this.opts.virtualCamera}. ` + + `Ensure v4l2loopback is loaded — see scripts/setup-virtual-camera.sh.` + ); + } + console.log(`[virtual-camera] Output target: ${this.opts.virtualCamera} (pix_fmt=${this.opts.virtualCameraPixFmt || 'yuv420p'})`); + } // Direct video file mode: skip browser entirely if (this.opts.videoFile) { if (!fs.existsSync(this.opts.videoFile)) { @@ -284,23 +301,27 @@ export class PageStreamer { '-video_size', `${width}x${height}`, '-i', display, ]; - if (audioBitrate) { - // Silent audio source input before specifying output codecs + const virtualCameraOutput = !!this.opts.virtualCamera; + if (audioBitrate && !virtualCameraOutput) { + // Silent audio source input before specifying output codecs. + // Skipped for virtual camera output: v4l2 devices are video-only. args.push('-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100'); } - // Encoding options (apply to outputs, must come after all -i inputs) - args.push( - '-c:v','libx264', - '-preset', preset, - '-tune','zerolatency', - '-pix_fmt','yuv420p', - '-b:v', videoBitrate, - '-maxrate', videoBitrate, - '-bufsize', (parseInt(videoBitrate) * 2) + 'k', - '-g', String(fps * 2) - ); - if (audioBitrate) { - args.push('-c:a','aac','-b:a', audioBitrate); + if (!virtualCameraOutput) { + // Encoding options (apply to outputs, must come after all -i inputs) + args.push( + '-c:v','libx264', + '-preset', preset, + '-tune','zerolatency', + '-pix_fmt','yuv420p', + '-b:v', videoBitrate, + '-maxrate', videoBitrate, + '-bufsize', (parseInt(videoBitrate) * 2) + 'k', + '-g', String(fps * 2) + ); + if (audioBitrate) { + args.push('-c:a','aac','-b:a', audioBitrate); + } } // Inject crop filter if requested (before user-supplied extra args so they can still override with -filter_complex later) if (this.opts.cropInfobar && this.opts.cropInfobar > 0) { @@ -322,10 +343,25 @@ export class PageStreamer { } // Extra user-supplied args before container/output format args.push(...extraFfmpeg); - args.push('-f', format, ingest); + if (virtualCameraOutput) { + args.push(...this.buildVirtualCameraOutputArgs()); + } else { + args.push('-f', format, ingest); + } return args; } + // Output stanza for v4l2loopback: raw frames in the requested pixel format, + // muxed via the v4l2 output. No audio, no codec — the consuming app reads + // raw video from the device node like any other webcam. + private buildVirtualCameraOutputArgs(): string[] { + return [ + '-pix_fmt', this.opts.virtualCameraPixFmt || 'yuv420p', + '-f', 'v4l2', + this.opts.virtualCamera!, + ]; + } + private buildVideoFileArgs(): string[] { const { videoFile, videoLoop, fps, ingest, preset, videoBitrate, audioBitrate, format, extraFfmpeg, width, height } = this.opts; // Allow input-level tuning flags via environment variable @@ -345,10 +381,14 @@ export class PageStreamer { args.push('-re'); // Read input at native frame rate (real-time) args.push('-i', videoFile!); - // Check if the video file likely has audio (we'll let FFmpeg handle it) - // If no audio track exists, add silent audio source - // Use a filter to handle audio conditionally - simpler approach: always add null audio and map - args.push('-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'); + const virtualCameraOutput = !!this.opts.virtualCamera; + + if (!virtualCameraOutput) { + // Check if the video file likely has audio (we'll let FFmpeg handle it) + // If no audio track exists, add silent audio source + // Use a filter to handle audio conditionally - simpler approach: always add null audio and map + args.push('-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'); + } // Build filter graph: scale video to target resolution, use original audio if present or fallback to null const videoFilters: string[] = []; @@ -362,38 +402,41 @@ export class PageStreamer { args.push('-vf', videoFilters.join(',')); - // Encoding options - args.push( - '-c:v', 'libx264', - '-preset', preset, - '-tune', 'zerolatency', - '-pix_fmt', 'yuv420p', - '-b:v', videoBitrate, - '-maxrate', videoBitrate, - '-bufsize', (parseInt(videoBitrate) * 2) + 'k', - '-g', String(fps * 2) - ); - - // Audio: prefer source audio (0:a) if available, otherwise use null source (1:a) - // Using -map with fallback: first try file audio, then null audio - args.push('-map', '0:v:0'); // Video from file - args.push('-map', '0:a:0?'); // Audio from file (optional - ? means don't fail if missing) - - if (audioBitrate) { - args.push('-c:a', 'aac', '-b:a', audioBitrate); + if (!virtualCameraOutput) { + // Encoding options + args.push( + '-c:v', 'libx264', + '-preset', preset, + '-tune', 'zerolatency', + '-pix_fmt', 'yuv420p', + '-b:v', videoBitrate, + '-maxrate', videoBitrate, + '-bufsize', (parseInt(videoBitrate) * 2) + 'k', + '-g', String(fps * 2) + ); + + // Audio: prefer source audio (0:a) if available, otherwise use null source (1:a) + // Using -map with fallback: first try file audio, then null audio + args.push('-map', '0:v:0'); // Video from file + args.push('-map', '0:a:0?'); // Audio from file (optional - ? means don't fail if missing) + + if (audioBitrate) { + args.push('-c:a', 'aac', '-b:a', audioBitrate); + } + } else { + // Virtual camera: only video, drop the file's audio track entirely. + args.push('-map', '0:v:0'); } - // For files without audio, we need a different approach - // Use filter_complex for conditional audio handling - // Simpler: let ffmpeg fail gracefully on missing audio then handle - // Actually simplest: always add silent audio as a fallback track - // We'll use -shortest to avoid issues with infinite null audio - // Extra user-supplied args args.push(...extraFfmpeg); // Output format and destination - args.push('-f', format, ingest); + if (virtualCameraOutput) { + args.push(...this.buildVirtualCameraOutputArgs()); + } else { + args.push('-f', format, ingest); + } return args; } @@ -464,10 +507,11 @@ export class PageStreamer { } private scheduleRestartIfNeeded(code: number | null) { - const { ingest, reconnectAttempts, reconnectInitialDelayMs, reconnectMaxDelayMs } = this.opts; + const { ingest, reconnectAttempts, reconnectInitialDelayMs, reconnectMaxDelayMs, virtualCamera } = this.opts; if (this.stopping) return; if (code === 0) return; // clean exit - const retryProtocol = this.isRetryProtocol(ingest); + const target = virtualCamera || ingest; + const retryProtocol = this.isRetryProtocol(ingest) || !!virtualCamera; if (!retryProtocol) { console.error(`ffmpeg exited (code=${code}). Ingest protocol not configured for auto-retry. Exiting with code 11.`); // Give the event loop a tick so logs flush @@ -476,14 +520,14 @@ export class PageStreamer { } // Retry path if (reconnectAttempts !== 0 && this.restartAttempt >= reconnectAttempts) { - console.error(`${this.protocolName(ingest)} reconnect attempts exhausted (${this.restartAttempt}/${reconnectAttempts}). Giving up.`); - this.printFailureHelp(ingest); + console.error(`${this.protocolName(target)} reconnect attempts exhausted (${this.restartAttempt}/${reconnectAttempts}). Giving up.`); + this.printFailureHelp(target); setTimeout(()=> process.exit(10), 10); return; } this.restartAttempt += 1; const delay = Math.min(reconnectInitialDelayMs * Math.pow(2, this.restartAttempt - 1), reconnectMaxDelayMs); - console.warn(`ffmpeg exited (code=${code}). Scheduling ${this.protocolName(ingest)} reconnect attempt ${this.restartAttempt} in ${delay}ms`); + console.warn(`ffmpeg exited (code=${code}). Scheduling ${this.protocolName(target)} reconnect attempt ${this.restartAttempt} in ${delay}ms`); this.restartTimer = setTimeout(() => { if (this.stopping) return; this.launchFfmpeg().catch(err => console.error('ffmpeg restart failed', err)); @@ -517,34 +561,48 @@ export class PageStreamer { console.error(' • Increase verbosity with: --extra-ffmpeg -loglevel verbose'); } - private printFailureHelp(ingest: string) { - if (/^srt:\/\//i.test(ingest)) return this.printSrtFailureHelp(ingest); - if (/^rtmps?:\/\//i.test(ingest)) return this.printRtmpFailureHelp(ingest); + private printFailureHelp(target: string) { + if (/^\/dev\//.test(target)) return this.printVirtualCameraFailureHelp(target); + if (/^srt:\/\//i.test(target)) return this.printSrtFailureHelp(target); + if (/^rtmps?:\/\//i.test(target)) return this.printRtmpFailureHelp(target); + } + + private printVirtualCameraFailureHelp(device: string) { + console.error('\nVirtual camera output failed permanently. Troubleshooting suggestions:'); + console.error(` • Confirm the device exists and is writable: ls -l ${device}`); + console.error(' • Confirm v4l2loopback is loaded: lsmod | grep v4l2loopback'); + console.error(' • Reload the module with sensible defaults:'); + console.error(' sudo ./scripts/setup-virtual-camera.sh'); + console.error(' • Check that no other producer is currently writing to the device.'); + console.error(' • Increase verbosity with: --extra-ffmpeg -loglevel verbose'); } private isRetryProtocol(ingest: string) { return /^srt:\/\//i.test(ingest) || /^rtmps?:\/\//i.test(ingest); } - private protocolName(ingest: string) { - if (/^srt:\/\//i.test(ingest)) return 'SRT'; - if (/^rtmps?:\/\//i.test(ingest)) return 'RTMP'; + private protocolName(target: string) { + if (/^\/dev\//.test(target)) return 'V4L2'; + if (/^srt:\/\//i.test(target)) return 'SRT'; + if (/^rtmps?:\/\//i.test(target)) return 'RTMP'; return 'INGEST'; } private startHealthLoop() { - const { healthIntervalSeconds, ingest } = this.opts; + const { healthIntervalSeconds, ingest, virtualCamera } = this.opts; if (!healthIntervalSeconds || healthIntervalSeconds <= 0) return; const intervalMs = healthIntervalSeconds * 1000; this.healthTimer = setInterval(() => { const now = Date.now(); const uptimeSec = ((now - this.startTime) / 1000).toFixed(1); + const target = virtualCamera || ingest; const payload = { type: 'health', ts: new Date().toISOString(), uptimeSec: Number(uptimeSec), ingest, - protocol: this.protocolName(ingest), + virtualCamera: virtualCamera || undefined, + protocol: this.protocolName(target), restartAttempt: this.restartAttempt, lastFfmpegExitCode: this.lastFfmpegExitCode, retrying: !!this.restartTimer, @@ -669,8 +727,10 @@ async function main() { const program = new Command(); program .name('page-stream') - .description('Stream a web page (local file or URL) to an ingest (SRT/RTMP/etc)') - .requiredOption('-i, --ingest ', 'Ingest URI (e.g. srt://host:port?streamid=... or rtmp://...)') + .description('Stream a web page (local file or URL) to an ingest (SRT/RTMP/etc) or to a Linux v4l2loopback virtual camera') + .option('-i, --ingest ', 'Ingest URI (e.g. srt://host:port?streamid=... or rtmp://...)') + .option('--virtual-camera ', 'Linux v4l2loopback device path (e.g. /dev/video10) — outputs raw video so the stream appears as a webcam to host apps') + .option('--virtual-camera-pix-fmt ', 'Pixel format written to the v4l2 device', 'yuv420p') .option('-u, --url ', 'Page URL or local file path', DEMO_PAGE) .option('--width ', 'Width', (v: string)=>parseInt(v,10), 1280) .option('--height ', 'Height', (v: string)=>parseInt(v,10), 720) @@ -700,6 +760,11 @@ async function main() { .parse(process.argv); const opts = program.opts(); + // Require exactly one output target: --ingest OR --virtual-camera. + if (!opts.ingest && !opts.virtualCamera) { + console.error("error: required option '-i, --ingest ' or '--virtual-camera ' not specified"); + process.exit(1); + } // CLI flags --inject-css/--inject-js may be omitted; allow environment fallback if (!opts.injectCss && process.env.INJECT_CSS) opts.injectCss = process.env.INJECT_CSS; if (!opts.injectJs && process.env.INJECT_JS) opts.injectJs = process.env.INJECT_JS; @@ -719,7 +784,7 @@ async function main() { } const streamer = new PageStreamer({ url: opts.url, - ingest: opts.ingest, + ingest: opts.ingest || '', width: opts.width, height: opts.height, fps: opts.fps, @@ -743,14 +808,19 @@ async function main() { injectJs: opts.injectJs, videoFile: opts.videoFile, videoLoop: !!opts.videoLoop, + virtualCamera: opts.virtualCamera, + virtualCameraPixFmt: opts.virtualCameraPixFmt || 'yuv420p', }); // Print early log before heavy startup so tests can assert output. + const target = opts.virtualCamera + ? `virtual camera '${opts.virtualCamera}'` + : `ingest '${opts.ingest}'`; if (opts.videoFile) { - console.log(`Streaming video file '${opts.videoFile}' to ingest '${opts.ingest}' (${opts.width}x${opts.height}@${opts.fps}fps${opts.videoLoop ? ', loop' : ''})`) + console.log(`Streaming video file '${opts.videoFile}' to ${target} (${opts.width}x${opts.height}@${opts.fps}fps${opts.videoLoop ? ', loop' : ''})`); } else { - console.log(`Streaming page '${opts.url}' to ingest '${opts.ingest}' (${opts.width}x${opts.height}@${opts.fps}fps)`); + console.log(`Streaming page '${opts.url}' to ${target} (${opts.width}x${opts.height}@${opts.fps}fps)`); } if (process.env.PAGE_STREAM_TEST_MODE) { diff --git a/tests/virtual-camera.test.ts b/tests/virtual-camera.test.ts new file mode 100644 index 0000000..05b075c --- /dev/null +++ b/tests/virtual-camera.test.ts @@ -0,0 +1,197 @@ +import { spawn } from 'node:child_process'; +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { PageStreamer } from '../src/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const root = path.join(__dirname, '..'); + +// Tests for Linux v4l2loopback virtual camera output. + +function runCli(args: string[], extraEnv: Record = {}): Promise<{ code: number|null; stdout: string; stderr: string; }> { + return new Promise(res => { + const p = spawn('node', ['dist/index.js', ...args], { + cwd: root, + env: { ...process.env, PAGE_STREAM_TEST_MODE: '1', ...extraEnv }, + }); + let out = ''; let err = ''; + p.stdout?.on('data', d => out += d.toString()); + p.stderr?.on('data', d => err += d.toString()); + const killTimer = setTimeout(() => { try { p.kill('SIGINT'); } catch {} }, 800); + const hardTimer = setTimeout(() => { try { p.kill('SIGKILL'); } catch {}; res({ code: null, stdout: out, stderr: err }); }, 3000); + p.on('close', code => { clearTimeout(killTimer); clearTimeout(hardTimer); res({ code, stdout: out, stderr: err }); }); + }); +} + +function makeStreamer(overrides: Partial<{ + ingest: string; videoFile: string; videoLoop: boolean; + virtualCamera: string; virtualCameraPixFmt: string; + width: number; height: number; fps: number; +}> = {}) { + return new PageStreamer({ + url: 'demo/index.html', + ingest: overrides.ingest ?? '', + width: overrides.width ?? 1280, + height: overrides.height ?? 720, + fps: overrides.fps ?? 30, + preset: 'veryfast', + videoBitrate: '2500k', + audioBitrate: '128k', + format: 'mpegts', + extraFfmpeg: [], + headless: false, + fullscreen: true, + appMode: true, + reconnectAttempts: 0, + reconnectInitialDelayMs: 1000, + reconnectMaxDelayMs: 15000, + healthIntervalSeconds: 0, + autoRefreshSeconds: 0, + suppressAutomationBanner: true, + autoDismissInfobar: false, + cropInfobar: 0, + videoFile: overrides.videoFile, + videoLoop: overrides.videoLoop ?? false, + virtualCamera: overrides.virtualCamera, + virtualCameraPixFmt: overrides.virtualCameraPixFmt ?? 'yuv420p', + }); +} + +// ============================================================================= +// CLI parsing +// ============================================================================= + +test('CLI requires either --ingest or --virtual-camera', async () => { + const r = await runCli(['--url', 'demo/index.html']); + assert.notEqual(r.code, 0, 'Expected non-zero exit when neither --ingest nor --virtual-camera is provided'); + assert.ok(/--ingest|--virtual-camera/.test(r.stderr + r.stdout), 'Expected error to mention required output target'); +}); + +test('CLI accepts --virtual-camera without --ingest', async () => { + const r = await runCli(['--virtual-camera', '/dev/video10']); + const combined = r.stdout + r.stderr; + assert.ok(/Streaming page/.test(combined), 'Expected streaming startup log'); + assert.ok(/virtual camera '\/dev\/video10'/.test(combined), 'Expected log to mention virtual camera target'); +}); + +test('CLI accepts --virtual-camera together with --video-file', async () => { + const tmpFile = path.join(os.tmpdir(), `pgstream-vc-${Date.now()}.mp4`); + fs.writeFileSync(tmpFile, 'dummy video for vc test'); + try { + const r = await runCli([ + '--virtual-camera', '/dev/video10', + '--video-file', tmpFile, + ]); + const combined = r.stdout + r.stderr; + assert.ok(/Streaming video file/.test(combined), 'Expected video file streaming log'); + assert.ok(/virtual camera '\/dev\/video10'/.test(combined), 'Expected log to mention virtual camera target'); + } finally { + fs.unlinkSync(tmpFile); + } +}); + +// ============================================================================= +// buildFfmpegArgs(): browser/x11grab input → v4l2 output +// ============================================================================= + +test('browser mode + virtual camera emits v4l2 output and skips libx264', () => { + const streamer = makeStreamer({ virtualCamera: '/dev/video10' }); + const args = streamer.buildFfmpegArgs(); + + assert.ok(args.includes('x11grab'), 'Browser mode should still use x11grab as input'); + + // Output must be v4l2 with the device path as the muxed target + const outFmtIdx = args.lastIndexOf('-f'); + assert.equal(args[outFmtIdx + 1], 'v4l2', 'Expected -f v4l2 for virtual camera output'); + assert.equal(args[args.length - 1], '/dev/video10', 'Expected device path as final arg'); + + // Pixel format flag must be present + const pixIdx = args.lastIndexOf('-pix_fmt'); + assert.ok(pixIdx > 0, 'Expected -pix_fmt to be set for v4l2 output'); + assert.equal(args[pixIdx + 1], 'yuv420p'); + + // No streaming-protocol encoder/audio + assert.ok(!args.includes('libx264'), 'Should not encode with libx264 when writing to v4l2'); + assert.ok(!args.includes('aac'), 'Should not include aac (v4l2 is video-only)'); + assert.ok(!args.includes('-b:v'), 'Should not set video bitrate for raw v4l2 output'); + assert.ok(!args.some(a => a.startsWith('anullsrc')), 'Should not add silent audio source for v4l2'); +}); + +test('virtual camera honors custom --virtual-camera-pix-fmt', () => { + const streamer = makeStreamer({ virtualCamera: '/dev/video10', virtualCameraPixFmt: 'yuyv422' }); + const args = streamer.buildFfmpegArgs(); + const pixIdx = args.lastIndexOf('-pix_fmt'); + assert.equal(args[pixIdx + 1], 'yuyv422'); +}); + +// ============================================================================= +// buildFfmpegArgs(): video-file input → v4l2 output +// ============================================================================= + +test('video file + virtual camera emits v4l2 output, drops audio', () => { + const streamer = makeStreamer({ videoFile: '/path/to/video.mp4', virtualCamera: '/dev/video10' }); + const args = streamer.buildFfmpegArgs(); + + // Input is the file + const inputIdx = args.indexOf('-i'); + assert.equal(args[inputIdx + 1], '/path/to/video.mp4'); + + // Output is v4l2 with the device + const outFmtIdx = args.lastIndexOf('-f'); + assert.equal(args[outFmtIdx + 1], 'v4l2'); + assert.equal(args[args.length - 1], '/dev/video10'); + + // No anullsrc, no libx264, no aac, no -b:v + assert.ok(!args.some(a => /anullsrc/.test(a)), 'Should not append silent audio for v4l2 output'); + assert.ok(!args.includes('libx264'), 'Should not encode with libx264 for v4l2 output'); + assert.ok(!args.includes('aac'), 'Should not include aac for v4l2 output'); + assert.ok(!args.includes('-b:v'), 'Should not include -b:v for v4l2 output'); + + // Should still scale/pad/fps for the target resolution + const vfIdx = args.indexOf('-vf'); + assert.ok(vfIdx >= 0, 'Expected -vf filter chain'); + assert.ok(args[vfIdx + 1].includes('scale=1280:720'), 'Expected scale to target resolution'); +}); + +test('video file + virtual camera + loop preserves -stream_loop ordering', () => { + const streamer = makeStreamer({ + videoFile: '/path/to/video.mp4', + videoLoop: true, + virtualCamera: '/dev/video10', + }); + const args = streamer.buildFfmpegArgs(); + const loopIdx = args.indexOf('-stream_loop'); + const inputIdx = args.indexOf('-i'); + assert.ok(loopIdx >= 0 && loopIdx < inputIdx, '-stream_loop should appear before -i'); + assert.equal(args[loopIdx + 1], '-1'); +}); + +// ============================================================================= +// Validation in start() +// ============================================================================= + +test('start() throws on non-Linux platforms when virtualCamera is set', async (t) => { + const streamer = makeStreamer({ virtualCamera: '/dev/video10' }); + // Spoof process.platform + const original = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + try { + await assert.rejects(() => streamer.start(), /Linux-only/i); + } finally { + Object.defineProperty(process, 'platform', { value: original, configurable: true }); + } +}); + +test('start() throws when the virtual camera device does not exist', async () => { + if (process.platform !== 'linux') { + // On non-Linux the platform check fires first, so this scenario can't be reached. + return; + } + const streamer = makeStreamer({ virtualCamera: '/dev/definitely-not-a-real-device-xyz' }); + await assert.rejects(() => streamer.start(), /device not found|v4l2loopback/i); +});