WebCam als schlanke alternative zum appVideoControl

This commit is contained in:
chk
2026-06-02 22:19:08 +02:00
commit 9b1ae2ae14
11 changed files with 842 additions and 0 deletions

42
src/deviceDetect.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict';
const fs = require('fs');
const path = require('path');
// Returns ordered list of camera device paths.
// Priority: DEV0/DEV1/... env vars → /dev/v4l/by-id/ → /dev/video*
function detectDevices() {
// Explicit env vars take priority
const envDevices = [];
for (let i = 0; i < 8; i++) {
const dev = process.env[`DEV${i}`];
if (dev) envDevices.push(dev);
}
if (envDevices.length > 0) return envDevices;
// Stable device IDs (survive USB re-plug without renumbering)
const byIdDir = '/dev/v4l/by-id';
if (fs.existsSync(byIdDir)) {
try {
const found = fs.readdirSync(byIdDir)
.filter(name => !name.endsWith('-index1')) // skip audio/metadata endpoints
.map(name => fs.realpathSync(path.join(byIdDir, name)))
.filter((v, i, arr) => arr.indexOf(v) === i) // deduplicate symlink targets
.sort();
if (found.length > 0) return found;
} catch { /* fall through */ }
}
// Simple enumeration fallback (Linux)
try {
const found = fs.readdirSync('/dev')
.filter(name => /^video\d+$/.test(name))
.sort((a, b) => parseInt(a.slice(5)) - parseInt(b.slice(5)))
.map(name => `/dev/${name}`);
if (found.length > 0) return found;
} catch { /* fall through */ }
return ['/dev/video0', '/dev/video2'];
}
module.exports = { detectDevices };

50
src/snapshotService.js Normal file
View File

@@ -0,0 +1,50 @@
'use strict';
const express = require('express');
// Returns an Express router mounted at /api/snapshot
// GET /api/snapshot → JSON listing of cameras
// GET /api/snapshot/cam0 → latest JPEG from cam0
// GET /api/snapshot/cam1 → latest JPEG from cam1
function createSnapshotRouter(streams) {
const router = express.Router();
router.get('/', (_req, res) => {
res.json({
cameras: streams.map((s, i) => ({
id: `cam${i}`,
device: s.device,
running: s.isRunning,
clients: s.clientCount,
hasFrame: s.latestFrame !== null,
url: `/api/snapshot/cam${i}`,
})),
});
});
router.get('/:id', (req, res) => {
const idx = parseInt(req.params.id.replace('cam', ''), 10);
if (isNaN(idx) || !streams[idx]) {
return res.status(404).json({ error: 'camera not found' });
}
const frame = streams[idx].latestFrame;
if (!frame) {
return res.status(503).json({ error: 'no frame available yet stream may still be starting' });
}
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': frame.length,
'Cache-Control': 'no-store',
'X-Camera-Id': `cam${idx}`,
'X-Camera-Device': streams[idx].device,
'X-Timestamp': new Date().toISOString(),
});
res.end(frame);
});
return router;
}
module.exports = { createSnapshotRouter };

163
src/videoStream.js Normal file
View File

@@ -0,0 +1,163 @@
'use strict';
const { spawn } = require('child_process');
const { WebSocket } = require('ws');
// JPEG frame boundaries
const SOI = Buffer.from([0xff, 0xd8]);
const EOI = Buffer.from([0xff, 0xd9]);
// Max buffer before we assume stream corruption and discard
const MAX_BUFFER = 2 * 1024 * 1024;
// Drop frames to clients with a clogged send buffer (slow network/tab)
const MAX_CLIENT_BUFFER = 512 * 1024;
class VideoStream {
constructor(device, options = {}) {
this.device = device;
this.name = options.name ?? 'cam';
this.width = options.width ?? 640;
this.height = options.height ?? 480;
this.fps = options.fps ?? 30;
this.quality = options.quality ?? 5; // FFmpeg MJPEG quality: 2=best … 31=worst
this._clients = new Set();
this._process = null;
this._restartTimer = null;
this._restartDelay = 1000;
this._running = false;
this._latestFrame = null;
}
get latestFrame() { return this._latestFrame; }
get isRunning() { return this._running; }
get clientCount() { return this._clients.size; }
start() {
if (this._running) return;
this._running = true;
this._spawn();
}
stop() {
this._running = false;
clearTimeout(this._restartTimer);
if (this._process) {
this._process.kill('SIGKILL');
this._process = null;
}
}
addClient(ws) {
this._clients.add(ws);
// Send latest frame immediately client sees picture right away
if (this._latestFrame) {
ws.send(this._latestFrame, { binary: true });
}
}
removeClient(ws) {
this._clients.delete(ws);
}
// ---------------------------------------------------------------------------
// FFmpeg pipeline
// ---------------------------------------------------------------------------
_buildArgs() {
return [
'-hide_banner', '-loglevel', 'warning',
// Minimize input buffering for low latency
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '32',
'-analyzeduration', '0',
// Input: USB camera via Video4Linux2
'-f', 'v4l2',
'-input_format', 'mjpeg', // prefer hardware MJPEG (no re-decode if res matches)
'-video_size', `${this.width}x${this.height}`,
'-framerate', String(this.fps),
'-i', this.device,
// Output: scale to target size, encode as MJPEG to stdout
// If camera delivers native MJPEG at target resolution, consider -vcodec copy
'-vf', `scale=${this.width}:${this.height}`,
'-f', 'mjpeg',
'-q:v', String(this.quality),
'pipe:1',
];
}
_spawn() {
console.log(`[${this.name}] starting ffmpeg on ${this.device}`);
const startedAt = Date.now();
const proc = spawn('ffmpeg', this._buildArgs(), {
stdio: ['ignore', 'pipe', 'pipe'],
});
this._process = proc;
let buf = Buffer.alloc(0);
proc.stdout.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
let offset = 0;
while (true) {
const soi = buf.indexOf(SOI, offset);
if (soi === -1) break;
const eoi = buf.indexOf(EOI, soi + 2);
if (eoi === -1) break;
const frame = buf.slice(soi, eoi + 2);
this._latestFrame = frame;
this._broadcast(frame);
this._restartDelay = 1000; // reset backoff on good frames
offset = eoi + 2;
}
buf = offset > 0 ? buf.slice(offset) : buf;
if (buf.length > MAX_BUFFER) {
console.warn(`[${this.name}] buffer overflow, discarding`);
buf = Buffer.alloc(0);
}
});
proc.stderr.on('data', (chunk) => {
const msg = chunk.toString().trimEnd();
if (msg) console.error(`[${this.name}] ffmpeg: ${msg}`);
});
proc.on('close', (code) => {
this._process = null;
const uptime = Date.now() - startedAt;
console.log(`[${this.name}] ffmpeg closed (code=${code}, uptime=${uptime}ms)`);
if (!this._running) return;
// Exponential backoff: 1s → 2s → 4s → 8s max
console.log(`[${this.name}] restart in ${this._restartDelay}ms`);
this._restartTimer = setTimeout(() => {
this._restartDelay = Math.min(this._restartDelay * 2, 8000);
this._spawn();
}, this._restartDelay);
});
proc.on('error', (err) => {
console.error(`[${this.name}] spawn error: ${err.message}`);
});
}
_broadcast(frame) {
for (const ws of this._clients) {
if (ws.readyState !== WebSocket.OPEN) continue;
// Drop frame for slow clients rather than queuing indefinitely
if (ws.bufferedAmount > MAX_CLIENT_BUFFER) continue;
ws.send(frame, { binary: true });
}
}
}
module.exports = { VideoStream };