demo console: polled frames and the real stats field names

The camera used an MJPEG stream through the proxy and the engine's
stats under names it does not use (frames/faces rather than
frames_processed/faces_seen), so the picture was blank and the counter
read zero. The engine re-serves its latest frame until the pipeline
produces a new one, so a polled still is the same picture with none of
the multipart fragility - which matters when the audience is in the
room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-24 13:50:08 +05:30
parent 62c2cc8a7b
commit f97ffc913a
2 changed files with 39 additions and 10 deletions

View File

@@ -230,11 +230,18 @@ async function tick(){
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable'); setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
$('#camname').textContent = cam ? cam.id : ''; $('#camname').textContent = cam ? cam.id : '';
if (cam && cam.connected && camStarted !== cam.id){ if (cam && cam.connected){
camStarted = cam.id; if (camStarted !== cam.id){
$('#feed').src = '/camera.mjpeg?id=' + encodeURIComponent(cam.id); camStarted = cam.id;
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none'; $('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
$('#camtag').style.display = 'block'; $('#camtag').textContent = cam.id; $('#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]; const e = (s.chain||[])[0];

View File

@@ -133,8 +133,11 @@ def watch():
try: try:
auth = engine_auth() auth = engine_auth()
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth}) st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
if st == 200 and isinstance(d, dict): # The engine returns a bare list; a dict with "events" is accepted
for e in d.get("events", []): # 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"): if e.get("type") not in ("person.new", "person.seen"):
continue continue
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}" 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")), "up": isinstance(health, dict) and bool(health.get("status")),
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None, "model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"), "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, "cloud_ok": st_v == 200,
"chain": chain, "chain": chain,
@@ -256,9 +261,26 @@ def main():
return self._send(400, b'{"error":"no id"}') return self._send(400, b'{"error":"no id"}')
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8") st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
return self._send(200, json.dumps({"status": st, "history": d}).encode()) 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] 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"}') self._send(404, b'{"error":"no"}')
def do_POST(self): def do_POST(self):