diff --git a/demo/console.html b/demo/console.html index 05ffecf..1cf8ad7 100644 --- a/demo/console.html +++ b/demo/console.html @@ -230,11 +230,18 @@ async function tick(){ setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable'); $('#camname').textContent = cam ? cam.id : ''; - if (cam && cam.connected && camStarted !== cam.id){ - camStarted = cam.id; - $('#feed').src = '/camera.mjpeg?id=' + encodeURIComponent(cam.id); - $('#feed').style.display = 'block'; $('#feednone').style.display = 'none'; - $('#camtag').style.display = 'block'; $('#camtag').textContent = cam.id; + if (cam && cam.connected){ + if (camStarted !== cam.id){ + camStarted = cam.id; + $('#feed').style.display = 'block'; $('#feednone').style.display = 'none'; + $('#camtag').style.display = 'block'; + // A polled still rather than an MJPEG stream: the engine re-serves its + // latest frame anyway, and a multipart stream through a proxy is one + // more thing to fail in front of an audience. + setInterval(() => { $('#feed').src = '/camera.jpg?id=' + + encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350); + } + $('#camtag').textContent = cam.id + (cam.tracks ? ` ยท ${cam.tracks} in frame` : ''); } const e = (s.chain||[])[0]; diff --git a/demo/console.py b/demo/console.py index 421f901..67380ad 100644 --- a/demo/console.py +++ b/demo/console.py @@ -133,8 +133,11 @@ def watch(): try: auth = engine_auth() st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth}) - if st == 200 and isinstance(d, dict): - for e in d.get("events", []): + # The engine returns a bare list; a dict with "events" is accepted + # too so this survives either shape. + evs = d if isinstance(d, list) else (d or {}).get("events", []) if isinstance(d, dict) else [] + if st == 200: + for e in evs: if e.get("type") not in ("person.new", "person.seen"): continue key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}" @@ -215,7 +218,9 @@ def snapshot() -> dict: "up": isinstance(health, dict) and bool(health.get("status")), "model": (health or {}).get("recognition_model") if isinstance(health, dict) else None, "cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"), - "frames": c.get("frames"), "faces": c.get("faces")} for c in cams], + "frames": c.get("frames_processed") or c.get("frames_total"), + "faces": c.get("faces_seen"), + "tracks": c.get("active_tracks")} for c in cams], }, "cloud_ok": st_v == 200, "chain": chain, @@ -256,9 +261,26 @@ def main(): return self._send(400, b'{"error":"no id"}') st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8") return self._send(200, json.dumps({"status": st, "history": d}).encode()) - if u.path == "/camera.mjpeg": + if u.path == "/camera.jpg": + # A polled still, not the MJPEG stream. The engine re-serves its + # latest frame until the pipeline produces a new one, so polling + # shows the same picture - and a multipart stream through a + # proxy is one more thing to fail in front of an audience. cam = parse_qs(u.query).get("id", [""])[0] - return self._proxy_stream(f"{ENGINE}/api/cameras/{cam}/stream.mjpeg") + st, body = fetch(f"{ENGINE}/api/cameras/{cam}/frame.jpg", + headers={"authorization": engine_auth()}, timeout=15) + if st != 200 or not isinstance(body, (bytes, bytearray)): + return self._send(502, b'{"error":"no frame"}') + self.send_response(200) + self.send_header("content-type", "image/jpeg") + self.send_header("cache-control", "no-store") + self.send_header("content-length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + return self._send(404, b'{"error":"no"}') def do_POST(self):