Umbau mit cameraSwitch Fix Delay

This commit is contained in:
chk
2026-06-05 07:07:29 +02:00
parent 4c73064955
commit 39898e3a15
6 changed files with 165 additions and 56 deletions

View File

@@ -388,14 +388,17 @@ Eine `CameraSwitch`-Instanz pro Gerät — der **einzige** Öffner von `/dev/vid
immer nur **einen** FFmpeg. Zustände `stopped | live | grabbing`, Mutex pro Kamera.
- **Live (Dauerbetrieb):** `ffmpeg -f v4l2 -input_format mjpeg -video_size 640x480
-framerate 30 -i /dev/videoN -c:v mjpeg -q:v 5 -f mpjpeg pipe:1`. Node parst
die mpjpeg-Frames (Content-Length-basiert), hält den letzten (für `/api/snapshot`),
sendet sie an alle Stream-Clients. Crash → Auto-Restart nach 1,5 s.
> ⚠ **`-c:v mjpeg` (Re-Encode), NICHT `-c:v copy`.** `copy` ist auf dieser Kamera
> empirisch tot: 04/09 dokumentieren CPU **107%** + hängendes Bild (FFmpeg verschluckt
> sich an den APP-Feldern des Kamera-MJPEG). Re-Encode = der bewährte ~50%-Pfad
> (entspricht go2rtcs `#video=mjpeg`). **Dieser Fehler wurde am 2026-06-05 zunächst
> wiederholt (copy) und dann korrigiert.**
-framerate 30 -i /dev/videoN -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1` (Default
`ENCODE_MODE=copybsf`). Node parst die mpjpeg-Frames (Content-Length-basiert), hält den
letzten (für `/api/snapshot`), sendet sie an alle Stream-Clients. Crash → Restart 1,5 s.
> ⚠ **Der `mjpeg2jpeg`-Bitstream-Filter ist Pflicht.** Plain `-c:v copy` (ohne Filter)
> ist auf dieser Kamera tot: **107% CPU + Hang** (04/09), weil das Kamera-MJPEG die
> JPEG-Tables weglässt und der mpjpeg-Muxer sich verschluckt. **Auf dem Host getestet
> (2026-06-05):** `copy -bsf:v mjpeg2jpeg` läuft sauber (der „APP fields"-Hinweis ist
> eine einmalige Probe-Warnung) → kein Transcode → CPU < Re-Encode, browser-valide JPEGs.
> Fallback `ENCODE_MODE=mjpeg` = Re-Encode ~50% (go2rtcs `#video=mjpeg`).
> **Lehrgeld 2026-06-05:** erst `copy` ohne Filter ausgeliefert (107%), dann via Host-
> Messung auf `copy+mjpeg2jpeg` korrigiert.
- **HD-Grab (`grabHires`):** Live-FFmpeg `SIGTERM` → **auf `close` warten** (FD frei) →
1280-FFmpeg, warmlaufen (ab Frame ≥6, ≥15 KB, Breite ≥1000), besten Frame greifen →
beenden, auf `close` warten → `finally`: **immer** Live zurück (Live hat Priorität).
@@ -413,14 +416,34 @@ droppen, andere bleiben flüssig. Clients halten **kein** Gerät → **Multi-Use
Ein Node-Container mit FFmpeg, Geräte durchgereicht, `group_add: video`. Env-Overrides:
`DEV0/DEV1`, `LIVE_SIZE/LIVE_FPS`, `HIRES_SIZE/HIRES_FPS`. Firewall: nur noch **TCP 8444**.
## Latenz-Tuning (2026-06-05)
Gemessen ~340 ms Kamera→Browser. Gegenmaßnahmen (verlustarm, Lost Frames erlaubt):
- **FFmpeg Live:** `-fflags nobuffer` (Input nicht puffern) + `-flush_packets 1` (jedes
Frame sofort aus dem Muxer in die Pipe).
- **Node-Stream:** `socket.setNoDelay(true)` (Nagle aus) + `cork/uncork` um Header+JPEG+
Trailer → ein TCP-Segment pro Frame, sofort gesendet.
- Backpressure droppt Frames für langsame Clients statt zu puffern → Latenz steigt nicht.
- Weitere Hebel, falls nötig: `LIVE_FPS` runter ändert die Latenz NICHT (nur Buffering),
aber `HIRES_FPS` etc. egal hier. Browser-`<img>` fügt ~1 Frame Anzeige-Latenz dazu.
## On-Demand (2026-06-05, umgesetzt)
Live-FFmpeg läuft nur, solange Verbraucher da sind (Stream-Clients oder ein laufender
Snapshot). `acquire()`/`release()` zählen Verbraucher; nach dem letzten + `IDLE_GRACE_MS`
(15 s) Stop → **0 % idle**. `/api/snapshot` (`getFrame()`) startet die Kamera bei Bedarf
und wartet auf ein frisches Bild (`latest` wird beim Stop genullt → kein stale Frame).
Reconnect innerhalb der Karenz hält Live (kein Thrashing). Abschaltbar: `ON_DEMAND=false`.
## Verifiziert vs. offen
- **Lokal verifiziert (ohne Kamera):** MJPEG-Parser (Unittest, Chunk-robust, `\r\n\r\n`
im Body), HTTP-Routing (snapshot/stream/health, 404/503), Crash-Auto-Restart rate-limitiert.
- **FFmpeg = der bewährte go2rtc-`#video=mjpeg`-Pfad** (Re-Encode mjpeg→mjpeg, ~50% für
2 Kameras), Ausgabe `-c:v mjpeg -q:v 5 -f mpjpeg`. **Nicht** `copy` (= 107%, s.o.).
- **Auf der Hardware noch zu verifizieren:** CPU-Last, Latenz, HD-Blackout-Dauer, und der
Bug-Reproweg unten.
im Body); HTTP-Routing (snapshot/stream/health, 404/503); On-Demand-Lebenszyklus
(acquire/release/idle-Stop/Reconnect-Karenz/getFrame/always-on) per Mock-Unittest.
- **FFmpeg Default `copybsf`** = `-c:v copy -bsf:v mjpeg2jpeg` (Bitstream-Copy, kein
Transcode; auf dem Host getestet). Fallback `ENCODE_MODE=mjpeg` (~50%, Re-Encode).
- **Auf der Hardware:** CPU **69 % für 2 Kameras bestätigt** (User, copybsf). Latenz nach
den Flags oben + Bug-Reproweg noch gegenzumessen.
## Hardware-Testplan
@@ -446,9 +469,7 @@ Ein Node-Container mit FFmpeg, Geräte durchgereicht, `group_add: video`. Env-Ov
30 fps** (HD). ABER: trotz nativem MJPG ist `-c:v copy` auf dieser Kamera tot (107%,
APP-Feld-Fehler) → **`-c:v mjpeg` (Re-Encode)**. (Optional `HIRES_FPS=30` verkürzt den
Warmup leicht.)
- **CPU unter 50% drücken?** Re-Encode 2×640@30 ≈ 50% (wie go2rtc). Hebel, falls nötig
(zuerst auf dem Host **messen**, nicht raten): `LIVE_FPS=15` (halbiert Encode-Last) oder
Test des Bitstream-Filters `-c:v copy -bsf:v mjpeg2jpeg` (fügt fehlende Tables ohne
Decode hinzu — ungetestet, könnte echtes Low-CPU bringen oder auch scheitern).
- **On-Demand Live** (FFmpeg erst bei erstem Client) wäre stromsparender, ist aber bewusst
weggelassen — Dauerbetrieb hält die Übergabe-Logik simpel (weniger Race-Fläche).
- **CPU:** `copybsf` (Bitstream-Copy) ist Default und liefert 69 % für 2 Kameras (gemessen).
Weiter drücken nur falls nötig: `LIVE_FPS=15`. Fallback bei Problemen: `ENCODE_MODE=mjpeg`.
- **On-Demand Live:** ✅ umgesetzt (s. o.) — Idle-CPU 0 statt ~60 %. Per `ON_DEMAND=false`
abschaltbar, falls je ein dauerhaft warmer Stream gewünscht ist.

View File

@@ -106,16 +106,22 @@ Encoder auf einem `/dev/videoN` sind konstruktiv ausgeschlossen.
Crash-Auto-Restart rate-limitiert. **Auf der Hardware noch zu verifizieren:** CPU-Last,
Latenz, HD-Blackout-Dauer, kein 106% nach Screenshot+Reconnect (Testplan in 05).
**FFmpeg = der bewährte go2rtc-`#video=mjpeg`-Pfad:** `-f v4l2 -input_format mjpeg
-video_size 640x480 -framerate 30 -i … -c:v mjpeg -q:v 5 -f mpjpeg pipe:1` (Re-Encode
mjpeg→mjpeg, ~50% für 2 Kameras).
**FFmpeg (Default `ENCODE_MODE=copybsf`):** `-f v4l2 -input_format mjpeg -video_size
640x480 -framerate 30 -i … -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1` — Bitstream-Copy,
kein Transcode. Fallback `mjpeg` = Re-Encode mjpeg→mjpeg (~50%, wie go2rtc).
### ⚠ Regression am 2026-06-05 (eingebaut **und** korrigiert): `-c:v copy`
### ⚠ Regression am 2026-06-05 (eingebaut korrigiert → optimiert)
Erste Fassung des Schalters nutzte `-c:v copy` (Passthrough) → **CPU 100%+ und wieder
hängendes Bild.** Ursache: `copy` ist auf dieser Kamera empirisch tot — 04/09 dokumentieren
es (107%), FFmpeg verschluckt sich an den APP-Feldern des Kamera-MJPEG
(`[mjpeg] unable to decode APP fields: Invalid data`) → keine validen Frames → Freeze.
**Das war exakt der in 04/05 dokumentierte und ignorierte Punkt.** Fix: `-c:v copy`
`-c:v mjpeg` (Re-Encode, wie go2rtc). Lehre erneut: **erst die Doku-Fakten anwenden,
dann bauen.**
1. **Fehler:** Erste Schalter-Fassung nutzte plain `-c:v copy` (ohne Bitstream-Filter)
**CPU 100%+ und hängendes Bild.** Das Kamera-MJPEG lässt JPEG-Tables weg, der
mpjpeg-Muxer verschluckt sich (`[mjpeg] unable to decode APP fields`) → keine validen
Frames. **Exakt der in 04/05 dokumentierte und von mir ignorierte Punkt.**
2. **Sofort-Fix:** `-c:v mjpeg` (Re-Encode, ~50%, wie go2rtc) — stabil, aber nicht besser
als der alte Stand.
3. **Host-Messung (User, 2026-06-05):** `-c:v copy -bsf:v mjpeg2jpeg` läuft 10 s sauber
durch (Copy bestätigt, nur einmalige Probe-Warnung). Der `mjpeg2jpeg`-Filter ergänzt
die fehlenden Tables ohne Decode → **kein Transcode, CPU < Re-Encode, valide JPEGs.**
→ als Default `copybsf` verdrahtet, `mjpeg` bleibt als Fallback (`ENCODE_MODE`).
Lehre erneut: **erst die Doku-Fakten anwenden, dann bauen** — und Optimierungen **messen**
(Punkt 3), nicht vorhersagen.

View File

@@ -61,6 +61,10 @@ services:
# - LIVE_FPS=30
# - HIRES_SIZE=1280x960
# - HIRES_FPS=15
# - ENCODE_MODE=copybsf # copybsf = Bitstream-Copy, niedrige CPU (Default)
# # mjpeg = Re-Encode (~50%, Fallback falls copybsf zickt)
# - ON_DEMAND=true # Live nur bei Zuschauern (Default); 'false' = dauerhaft an
# - IDLE_GRACE_MS=15000 # Karenz nach letztem Zuschauer vor dem Stop
# ── Hinweise ────────────────────────────────────────────────────────────────────
# • Bleibt eine Kamera schwarz? Geräte prüfen: v4l2-ctl --list-devices

View File

@@ -12,13 +12,16 @@ const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10);
const ENCODE_MODE = process.env.ENCODE_MODE ?? 'copybsf'; // 'copybsf' (niedrige CPU) | 'mjpeg' (Re-Encode-Fallback)
const ON_DEMAND = (process.env.ON_DEMAND ?? 'true') !== 'false'; // Live nur bei Verbrauchern (spart idle-CPU)
const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10);
// ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─
const devices = detectDevices();
const switches = {};
devices.forEach((device, i) => {
const id = `cam${i}`;
switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS });
switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS, encode: ENCODE_MODE, onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS });
});
const app = express();
@@ -52,7 +55,7 @@ const server = http.createServer(app);
server.listen(PORT, '0.0.0.0', () => {
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
console.log(` Kameras: ${Object.entries(switches).map(([id, sw]) => `${id}=${sw.device}`).join(', ')}`);
console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS}`);
console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS} · Encode: ${ENCODE_MODE} · On-Demand: ${ON_DEMAND}`);
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
console.log(` Live-Stream: http://0.0.0.0:${PORT}/api/stream/cam0`);
console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0 (+ /hires)`);

View File

@@ -5,6 +5,17 @@ const EventEmitter = require('events');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Video-Ausgabe-Args, umschaltbar via ENCODE_MODE (server.js):
// 'copybsf' (Default) → Bitstream-Passthrough. KEIN De-/Encode → niedrigste CPU.
// `mjpeg2jpeg` ergänzt die JPEG-Tables, die das Kamera-MJPEG weglässt → ohne den
// Filter verschluckt sich der mpjpeg-Muxer (107% + Hang, siehe 09). MIT Filter:
// sauberer Copy, browser-valide JPEGs. Auf der Hardware getestet (2026-06-05).
// 'mjpeg' → Re-Encode mjpeg→mjpeg (~50%, wie go2rtc). Sicherer Fallback.
function videoOutArgs(mode) {
if (mode === 'mjpeg') return ['-c:v', 'mjpeg', '-q:v', '5'];
return ['-c:v', 'copy', '-bsf:v', 'mjpeg2jpeg'];
}
// Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek.
// Gibt null zurück wenn der Marker nicht gefunden wird.
function readJpegWidth(buf) {
@@ -66,7 +77,7 @@ class MpjpegParser {
//
// Events: 'frame' (Buffer) je ein Live-JPEG
class CameraSwitch extends EventEmitter {
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15 }) {
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', onDemand = true, idleGraceMs = 15000 }) {
super();
this.setMaxListeners(0); // beliebig viele Stream-Clients
this.id = id;
@@ -75,28 +86,77 @@ class CameraSwitch extends EventEmitter {
this.liveFps = liveFps;
this.hiresSize = hiresSize;
this.hiresFps = hiresFps;
this.encode = encode; // 'copybsf' (Default, niedrige CPU) | 'mjpeg' (Re-Encode)
this.onDemand = onDemand; // Live nur laufen lassen, solange Verbraucher da sind
this.idleGraceMs = idleGraceMs; // Karenz nach letztem Verbraucher vor dem Stop
this.proc = null; // aktueller FFmpeg-Prozess (Live ODER Grab)
this.latest = null; // letztes Live-JPEG (für /api/snapshot)
this.latest = null; // letztes Live-JPEG (für /api/snapshot); null wenn Live aus
this.state = 'stopped'; // stopped | live | grabbing
this.lock = false; // Mutex: nur ein Grab gleichzeitig
this.stopping = false; // unterscheidet absichtliches Kill von Crash
this.restartTimer = null;
this.subscribers = 0; // aktive Verbraucher (Stream-Clients + laufende Snapshots)
this.idleTimer = null;
}
start() {
// On-Demand: lazy Live startet erst beim ersten Verbraucher (acquire()).
if (this.onDemand) return;
if (this.state === 'stopped' && !this.proc) this._spawnLive();
}
// ── Verbraucher-Zählung / On-Demand ────────────────────────────────────────
acquire() {
this.subscribers++;
if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
if (this.onDemand && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive();
}
release() {
this.subscribers = Math.max(0, this.subscribers - 1);
if (this.subscribers === 0 && this.onDemand) this._scheduleIdleStop();
}
_scheduleIdleStop() {
if (this.idleTimer) return;
this.idleTimer = setTimeout(() => {
this.idleTimer = null;
if (this.subscribers === 0 && this.state === 'live' && !this.lock && this.proc) {
console.log(`[cam ${this.id}] idle (keine Clients) → Live gestoppt`);
this.stopping = true; // → 'close'-Handler startet NICHT neu
try { this.proc.kill('SIGTERM'); } catch (_e) {}
}
}, this.idleGraceMs);
}
// Aktuelles Frame für /api/snapshot. Startet bei Bedarf on-demand und wartet
// auf das erste frische Frame (latest ist null, solange Live aus ist).
async getFrame(timeoutMs = 4000) {
this.acquire();
try {
if (this.latest) return this.latest; // frisch (nur gesetzt solange Live läuft)
return await new Promise((resolve, reject) => {
const t = setTimeout(() => { this.removeListener('frame', onF); reject(new Error('Frame-Timeout')); }, timeoutMs);
const onF = (f) => { clearTimeout(t); this.removeListener('frame', onF); resolve(f); };
this.on('frame', onF);
});
} finally {
this.release();
}
}
// ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
_spawnLive() {
this.stopping = false;
const args = [
'-hide_banner', '-loglevel', 'warning',
'-fflags', 'nobuffer', // Input nicht puffern → niedrige Latenz
'-f', 'v4l2', '-input_format', 'mjpeg',
'-video_size', this.liveSize, '-framerate', String(this.liveFps),
'-i', this.device,
'-c:v', 'mjpeg', '-q:v', '5', '-f', 'mpjpeg', 'pipe:1',
...videoOutArgs(this.encode),
'-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben
];
let p;
try {
@@ -121,9 +181,10 @@ class CameraSwitch extends EventEmitter {
p.on('error', (e) => console.error(`[cam ${this.id}] live ffmpeg error: ${e.message}`));
p.on('close', (code, sig) => {
this.proc = null;
this.latest = null; // Producer weg → gepuffertes Frame ist stale
const wasStopping = this.stopping;
if (this.state === 'live') this.state = 'stopped';
if (wasStopping) return; // beabsichtigt (HD-Grab) → grabHires startet Live neu
if (wasStopping) return; // beabsichtigt (HD-Grab/idle) → kein Auto-Restart hier
console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`);
this._scheduleRestart();
});
@@ -131,10 +192,11 @@ class CameraSwitch extends EventEmitter {
}
_scheduleRestart() {
if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
if (this.restartTimer) return;
this.restartTimer = setTimeout(() => {
this.restartTimer = null;
if (this.state === 'stopped' && !this.lock) this._spawnLive();
if (this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
}, 1500);
}
@@ -159,11 +221,11 @@ class CameraSwitch extends EventEmitter {
console.log(`[cam ${this.id}] HD OK ${jpeg.length} bytes, Breite=${readJpegWidth(jpeg) ?? '?'} (${Date.now() - t0}ms)`);
return jpeg;
} finally {
// 3. IMMER zurück auf Live (auch bei Fehler) Live hat Priorität
// 3. Zurück auf Live, sofern noch Verbraucher da sind (On-Demand). Live hat Priorität.
this.state = 'stopped';
this._spawnLive();
if (!this.onDemand || this.subscribers > 0) this._spawnLive();
this.lock = false;
console.log(`[cam ${this.id}] HD beendet, Live zurück (gesamt ${Date.now() - t0}ms)`);
console.log(`[cam ${this.id}] HD beendet${(!this.onDemand || this.subscribers > 0) ? ', Live zurück' : ' (idle, kein Live-Restart)'} (gesamt ${Date.now() - t0}ms)`);
}
}
@@ -189,7 +251,7 @@ class CameraSwitch extends EventEmitter {
'-f', 'v4l2', '-input_format', 'mjpeg',
'-video_size', this.hiresSize, '-framerate', String(this.hiresFps),
'-i', this.device,
'-c:v', 'mjpeg', '-q:v', '5', '-f', 'mpjpeg', 'pipe:1',
...videoOutArgs(this.encode), '-f', 'mpjpeg', 'pipe:1',
];
let p;
try {

View File

@@ -42,11 +42,12 @@ function createSnapshotRouter(switches) {
}
});
router.get('/:id', (req, res) => {
router.get('/:id', async (req, res) => {
const sw = switches[req.params.id];
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
const frame = sw.latest;
if (!frame) return res.status(503).json({ error: 'noch kein Frame verfügbar' });
try {
// getFrame() startet die Kamera bei Bedarf on-demand und wartet auf ein frisches Bild
const frame = await sw.getFrame();
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': frame.length,
@@ -55,6 +56,9 @@ function createSnapshotRouter(switches) {
'X-Timestamp': new Date().toISOString(),
});
res.end(frame);
} catch (err) {
res.status(503).json({ error: `kein Frame: ${err.message}` });
}
});
return router;
@@ -76,27 +80,36 @@ function createStreamRouter(switches) {
'Connection': 'close',
'X-Camera-Id': req.params.id,
});
if (res.socket) res.socket.setNoDelay(true); // Nagle aus → kein Sammel-Delay
let closed = false;
const cleanup = () => { if (!closed) { closed = true; sw.removeListener('frame', onFrame); } };
const cleanup = () => {
if (closed) return;
closed = true;
sw.removeListener('frame', onFrame);
sw.release(); // On-Demand: Verbraucher abmelden
};
const onFrame = (buf) => {
if (closed) return;
// Backpressure: langsamer Client bremst die anderen nicht Frames droppen
if (res.writableLength > (1 << 20)) return;
// try/catch: ein kaputter Client darf die anderen nicht aushungern
// (ein werfender 'frame'-Listener würde sonst emit() abbrechen)
// cork/uncork: Header+JPEG+Trailer als EIN TCP-Segment → minimale Latenz.
// try/catch: ein kaputter Client darf die anderen nicht aushungern.
try {
res.cork();
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`);
res.write(buf);
res.write('\r\n');
res.uncork();
} catch (_e) {
cleanup();
}
};
sw.acquire(); // On-Demand: Verbraucher anmelden (startet Live falls nötig)
sw.on('frame', onFrame);
if (sw.latest) onFrame(sw.latest); // sofort erstes Bild
if (sw.latest) onFrame(sw.latest); // sofort erstes Bild, falls schon eins da
req.on('close', cleanup);
res.on('error', cleanup);