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):