Spaces:
Sleeping
Sleeping
File size: 15,002 Bytes
772074b 8382377 772074b 8382377 7c50b41 8382377 772074b 8382377 772074b 8382377 772074b 8382377 772074b 8382377 772074b 8382377 772074b 7c50b41 8382377 7c50b41 772074b 8382377 772074b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | // 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(),
},
};
}
|