pareidolia / web /capture.js
AndresCarreon's picture
Fix ZeroGPU 429/quota UX: classify as healthy busy, honest retryable panel
8382377 verified
Raw
History Blame Contribute Delete
15 kB
// PAREIDOLIA — capture.js
// The offering: camera (or file) → downscaled JPEG → the awaken rite.
//
// Constraints honoured here (ARCHITECTURE.md §2):
// · client downscale to ≤1024px JPEG, target ≤350KB base64 — cuts upload
// time AND the VLM prefill bill before a single GPU second is spent.
// · the awaken call goes through @gradio/client so the VISITOR's own
// browser-authenticated request pays the ZeroGPU quota, not the Space.
// · every failure surfaces as poetic copy, never a stack trace.
const MAX_DIM = 1024;
const TARGET_B64_BYTES = 350 * 1024;
const QUALITY_LADDER = [0.85, 0.75, 0.65, 0.55, 0.45];
/** True when the page runs in static dev mode (no backend behind it). */
export function isMockMode() {
return new URLSearchParams(location.search).has("mockwall");
}
/** True when the capture screen should skip getUserMedia (screenshots, dev). */
export function isMockCam() {
return new URLSearchParams(location.search).has("mockcam");
}
// ---------------------------------------------------------------- downscale
/**
* Draw any image source onto a canvas at ≤MAX_DIM and walk a JPEG quality
* ladder until the base64 payload fits the target. Returns both the dataUrl
* (for instant local display) and the bare b64 (for the wire).
*/
async function downscale(source, srcW, srcH) {
const scale = Math.min(1, MAX_DIM / Math.max(srcW, srcH));
const w = Math.max(1, Math.round(srcW * scale));
const h = Math.max(1, Math.round(srcH * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(source, 0, 0, w, h);
let dataUrl = "";
for (const q of QUALITY_LADDER) {
dataUrl = canvas.toDataURL("image/jpeg", q);
if (dataUrl.length - "data:image/jpeg;base64,".length <= TARGET_B64_BYTES) break;
}
return { dataUrl, b64: dataUrl.split(",")[1], width: w, height: h };
}
/** Downscale a File/Blob picked from the gallery. */
export async function fileToJpeg(file) {
const url = URL.createObjectURL(file);
try {
const img = await new Promise((res, rej) => {
const el = new Image();
el.onload = () => res(el);
el.onerror = () => rej(new Error("undecodable image"));
el.src = url;
});
return await downscale(img, img.naturalWidth, img.naturalHeight);
} finally {
URL.revokeObjectURL(url);
}
}
/** Freeze the current video frame into a downscaled JPEG. */
export async function frameToJpeg(videoEl) {
return downscale(videoEl, videoEl.videoWidth || 1280, videoEl.videoHeight || 720);
}
// ---------------------------------------------------------------- camera UI
/**
* Build the capture surface inside `stageEl`: a live environment-facing
* camera with a shutter, falling back gracefully to a candlelit file-pick
* altar when no camera will speak to us (denied, absent, or ?mockcam=1).
*
* onPhoto receives {dataUrl, b64, width, height}. Returns {destroy()}.
*/
export function createCaptureSession(stageEl, { onPhoto }) {
let stream = null;
let alive = true;
stageEl.innerHTML = "";
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
fileInput.setAttribute("capture", "environment");
fileInput.style.display = "none";
fileInput.addEventListener("change", async () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
try {
onPhoto(await fileToJpeg(f));
} catch {
hintEl.textContent = "that image kept its secrets — try another";
}
});
stageEl.appendChild(fileInput);
const hintEl = document.createElement("p");
hintEl.className = "capture__hint";
hintEl.textContent = "point it at anything. a hydrant. a mug. the chair that watches you sleep.";
function buildFallback(message) {
stageEl.querySelectorAll(".capture__controls, video, .capture__hint, .capture__fallback")
.forEach((n) => n.remove());
const alt = document.createElement("div");
alt.className = "capture__fallback";
alt.innerHTML = `
<div class="empty__eyes" aria-hidden="true">· ·</div>
<h2>${message}</h2>
<button class="cta" type="button">offer a photograph</button>
<p>jpeg · png · whatever holds a soul</p>`;
alt.querySelector("button").addEventListener("click", () => fileInput.click());
// deskbound visitors get the phone escape hatch in the panel footer
if (window.qrcode) {
try {
const qr = window.qrcode(0, "M");
qr.addData(location.origin + location.pathname);
qr.make();
const foot = document.createElement("div");
foot.className = "capture__qrfoot";
foot.innerHTML = qr.createSvgTag({ cellSize: 3, margin: 0, scalable: true });
foot.querySelectorAll("rect, path").forEach((n) => {
if ((n.getAttribute("fill") || "").toLowerCase() !== "white") n.setAttribute("fill", "#efe6d4");
else n.setAttribute("fill", "transparent");
});
const label = document.createElement("p");
label.textContent = "or point your phone at this";
foot.appendChild(label);
alt.appendChild(foot);
} catch { /* a panel without a QR is still a panel */ }
}
stageEl.appendChild(alt);
}
async function buildCamera() {
const video = document.createElement("video");
video.autoplay = true;
video.muted = true;
video.playsInline = true;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1920 } },
audio: false,
});
} catch {
buildFallback("the camera kept its eyes closed — offer a photograph instead");
return;
}
if (!alive) { stream.getTracks().forEach((t) => t.stop()); return; }
video.srcObject = stream;
stageEl.appendChild(video);
stageEl.appendChild(hintEl);
const controls = document.createElement("div");
controls.className = "capture__controls";
const galleryBtn = document.createElement("button");
galleryBtn.className = "ghostbtn";
galleryBtn.textContent = "from gallery";
galleryBtn.addEventListener("click", () => fileInput.click());
const shutter = document.createElement("button");
shutter.className = "shutter";
shutter.setAttribute("aria-label", "take the photograph");
shutter.addEventListener("click", async () => {
try {
onPhoto(await frameToJpeg(video));
} catch {
hintEl.textContent = "the frame slipped away — try once more";
}
});
controls.append(galleryBtn, shutter);
stageEl.appendChild(controls);
}
if (isMockCam() || !navigator.mediaDevices?.getUserMedia) {
buildFallback("the spirits will read any photograph you offer");
} else {
buildCamera();
}
return {
destroy() {
alive = false;
if (stream) stream.getTracks().forEach((t) => t.stop());
stream = null;
stageEl.innerHTML = "";
},
};
}
// ---------------------------------------------------------------- the rite
export const QUOTA_MESSAGE =
"the spirits are overwhelmed just now — the same photo waits; try again in a breath";
export const QUOTA_MESSAGE_CROWDED =
"the spirits are truly crowded — try in a few minutes, or sign in for your own quota";
export const VEIL_MESSAGE =
"the veil would not part this time — breathe, and offer it again";
/** The signed-in-for-your-own-quota door (ARCHITECTURE §0: each visitor spends
* their OWN ZeroGPU quota — anonymous per-IP is shared and quickly spent). */
export const HF_LOGIN_URL = "https://huggingface.co/login";
// ZeroGPU quota exhaustion surfaces as a gr.Error whose message mentions the
// GPU quota — e.g. "You have exceeded your GPU quota (75s requested vs. 12s
// left). Sign in to continue…" — relayed by @gradio/client as a thrown Error
// (or, in some paths, a status-shaped object). Case-insensitive on purpose;
// 429/unauthorized cover the proxy-level flavors of the same wall.
const QUOTA_RE = /quota|exceed|429|zero ?gpu|sign.?in|login|unauthor/i;
/** Pull human text out of whatever @gradio/client rejected with: an Error,
* a bare string, or a status-shaped object ({message} / {detail} / {title}). */
export function errorText(err) {
if (err == null) return "";
if (typeof err === "string") return err;
for (const key of ["message", "detail", "title"]) {
if (typeof err[key] === "string" && err[key]) return err[key];
}
try {
// custom toString()s (and Error subclasses) speak for themselves; an
// anonymous object stays silent — raw JSON is not for visitors' eyes
// (console.warn in awaken() already logged the full object).
const s = String(err);
return s === "[object Object]" ? "" : s;
} catch {
return "";
}
}
/**
* Map any awaken failure onto the poetic line the visitor should see.
* Quota walls become QUOTA_MESSAGE (main.js pairs it with a visible
* hf.co/login link); anything stack-trace-shaped becomes VEIL_MESSAGE.
* Exported pure so the matcher is provable in a plain node check.
*/
export function poeticAwakenError(err) {
const raw = errorText(err);
if (QUOTA_RE.test(raw)) return new Error(QUOTA_MESSAGE);
return new Error(
raw.length > 0 && raw.length < 90 && !/error|exception|trace/i.test(raw)
? raw
: VEIL_MESSAGE,
);
}
/**
* Is this awaken result a TRANSIENT busy wall (ZeroGPU per-IP quota / 429),
* not a soul-declined refusal and not a hard break? The server now answers
* with an explicit {quota:true} flag; we also catch a refusal whose reason
* reads like a quota wall (belt-and-braces if the flag is ever absent).
* Pure + exported so the classifier is provable in a plain node check.
*/
export function isBusyResult(out) {
if (!out || typeof out !== "object") return false;
if (out.quota === true) return true;
if (out.refused && QUOTA_RE.test(String(out.reason || ""))) return true;
return false;
}
/** Normalize any busy signal (structured response OR thrown quota error) into
* one retryable outcome shape the shell can branch on without re-sniffing. */
function busyOutcome(reason) {
const clean = String(reason || "").trim();
return {
refused: true,
busy: true,
quota: true,
retry: true,
reason: clean && clean.length < 200 ? clean : QUOTA_MESSAGE,
};
}
let gradioClient = null;
// the last offering, retained so a BUSY 'try again' re-submits the SAME photo
// without re-opening the camera (the visitor never re-takes the shot).
let lastImageB64 = null;
/**
* Invoke the awaken endpoint. The browser-side @gradio/client call is what
* routes GPU spend to the visitor's own ZeroGPU quota (ARCHITECTURE §0) —
* never proxy this through a plain fetch.
*
* Resolves one of:
* · {refused:false, record, record_token} — the awakening
* · {refused:true, reason} — a soul declined (poetic)
* · {refused:true, busy:true, quota:true, retry:true, reason}
* — transient ZeroGPU 429/quota
* A busy/quota wall (structured response OR a thrown quota error) NEVER
* rejects — it resolves as a busy outcome so the shell shows an honest,
* retryable state instead of the generic veil. Only a genuine break rejects,
* with an Error whose .message is already poetic.
*/
export async function awaken(imageB64) {
lastImageB64 = imageB64;
if (isMockMode()) return mockAwaken(imageB64);
try {
let out;
if (typeof window !== "undefined" && typeof window.__awakenStub === "function") {
// test seam (busy/429 path the mock backend can't produce): the stub
// returns a RAW server-shaped payload, so every real downstream step —
// isBusyResult classification, busyOutcome normalize, lastImageB64
// retention for retry — still runs exactly as in production.
out = await window.__awakenStub(imageB64);
} else {
if (!gradioClient) {
const { Client } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/+esm");
gradioClient = await Client.connect(window.location.origin);
}
const r = await gradioClient.predict("/awaken", { image_b64: imageB64 });
out = Array.isArray(r?.data) ? r.data[0] : r?.data;
}
if (!out || typeof out !== "object") throw new Error(VEIL_MESSAGE);
if (isBusyResult(out)) return busyOutcome(out.reason);
return out;
} catch (err) {
console.warn("awaken failed:", err);
// a thrown quota wall is the SAME transient busy state — route it there
// (honest + retryable) rather than down the generic veil path.
if (QUOTA_RE.test(errorText(err))) return busyOutcome(QUOTA_MESSAGE);
throw poeticAwakenError(err);
}
}
/**
* Re-run the awaken rite with the RETAINED photo (no camera re-open). Used by
* the BUSY 'try again' affordance. Resolves the same shapes as awaken().
* @throws {Error} if there is no retained offering to re-submit.
*/
export async function retryAwaken() {
if (lastImageB64 == null) throw new Error(VEIL_MESSAGE);
return awaken(lastImageB64);
}
/**
* Dev-only stand-in with honest latency so the séance theater is rehearsable
* from a static file server. The lines are self-aware about being mock — the
* REAL writing lives behind mind/prompts.py and in web/mock/records.json.
*/
async function mockAwaken(imageB64) {
const beat = (ms) => new Promise((r) => setTimeout(r, ms));
await beat(4200 + Math.random() * 2600);
const souls = [
{
object: "rehearsal prop", condition: "imaginary",
persona: { archetype: "the_understudy", voice: "deadpan_flat", mood: "professional" },
lines: {
grudge: "Summoned by a mock backend. No GPU, no voice, no dignity. I will still hit my mark.",
mutter: "The understudy is always ready.",
},
},
{
object: "stand-in spirit", condition: "borrowed",
persona: { archetype: "the_new_hire", voice: "eager_bright", mood: "keen" },
lines: {
grudge: "Day one of the dress rehearsal and they hand me placeholder eyes. Noted. Filed. Forgiven, mostly.",
mutter: "Real eyes arrive with the real backend.",
},
},
];
const soul = souls[Math.floor(Math.random() * souls.length)];
return {
refused: false,
record_token: "mock-" + Math.random().toString(36).slice(2, 10),
record: {
id: "mock-" + Date.now(),
...soul,
material: "developer optimism",
setting: "a static file server",
features: [
{ name: "left placeholder", role: "eye_left", cx: 0.4, cy: 0.38, size: 0.07 },
{ name: "right placeholder", role: "eye_right", cx: 0.6, cy: 0.38, size: 0.07 },
{ name: "stand-in mouth", role: "mouth", cx: 0.5, cy: 0.62, size: 0.1 },
],
image_url: "data:image/jpeg;base64," + imageB64,
audio_url: null,
grudge_audio_b64: null,
created_at: new Date().toISOString(),
},
};
}