fix(camera): transcode non-JPEG external snapshots to JPEG (#1902)

External cameras in HTTP-snapshot mode failed to load with a repeating
    "connection lost" when the endpoint served PNG/WebP/BMP stills instead of
    JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
    rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
    every part in a hard-coded Content-Type: image/jpeg boundary, so a
    non-JPEG payload labelled as JPEG made the browser reject the frame and
    tear down the whole multipart/x-mixed-replace stream.

    _capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
    (already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
    path; truly undecodable responses (HTML error pages, auth redirects) fall
    back to the previous raw-return behaviour with a single clear warning
    instead of a per-frame log flood.
This commit is contained in:
maziggy
2026-07-07 11:04:58 +02:00
2 changed files with 175 additions and 8 deletions
+61 -8
View File
@@ -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.
@@ -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"<html><body>404 Not Found</body></html>") 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"<html><body>unauthorized</body></html>"
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