Standbild
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
48
test/grabSnapshot.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user