Standbild

This commit is contained in:
chk
2026-06-07 10:42:28 +02:00
parent faccbf55ce
commit d3e45262ce
6 changed files with 155 additions and 19 deletions

View File

@@ -75,8 +75,12 @@ Separate Admin-Seite (kein Viewer-Umbau nötig). Pro Kamera eine Zeile mit Combo
**ComboBox-Optionen pro Kamera** (zentral als Konstante, von Server + Client geteilt): **ComboBox-Optionen pro Kamera** (zentral als Konstante, von Server + Client geteilt):
`Aus`, `160×120`, `320×240`, `640×360`, `640×480`, `800×600`, `1280×720` `Aus`, `160×120`, `320×240`, `640×360`, `640×480`, `800×600`, `1280×720`
- `Aus``stream: false` (kein Live-Stream; Viewer zeigt Platzhalter nach Reload) - `Aus``stream: false` = **Snapshot-Modus**: kein Video. Der Viewer zeigt stattdessen
- Auflösung → `stream: true` + `liveSize: "<W>x<H>"` alle 5 s ein Einzelbild (`GET /api/snapshot/<id>`) mit grossem Banner „Single Picture
no Video". Server-seitig öffnet jeder Snapshot das Gerät kurz (one-shot via
`grabSnapshot()`) und schliesst es wieder → Gerät meist geschlossen, minimale Mobil-
Bandbreite (1 JPEG/5 s statt 30/s).
- Auflösung → `stream: true` + `liveSize: "<W>x<H>"` (kontinuierlicher MJPEG-Stream)
- Bei cam2 (C920) ein dezenter ⚠-Hinweis-Tooltip: „C920 braucht bei kleinen 4:3-Auflösungen - Bei cam2 (C920) ein dezenter ⚠-Hinweis-Tooltip: „C920 braucht bei kleinen 4:3-Auflösungen
überdurchschnittlich Bandbreite siehe Doku." überdurchschnittlich Bandbreite siehe Doku."
@@ -126,11 +130,12 @@ async reconfigure({ liveSize, stream } = {}) {
} }
``` ```
> Hinweis: `stream:false`-Handling läuft primär über `camsMeta` (Viewer baut dann keinen > **Umgesetzt:** `CameraSwitch` hat ein `streamEnabled`-Flag, das NUR den kontinuierlichen
> `<img>` → kein `acquire()` → On-Demand lässt FFmpeg aus). `reconfigure` muss für `Aus` > Live-Stream sperrt (`_spawnLive`/`acquire`/`_scheduleRestart` sind dadurch gegated).
> daher nichts hart killen; ein bereits laufender Stream stoppt nach Viewer-Reload via > Einzel-Snapshots umgehen das über `grabSnapshot()` (one-shot open/grab/close an
> Idle-Grace. Optional kann bei `stream:false` zusätzlich `_killCurrentAndWait()` gerufen > `liveSize`) so liefert eine „Aus"-Kamera weiterhin Bilder, ohne dauerhaft zu streamen.
> werden, falls sofortiges Stoppen gewünscht ist. > `reconfigure({stream:false})` killt einen evtl. laufenden Live-Prozess und startet ihn
> nicht neu.
**Wichtig laufende Browser-`<img>`-Streams:** Eine reine *Auflösungs*-Änderung ist für **Wichtig laufende Browser-`<img>`-Streams:** Eine reine *Auflösungs*-Änderung ist für
den Client nahtlos der multipart-Stream läuft weiter, nur die Frame-Grösse ändert sich den Client nahtlos der multipart-Stream läuft weiter, nur die Frame-Grösse ändert sich

View File

@@ -79,6 +79,15 @@
color: #444; font-size: 0.82rem; letter-spacing: 0.04em; color: #444; font-size: 0.82rem; letter-spacing: 0.04em;
} }
/* Snapshot-Modus: gross+fett über dem Einzelbild */
.single-pic-banner {
position: absolute; top: 0; left: 0; right: 0; z-index: 3;
text-align: center; padding: 8px 4px;
font-size: 1.15rem; font-weight: bold; letter-spacing: 0.06em;
text-transform: uppercase;
color: #fc6; background: rgba(0,0,0,.72);
}
/* Hi-Res-Test-Button (Phase 1) links neben dem Ein/Aus-Schalter */ /* Hi-Res-Test-Button (Phase 1) links neben dem Ein/Aus-Schalter */
.cam-hdtest { .cam-hdtest {
position: absolute; top: 5px; right: 40px; z-index: 2; position: absolute; top: 5px; right: 40px; z-index: 2;

View File

@@ -38,6 +38,37 @@ function stopStream(cam) {
log(cam.id, 'Live aus'); log(cam.id, 'Live aus');
} }
// ── Snapshot-Modus (stream:false): alle 5 s ein Einzelbild ──────────────────
// Kein Video der Server öffnet das Gerät pro Snapshot kurz (one-shot). Fehler
// (z. B. Gerät gerade durch HD-Grab belegt) werden still übersprungen, das letzte
// gute Bild bleibt stehen.
const SNAPSHOT_INTERVAL_MS = 5000;
async function fetchSnapshot(cam) {
if (!cam.snapshotActive) return;
try {
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}?t=${Date.now()}`,
{ signal: AbortSignal.timeout(8000) });
if (!r.ok) { setInfo(cam, `Snapshot-Fehler (HTTP ${r.status})`, 'warn'); return; }
const blob = await r.blob();
const url = URL.createObjectURL(blob);
cam.img.src = url;
if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl);
cam.lastBlobUrl = url;
setInfo(cam, `Einzelbild · ${new Date().toLocaleTimeString()}`, 'ok');
} catch (_e) {
setInfo(cam, 'Snapshot-Timeout', 'warn');
}
}
function startSnapshotMode(cam) {
cam.snapshotActive = true;
setInfo(cam, 'Einzelbild lädt…', '');
fetchSnapshot(cam); // sofort das erste Bild
cam.snapTimer = setInterval(() => fetchSnapshot(cam), SNAPSHOT_INTERVAL_MS);
log(cam.id, `Snapshot-Modus (alle ${SNAPSHOT_INTERVAL_MS / 1000} s)`);
}
// ── HD-Snapshot ─────────────────────────────────────────────────────────────── // ── HD-Snapshot ───────────────────────────────────────────────────────────────
async function runHiresGrab(cam) { async function runHiresGrab(cam) {
if (cam.busy) return; if (cam.busy) return;
@@ -144,11 +175,21 @@ function buildCamera(camMeta, container) {
box.appendChild(toggle); box.appendChild(toggle);
startStream(cam); startStream(cam);
} else { } else {
const placeholder = document.createElement('div'); // Snapshot-Modus: grosser Banner + Einzelbild, das alle 5 s aktualisiert wird.
placeholder.className = 'cam-img cam-placeholder'; const banner = document.createElement('div');
placeholder.textContent = 'Kein Live-Stream'; banner.className = 'single-pic-banner';
hd.title = 'Hi-Res-Snapshot (1280×960) Download'; banner.textContent = 'Single Picture no Video';
box.appendChild(placeholder);
const img = document.createElement('img');
img.className = 'cam-img';
img.alt = labelText;
cam.img = img;
hd.title = 'Hi-Res-Snapshot Download';
box.appendChild(banner);
box.appendChild(img);
startSnapshotMode(cam);
} }
box.appendChild(label); box.appendChild(label);

View File

@@ -250,7 +250,7 @@ class CameraSwitch extends EventEmitter {
async grabHires(opts = {}) { async grabHires(opts = {}) {
// Shortcut: keine Format-Umschaltung wenn Live- und Hires-Auflösung identisch // Shortcut: keine Format-Umschaltung wenn Live- und Hires-Auflösung identisch
if (this.liveSize === this.hiresSize) { if (this.liveSize === this.hiresSize) {
return this.getFrame(); return this.grabSnapshot();
} }
const hiresW = parseInt(this.hiresSize.split('x')[0], 10); const hiresW = parseInt(this.hiresSize.split('x')[0], 10);
@@ -278,7 +278,7 @@ class CameraSwitch extends EventEmitter {
// 2. hires-FFmpeg starten, warmlaufen lassen (settleFrames), besten Frame greifen. // 2. hires-FFmpeg starten, warmlaufen lassen (settleFrames), besten Frame greifen.
// minWidth lehnt etwaige Übergangs-Frames in falscher Auflösung ab. // minWidth lehnt etwaige Übergangs-Frames in falscher Auflösung ab.
const jpeg = await this._captureHires({ minSize, minWidth, settleFrames, maxWaitMs }); const jpeg = await this._captureAt(this.hiresSize, this.hiresFps, this.hiresEncode, { minSize, minWidth, settleFrames, maxWaitMs });
const gotW = readJpegWidth(jpeg) ?? '?'; const gotW = readJpegWidth(jpeg) ?? '?';
console.log(`[cam ${this.id}] HD OK ${jpeg.length} bytes, Breite=${gotW}px (Soll: ${hiresW}px, ${Date.now() - t0}ms)`); console.log(`[cam ${this.id}] HD OK ${jpeg.length} bytes, Breite=${gotW}px (Soll: ${hiresW}px, ${Date.now() - t0}ms)`);
return jpeg; return jpeg;
@@ -291,6 +291,36 @@ class CameraSwitch extends EventEmitter {
} }
} }
// ── Einzelbild-Snapshot (für /api/snapshot, inkl. Snapshot-Modus) ──────────
// Läuft Live → aktuelles Frame (schnell, kein Geräte-Neustart). Sonst:
// • streamEnabled (Live an, aber Frame noch nicht da) → getFrame() (on-demand).
// • stream:false (Snapshot-Modus) → one-shot open/grab/close an liveSize. Das
// Gerät bleibt dazwischen geschlossen → spart CPU/USB; der Viewer pollt das
// alle paar Sekunden. streamEnabled bleibt aus (kein Dauer-Stream).
async grabSnapshot() {
if (this.latest) return this.latest; // Live läuft → sofort
if (this.streamEnabled) return this.getFrame(); // Live an, Frame kommt gleich
if (this.lock) throw new Error('Gerät belegt (Grab läuft)');
this.lock = true;
if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; }
this.state = 'grabbing';
try {
const w = parseInt(this.liveSize.split('x')[0], 10) || 0;
return await this._captureAt(this.liveSize, this.liveFps, this.encode, {
minSize: 1000,
minWidth: w ? Math.floor(w * 0.8) : 0,
settleFrames: 3,
maxWaitMs: 6000,
});
} finally {
this.state = 'stopped';
this.lock = false;
// Falls inzwischen wieder Live gewünscht ist (stream:true + Verbraucher):
if (this.streamEnabled && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
}
}
// Beendet den aktuellen Prozess und resolved erst nach dessen 'close' (FD frei). // Beendet den aktuellen Prozess und resolved erst nach dessen 'close' (FD frei).
_killCurrentAndWait(timeoutMs = 4000) { _killCurrentAndWait(timeoutMs = 4000) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -306,7 +336,9 @@ class CameraSwitch extends EventEmitter {
}); });
} }
_captureHires({ minSize, minWidth, settleFrames, maxWaitMs }) { // Generischer one-shot Capture an beliebiger Auflösung (open → settle → grab → close).
// Genutzt von grabHires (hiresSize) und grabSnapshot (liveSize, Snapshot-Modus).
_captureAt(size, fps, encode, { minSize, minWidth, settleFrames, maxWaitMs }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const args = [ const args = [
'-hide_banner', '-loglevel', 'warning', '-hide_banner', '-loglevel', 'warning',
@@ -314,9 +346,9 @@ class CameraSwitch extends EventEmitter {
'-probesize', '5000000', '-probesize', '5000000',
'-analyzeduration', '1000000', '-analyzeduration', '1000000',
'-f', 'v4l2', '-input_format', 'mjpeg', '-f', 'v4l2', '-input_format', 'mjpeg',
'-video_size', this.hiresSize, '-framerate', String(this.hiresFps), '-video_size', size, '-framerate', String(fps),
'-i', this.device, '-i', this.device,
...videoOutArgs(this.hiresEncode), '-f', 'mpjpeg', 'pipe:1', ...videoOutArgs(encode), '-f', 'mpjpeg', 'pipe:1',
]; ];
let p; let p;
try { try {

View File

@@ -54,8 +54,9 @@ function createSnapshotRouter(switches, cameras) {
const sw = switches[req.params.id]; const sw = switches[req.params.id];
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` }); if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
try { try {
// getFrame() startet die Kamera bei Bedarf on-demand und wartet auf ein frisches Bild // grabSnapshot(): liefert das Live-Frame falls vorhanden, sonst (Snapshot-Modus,
const frame = await sw.getFrame(); // stream:false) ein one-shot Bild öffnet das Gerät kurz und schliesst es wieder.
const frame = await sw.grabSnapshot();
res.set({ res.set({
'Content-Type': 'image/jpeg', 'Content-Type': 'image/jpeg',
'Content-Length': frame.length, 'Content-Length': frame.length,

48
test/grabSnapshot.test.js Normal file
View File

@@ -0,0 +1,48 @@
'use strict';
const { CameraSwitch } = require('../src/cameraSwitch');
// Testet die Entscheidungslogik von grabSnapshot() ohne Hardware:
// _captureAt (FFmpeg) und _spawnLive sind gemockt.
function makeSwitch(opts = {}) {
const sw = new CameraSwitch({ id: 'camT', device: '/dev/null', liveSize: '640x480', ...opts });
sw._captureAt = jest.fn(() => Promise.resolve(Buffer.from([0xFF, 0xD8, 9])));
sw._spawnLive = jest.fn(() => { sw.proc = {}; sw.state = 'live'; });
return sw;
}
describe('CameraSwitch.grabSnapshot', () => {
test('Live läuft (latest gesetzt) → liefert latest, kein Capture', async () => {
const sw = makeSwitch({ stream: true });
sw.latest = Buffer.from([1, 2, 3]);
const f = await sw.grabSnapshot();
expect(f).toBe(sw.latest);
expect(sw._captureAt).not.toHaveBeenCalled();
});
test('stream:false ohne latest → one-shot _captureAt an liveSize', async () => {
const sw = makeSwitch({ stream: false });
sw.latest = null;
const f = await sw.grabSnapshot();
expect(Buffer.isBuffer(f)).toBe(true);
expect(sw._captureAt).toHaveBeenCalledTimes(1);
expect(sw._captureAt.mock.calls[0][0]).toBe('640x480'); // size = liveSize
expect(sw._spawnLive).not.toHaveBeenCalled(); // kein Dauer-Stream
});
test('Lock wird nach dem one-shot wieder freigegeben', async () => {
const sw = makeSwitch({ stream: false });
sw.latest = null;
await sw.grabSnapshot();
expect(sw.lock).toBe(false);
expect(sw.state).toBe('stopped');
});
test('belegtes Gerät (lock) → Fehler, kein zweiter Capture', async () => {
const sw = makeSwitch({ stream: false });
sw.latest = null;
sw.lock = true;
await expect(sw.grabSnapshot()).rejects.toThrow(/belegt/i);
expect(sw._captureAt).not.toHaveBeenCalled();
});
});