From eabc81ebbabb302db92ddfbdf62299bf08b8697c Mon Sep 17 00:00:00 2001 From: Marcos Araujo Sobrinho Date: Sun, 26 Jul 2026 19:27:13 -0300 Subject: [PATCH] feat(camera): cap the H.264 transcode bitrate and make it optional Inspecting the live go2rtc API with the camera open showed the transcode spending nine times the source bitrate: 0.73 Mbit/s of H.265 in, 6.60 Mbit/s of H.264 out, both at 2560x1440. go2rtc's built-in h264 template carries no rate control at all, so x264 falls back to CRF 23 and spends bits preserving the compression artefacts the DVR already introduced. The WebRTC consumer was on a public IPv6, so that was 6.6 Mbit/s going out over the internet for a camera whose source fits in under one. go2rtc's parseArgs supports #bitrate=, applied after the codec args as -b:v/-maxrate/-bufsize, so it overrides the CRF. The ffmpeg source becomes ffmpeg:#video=h264#audio=opus#bitrate=k. Two per-device options, both in the existing options flow: - Transcode H.265 to H.264 (toggle, default on). Off registers nothing with go2rtc and clears both repair advisories, since the trade-off was made deliberately. - H.264 transcode bitrate cap (slider, 256-8192 kbit/s, default 2048). Resolution is deliberately left alone: clients that decode HEVC keep receiving the original 2560x1440, and the cap only affects the browser-facing path. The existing options update listener reloads the entry, so changing either value tears down the old entities (unregistering the stream) and re-registers with the new source string. _stream_has_sources keeps matching because the connected producer's URL carries the whole source string percent-encoded in ?source=, suffix included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UpaDKeLbpQB7EapQBpKoJ --- README.md | 20 ++++++- custom_components/xmeye/camera.py | 39 ++++++++++++-- custom_components/xmeye/config_flow.py | 28 ++++++++++ custom_components/xmeye/const.py | 14 +++++ custom_components/xmeye/manifest.json | 2 +- custom_components/xmeye/strings.json | 4 +- custom_components/xmeye/translations/de.json | 4 +- custom_components/xmeye/translations/en.json | 4 +- custom_components/xmeye/translations/es.json | 4 +- custom_components/xmeye/translations/fr.json | 4 +- custom_components/xmeye/translations/it.json | 4 +- custom_components/xmeye/translations/ja.json | 4 +- custom_components/xmeye/translations/nl.json | 4 +- custom_components/xmeye/translations/pl.json | 4 +- custom_components/xmeye/translations/pt.json | 4 +- custom_components/xmeye/translations/ru.json | 4 +- custom_components/xmeye/translations/tr.json | 4 +- .../xmeye/translations/zh-Hans.json | 4 +- docs/STREAM_INVESTIGATION.md | 52 ++++++++++++++++++- 19 files changed, 187 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9dfcdd8..2876078 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ stream with two producers: ```yaml xmeye__ch_camera: - - rtsp://:554/user=…&password=…&channel=N&stream=0.sdp # H.265, passthrough - - ffmpeg:xmeye__ch_camera#video=h264#audio=opus # H.264, on demand + - rtsp://:554/user=…&password=…&channel=N&stream=0.sdp # H.265, passthrough + - ffmpeg:xmeye__ch_camera#video=h264#audio=opus#bitrate=2048k # H.264, on demand ``` go2rtc walks the producers in order and only dials the second one when the first one's @@ -103,6 +103,22 @@ audio-only variant — its provider skips the rewrite when the stream already ex of its producers matches the camera's stream source. The registration is re-asserted on every `stream_source()` call, so it self-heals. +### Options + +Both settings live in the integration's options (⚙️ on the device page) and apply per +device. Changing either reloads the entry and re-registers the streams. + +- **Transcode H.265 to H.264** (default on). Turn it off and the integration registers + nothing with go2rtc, leaving H.265 handling to Home Assistant's defaults — live view then + only works in clients that decode HEVC. The repair notices disappear too, since the + trade-off was made deliberately. +- **H.264 transcode bitrate cap** (default 2048 kbit/s). go2rtc's built-in h264 template + carries no rate control, so x264 falls back to CRF 23 and spends bits preserving the + compression artefacts the DVR already introduced. On a 2560×1440 main stream that + measured **6.6 Mbit/s out of a 0.73 Mbit/s H.265 source** — nine times the input. The cap + becomes `-b:v/-maxrate/-bufsize` on the encoder. Raise it if the picture looks soft on + busy scenes; it only affects the transcoded path, never what HEVC-capable clients receive. + Notes: - **Any go2rtc works.** The integration reuses the connection the `go2rtc` integration diff --git a/custom_components/xmeye/camera.py b/custom_components/xmeye/camera.py index efc5f17..4d2a5f5 100644 --- a/custom_components/xmeye/camera.py +++ b/custom_components/xmeye/camera.py @@ -31,6 +31,10 @@ CODEC_H264, CODEC_H265, CODEC_UNKNOWN, + CONF_TRANSCODE_BITRATE, + CONF_TRANSCODE_H265, + DEFAULT_TRANSCODE_BITRATE, + DEFAULT_TRANSCODE_H265, DOMAIN, REPAIR_H265_NO_GO2RTC, REPAIR_H265_TRANSCODING, @@ -446,10 +450,22 @@ async def _maybe_register_go2rtc(self) -> None: """Arm go2rtc transcoding if the chosen stream turned out to be H.265.""" if self._stream_url is None or self._codec != CODEC_H265: return + options = self._coordinator.entry.options + if not options.get(CONF_TRANSCODE_H265, DEFAULT_TRANSCODE_H265): + _LOGGER.info( + "ch%d is H.265 but transcoding is disabled for this device — " + "leaving the stream to Home Assistant's defaults", + self._channel + 1, + ) + # Registers nothing; called only so any advisory left over from + # before it was switched off gets cleared. + await self._ensure_go2rtc_stream(self._stream_url) + return _LOGGER.info( "ch%d is H.265 — registering with go2rtc (H.265 passthrough + " - "on-demand H.264 transcode)", + "on-demand H.264 transcode capped at %d kbit/s)", self._channel + 1, + int(options.get(CONF_TRANSCODE_BITRATE, DEFAULT_TRANSCODE_BITRATE)), ) if not await self._ensure_go2rtc_stream(self._stream_url): _LOGGER.debug( @@ -467,7 +483,7 @@ async def _ensure_go2rtc_stream(self, url: str) -> bool: 1. the DVR's RTSP URL — H.265 handed through untouched to clients that can decode it (VLC, the mobile apps, Safari); - 2. ``ffmpeg:#video=h264#audio=opus`` — go2rtc's + 2. ``ffmpeg:#video=h264#audio=opus#bitrate=…`` — go2rtc's ``AddConsumer`` walks producers in order and only dials this one when the previous producer's codecs do not match the consumer, so the transcode costs nothing until a browser without HEVC asks. @@ -478,10 +494,27 @@ async def _ensure_go2rtc_stream(self, url: str) -> bool: see ``homeassistant/components/go2rtc/__init__.py``: ``_update_stream_source`` skips the rewrite when the stream exists and one of its producers matches ``await camera.stream_source()``. + + Returns False without touching go2rtc when the user has turned + transcoding off for this device. """ + options = self._coordinator.entry.options + if not options.get(CONF_TRANSCODE_H265, DEFAULT_TRANSCODE_H265): + # Deliberate choice by the user: register nothing and let HA + # handle the H.265 stream its own way. Dropping the channel also + # clears both advisories — nobody should be nagged about a + # trade-off they made on purpose. + if self._coordinator.h265_channels.pop(self._channel, None) is not None: + _refresh_h265_issues(self.hass, self._coordinator) + return False + client: Go2RTCClient | None = self._coordinator.go2rtc_client identifier = _go2rtc_identifier(self) - sources = [url, f"ffmpeg:{identifier}#video=h264#audio=opus"] + bitrate = int(options.get(CONF_TRANSCODE_BITRATE, DEFAULT_TRANSCODE_BITRATE)) + sources = [ + url, + f"ffmpeg:{identifier}#video=h264#audio=opus#bitrate={bitrate}k", + ] ok = False if client is not None: try: diff --git a/custom_components/xmeye/config_flow.py b/custom_components/xmeye/config_flow.py index b72fe50..7f307e9 100644 --- a/custom_components/xmeye/config_flow.py +++ b/custom_components/xmeye/config_flow.py @@ -33,14 +33,20 @@ CONF_DEVICE_TYPE, CONF_MOTION_CLEAR_DELAY, CONF_STORAGE_REFRESH_INTERVAL, + CONF_TRANSCODE_BITRATE, + CONF_TRANSCODE_H265, CONFIG_ENTRY_VERSION, DEFAULT_MOTION_CLEAR_DELAY, DEFAULT_PORT, DEFAULT_STORAGE_REFRESH_INTERVAL, + DEFAULT_TRANSCODE_BITRATE, + DEFAULT_TRANSCODE_H265, DEFAULT_USERNAME, DOMAIN, MAX_STORAGE_REFRESH_INTERVAL, + MAX_TRANSCODE_BITRATE, MIN_STORAGE_REFRESH_INTERVAL, + MIN_TRANSCODE_BITRATE, UDP_DISCOVERY_PORT, ) @@ -366,6 +372,8 @@ async def async_step_init( CONF_STORAGE_REFRESH_INTERVAL: int( user_input[CONF_STORAGE_REFRESH_INTERVAL] ), + CONF_TRANSCODE_H265: bool(user_input[CONF_TRANSCODE_H265]), + CONF_TRANSCODE_BITRATE: int(user_input[CONF_TRANSCODE_BITRATE]), }, ) @@ -376,6 +384,12 @@ async def async_step_init( current_interval = int( options.get(CONF_STORAGE_REFRESH_INTERVAL, DEFAULT_STORAGE_REFRESH_INTERVAL) ) + current_transcode = bool( + options.get(CONF_TRANSCODE_H265, DEFAULT_TRANSCODE_H265) + ) + current_bitrate = int( + options.get(CONF_TRANSCODE_BITRATE, DEFAULT_TRANSCODE_BITRATE) + ) return self.async_show_form( step_id="init", data_schema=vol.Schema({ @@ -399,5 +413,19 @@ async def async_step_init( mode=NumberSelectorMode.SLIDER, ) ), + vol.Optional( + CONF_TRANSCODE_H265, default=current_transcode + ): bool, + vol.Optional( + CONF_TRANSCODE_BITRATE, default=current_bitrate + ): NumberSelector( + NumberSelectorConfig( + min=MIN_TRANSCODE_BITRATE, + max=MAX_TRANSCODE_BITRATE, + step=256, + unit_of_measurement="kbit/s", + mode=NumberSelectorMode.SLIDER, + ) + ), }), ) diff --git a/custom_components/xmeye/const.py b/custom_components/xmeye/const.py index f7d4f47..d7d5c73 100644 --- a/custom_components/xmeye/const.py +++ b/custom_components/xmeye/const.py @@ -10,12 +10,26 @@ CONF_DEVICE_TYPE = "device_type" CONF_MOTION_CLEAR_DELAY = "motion_clear_delay" CONF_STORAGE_REFRESH_INTERVAL = "storage_refresh_interval" +CONF_TRANSCODE_H265 = "transcode_h265" +CONF_TRANSCODE_BITRATE = "transcode_bitrate" DEFAULT_MOTION_CLEAR_DELAY = 30 # seconds; 0 = disable debounce DEFAULT_STORAGE_REFRESH_INTERVAL = 300 # seconds (5 min) MIN_STORAGE_REFRESH_INTERVAL = 60 # 1 min MAX_STORAGE_REFRESH_INTERVAL = 3600 # 1 hour +# Whether to hand H.265 channels to go2rtc for on-demand H.264 transcoding. +DEFAULT_TRANSCODE_H265 = True + +# Bitrate cap for that transcode, in kbit/s. go2rtc's built-in h264 template +# carries no rate control at all, so x264 falls back to CRF 23 and spends bits +# preserving the compression artefacts the DVR already introduced — a 2K main +# stream measured 6.6 Mbit/s out of a 0.73 Mbit/s H.265 source. `#bitrate=` is +# applied after the codec args, so it overrides that. +DEFAULT_TRANSCODE_BITRATE = 2048 +MIN_TRANSCODE_BITRATE = 256 +MAX_TRANSCODE_BITRATE = 8192 + # DVRIP message IDs MSG_LOGIN = 1000 MSG_LOGIN_RSP = 1001 diff --git a/custom_components/xmeye/manifest.json b/custom_components/xmeye/manifest.json index 6a59e52..ee49081 100644 --- a/custom_components/xmeye/manifest.json +++ b/custom_components/xmeye/manifest.json @@ -11,5 +11,5 @@ "issue_tracker": "https://github.com/equake/hass-xmeye/issues", "quality_scale": "silver", "requirements": [], - "version": "0.8.1" + "version": "0.8.2" } diff --git a/custom_components/xmeye/strings.json b/custom_components/xmeye/strings.json index eec5576..9ba7dfa 100644 --- a/custom_components/xmeye/strings.json +++ b/custom_components/xmeye/strings.json @@ -58,7 +58,9 @@ "description": "Adjust behaviour for this device.", "data": { "motion_clear_delay": "Motion clear delay (0 = immediate)", - "storage_refresh_interval": "Storage (HDD) refresh interval" + "storage_refresh_interval": "Storage (HDD) refresh interval", + "transcode_h265": "Transcode H.265 to H.264 for browsers without HEVC", + "transcode_bitrate": "H.264 transcode bitrate cap" } } } diff --git a/custom_components/xmeye/translations/de.json b/custom_components/xmeye/translations/de.json index 2836397..bfa13b7 100644 --- a/custom_components/xmeye/translations/de.json +++ b/custom_components/xmeye/translations/de.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Verzögerung bis Bewegungsmeldung endet (0 = sofort)", - "storage_refresh_interval": "Aktualisierungsintervall für Speicher (HDD)" + "storage_refresh_interval": "Aktualisierungsintervall für Speicher (HDD)", + "transcode_bitrate": "Bitratenlimit der H.264-Transkodierung", + "transcode_h265": "H.265 für Browser ohne HEVC nach H.264 transkodieren" }, "description": "Verhalten dieses Geräts anpassen.", "title": "XMEye / Sofia Optionen" diff --git a/custom_components/xmeye/translations/en.json b/custom_components/xmeye/translations/en.json index 5ffc31e..b861fd3 100644 --- a/custom_components/xmeye/translations/en.json +++ b/custom_components/xmeye/translations/en.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Motion clear delay (0 = immediate)", - "storage_refresh_interval": "Storage (HDD) refresh interval" + "storage_refresh_interval": "Storage (HDD) refresh interval", + "transcode_bitrate": "H.264 transcode bitrate cap", + "transcode_h265": "Transcode H.265 to H.264 for browsers without HEVC" }, "description": "Adjust behaviour for this device.", "title": "XMEye / Sofia Options" diff --git a/custom_components/xmeye/translations/es.json b/custom_components/xmeye/translations/es.json index 818848e..2326d9b 100644 --- a/custom_components/xmeye/translations/es.json +++ b/custom_components/xmeye/translations/es.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Retraso al limpiar detección de movimiento (0 = inmediato)", - "storage_refresh_interval": "Intervalo de actualización del almacenamiento (HDD)" + "storage_refresh_interval": "Intervalo de actualización del almacenamiento (HDD)", + "transcode_bitrate": "Límite de bitrate de la transcodificación H.264", + "transcode_h265": "Transcodificar H.265 a H.264 para navegadores sin HEVC" }, "description": "Ajuste el comportamiento de este dispositivo.", "title": "Opciones de XMEye / Sofia" diff --git a/custom_components/xmeye/translations/fr.json b/custom_components/xmeye/translations/fr.json index c83150f..4ca2ae6 100644 --- a/custom_components/xmeye/translations/fr.json +++ b/custom_components/xmeye/translations/fr.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Délai avant fin de la détection de mouvement (0 = immédiat)", - "storage_refresh_interval": "Intervalle de rafraîchissement du stockage (HDD)" + "storage_refresh_interval": "Intervalle de rafraîchissement du stockage (HDD)", + "transcode_bitrate": "Plafond de débit du transcodage H.264", + "transcode_h265": "Transcoder le H.265 en H.264 pour les navigateurs sans HEVC" }, "description": "Ajustez le comportement de cet appareil.", "title": "Options XMEye / Sofia" diff --git a/custom_components/xmeye/translations/it.json b/custom_components/xmeye/translations/it.json index 9f00fac..36923ac 100644 --- a/custom_components/xmeye/translations/it.json +++ b/custom_components/xmeye/translations/it.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Ritardo di chiusura rilevamento movimento (0 = immediato)", - "storage_refresh_interval": "Intervallo di aggiornamento archiviazione (HDD)" + "storage_refresh_interval": "Intervallo di aggiornamento archiviazione (HDD)", + "transcode_bitrate": "Limite di bitrate della transcodifica H.264", + "transcode_h265": "Transcodifica H.265 in H.264 per i browser senza HEVC" }, "description": "Regolare il comportamento di questo dispositivo.", "title": "Opzioni XMEye / Sofia" diff --git a/custom_components/xmeye/translations/ja.json b/custom_components/xmeye/translations/ja.json index b2090d5..2c8979d 100644 --- a/custom_components/xmeye/translations/ja.json +++ b/custom_components/xmeye/translations/ja.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "モーション検知解除の遅延 (0 = 即時)", - "storage_refresh_interval": "ストレージ(HDD)更新間隔" + "storage_refresh_interval": "ストレージ(HDD)更新間隔", + "transcode_bitrate": "H.264 トランスコードのビットレート上限", + "transcode_h265": "HEVC 非対応ブラウザー向けに H.265 を H.264 へトランスコード" }, "description": "このデバイスの動作を調整します。", "title": "XMEye / Sofia オプション" diff --git a/custom_components/xmeye/translations/nl.json b/custom_components/xmeye/translations/nl.json index b0e389b..0b1c1f4 100644 --- a/custom_components/xmeye/translations/nl.json +++ b/custom_components/xmeye/translations/nl.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Vertraging bewegingsdetectie wissen (0 = onmiddellijk)", - "storage_refresh_interval": "Verversingsinterval opslag (HDD)" + "storage_refresh_interval": "Verversingsinterval opslag (HDD)", + "transcode_bitrate": "Bitratelimiet van de H.264-transcodering", + "transcode_h265": "H.265 naar H.264 transcoderen voor browsers zonder HEVC" }, "description": "Pas het gedrag van dit apparaat aan.", "title": "XMEye / Sofia opties" diff --git a/custom_components/xmeye/translations/pl.json b/custom_components/xmeye/translations/pl.json index 114b3dc..cfce11a 100644 --- a/custom_components/xmeye/translations/pl.json +++ b/custom_components/xmeye/translations/pl.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Opóźnienie czyszczenia wykrycia ruchu (0 = natychmiast)", - "storage_refresh_interval": "Interwał odświeżania pamięci masowej (HDD)" + "storage_refresh_interval": "Interwał odświeżania pamięci masowej (HDD)", + "transcode_bitrate": "Limit bitrate'u transkodowania H.264", + "transcode_h265": "Transkoduj H.265 na H.264 dla przeglądarek bez HEVC" }, "description": "Dostosuj zachowanie tego urządzenia.", "title": "Opcje XMEye / Sofia" diff --git a/custom_components/xmeye/translations/pt.json b/custom_components/xmeye/translations/pt.json index dc5f307..2f6a8b9 100644 --- a/custom_components/xmeye/translations/pt.json +++ b/custom_components/xmeye/translations/pt.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Delay para limpar movimento (0 = imediato)", - "storage_refresh_interval": "Intervalo de atualização do storage (HDD)" + "storage_refresh_interval": "Intervalo de atualização do storage (HDD)", + "transcode_bitrate": "Teto de bitrate do transcode H.264", + "transcode_h265": "Transcodificar H.265 para H.264 em navegadores sem HEVC" }, "description": "Ajuste o comportamento deste dispositivo.", "title": "Opções XMEye / Sofia" diff --git a/custom_components/xmeye/translations/ru.json b/custom_components/xmeye/translations/ru.json index 5ca2458..dd12c4e 100644 --- a/custom_components/xmeye/translations/ru.json +++ b/custom_components/xmeye/translations/ru.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Задержка сброса детекции движения (0 = мгновенно)", - "storage_refresh_interval": "Интервал обновления информации о хранилище (HDD)" + "storage_refresh_interval": "Интервал обновления информации о хранилище (HDD)", + "transcode_bitrate": "Ограничение битрейта перекодирования H.264", + "transcode_h265": "Перекодировать H.265 в H.264 для браузеров без HEVC" }, "description": "Настройка поведения для этого устройства.", "title": "Параметры XMEye / Sofia" diff --git a/custom_components/xmeye/translations/tr.json b/custom_components/xmeye/translations/tr.json index 2d642e9..13c9b58 100644 --- a/custom_components/xmeye/translations/tr.json +++ b/custom_components/xmeye/translations/tr.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Hareket algılama temizleme gecikmesi (0 = anında)", - "storage_refresh_interval": "Depolama (HDD) yenileme aralığı" + "storage_refresh_interval": "Depolama (HDD) yenileme aralığı", + "transcode_bitrate": "H.264 dönüştürme bit hızı üst sınırı", + "transcode_h265": "HEVC desteklemeyen tarayıcılar için H.265'i H.264'e dönüştür" }, "description": "Bu cihazın davranışını ayarlayın.", "title": "XMEye / Sofia Seçenekleri" diff --git a/custom_components/xmeye/translations/zh-Hans.json b/custom_components/xmeye/translations/zh-Hans.json index 447bd30..0f78633 100644 --- a/custom_components/xmeye/translations/zh-Hans.json +++ b/custom_components/xmeye/translations/zh-Hans.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "移动侦测清除延迟 (0 = 立即)", - "storage_refresh_interval": "存储设备(硬盘)刷新间隔" + "storage_refresh_interval": "存储设备(硬盘)刷新间隔", + "transcode_bitrate": "H.264 转码码率上限", + "transcode_h265": "为不支持 HEVC 的浏览器把 H.265 转码为 H.264" }, "description": "调整此设备的行为。", "title": "XMEye / Sofia 选项" diff --git a/docs/STREAM_INVESTIGATION.md b/docs/STREAM_INVESTIGATION.md index 2f37da5..209a6b5 100644 --- a/docs/STREAM_INVESTIGATION.md +++ b/docs/STREAM_INVESTIGATION.md @@ -2,7 +2,7 @@ **Data:** 2026-06-16 **Device:** HVR XMEye, 9 canais, host `192.168.16.10` (DVRIP porta 34567, RTSP 554) -**Status:** Causa raiz identificada e confirmada. Correção pendente (escolha de remediação). +**Status:** Resolvido nas v0.8.0/v0.8.1; teto de bitrate do transcode na v0.8.2. Ver §8–§10. --- @@ -285,3 +285,53 @@ await document.querySelector("home-assistant").hass ``` Antes: `{frontend_stream_types: ["hls"]}`. Depois: `["hls", "webrtc"]`. + +--- + +## 10. Ajuste (v0.8.2) — o transcode gastava 9× o bitrate da origem + +Com tudo funcionando, uma inspeção ao vivo do `/api/streams` (câmera aberta no Chrome) +mostrou um número ruim. Amostrando `bytes_recv` dos dois producers do `ch0` com 20 s de +intervalo: + +| | | +|---|---| +| Origem H.265 do DVR | **0,73 Mbps** @ 2560×1440 | +| Producer H.264 transcodificado | **6,60 Mbps** @ 2560×1440 | + +Resolução obtida decodificando o `sprop-parameter-sets` do SDP do producer transcodificado. + +Causa: o template `h264` embutido do go2rtc (`internal/ffmpeg/ffmpeg.go`) é + +``` +-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p +``` + +— **sem controle de taxa nenhum**. O x264 cai no CRF 23 padrão e gasta bits tentando +preservar os artefatos de compressão que o próprio DVR já introduziu. Agravante: o consumer +WebRTC observado estava num IPv6 público, ou seja, 6,6 Mbps saindo pela internet para +assistir a uma câmera cuja origem cabe em 0,73 Mbps. + +O `parseArgs` do go2rtc aceita `#bitrate=`, aplicado **depois** dos args de codec: + +```go +if query["bitrate"] != nil { + b := query["bitrate"][0] + args.AddCodec("-b:v " + b + " -maxrate " + b + " -bufsize " + b) +} +``` + +Correção: a fonte do ffmpeg passa a ser +`ffmpeg:#video=h264#audio=opus#bitrate=k`, com `N` configurável por device no fluxo +de opções (slider, 256–8192 kbit/s, padrão 2048). Junto veio um toggle para desligar o +transcode por completo — nesse caso não registramos nada no go2rtc e os dois repair issues +somem, porque a escolha foi deliberada. + +Resolução não é alterada de propósito: quem decodifica HEVC continua recebendo os +2560×1440 originais, e o teto vale só para o caminho do browser. + +Nota sobre o `_stream_has_sources`: o sufixo novo não quebra o match. A URL do producer +conectado é +`rtsp://127.0.0.1:18554/?video&audio&source=ffmpeg:%23video%3Dh264%23audio%3Dopus…`, +que depois do `unquote` contém a string da fonte literalmente, sufixo incluído — verificado +em campo. Trocar a opção muda a string, o match falha de propósito, e o PUT reescreve.