diff --git a/backend/app/services/external_camera.py b/backend/app/services/external_camera.py index f2d76d607..37a48c99f 100644 --- a/backend/app/services/external_camera.py +++ b/backend/app/services/external_camera.py @@ -461,6 +461,39 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None: await proxy_server.wait_closed() +def _transcode_to_jpeg(data: bytes) -> bytes | None: + """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG. + + Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than + JPEG. A browser opened directly at the URL renders those fine, but our MJPEG + ``multipart/x-mixed-replace`` stream hard-labels every part + ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject + the frame and drop the whole stream ("connection lost", #1902). Transcoding to + JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream + (plate detection, Obico, finish photo) working. + + Returns None if the bytes are not a decodable image (e.g. an HTML error page) + or if the imaging libraries are unavailable — callers fall back to the raw + bytes so behaviour is never worse than before. + """ + try: + import cv2 + import numpy as np + except ImportError: + return None + try: + img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) + if img is None: + return None + ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + if not ok: + return None + return buf.tobytes() + except Exception as e: # cv2 raises cv2.error (a subclass of Exception) on bad input + logger.debug("Snapshot transcode to JPEG failed: %s", e) + return None + + async def _capture_snapshot(url: str, timeout: int) -> bytes | None: """Fetch snapshot from HTTP URL. @@ -484,14 +517,6 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None: return None data = await response.read() - - # Validate it looks like JPEG - if not data.startswith(b"\xff\xd8"): - logger.warning("Snapshot does not appear to be JPEG") - # Still return it - might be valid with different header - - return data - except TimeoutError: logger.warning("Snapshot capture timed out after %ss", timeout) return None @@ -499,6 +524,34 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None: logger.error("Snapshot capture failed: %s", e) return None + # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode). + if data.startswith(b"\xff\xd8"): + return data + + # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so + # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of + # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is + # CPU-bound and this can be polled at up to 15 fps while a camera view is open. + transcoded = await asyncio.to_thread(_transcode_to_jpeg, data) + if transcoded is not None: + logger.debug( + "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG", + len(data), + data[:4].hex(), + ) + return transcoded + + # Couldn't decode it as an image at all — most likely not an image response + # (HTML error page, auth redirect, wrong URL). Return the raw bytes as a last + # resort (unchanged behaviour) but log enough to debug. + logger.warning( + "External camera snapshot is not a decodable image " + "(%d bytes, header %s) — verify the camera URL returns an image", + len(data), + data[:4].hex(), + ) + return data + async def test_connection(url: str, camera_type: str) -> dict: """Test camera connection. diff --git a/backend/tests/unit/services/test_external_camera.py b/backend/tests/unit/services/test_external_camera.py index 5e4b49295..ac388f314 100644 --- a/backend/tests/unit/services/test_external_camera.py +++ b/backend/tests/unit/services/test_external_camera.py @@ -591,3 +591,117 @@ class TestUsbCameraHandling: result = await capture_frame("http://example.com", "usb", timeout=1) assert result is None + + +def _encode_image(ext: str) -> bytes: + """Encode a small solid test image to the given container (.png/.webp/.jpg).""" + import cv2 + import numpy as np + + img = np.zeros((16, 24, 3), dtype=np.uint8) + img[:, :12] = (0, 0, 255) # half red so the frame isn't uniformly black + ok, buf = cv2.imencode(ext, img) + assert ok, f"failed to encode {ext}" + return buf.tobytes() + + +def _fake_snapshot_session(body: bytes, status: int = 200): + """Build an aiohttp.ClientSession stand-in whose GET yields `body`. + + Matches the `async with ClientSession(...) as session, session.get(url) as + response` usage inside `_capture_snapshot`. + """ + + class _Resp: + def __init__(self): + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def read(self): + return body + + class _Session: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + def get(self, _url): + return _Resp() + + return _Session + + +class TestSnapshotTranscode: + """Regression for #1902. Snapshot endpoints that serve PNG/WebP (not JPEG) + broke the browser MJPEG stream, because every multipart part is hard-labelled + ``Content-Type: image/jpeg`` — the browser rejected the non-JPEG payload and + dropped the whole stream ("connection lost"). ``_capture_snapshot`` now + transcodes non-JPEG stills to JPEG; only genuinely undecodable payloads fall + through to the raw bytes (unchanged last-resort behaviour).""" + + def test_transcode_png_to_jpeg(self): + from backend.app.services.external_camera import _transcode_to_jpeg + + png = _encode_image(".png") + assert not png.startswith(JPEG_START) # sanity: input really is PNG + out = _transcode_to_jpeg(png) + assert out is not None and out.startswith(JPEG_START) + + def test_transcode_webp_to_jpeg(self): + from backend.app.services.external_camera import _transcode_to_jpeg + + webp = _encode_image(".webp") + assert not webp.startswith(JPEG_START) + out = _transcode_to_jpeg(webp) + assert out is not None and out.startswith(JPEG_START) + + def test_transcode_returns_none_for_non_image(self): + """HTML error pages / auth redirects / empty bodies aren't images — + transcode returns None so the caller can log and fall back.""" + from backend.app.services.external_camera import _transcode_to_jpeg + + assert _transcode_to_jpeg(b"404 Not Found") is None + assert _transcode_to_jpeg(b"") is None + + @pytest.mark.asyncio + async def test_capture_snapshot_transcodes_png_response(self): + """The reported case: a snapshot URL returning PNG yields JPEG bytes.""" + from backend.app.services import external_camera as ec + + png = _encode_image(".png") + with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(png)): + out = await ec._capture_snapshot("http://192.168.50.50/snapshot.png", 10) + assert out is not None and out.startswith(JPEG_START) + + @pytest.mark.asyncio + async def test_capture_snapshot_jpeg_passthrough_unchanged(self): + """A JPEG snapshot must be returned byte-for-byte (fast path, no + re-encode) so we don't degrade quality or waste CPU on JPEG cameras.""" + from backend.app.services import external_camera as ec + + jpeg = _encode_image(".jpg") + assert jpeg.startswith(JPEG_START) + with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(jpeg)): + out = await ec._capture_snapshot("http://192.168.50.50/snapshot.jpg", 10) + assert out == jpeg # identical object bytes — proves no transcode ran + + @pytest.mark.asyncio + async def test_capture_snapshot_non_image_falls_back_to_raw(self): + """Undecodable (non-image) responses return the raw bytes unchanged, so + behaviour is never worse than before the fix.""" + from backend.app.services import external_camera as ec + + html = b"unauthorized" + with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(html)): + out = await ec._capture_snapshot("http://192.168.50.50/snapshot", 10) + assert out == html