WebCam als schlanke alternative zum appVideoControl
This commit is contained in:
42
src/deviceDetect.js
Normal file
42
src/deviceDetect.js
Normal 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
50
src/snapshotService.js
Normal 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
163
src/videoStream.js
Normal 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 };
|
||||
Reference in New Issue
Block a user