Live view at head office, relayed through the agent's outbound connection

I got this wrong first time. "Head office cannot show live video cheaply"
conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs
WebRTC and a TURN server.

The shop PC is behind a router with no inbound route, so head office
cannot pull the engine's MJPEG. It can answer the agent's outbound
requests, which is the shape of everything else here: the server holds a
poll open, the agent asks "is anyone watching?", and pushes JPEGs up for
exactly as long as somebody is.

Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at
640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds -
4 fps, as configured. The UI says "about 4 frames a second" rather than
letting anyone conclude the camera stutters.

Nothing is uploaded when nobody is looking, which is the whole cost
argument: Publish returns false once the last viewer goes, interest lapses
on a timer each viewer refreshes as it reads (so a closed tab stops the
upload within seconds), one push is capped at five minutes, and the UI
streams one camera at a time.

LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell
pushes nothing because nothing may be lost; here a dropped frame is the
correct outcome, so each viewer has a one-slot buffer that is overwritten -
the only frame worth having is the newest, and a queue would show an
ever-growing delay behind the shop instead of dropping back to live.

Ownership is proved once, before anything streams: the relay is keyed on a
camera id, a hub does not know whose camera it holds, and a camera id is
not a secret. Verified: another tenant gets 404, no session gets 401, and
an agent cannot push into another site's camera.

Also fixes a bug I introduced with it - the Live button was gated on
`connected`, which is head office's last report and up to two minutes
stale, so it hid itself during every reconnect. "Is that camera really
down?" is exactly when somebody wants to look, and a hidden control says
"you cannot" where the honest answer is "here is why".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 16:46:23 +05:30
parent 2cd7a78ddc
commit 18686cbceb
23 changed files with 1250 additions and 67 deletions

View File

@@ -111,6 +111,35 @@ def _auth_dependencies(api_cfg: ApiSection) -> list:
return [Depends(check)]
def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
"""Decode, scale and re-encode one frame. None on any failure.
None rather than an exception on purpose: the caller falls back to the
original frame, so a re-encode that fails costs bandwidth rather than the
picture. A live view that goes blank because a resize failed is a worse
outcome than one that is briefly larger than asked for.
"""
try:
import cv2
import numpy as np
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return None
if 0 < width < img.shape[1]:
# Only ever DOWN. Upscaling a frame to a requested width would send
# more bytes than the original for no more detail.
scale = width / img.shape[1]
img = cv2.resize(img, (width, max(1, int(img.shape[0] * scale))),
interpolation=cv2.INTER_AREA)
q = quality if 1 <= quality <= 100 else 75
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
return buf.tobytes() if ok else None
except Exception:
log.debug("frame re-encode failed", exc_info=True)
return None
def create_app(engine: Engine) -> FastAPI:
app = FastAPI(title="Behavision", version="1.0.0",
dependencies=_auth_dependencies(engine.cfg.api))
@@ -313,10 +342,23 @@ def create_app(engine: Engine) -> FastAPI:
return probe_source(source, cam.max_width)
@app.get("/api/cameras/{camera_id}/frame.jpg")
def frame(camera_id: str) -> Response:
def frame(camera_id: str, width: int = 0, quality: int = 0) -> Response:
"""The latest frame, optionally re-encoded smaller.
`width`/`quality` exist for the live relay, which sends several frames
a second up a shop's uplink and cannot afford the full-size picture the
dashboard uses. The re-encode happens here rather than in the agent
because this process already has OpenCV open and the frame decoded;
shipping a scaler into the agent would be the same work done twice.
Done on demand, not on every frame: a camera nobody is watching must
not pay for a second encode it will never use.
"""
jpeg = worker_or_404(camera_id).latest_jpeg()
if jpeg is None:
raise HTTPException(503, "no frame yet")
if width > 0 or quality > 0:
jpeg = _reencode(jpeg, width, quality) or jpeg
return Response(jpeg, media_type="image/jpeg")
@app.get("/api/cameras/{camera_id}/stream.mjpeg")