// 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 = `

${message}

jpeg · png · whatever holds a soul

`; 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(), }, }; }