system-one-arcade / game /web /stream.js
Aurelien-Lac's picture
d1-2B, Boxes, latest arcade code (liquid-d1 main 4df09f49)
732426d verified
Raw History Blame Contribute Delete
6.28 kB
// Requests to the model, and webcam frames streamed to it. Each new camera frame
// is sent as it arrives, unless `inflight` frames are already waiting, so no
// stale frame queues up; an answer older than the last one delivered is dropped.
// Every answer is timed on this page's clock, for the HUD.
const token = new URLSearchParams(location.search).get("token") || "";
// Frames in flight at once. On this machine one: the model answers frames one after
// another, so a second only waits in line (round trip 64 ms with one, 125 ms with
// two, at the same 15 decisions/s on an M5 Pro). Across a network two, so a frame
// travels while the last one is answered.
const LOCAL = ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
const INFLIGHT = LOCAL ? 1 : 2;
const recent = []; // {at, latency, model} of the last two seconds, every request
// One request to the model: `{image}` (a JPEG data URL) or `{state}` (text or an
// object), and its questions. A question is `{text}` (yes/no), `{text, options}`
// (one of named options) or `{text, levels}` (an ordered score).
export async function decide(body) {
const t0 = performance.now();
const out = await (socket.usable() ? socket.ask(body) : post(body));
const now = performance.now();
recent.push({ at: now, latency: now - t0, model: out.ms });
while (recent.length && now - recent[0].at > 2000) recent.shift();
return out;
}
async function post(body) {
const res = await fetch("decide", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Token": token },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`model server: ${res.status}`);
return res.json();
}
// One WebSocket for every request: no HTTP headers per frame, and behind a proxy
// (a Space) no per-request checks. Answers carry their request's id. A socket that
// never opens sends the page back to plain POSTs for good.
const socket = {
ws: null, pending: new Map(), next: 0, broken: false, ready: null,
usable() { return !this.broken; },
open() {
if (this.ready) return this.ready;
const url = new URL(`ws${token ? `?token=${encodeURIComponent(token)}` : ""}`, location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(url);
this.ready = new Promise((resolve, reject) => {
ws.onopen = () => resolve(ws);
ws.onerror = () => reject(new Error("websocket failed"));
});
ws.onmessage = (e) => {
const out = JSON.parse(e.data);
const p = this.pending.get(out.id);
if (!p) return;
this.pending.delete(out.id);
out.error ? p.reject(new Error(`model server: ${out.error}`)) : p.resolve(out);
};
ws.onclose = () => {
for (const p of this.pending.values()) p.reject(new Error("websocket closed"));
this.pending.clear();
this.ready = null; // the next request opens a new one
};
this.ws = ws;
return this.ready;
},
async ask(body) {
let ws;
try {
ws = await this.open();
} catch (e) {
this.broken = true;
this.ready = null;
return post(body);
}
const id = this.next++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ ...body, id }));
});
},
};
// decisions per second, and the median round trip and model time, over two seconds
export function summary() {
const now = performance.now();
const live = recent.filter((s) => now - s.at < 2000);
const median = (xs) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? 0;
return { rate: live.length / 2, latency: median(live.map((s) => s.latency)), model: median(live.map((s) => s.model)) };
}
// The expected level of a score's distribution, 0 to levels - 1.
export const expected = (probs) => probs.reduce((s, p, i) => s + i * p, 0);
export class Stream {
constructor(video) {
this.video = video;
this.canvas = document.createElement("canvas");
}
// `questions()` is read at every frame, so a game can change them between phases.
// `crop` keeps a horizontal slice of the camera frame, as fractions of its width.
start({ questions, side = 384, inflight = INFLIGHT, crop = [0, 1] }, onAnswer) {
this.stop();
const run = { alive: true, sent: 0, shown: -1, inFlight: 0 };
this.run = run;
// a frame goes out at each camera frame while fewer than `inflight` wait, and at each answer: the model is
// never left idle until the camera's next frame (up to 33 ms at 30 fps)
const pump = () => {
const qs = run.alive ? questions() : [];
if (run.inFlight < inflight && this.video.videoWidth && qs.length) this.#send(run, qs, side, crop, onAnswer, pump);
};
const onFrame = () => {
if (!run.alive) return;
pump();
this.video.requestVideoFrameCallback(onFrame);
};
this.video.requestVideoFrameCallback(onFrame);
}
stop() {
if (this.run) this.run.alive = false;
}
async #send(run, questions, side, crop, onAnswer, next) {
const id = run.sent++;
const frame = this.snapshot(side, crop);
const captured = performance.now();
run.inFlight++;
let answered = false;
try {
const out = await decide({ image: frame, questions });
answered = true;
if (!run.alive || id <= run.shown) return;
run.shown = id;
onAnswer({ probs: out.probs, model: out.ms, captured, latency: performance.now() - captured, frame, questions });
} catch (e) {
console.warn("decide failed", e);
} finally {
run.inFlight--;
if (answered) next(); // after a failure, the camera's next frame: no loop against a server that is down
}
}
// the current camera frame as a JPEG data URL, the full frame's longer side at most `side`
snapshot(side = 384, [x0, x1] = [0, 1]) {
const { videoWidth: vw, videoHeight: h } = this.video;
const w = vw * (x1 - x0);
const scale = Math.min(1, side / Math.max(vw, h));
this.canvas.width = Math.round(w * scale);
this.canvas.height = Math.round(h * scale);
this.canvas.getContext("2d").drawImage(this.video, vw * x0, 0, w, h, 0, 0, this.canvas.width, this.canvas.height);
return this.canvas.toDataURL("image/jpeg", 0.8);
}
}