Multicam (c) fix 3
This commit is contained in:
@@ -1,483 +1,204 @@
|
||||
> # ⛔ ABGELÖST (2026-06-05) — dieser Ansatz war die Ursache des 106%-Bugs
|
||||
>
|
||||
> Der unten beschriebene **Consumer-Umhängen-Ansatz mit go2rtc** (`cam0` loslassen →
|
||||
> go2rtc gibt Gerät frei → `cam0_hires` greifen) hat sich als **prinzipiell racy**
|
||||
> erwiesen: go2rtcs API kann nicht zuverlässig melden, wann FFmpeg `/dev/videoN`
|
||||
> freigibt → zwei Encoder auf einem Gerät → **106% CPU + Freeze** (siehe `09_Bug_reports.md`).
|
||||
>
|
||||
> **Aktuelle, maßgebliche Architektur:** **Node-MJPEG-Schalter, go2rtc entfernt.**
|
||||
> Node besitzt die Kameras selbst; das `close`-Event des eigenen FFmpeg ist der harte
|
||||
> Beweis „Gerät frei". Das Race ist damit konstruktiv ausgeschlossen.
|
||||
>
|
||||
> | | alt (unten, abgelöst) | **neu (maßgeblich)** |
|
||||
> |-|----------------------|----------------------|
|
||||
> | Geräte-Öffner | go2rtc | **Node** `src/cameraSwitch.js` |
|
||||
> | Live | go2rtc-WS + `video-stream.js` | MJPEG multipart → `<img>` |
|
||||
> | HD-Grab | 2. go2rtc-Stream `cam_hires` (Race) | Schalter: Live stoppen (`close`=FD frei) → 1280 → zurück |
|
||||
> | Multi-User | brach | gelöst (ein FFmpeg → Fan-out) |
|
||||
>
|
||||
> **→ Neue Architektur + Hardware-Testplan stehen weiter unten in diesem Dokument
|
||||
> (Abschnitt „## Node-MJPEG-Schalter").** Alles ab hier bis dorthin ist **Historie**.
|
||||
# AppRobotWebcam – Snapshot & HD-Grab
|
||||
|
||||
---
|
||||
|
||||
# AppRobotWebcam – Hi-Res-Snapshot via Consumer-Umhängen ⛔ (historisch)
|
||||
|
||||
> Status: **Phase 2 implementiert und funktional** (2026-06-04):
|
||||
> HD-Grab liefert echten 1280×960-Frame (76071 bytes bestätigt). Bekanntes Problem
|
||||
> behoben: `_hires`-Streams aus Kameraliste gefiltert. CPU ~35 % stabil.
|
||||
> Vorgeschichte & gescheiterte Ansätze: siehe `04_Delay_roadmap.md` (Abschnitt
|
||||
> „KONSOLIDIERT"). Diese Datei beschreibt den Ansatz, der die dort dokumentierten
|
||||
> Fehler **strukturell** umgeht.
|
||||
|
||||
---
|
||||
|
||||
## Grundidee
|
||||
|
||||
Das Kernproblem aller bisherigen Versuche: **Eine USB-Kamera lässt sich nur einmal
|
||||
öffnen**, und der Live-Viewer zwingt go2rtc, das Gerät zu halten (per on-demand-
|
||||
Reconnect). Jeder Versuch, go2rtc das Gerät zu *entreißen* oder die Live-Quelle zur
|
||||
Laufzeit *umzuschalten*, ist gescheitert (device-busy, bzw. `PATCH` hängt an → 107 %).
|
||||
|
||||
**Neuer Ansatz – das Problem umdrehen:** Nicht das Gerät dem Stream entreißen, sondern
|
||||
**die Zuschauer vom Live-Stream wegziehen.** Hat `cam0` keine Zuschauer mehr, stoppt
|
||||
go2rtc den Producer von selbst (on-demand) und gibt das Gerät frei. Dann kann ein
|
||||
separater Hi-Res-Stream es kurz für sich haben.
|
||||
|
||||
### Warum das sicher ist (im Gegensatz zu allem vorher)
|
||||
|
||||
- **`cam0` wird nie verändert.** Kein `PATCH`/`PUT`/`DELETE` auf den Live-Stream.
|
||||
Das Append-Problem (107 %) kann nicht auftreten.
|
||||
- **Zur Laufzeit nur LESENDE go2rtc-Aufrufe**: `GET /api/streams`, `GET /api/frame.jpeg`.
|
||||
Die einzige „schreibende" Änderung ist das Hinzufügen von `cam0_hires` in der Config
|
||||
(per Redeploy, nicht zur Laufzeit).
|
||||
- **Kleiner Schadensradius**: Geht etwas schief, ist die Erholung „Browser wieder auf
|
||||
`cam0` hängen" → go2rtc startet `cam0` neu. Keine kaputte Stream-Definition, die bis
|
||||
zum Neustart hängt.
|
||||
|
||||
Damit respektiert der Ansatz die eisernen Regeln aus `04_*` (Snapshot-Pfad read-only,
|
||||
keine Laufzeit-Mutation von cam0/cam1).
|
||||
> Status: **Implementiert**. Architektur seit 2026-06-05 (Node-MJPEG-Schalter).
|
||||
> Gemessene Werte: Latenz 139 ms, ~5 % idle, ~35 %/Kamera aktiv, HD-Grab ~2–3 s.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
| Stream | Quelle | on-demand | Zweck |
|
||||
|--------|--------|-----------|-------|
|
||||
| `cam0` | Kamera @640 | ja | **unverändert** – Live-Stream |
|
||||
| `cam1` | Kamera @640 | ja | **unverändert** – Live-Stream |
|
||||
| `cam0_hires` | Kamera @1280×960 | ja | **nur** für den Hi-Res-Grab (Phase 2) |
|
||||
| `cam1_hires` | Kamera @1280×960 | ja | dito für cam1 |
|
||||
|
||||
**Platzhalter = rein clientseitig**, kein go2rtc-Stream nötig:
|
||||
Der Browser friert beim Umhängen den zuletzt gezeigten Live-Frame auf einem `<canvas>`
|
||||
ein und blendet „HD Image Work" ein (ca. 30 % der Bildgröße, unten rechts). Das ist
|
||||
das, was während des Grabs zu sehen ist. Vorteil: keine zusätzliche go2rtc-Last, kein
|
||||
Nachschieben von Bildern in go2rtc.
|
||||
|
||||
> Alternative (nur falls *mehrere* Zuschauer gleichzeitig denselben Platzhalter sehen
|
||||
> sollen): ein echter go2rtc-`standbild0`-Stream aus einer statischen Bilddatei. Mehr
|
||||
> Aufwand, hier zunächst nicht nötig.
|
||||
|
||||
---
|
||||
|
||||
## Ziel-Ablauf (vollständig, Phase 1 + 2)
|
||||
Eine `CameraSwitch`-Instanz pro physischem Gerät — der einzige Öffner von `/dev/videoN`.
|
||||
Hält immer nur **einen** FFmpeg: entweder Live **oder** Grab. Nie beide.
|
||||
|
||||
```
|
||||
1. Browser hängt um: cam0 → Canvas-Standbild ("HD Image Work") [clientseitig]
|
||||
2. cam0 hat 0 Zuschauer → go2rtc stoppt cam0-Producer → Gerät frei
|
||||
3. [Pause ~4s] ← Wert ist FREI WÄHLBAR, wird aus Phase-1-Messung gesetzt
|
||||
4. Node holt 1 Frame von cam0_hires → go2rtc öffnet Gerät @1280 → frame.jpeg
|
||||
(Breite ≥1000px prüfen, sonst retry; Warmup beachten – s.u.)
|
||||
5. cam0_hires-Consumer endet → Gerät frei [Pause ~4s]
|
||||
6. Browser hängt zurück: Canvas → cam0 → Live @640 wieder da
|
||||
cameras.json
|
||||
└── server.js → CameraSwitch (besitzt /dev/videoN)
|
||||
│
|
||||
├── Live: ffmpeg → mpjpeg pipe → Fan-out an N Browser-Clients (<img>)
|
||||
└── Grab: Live SIGTERM → close-Event = FD frei → hires-FFmpeg → zurück
|
||||
```
|
||||
|
||||
Blackout auf **cam0** ~8–10 s (mit 4s-Pausen), cam1 unberührt. Für „alle 30 s,
|
||||
Button-getriggert, Blackout ok" passt das. Die Pausen sind großzügig gewählt; sobald
|
||||
Phase 1 die echte Freigabe-Zeit liefert, können sie gekürzt werden.
|
||||
|
||||
> **Die 4s sind ein Startwert, keine feste Größe.** Phase 1 misst, wie schnell go2rtc
|
||||
> das Gerät wirklich freigibt → daraus wird der reale Pausenwert.
|
||||
Das `close`-Event des Kindprozesses ist der harte Beweis „Gerät frei" — kein Timing,
|
||||
keine Polls. Das Race (zwei Encoder auf einem Gerät) ist konstruktiv ausgeschlossen.
|
||||
|
||||
---
|
||||
|
||||
## Der eine Dreh- und Angelpunkt (= warum Phase 1 zuerst kommt)
|
||||
## Live-Stream
|
||||
|
||||
Der ganze Ansatz steht und fällt mit **einer** Annahme:
|
||||
```
|
||||
ffmpeg -fflags nobuffer -f v4l2 -input_format mjpeg
|
||||
-video_size <liveSize> -framerate <liveFps>
|
||||
-i /dev/videoN
|
||||
-c:v copy -bsf:v mjpeg2jpeg ← copybsf (Default)
|
||||
-f mpjpeg -flush_packets 1 pipe:1
|
||||
```
|
||||
|
||||
> **Gibt go2rtc das Gerät frei, wenn `cam0` den letzten Zuschauer verliert — und wie
|
||||
> schnell?**
|
||||
| Encode-Modus | CPU | Wann |
|
||||
|---|---|---|
|
||||
| `copybsf` (Default) | ~35 %/Kamera | Kamera liefert natives MJPEG |
|
||||
| `mjpeg` (Fallback) | ~50 %/Kamera | Re-Encode, falls copybsf Probleme macht |
|
||||
|
||||
go2rtc *kann* einen Producer nach dem letzten Consumer „warm" halten statt ihn sofort
|
||||
zu stoppen. Tut es das, bleibt das Gerät belegt und `cam0_hires` läuft auf „device
|
||||
busy". **Diese Zahl wird in Phase 1 gemessen, bevor irgendein Hi-Res-Grab gebaut wird.**
|
||||
**`mjpeg2jpeg` ist Pflicht bei copybsf** — Kamera-MJPEG lässt JPEG-Huffman-Tabellen
|
||||
weg; ohne den Filter verschluckt sich der mpjpeg-Muxer (107 % CPU + Hang).
|
||||
|
||||
**Latenz-Tuning:** `-fflags nobuffer` + `-flush_packets 1` + `socket.setNoDelay(true)` +
|
||||
`cork/uncork` (Header+JPEG+Trailer = ein TCP-Segment). Gemessen: **139 ms** Kamera→Browser.
|
||||
|
||||
**On-Demand:** Live läuft nur wenn Clients verbunden sind. `acquire()`/`release()` zählen
|
||||
Verbraucher. Nach dem letzten + `IDLE_GRACE_MS` (15 s): Stop → **0 % idle**. Abschaltbar
|
||||
via `ON_DEMAND=false`.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Freigabe verifizieren (kein Grab, voll reversibel)
|
||||
## HD-Grab (`grabHires`)
|
||||
|
||||
**Ziel:** Beweisen, dass Schritt 1 → 2 → 6 funktioniert und das Gerät tatsächlich
|
||||
(und wie schnell) frei wird. Kein `cam0_hires`, kein 1280-Zugriff. Risiko ~null:
|
||||
im schlimmsten Fall ist es ein Reconnect von cam0.
|
||||
### Fall A — liveSize == hiresSize
|
||||
|
||||
### Umzusetzen
|
||||
Wenn die Live-Auflösung bereits der gewünschten Hires-Auflösung entspricht (z.B. C920
|
||||
mit `liveSize: "1920x1080"`): kein Format-Wechsel nötig. `grabHires` ruft direkt
|
||||
`getFrame()` auf dem laufenden Live-Stream auf. Kein Neustart, kein Übergangs-Problem.
|
||||
|
||||
**A. Viewer (`public/viewer.js`)** – Button „Hi-Res-Test (Phase 1)":
|
||||
1. Aktuellen `cam0`-Frame auf ein `<canvas>` zeichnen, „HD Image Work" einblenden,
|
||||
Canvas anstelle des `<video-stream>` zeigen.
|
||||
2. `<video-stream>` für cam0 **entfernen/stoppen** (das ist das „Umhängen" – cam0
|
||||
verliert seinen Consumer).
|
||||
3. `GET /api/snapshot/cam0/release-test` aufrufen (neuer Node-Endpunkt, s.u.).
|
||||
4. Auf Antwort: `<video-stream>` für cam0 wieder einsetzen (Live zurück), Canvas weg.
|
||||
```
|
||||
Kamera ──live 1920×1080──► getFrame() → JPEG 1920×1080
|
||||
```
|
||||
|
||||
**B. Node (`src/snapshotService.js`)** – neuer read-only Endpunkt
|
||||
`GET /api/snapshot/:id/release-test`:
|
||||
1. Startzeit loggen.
|
||||
2. `GET ${go2rtc}/api/streams` alle 200 ms pollen (max. ~10 s).
|
||||
3. Loggen:
|
||||
- Wann erreicht `cam0` **0 Consumer**?
|
||||
- Wann ist der `cam0`-**Producer gestoppt** (Feld `producers` leer bzw. `state` ≠
|
||||
`running`) → das ist der Proxy für „Gerät frei".
|
||||
- Dauer von „0 Consumer" → „Producer gestoppt" in ms.
|
||||
4. Ergebnis ins Log schreiben **und** als JSON zurückgeben, z. B.:
|
||||
```json
|
||||
{ "freed": true, "msUntilFree": 1700, "samples": [...] }
|
||||
```
|
||||
5. Kein Schreibzugriff auf go2rtc. Nur Lesen.
|
||||
### Fall B — liveSize ≠ hiresSize (C270: 640×480 → 1280×960)
|
||||
|
||||
### Umgesetzt am 2026-06-04
|
||||
- **Node:** `GET /api/snapshot/:id/release-test` in `src/snapshotService.js` – pollt
|
||||
`/api/streams` alle 200 ms (max. 10 s), misst `zeroConsumerAt`/`producerStoppedAt`,
|
||||
liefert `{ freed, msUntilFree, samples }`. Rein lesend. Parser an den bestehenden
|
||||
`server.js`-Monitor angelehnt (`producers[].state === 'running'`, `consumers.length`).
|
||||
- **Viewer:** Pro Kamera Button „HD?" in `public/viewer.js`. Friert den Frame auf ein
|
||||
`<canvas>` („HD Image Work"), entfernt den `<video-stream>` (Umhängen), ruft den
|
||||
Endpunkt, hängt im `finally` **immer** wieder auf Live zurück.
|
||||
- **Messung an der Live-Instanz steht noch aus** (Docker/go2rtc auf dem Server) – erst
|
||||
diese liefert das echte `msUntilFree` für Schritt 3/5.
|
||||
```
|
||||
1. Live-FFmpeg SIGTERM → warte auf close (= FD frei)
|
||||
2. sleep(300ms) ← v4l2-Buffer leeren, Kamera-Reset abwarten
|
||||
3. hires-FFmpeg bei hiresSize/hiresFps starten
|
||||
4. Frames warmlaufen lassen (settleFrames=6) + minWidth-Check (90 % der Soll-Breite)
|
||||
5. Ersten validen Frame nehmen → hires-FFmpeg SIGTERM
|
||||
6. finally: Live-FFmpeg neu starten (immer, auch bei Fehler)
|
||||
```
|
||||
|
||||
### Erfolgskriterium Phase 1
|
||||
- Log/JSON zeigt `freed: true` und eine **konkrete** `msUntilFree`.
|
||||
- Nach dem Test (Schritt 6) zeigt cam0 wieder normal Live (~50 % CPU, stabil).
|
||||
**Blackout:** Der `<img>` friert ~2–3 s ein und läuft danach weiter. Kein Client-Handling nötig.
|
||||
|
||||
### Was wir daraus lernen
|
||||
- `msUntilFree` → der reale Pausenwert für Schritt 3/5 (statt der geratenen 4 s).
|
||||
- **Gemessen (2026-06-04):** `freed: true`, `msUntilFree: 0`, `zeroConsumerAt: 4850 ms`.
|
||||
go2rtc hält das Gerät **nicht** warm — es stoppt den Producer sofort wenn der letzte
|
||||
Consumer weg ist. Der Ansatz ist bestätigt.
|
||||
- Würde der Producer nicht gestoppt (`freed: false`): go2rtc hält das Gerät warm →
|
||||
Ansatz so nicht tragfähig → zurück zu Weg A (separate Kamera, siehe `04_*`). Trat
|
||||
**nicht** ein.
|
||||
|
||||
> ⚠ Die genaue JSON-Form von `/api/streams` (Felder `producers`/`consumers`/`state`)
|
||||
> vor dem Bauen kurz an der echten Instanz ansehen (`curl -s localhost:1984/api/streams`)
|
||||
> und den Parser danach ausrichten — nicht annehmen.
|
||||
**minWidth** wird automatisch aus `hiresSize` abgeleitet (`floor(hiresW × 0.9)`).
|
||||
Damit werden Frames abgelehnt, die noch auf der alten Live-Auflösung liegen
|
||||
(v4l2-Buffer-Reste vom vorherigen Format).
|
||||
|
||||
---
|
||||
|
||||
## Erster Live-Test (2026-06-04) — INCONCLUSIVE (nicht widerlegt, ersetzt durch zweiten)
|
||||
## Kamera-spezifische Konfiguration
|
||||
|
||||
Antwort: `{ freed: false, zeroConsumerAt: null, producerStoppedAt: null }`.
|
||||
### C920 (HD Pro Webcam) — liveSize = hiresSize
|
||||
|
||||
**Richtig gelesen:** `zeroConsumerAt: null` = cam0 hatte während der vollen 10 s Messung
|
||||
**nie 0 Consumer**. Damit wurde die eigentliche Linchpin-Frage (*stoppt der Producer bei
|
||||
0 Consumern?*) **gar nicht getestet** — der Vorzustand „0 Consumer" wurde nie erreicht.
|
||||
Der Test ist **ergebnislos, nicht widerlegend.** (Frühere Notiz „Producer warm gehalten →
|
||||
Ansatz tot" war voreilig und ist zurückgezogen.)
|
||||
Die C920 hat ein v4l2-Treiber-Eigenheit: nach einem Live-Stream bei 640×480 kann sie
|
||||
beim Neustart auf 1920×1080 nicht sauber wechseln — der Treiber gibt 1280×720-Frames
|
||||
zurück (Übergangs-Artefakt, trotz korrekter Format-Anforderung).
|
||||
|
||||
**CPU-Spike 106 % war transient.** Direkt nach Disconnect→Reconnect kurz 106 %, danach
|
||||
von selbst auf **23 %** zurück (`docker stats AppRobotGo2RTC`). `curl /api/streams` direkt
|
||||
danach zeigte einen **sauberen Zustand**: je **1** mjpeg-Producer (`-c:v mjpeg`, 640×480)
|
||||
+ **1** Consumer pro Kamera — **kein** doppelter Producer, **kein** H.264. Der Test hat
|
||||
**keinen kaputten State hinterlassen**; cam0/cam1 unangetastet. Read-only-Pfad bestätigt.
|
||||
**Lösung:** `liveSize` und `hiresSize` identisch setzen. `grabHires` wechselt dann kein
|
||||
Format — es liest direkt aus dem laufenden 1920×1080-Stream (Fall A).
|
||||
|
||||
**Warum nie 0 Consumer? — vor dem Weiterbauen klären:**
|
||||
- **Verdacht A — zweiter Consumer:** weiterer Browser-Tab, oder die go2rtc-Debug-UI auf
|
||||
`:1984` zeigte cam0 (= persistenter Consumer). Frühes Monitor-Log zeigte `consumers → 2`.
|
||||
- **Verdacht B — Abmelde-Lag:** `el.remove()` schließt die WS (Browser-Log zeigt
|
||||
`stream.onclose`), go2rtc meldet den Consumer aber nicht (rechtzeitig) als entfernt.
|
||||
- **Datenquelle:** das `samples`-Array der Antwort (Consumer-Zahl alle 200 ms) zeigt es
|
||||
exakt — beim nächsten Lauf auswerten (F12-Console: geloggtes `release-test JSON` aufklappen).
|
||||
```json
|
||||
{
|
||||
"id": "cam2",
|
||||
"liveSize": "1920x1080",
|
||||
"liveFps": 15,
|
||||
"hiresSize": "1920x1080"
|
||||
}
|
||||
```
|
||||
|
||||
**Nächster Schritt (tragfähig):** sicherstellen, dass **nur ein** Consumer total existiert
|
||||
(alle anderen Tabs + go2rtc-UI schließen) und der Feed auf Klick **vollständig** beendet
|
||||
wird, dann neu messen. Erst wenn die Consumer-Zahl nachweislich auf 0 fällt (`samples`
|
||||
zeigt `consumers: 0`), ist die Linchpin-Frage beantwortbar. Fällt sie dann auf 0 und der
|
||||
Producer stoppt → `freed: true` → Phase 2. Stoppt der Producer trotz 0 Consumern nicht →
|
||||
*dann erst* ist der Ansatz widerlegt → Weg A (separate Kamera, `04_*`).
|
||||
### C270 — Standard (Fall B)
|
||||
|
||||
## Zweiter Live-Test (2026-06-04) — ✅ Phase 1 abgeschlossen
|
||||
```json
|
||||
{
|
||||
"id": "cam0",
|
||||
"hiresSize": "1280x960"
|
||||
}
|
||||
```
|
||||
|
||||
Kamera: cam1. Alle anderen Consumer geschlossen (offener Tab an unerwartetem Ort gefunden
|
||||
und geschlossen). Test mit einem einzigen Browser-Tab durchgeführt.
|
||||
Live bei 640×480, Grab bei 1280×960. Format-Wechsel + 300ms-Pause funktioniert.
|
||||
|
||||
### Auflösung prüfen
|
||||
|
||||
Nur MJPG-native Auflösungen in `liveSize`/`hiresSize` verwenden (kein Software-Encode):
|
||||
|
||||
```bash
|
||||
v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
| Endpunkt | Beschreibung |
|
||||
|---|---|
|
||||
| `GET /api/snapshot/:id` | 640er JPEG aus dem Live-Puffer (on-demand, sofort) |
|
||||
| `GET /api/snapshot/:id/hires` | HD-JPEG via `grabHires` (2–3 s, Blackout) |
|
||||
| `GET /api/stream/:id` | MJPEG multipart/x-mixed-replace (Live, Browser `<img>`) |
|
||||
| `GET /api/cameras` | Metadaten aller Kameras aus cameras.json |
|
||||
| `GET /health` | Zustand aller CameraSwitch-Instanzen |
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Restprobleme
|
||||
|
||||
| Problem | Priorität | Zustand |
|
||||
|---|---|---|
|
||||
| Stream friert selten dauerhaft ein | niedrig | Einzelfall; clientseitiger Watchdog (Frame-Timeout → `img.src` neu) noch nicht implementiert |
|
||||
| `unable to decode APP fields` im Log (C270) | kosmetisch | Einmalige Probe-Warnung von FFmpeg, kein Auswirkung |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Entwicklung (Historie)
|
||||
|
||||
Dieser Abschnitt dokumentiert den Weg zur aktuellen Lösung.
|
||||
Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben.
|
||||
|
||||
### go2rtc-Ansatz (2026-06-04) — abgelöst
|
||||
|
||||
#### Grundidee: Consumer-Umhängen
|
||||
|
||||
go2rtc verwaltete die Kameras. Für HD-Grabs wurde der Live-Consumer (`cam0`) losgelassen,
|
||||
damit go2rtc das Gerät freigibt, dann ein separater `cam0_hires`-Stream bei 1280×960 geöffnet.
|
||||
|
||||
#### Phase 1 — Freigabe messen (2026-06-04, ✅ erfolgreich)
|
||||
|
||||
Endpunkt `GET /api/snapshot/:id/release-test` pollt go2rtcs `/api/streams` alle 200 ms
|
||||
und misst, wann nach Consumer-Verlust der Producer stoppt (= Gerät frei).
|
||||
|
||||
**Ergebnis:**
|
||||
```json
|
||||
{ "id": "cam1", "freed": true, "msUntilFree": 0,
|
||||
"zeroConsumerAt": 4850, "producerStoppedAt": 4850 }
|
||||
{ "freed": true, "msUntilFree": 0, "zeroConsumerAt": 4850 }
|
||||
```
|
||||
|
||||
**Was das bedeutet:**
|
||||
- **`freed: true` → Linchpin hält.** go2rtc gibt das Gerät frei, sobald der letzte
|
||||
Consumer weg ist. Der Grundansatz ist tragfähig.
|
||||
- **`msUntilFree: 0`** — Producer stoppt **gleichzeitig** mit dem letzten Consumer-Abgang
|
||||
(kein „warm halten"). Die Pause für Phase 2 kann sehr kurz gewählt werden.
|
||||
- **`zeroConsumerAt: 4850ms`** — es dauert ~5 s, bis go2rtc den WS-Consumer nach
|
||||
`el.remove()` als entfernt registriert. Das ist der Wert, der in Schritt 3 als Pause
|
||||
gebraucht wird: **Browser muss cam0 loslassen und dann ~5 s warten**, bevor der
|
||||
Hi-Res-Grab starten kann. (Nicht 4 s wie geraten — real ~5 s, vermutlich ein go2rtc
|
||||
internen Timeout.)
|
||||
go2rtc gibt das Gerät sofort frei wenn der letzte Consumer weg ist. Kein „warm halten".
|
||||
Die ~5 s (`zeroConsumerAt`) sind die WS-Abmeldelatenz von go2rtc selbst.
|
||||
|
||||
**Bug 1 — Stream blieb schwarz (behoben, Ursache: zweiter Tab):**
|
||||
`startStream()` im `finally` feuerte `stream.onopen`, danach sofort `ondisconnect →
|
||||
Video-Fehler: MEDIA_ELEMENT_ERROR: Empty src attribute → onclose`. Ursache war ein
|
||||
**zweiter offener Browser-Tab** an unerwartetem Ort. Mit genau einem Tab **kommt der
|
||||
Stream korrekt in der 640er-Auflösung zurück.** Kein Code-Fix nötig.
|
||||
#### Phase 2 — HD-Grab implementiert (2026-06-04, ✅ funktionierte)
|
||||
|
||||
**Bug 2 — CPU 106 % nach Test (behoben, Ursache: zweiter Tab):**
|
||||
Direkt nach dem ersten Test 106 %, erforderte Container-Recreate. Nach Schließen des
|
||||
zweiten Tabs und erneutem Test: CPU geht danach auf ~40 % zurück, stabil. Ursache war
|
||||
der zweite Consumer, der go2rtc in einen unklaren Zustand beim Reconnect trieb.
|
||||
**Mit einem Tab: kein CPU-Problem. Kein Code-Fix nötig für Single-Operator-Betrieb.**
|
||||
HD-Grab lieferte echte 1280×960-Frames (~76 KB). Einschränkungen:
|
||||
- Nur bei einem einzigen Viewer-Tab zuverlässig (bei mehreren fiel Consumer-Count nie auf 0)
|
||||
- Sequenz: Browser löst cam0 → 5 s warten → Grab → Browser hängt zurück
|
||||
- Blackout ~8–10 s (5 s Pause + Grab-Zeit)
|
||||
|
||||
> ⚠ Einschränkung bleibt: **Immer nur ein Tab/Client pro Kamera**, wenn der HD-Test
|
||||
> läuft — sonst fällt der Consumer-Count nie auf 0 und der Test schlägt fehl. Für
|
||||
> Single-Operator-Betrieb (Button auf Anforderung) ist das akzeptiert.
|
||||
#### Race-Bug entdeckt (2026-06-05) → go2rtc entfernt
|
||||
|
||||
---
|
||||
go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg `/dev/videoN` freigibt.
|
||||
Folge: zwei FFmpeg gleichzeitig auf demselben Gerät → **106 % CPU + Hang** (reproduzierbar).
|
||||
Details: `09_Bug_reports.md`.
|
||||
|
||||
## Phase 2 — Ergebnis (2026-06-04)
|
||||
**Erkenntnis:** Das Problem ist strukturell — go2rtc gibt keine harten Garantien über
|
||||
den Gerätezustand. Der einzige zuverlässige Beweis „Gerät frei" ist das `close`-Event
|
||||
des FFmpeg-Prozesses selbst.
|
||||
|
||||
### ✅ Funktioniert
|
||||
- HD-Grab pro Kamera via HD-Button: **76071 bytes, echter 1280×960-Frame** bestätigt
|
||||
- Freeze-Canvas zeigt echten 640er-Frame (via `/api/snapshot/:id`, robust im MJPEG-Modus)
|
||||
- Stream erholt sich nach Grab korrekt (Live zurück, ~35 % CPU stabil)
|
||||
- Mutex verhindert parallele Grabs
|
||||
→ **Entscheidung 2026-06-05:** go2rtc entfernen. Node startet FFmpeg direkt.
|
||||
|
||||
### 🐛 Bug gefunden + behoben: `_hires`-Filter
|
||||
`/api/snapshot`-Liste enthielt alle go2rtc-Streams inkl. `cam0_hires`/`cam1_hires`.
|
||||
Folge: Viewer baute Live-Boxen für Hires-Streams → go2rtc versuchte Geräte zu öffnen
|
||||
→ „Resource busy" (cam0/cam1 hielten sie bereits).
|
||||
### Node-MJPEG-Schalter (2026-06-05) — aktuelle Architektur
|
||||
|
||||
Fix in `snapshotService.js`: `.filter(id => !id.endsWith('_hires'))` auf die Kameraliste.
|
||||
Danach: nur cam0/cam1 im Viewer, `_hires`-Streams bleiben dormant. ✓
|
||||
Mit `src/cameraSwitch.js` besitzt Node die Kameras direkt. Das `close`-Event des eigenen
|
||||
FFmpeg-Kindprozesses ist der harte Beweis „FD geschlossen = Gerät frei". Keine go2rtc-
|
||||
Abhängigkeit, kein Race.
|
||||
|
||||
### Offener Punkt: „Snapshot alle" mit HD
|
||||
Aktuell: Button lädt 640er-Frames aller Kameras herunter.
|
||||
Gewünscht: HD-Grabs für alle Kameras synchron auslösen.
|
||||
**Sofort gemessene Werte:**
|
||||
- Latenz: 139 ms (vorher ~340 ms mit go2rtc)
|
||||
- CPU idle: ~5 % (On-Demand)
|
||||
- CPU aktiv: ~35 %/Kamera (copybsf)
|
||||
- HD-Grab beide Kameras parallel: ~2,3 s, je 1280×960
|
||||
|
||||
**Machbarkeit:** sicher und möglich. Cam0 und cam1 liegen auf getrennten Geräten
|
||||
(`/dev/video0` ≠ `/dev/video2`) → parallele Grabs ohne Geräte-Konflikt.
|
||||
Nötige Änderung: globalen `hiresLock` durch per-Kamera-Locks ersetzen (`{ cam0: false, cam1: false }`).
|
||||
Blackout: beide Kameras ~8–10 s gleichzeitig (für Homing besser als versetzt).
|
||||
**Noch nicht implementiert — wartet auf Entscheid.**
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Den Hi-Res-Grab ergänzen (Schritt 4 + 5)
|
||||
|
||||
Phase 1 hat `freed: true` geliefert. **Phase 2 implementiert (s.o.).**
|
||||
Realer Pausenwert aus der Messung: `zeroConsumerAt: 4850 ms` → Schritt 3/5 mit **5 s**
|
||||
planen (statt der geratenen 4 s).
|
||||
|
||||
### Vorbereitung (Config, per Redeploy – nicht zur Laufzeit)
|
||||
`docker-compose.yaml`, go2rtc-`streams` ergänzen:
|
||||
```yaml
|
||||
cam0_hires: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
|
||||
cam1_hires: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
|
||||
```
|
||||
- `#video=mjpeg` (Re-Encode) ist ok – läuft nur ~1–2 s pro Grab. (`#video=copy` ist
|
||||
laut `04_*` auf dieser Kamera tot.)
|
||||
- **Präzondition prüfen:** Nach Redeploy via `/api/streams` bestätigen, dass
|
||||
`cam0_hires` **dormant** ist (kein laufender Producer, solange niemand es anfragt).
|
||||
Sonst würde es beim Start das Gerät greifen und mit `cam0` kollidieren.
|
||||
|
||||
### Node-Endpunkt `GET /api/snapshot/:id/hires` (Phase-2-Variante)
|
||||
Voraussetzung: der **Client hat cam0 bereits losgelassen** (Browser-Dance wie Phase 1).
|
||||
Ablauf im Endpunkt:
|
||||
1. Mutex setzen (kein paralleler Grab).
|
||||
2. (optional) via `/api/streams` verifizieren: `cam0` hat 0 Consumer → sonst abbrechen
|
||||
(Gerät noch belegt).
|
||||
3. `sleep(msUntilFree)` – Gerät freigeben lassen.
|
||||
4. **Grab mit Warmup** (robuste Variante):
|
||||
- Kurz `cam0_hires` als Stream konsumieren (z. B. `GET /api/stream.mjpeg?src=cam0_hires`)
|
||||
für ~1,5 s, damit die Kamera-Belichtung einschwingt und der Producer warm bleibt.
|
||||
- Den **letzten** Frame behalten, der `Breite ≥ 1000 px` **und** nicht „zu klein/
|
||||
schwarz" ist (Warmup-Schutz, vgl. das frühere 1-KB-Schwarzbild).
|
||||
- Einfachere Alternative: `GET /api/frame.jpeg?src=cam0_hires` mit Retry (mehrfach,
|
||||
bis Breite ≥1000 px und plausible Größe).
|
||||
5. Consumer von `cam0_hires` beenden → Gerät frei.
|
||||
6. Mutex lösen. JPEG (1280×960) zurückgeben.
|
||||
|
||||
**Client** nach Antwort: Canvas weg, `<video-stream>` cam0 wieder einsetzen (Schritt 6).
|
||||
|
||||
### Robustheit (Pflicht)
|
||||
- **`finally`/Recovery:** Egal was schiefgeht – der Client MUSS am Ende wieder auf `cam0`
|
||||
hängen. Da `cam0` nie verändert wurde, reicht „wieder anhängen" zur vollen Erholung.
|
||||
- **Timeout** auf den Grab (z. B. 8 s) → sonst Fehler + Recovery.
|
||||
- **Mutex**: nie zwei Grabs gleichzeitig (würde zwei 1280-Producer = Gerätekonflikt
|
||||
provozieren).
|
||||
|
||||
---
|
||||
|
||||
## Platzhalter-Detail (Canvas „HD Image Work")
|
||||
|
||||
- Beim Umhängen den aktuellen Frame des `<video>`-Elements per
|
||||
`canvasCtx.drawImage(video, …)` einfrieren.
|
||||
- Text „HD Image Work" unten rechts, ca. 30 % der Bildbreite, mit halbtransparentem
|
||||
Hintergrund (Lesbarkeit).
|
||||
- Canvas über/anstelle des gestoppten `<video-stream>` zeigen.
|
||||
- Nach Schritt 6 Canvas entfernen.
|
||||
- Rein clientseitig – go2rtc sieht davon nichts.
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte / Risiken (ehrlich)
|
||||
|
||||
| Punkt | Status | Umgang |
|
||||
|-------|--------|--------|
|
||||
| **Gibt go2rtc das Gerät frei + wie schnell?** | **ungeklärt – Linchpin** | **Phase 1 misst es.** Erst danach Phase 2. |
|
||||
| Warmup-Schwarzbild bei 1280 | bekannt | kurz konsumieren + Breiten/Größen-Check + Retry |
|
||||
| Mehrere gleichzeitige Zuschauer | Einschränkung | Gerät wird nur frei, wenn **alle** cam0 loslassen. Für 1 Operator + Button ok; Multi-Client bräuchte ein Broadcast-Signal „alle auf Platzhalter". |
|
||||
| `cam0_hires` greift Gerät schon beim Start? | zu prüfen | nach Redeploy via `/api/streams` bestätigen, dass es dormant ist |
|
||||
| Fehler mitten in der Sequenz | beherrschbar | `finally` → Client immer zurück auf cam0; Worst case go2rtc-Restart, cam0-Definition bleibt heil |
|
||||
| Orchestrierung Client↔Server | Komplexität | klare Reihenfolge: Client löst cam0 → ruft Endpunkt → Endpunkt wartet+grabt → Client hängt zurück |
|
||||
|
||||
---
|
||||
|
||||
## Käme das so hin? — kurz
|
||||
|
||||
**Ja.** Der Ablauf 1–6 mit anpassbaren Pausen ist tragfähig, **wenn** der Linchpin
|
||||
(Geräte-Freigabe nach Consumer-Verlust) hält — und genau das beweist Phase 1, ohne
|
||||
cam0 anzufassen und ohne einen einzigen schreibenden go2rtc-Aufruf. Zwei Vereinfachungen
|
||||
gegenüber der ersten Skizze: der Platzhalter ist clientseitig (kein eigener Stream),
|
||||
und zur Laufzeit wird go2rtc nur **gelesen**, nie verändert.
|
||||
|
||||
**Reihenfolge:** Phase 1 (messen, ~null Risiko) → Pausen aus der Messung setzen →
|
||||
Phase 2 (Grab). Fällt Phase 1, bleibt Weg A (separate Kamera) aus `04_*` der sichere
|
||||
Fallback.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# ✅ Node-MJPEG-Schalter (2026-06-05) — maßgebliche Architektur
|
||||
|
||||
> Ersetzt den gesamten go2rtc-Ansatz oben. Bei Widerspruch gilt dieser Abschnitt.
|
||||
|
||||
## Kernidee: Node besitzt die Kamera selbst
|
||||
|
||||
Das 106%-Race entstand, weil **zwei** FFmpeg (Live 640 + HD 1280) gleichzeitig auf
|
||||
**demselben** `/dev/videoN` liefen, und go2rtcs API nicht zuverlässig melden konnte, wann
|
||||
ein FFmpeg das Gerät freigibt. **Lösung:** Node startet die FFmpeg-Prozesse selbst → das
|
||||
`close`-Event des Kindprozesses ist der harte Beweis „Prozess weg ⇒ Kernel-FD geschlossen
|
||||
⇒ Gerät frei". Race konstruktiv ausgeschlossen, nicht über Timing entschärft.
|
||||
|
||||
```
|
||||
go2rtc ── ENTFERNT
|
||||
Node (server.js)
|
||||
├─ CameraSwitch cam0 ── besitzt /dev/video0 ── EIN FFmpeg (Live ODER HD)
|
||||
├─ CameraSwitch cam1 ── besitzt /dev/video2 ── EIN FFmpeg (Live ODER HD)
|
||||
├─ /api/stream/<id> ── MJPEG multipart/x-mixed-replace → Browser <img>
|
||||
└─ /api/snapshot/<id> ── 640 aus RAM · /<id>/hires → HD-Grab über den Schalter
|
||||
```
|
||||
|
||||
## Der Schalter (`src/cameraSwitch.js`)
|
||||
|
||||
Eine `CameraSwitch`-Instanz pro Gerät — der **einzige** Öffner von `/dev/videoN`. Hält
|
||||
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 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).
|
||||
- **Blackout:** `<img>` friert ~1–3 s ein, läuft dann weiter. **Kein Client-Handling
|
||||
nötig** (das war früher die Fehlerquelle).
|
||||
|
||||
## Auslieferung / Multi-User
|
||||
|
||||
`/api/stream/<id>` = `multipart/x-mixed-replace`; ein FFmpeg → Fan-out an N Clients.
|
||||
Backpressure: voller Socket-Puffer (>1 MB) eines langsamen Clients → Frames für ihn
|
||||
droppen, andere bleiben flüssig. Clients halten **kein** Gerät → **Multi-User gelöst.**
|
||||
|
||||
## Konfiguration (`docker-compose.yaml`)
|
||||
|
||||
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); 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 bestätigt (User, 2026-06-05):**
|
||||
- **Latenz 139 ms** Kamera→Browser (vorher 340 ms) — nach `nobuffer`/`flush_packets`/`setNoDelay`/`cork`.
|
||||
- **CPU ~5 % idle** (On-Demand, keine Clients), ~35 %/Kamera beim aktiven Streamen (copybsf).
|
||||
- **HD-Grab beider Kameras parallel:** je echtes 1280×960-JPEG (~133 KB) in ~2,3 s. Live kehrt sauber zurück.
|
||||
- **Login/Logout + Screenshot+Reconnect:** kein 106%-Race mehr.
|
||||
- **Bekanntes Restproblem (niedrige Prio):** ein Live-Stream ist einmal eingefroren
|
||||
(Einzelfall, akzeptiert). Verdacht: gedroppte/abgebrochene multipart-Verbindung, die
|
||||
nicht von selbst reconnectet. Später prüfen: clientseitiger Watchdog (Frame-Timeout →
|
||||
`img.src` neu setzen) bzw. ein abgebrochener `onFrame`-Write, der `cleanup()` auslöst,
|
||||
ohne dass der Browser neu verbindet.
|
||||
|
||||
## Hardware-Testplan
|
||||
|
||||
1. Code syncen, Stack neu deployen (Image baut FFmpeg ein — erster Build dauert länger).
|
||||
2. Viewer öffnen → beide Kameras Live (`MJPEG · live`). **CPU messen** (Erwartung < 50 %).
|
||||
3. **Bug-Reproweg:** Anmelden → „HD" → Download → Stream nach kurzem Freeze weiter →
|
||||
**neu anmelden / Tab neu laden.** Erwartung: **keine 106%, kein Dauer-Freeze.**
|
||||
4. Zwei Browser gleichzeitig → „HD" während beide verbunden → **kein 503** (Multi-User).
|
||||
5. HD-Bild: 1280×960, nicht schwarz. Blackout-Dauer notieren.
|
||||
6. Eine Kamera abziehen → Log rate-limitierter Restart, andere Kamera + Node unberührt.
|
||||
|
||||
`docker logs AppRobotWebcam` zeigt jeden Zustandswechsel des Schalters.
|
||||
|
||||
## Rollback
|
||||
|
||||
`git checkout <commit-vor-umbau> -- docker-compose.yaml server.js package.json public/ src/`
|
||||
(der go2rtc-Stand liegt vollständig in der Git-Historie).
|
||||
|
||||
## Mögliche Folgeschritte
|
||||
|
||||
- **Auflösungen nativ MJPG?** ✅ **Bestätigt (2026-06-05)** via `v4l2-ctl --list-formats-ext
|
||||
-d /dev/video0`: Kamera liefert `MJPG` in **640×480 @ 30 fps** (Live) UND **1280×960 @
|
||||
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:** `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.
|
||||
Alle weiteren Details: aktuelle Architektur-Abschnitte oben.
|
||||
|
||||
Reference in New Issue
Block a user