Spaces:
Sleeping
Sleeping
| // 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(), | |
| }, | |
| }; | |
| } | |