Spaces:
Sleeping
Sleeping
File size: 27,124 Bytes
772074b 8382377 772074b 8382377 772074b 89dbee1 772074b 89dbee1 772074b 89dbee1 772074b 89dbee1 772074b de59038 772074b de59038 772074b 89dbee1 772074b de59038 772074b de59038 772074b de59038 772074b 5643daf 772074b de59038 772074b de59038 772074b 8382377 772074b 8382377 772074b 8382377 772074b 8382377 772074b 8382377 772074b 12d838f 8382377 12d838f 772074b 8382377 772074b 8382377 772074b 89dbee1 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 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 | // PAREIDOLIA — main.js
// App shell: the menagerie wall (zero-GPU landing state), the capture rite,
// and the post-awakening reveal. State lives here; the LIFE engine
// (overlay.js) and the latency theater (seance.js) are sibling modules with
// fixed contracts — we import them dynamically so the wall still renders
// while they are being built in parallel (and so a broken module can never
// take the whole Space down with it).
//
// createOverlay(containerEl, record, imgEl, opts) -> {playLine, setIdle, destroy}
// runSeance(containerEl, imgUrl) -> {resolve(record), fail(msg)}
import {
createCaptureSession, awaken, retryAwaken, isMockMode, isBusyResult,
QUOTA_MESSAGE, QUOTA_MESSAGE_CROWDED, HF_LOGIN_URL,
} from "./capture.js";
// ---------------------------------------------------------------- contracts
let createOverlay = null;
let runSeance = null;
/** Quiet stand-ins so the wall is never hostage to a sibling module. The
* dev markers only paint when overlay.js is genuinely absent (parallel
* build), letting fixture feature coords be verified by eye. */
function fallbackOverlay(containerEl, record, imgEl) {
const marks = [];
for (const f of record.features || []) {
const m = document.createElement("div");
m.className = "devmark" + (f.role === "mouth" ? " devmark--mouth" : "");
const s = Math.max(0.025, f.size || 0.05);
m.style.left = `${f.cx * 100}%`;
m.style.top = `${f.cy * 100}%`;
m.style.width = `${s * 100}%`;
m.style.aspectRatio = f.role === "mouth" ? "2 / 1" : "1 / 1";
(imgEl.parentElement || containerEl).appendChild(m);
marks.push(m);
}
return {
playLine() {},
setIdle() {},
destroy() { marks.forEach((m) => m.remove()); },
};
}
function fallbackSeance(containerEl) {
containerEl.innerHTML =
`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(11,10,9,.45)"><p style="font-style:italic;color:#efe6d4;opacity:.8;
text-shadow:0 1px 8px #000;animation:pulse 2.4s ease-in-out infinite">the medium is looking…</p></div>`;
return {
resolve() { containerEl.innerHTML = ""; },
fail(message) {
const p = containerEl.querySelector("p");
if (p) p.textContent = message;
},
};
}
async function loadSiblings() {
try { ({ createOverlay } = await import("./overlay.js")); }
catch { createOverlay = fallbackOverlay; console.warn("overlay.js absent — dev markers active"); }
try { ({ runSeance } = await import("./seance.js")); }
catch { runSeance = fallbackSeance; console.warn("seance.js absent — plain veil active"); }
}
// ---------------------------------------------------------------- elements
const els = Object.fromEntries(
["wall", "empty", "count", "wakeBtn", "wakeBtnFloat", "qr", "qrCode", "toast",
"captureScreen", "captureStage", "captureCancel",
"revealScreen", "revealTitle", "revealPhoto", "revealImg", "seanceLayer",
"revealScrim", "revealName", "revealNameText", "revealGrudge", "revealChoice",
"addBtn", "restBtn", "revealFail", "revealFailText", "failBack"]
.map((id) => [id, document.getElementById(id)]),
);
const MOCK = isMockMode();
const state = {
records: [],
overlays: new Map(), // record.id -> overlay handle
cardEls: new Map(), // record.id -> card element
interacted: false,
muttering: null,
captureSession: null,
pending: null, // {record, record_token, dataUrl}
revealOverlay: null,
busy: null, // {seance, photo, retries} while a busy/retry panel is up
};
// ---------------------------------------------------------------- helpers
const displayName = (r) =>
`the ${[r.condition, r.object].filter(Boolean).join(" ")}`.toLowerCase();
const voiceLabel = (r) =>
[r.persona?.archetype?.replace(/^the_/, "the "), r.persona?.voice?.replace(/_/g, " ")]
.filter(Boolean).join(" · ");
function audioUrlFor(record) {
if (record.grudge_audio_b64) return `data:audio/wav;base64,${record.grudge_audio_b64}`;
return record.audio_url || null;
}
// every awakened object has a door of its own: the /o/{id} share page
const shareUrlFor = (record) => `${location.origin}/o/${record.id}`;
/** Copy to the clipboard; async API first, execCommand séance as fallback.
* Resolves true on success — never throws at the visitor. */
async function copyText(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* permission veil — try the old rite below */ }
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
ta.remove();
return ok;
} catch {
return false;
}
}
/** A quiet chain-link glyph (static path data, no record strings). */
function linkIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
for (const d of [
"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
]) {
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
p.setAttribute("d", d);
svg.appendChild(p);
}
return svg;
}
let toastTimer = 0;
/** Quiet bottom toast. An optional `action` ({label, onClick}) adds a single
* pressable verb — used for "copy its link" after a publish. All content via
* createElement/textContent; server strings never become HTML. */
function toast(message, action = null) {
els.toast.textContent = message;
els.toast.classList.toggle("toast--action", Boolean(action));
if (action) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "toast__action";
btn.textContent = action.label;
btn.addEventListener("click", (e) => {
e.stopPropagation();
action.onClick();
});
els.toast.appendChild(btn);
}
els.toast.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("is-on"), action ? 7000 : 4200);
}
function setCount(n) {
els.count.textContent =
n === 0 ? "the wall is listening" :
n === 1 ? "one object is awake" : `${n} objects are awake`;
}
// ------------------------------------------------- patience (container naps)
// The Space's ZeroGPU container recycles itself (~90s of downtime). Mid-nap,
// card images die with connection resets and the wall fetch / SSE stream can
// fail — and used to stay broken until a manual refresh. Everything network-
// touched now retries quietly on the same short backoff; while an image is
// away, a porcelain shimmer holds its seat (never the broken-image glyph).
const RETRY_DELAYS = [2000, 6000, 15000];
/** Retry a card photo with backoff + cache-buster when its load fails.
* Inline data:/blob: photos never travel the network — left alone. After the
* last attempt the shimmer simply stays: a quiet placeholder, not an error. */
function attachImgRetry(img, url, photoEl) {
if (!url || /^(data|blob):/i.test(url)) return;
let attempt = 0;
img.addEventListener("error", () => {
photoEl.classList.add("is-waiting");
if (attempt >= RETRY_DELAYS.length) return;
setTimeout(() => {
if (!img.isConnected) return; // the card left the wall while we waited
img.src = `${url}${url.includes("?") ? "&" : "?"}r=${Date.now()}`;
}, RETRY_DELAYS[attempt++]);
});
img.addEventListener("load", () => {
attempt = 0; // a recovered image earns a fresh budget
photoEl.classList.remove("is-waiting");
});
}
// ---------------------------------------------------------------- the wall
function makeCard(record, { fresh = false } = {}) {
const node = document.getElementById("cardTemplate").content.firstElementChild.cloneNode(true);
const img = node.querySelector("img");
attachImgRetry(img, record.image_url, node.querySelector(".card__photo"));
img.src = record.image_url;
img.alt = displayName(record);
node.querySelector(".card__name").textContent = displayName(record);
node.querySelector(".card__grudge").textContent = `“${record.lines?.grudge ?? ""}”`;
node.querySelector(".card__voice").textContent = voiceLabel(record);
// quiet share affordance — only records the server gave a door to (an id);
// legacy fixtures without one simply keep their silence
if (record.id) {
const share = document.createElement("button");
share.type = "button";
share.className = "card__share";
share.title = "copy its link";
share.setAttribute("aria-label", `copy link to ${displayName(record)}`);
share.appendChild(linkIcon());
share.addEventListener("click", async (e) => {
e.stopPropagation(); // the icon copies; it never makes the card speak
const ok = await copyText(shareUrlFor(record));
whisper(node, ok ? "link copied" : "the clipboard declined", 2400);
});
node.querySelector(".card__caption").appendChild(share);
}
if (fresh) {
node.classList.add("card--new");
setTimeout(() => node.classList.remove("card--new"), 6000);
}
// mount the overlay immediately — overlay.js tolerates a photo that has no
// pixels yet (it listens for the img load itself, then lays out + samples)
try {
const ov = createOverlay(node.querySelector(".card__photo"), record, img, { mode: "card" });
state.overlays.set(record.id, ov);
} catch (err) {
console.warn("overlay mount failed", err);
}
node.addEventListener("click", () => {
state.interacted = true;
speak(record, node);
});
return node;
}
function speak(record, cardEl) {
const ov = state.overlays.get(record.id);
try { ov?.playLine(audioUrlFor(record)); } catch { /* the wall keeps its composure */ }
// no bubble here — the caption + moving mouth already carry the grudge;
// the whisper bubble belongs to ambient mutters only
const bubble = cardEl.querySelector(".card__whisper");
if (bubble) { clearTimeout(bubble._t); bubble.classList.remove("is-on"); }
}
function whisper(cardEl, text, holdMs = 4200) {
if (!text) return;
const bubble = cardEl.querySelector(".card__whisper");
bubble.textContent = text;
bubble.classList.add("is-on");
clearTimeout(bubble._t);
bubble._t = setTimeout(() => bubble.classList.remove("is-on"), holdMs);
}
function addRecord(record, { fresh = false, prepend = false } = {}) {
if (state.cardEls.has(record.id)) return;
const card = makeCard(record, { fresh });
if (!fresh) card.style.setProperty("--enter-delay", `${Math.min(state.cardEls.size * 0.07, 0.8)}s`);
state.cardEls.set(record.id, card);
if (prepend) {
state.records.unshift(record);
els.wall.prepend(card);
} else {
state.records.push(record);
els.wall.appendChild(card);
}
els.empty.hidden = state.records.length > 0;
setCount(state.records.length);
}
let wallRetry = 0;
async function loadWall() {
let records = [];
try {
if (MOCK) {
const res = await fetch("./mock/records.json");
records = (await res.json()).records;
} else {
const res = await fetch("/api/menagerie");
if (!res.ok) throw new Error(`menagerie ${res.status}`);
const body = await res.json();
records = body.records ?? body ?? [];
}
} catch (err) {
console.warn("wall fetch failed:", err);
if (wallRetry < RETRY_DELAYS.length) {
// the container is mid-recycle — whisper, wait, knock again
els.count.textContent = "the wall is waking…";
setTimeout(loadWall, RETRY_DELAYS[wallRetry++]);
return;
}
setCount(state.records.length); // whatever already made it stays counted
els.empty.hidden = state.records.length > 0;
return;
}
wallRetry = 0;
// newest face first — the wall reads like a guestbook. Server records carry
// a numeric `ts` (epoch seconds); dev fixtures carry ISO `created_at`.
const recency = (r) =>
typeof r.ts === "number" ? r.ts :
r.created_at ? (Date.parse(r.created_at) || 0) / 1000 : 0;
records = [...records].sort((a, b) => recency(b) - recency(a));
for (const r of records) addRecord(r);
setCount(state.records.length);
els.empty.hidden = state.records.length > 0;
}
// ambient life: at most one whispered mutter every ~20s, only after the
// visitor has touched the page (autoplay etiquette — and good manners).
function startAmbientMutters() {
setInterval(() => {
if (!state.interacted || document.hidden || !els.captureScreen.hidden ||
!els.revealScreen.hidden || state.records.length === 0) return;
const r = state.records[Math.floor(Math.random() * state.records.length)];
const card = state.cardEls.get(r.id);
if (card) whisper(card, r.lines?.mutter, 4600);
// records published after June 12 carry the mutter as AUDIO too — the
// whisper becomes a voice (mouth syncs via the overlay's analyser).
// Same interaction gate as everything audible; absent on old records.
if (card && r.mutter_audio) {
try { state.overlays.get(r.id)?.playLine(r.mutter_audio); }
catch { /* the wall keeps its composure */ }
}
}, 20000);
window.addEventListener("pointerdown", () => { state.interacted = true; }, { once: true });
}
// live wall: new awakenings arrive over SSE
function startStream() {
if (MOCK) return;
const onAwakened = (payload) => {
const record = payload.record ?? payload;
if (record?.id && record?.features) {
addRecord(record, { fresh: true, prepend: true });
toast(`${displayName(record)} just woke up`);
}
};
let streamRetry = 0;
const open = () => {
let es;
try { es = new EventSource("/api/stream"); } catch { return; }
es.addEventListener("awakened", (e) => {
try { onAwakened(JSON.parse(e.data)); } catch { /* malformed whisper */ }
});
es.onmessage = (e) => {
try {
const m = JSON.parse(e.data);
if (m.type === "awakened") onAwakened(m);
} catch { /* heartbeats, hellos */ }
};
es.onopen = () => { streamRetry = 0; };
es.onerror = () => {
// EventSource heals transient drops itself; only a CLOSED stream is
// dead (the container is mid-recycle). Reload the whole wall when we
// return — addRecord dedupes — so nothing that woke meanwhile is lost.
if (es.readyState !== EventSource.CLOSED) return;
if (streamRetry >= RETRY_DELAYS.length) return; // it sleeps; a refresh revives it
els.count.textContent = "the wall is waking…";
setTimeout(() => {
loadWall();
open();
}, RETRY_DELAYS[streamRetry++]);
};
};
open();
}
// desktop QR — judges sit at desks; their phones do the haunting
function paintQr() {
if (!window.qrcode) return;
try {
const qr = window.qrcode(0, "M");
qr.addData(location.href);
qr.make();
els.qrCode.innerHTML = qr.createSvgTag({ cellSize: 3, margin: 0, scalable: true });
els.qrCode.querySelectorAll("rect, path").forEach((n) => {
if ((n.getAttribute("fill") || "").toLowerCase() !== "white") n.setAttribute("fill", "#efe6d4");
else n.setAttribute("fill", "transparent");
});
els.qr.hidden = false;
} catch { /* a wall without a QR is still a wall */ }
}
// ---------------------------------------------------------------- capture
function showScreen(which) {
els.captureScreen.hidden = which !== "capture";
els.revealScreen.hidden = which !== "reveal";
document.body.style.overflow = which ? "hidden" : "";
}
function openCapture() {
state.interacted = true;
showScreen("capture");
state.captureSession = createCaptureSession(els.captureStage, {
onPhoto: (photo) => beginSeance(photo),
});
}
function closeCapture() {
state.captureSession?.destroy();
state.captureSession = null;
showScreen(null);
}
// ---------------------------------------------------------------- séance + reveal
async function beginSeance(photo) {
state.captureSession?.destroy();
state.captureSession = null;
// our own copy of the photo sits UNDER the séance layer; whatever the
// theater does above it, the reveal always has a stable img to haunt.
els.revealImg.src = photo.dataUrl;
els.seanceLayer.classList.remove("is-done");
els.seanceLayer.innerHTML = "";
els.revealScrim.classList.remove("is-on");
els.revealName.classList.remove("is-on");
els.revealChoice.classList.remove("is-on");
els.revealFail.classList.remove("is-on", "reveal__fail--busy");
els.revealFail.querySelector(".reveal__busy")?.remove();
els.revealFail.querySelector(".reveal__signin")?.remove();
state.busy = null;
els.revealTitle.textContent = "the medium is looking…";
showScreen("reveal");
let seance;
try { seance = runSeance(els.seanceLayer, photo.dataUrl); }
catch { seance = fallbackSeance(els.seanceLayer); }
let out;
try {
out = await awaken(photo.b64);
} catch (err) {
seance.fail?.(err.message);
showFailure(err.message);
return;
}
// a TRANSIENT busy wall (ZeroGPU per-IP quota / 429) is NOT a broken app and
// NOT a soul declining — the same photo waits and 'try again' re-submits it.
if (isBusyResult(out)) {
state.busy = { seance, photo, retries: 0 };
seance.busy?.(out.reason);
showBusy(out.reason, 0);
return;
}
if (out.refused) {
const reason = out.reason || "It is already awake.";
seance.fail?.(reason);
showFailure(reason);
return;
}
proceedReveal(seance, photo, out);
}
/** The spirits answered. Hand the record to the séance + LIFE engine; stage the
* reveal around the overlay's own timing. Shared by the first awaken and any
* successful retry from the busy panel. */
function proceedReveal(seance, photo, out) {
state.busy = null;
els.revealFail.classList.remove("is-on");
const record = out.record;
record.image_url = record.image_url || photo.dataUrl;
state.pending = { record, record_token: out.record_token, dataUrl: photo.dataUrl };
seance.resolve?.(record);
els.revealTitle.textContent = "it was in there all along";
// resolve() opens with ONE record-derived whisper (seance.js WHISPER_BEAT_MS)
// — the medium names what it found — THEN the candlelight lifts.
const whisperBeat = (runSeance && runSeance.WHISPER_BEAT_MS) || 2100;
setTimeout(() => els.seanceLayer.classList.add("is-done"), whisperBeat);
// mist clears → eyes emerge → FIRST BLINK (~700ms, the money frame) →
// the grudge. The overlay owns its timing; we stage around it.
setTimeout(() => {
try {
state.revealOverlay = createOverlay(els.revealPhoto, record, els.revealImg, { mode: "reveal" });
} catch (err) {
console.warn("reveal overlay failed", err);
state.revealOverlay = fallbackOverlay(els.revealPhoto, record, els.revealImg);
}
setTimeout(() => {
try { state.revealOverlay?.playLine(audioUrlFor(record)); } catch { /* silence is also a voice */ }
els.revealNameText.textContent = displayName(record);
els.revealGrudge.textContent = `“${record.lines?.grudge ?? ""}”`;
els.revealScrim.classList.add("is-on");
els.revealName.classList.add("is-on");
}, 1500);
setTimeout(() => els.revealChoice.classList.add("is-on"), 4200);
}, whisperBeat + 1300);
}
/**
* The BUSY panel: an honest, retryable state for a transient ZeroGPU quota/429
* wall (distinct from the generic veil). Shows the server's reason in serif, a
* prominent 'try again' (re-submits the SAME photo via retryAwaken — no camera
* re-open), and a quieter 'sign in to Hugging Face ↗' link. After ~2 failed
* retries the copy softens. All DOM via createElement/textContent — server
* reason strings NEVER become innerHTML.
*
* @param {string} reason the server's busy line (or default)
* @param {number} retries how many retries have already failed (softens copy)
*/
function showBusy(reason, retries) {
els.revealTitle.textContent = "the spirits are overwhelmed";
els.revealFail.classList.add("reveal__fail--busy");
// soften after two crowded attempts; otherwise speak the server's own line
const crowded = retries >= 2;
els.revealFailText.textContent = crowded
? QUOTA_MESSAGE_CROWDED
: (reason && String(reason).trim()) || QUOTA_MESSAGE;
// rebuild the action row each time (state changes: copy, gathering spinner)
els.revealFail.querySelector(".reveal__busy")?.remove();
const box = document.createElement("div");
box.className = "reveal__busy";
const tryAgain = document.createElement("button");
tryAgain.type = "button";
tryAgain.className = "cta reveal__retry";
tryAgain.textContent = "try again";
const gathering = document.createElement("p");
gathering.className = "reveal__gathering";
gathering.textContent = "the spirits gather…";
const signin = document.createElement("a");
signin.className = "reveal__signin";
signin.href = HF_LOGIN_URL;
signin.target = "_blank";
signin.rel = "noopener";
signin.textContent = "sign in to Hugging Face ↗";
tryAgain.addEventListener("click", () => doRetry(tryAgain, box));
box.append(tryAgain, gathering, signin);
els.revealFailText.after(box);
// the back-to-the-wall verb already lives in #revealFail (failBack); keep it
setTimeout(() => els.revealFail.classList.add("is-on"), 700);
}
/** Re-submit the retained photo. While in flight: 'try again' disables and the
* 'the spirits gather…' line shows. On a real record → reveal proceeds; still
* busy → re-show busy with the (possibly softened) copy; a hard break → the
* generic veil. */
async function doRetry(btn, box) {
const ctx = state.busy;
if (!ctx) return;
btn.disabled = true;
box.classList.add("is-gathering");
let out;
try {
out = await retryAwaken();
} catch (err) {
// a genuine break surfaced mid-retry — fall back to the honest veil
ctx.seance.fail?.(err.message);
state.busy = null;
els.revealFail.classList.remove("reveal__fail--busy");
showFailure(err.message);
return;
}
if (isBusyResult(out)) {
ctx.retries += 1;
box.classList.remove("is-gathering");
showBusy(out.reason, ctx.retries);
return;
}
if (out.refused) {
const reason = out.reason || "It is already awake.";
ctx.seance.fail?.(reason);
state.busy = null;
els.revealFail.classList.remove("reveal__fail--busy");
showFailure(reason);
return;
}
// the spirits answered on retry — clear the busy chrome and reveal
els.revealFail.classList.remove("reveal__fail--busy");
els.revealFail.querySelector(".reveal__busy")?.remove();
proceedReveal(ctx.seance, ctx.photo, out);
}
function showFailure(message) {
state.busy = null;
els.revealFail.classList.remove("reveal__fail--busy");
els.revealFail.querySelector(".reveal__busy")?.remove();
els.revealTitle.textContent = "the spirits declined";
els.revealFailText.textContent = message;
// a quota fail must hand the visitor the door, not just the poetry:
// the line under the fail copy carries a real sign-in link (built with
// DOM nodes, never innerHTML — error strings can come from the server)
els.revealFail.querySelector(".reveal__signin")?.remove();
if (message === QUOTA_MESSAGE) {
const hint = document.createElement("p");
hint.className = "reveal__signin";
hint.style.cssText = "margin-top:.4rem;font-size:.95em;opacity:.9";
const link = document.createElement("a");
link.href = HF_LOGIN_URL;
link.target = "_blank";
link.rel = "noopener";
link.textContent = "sign in to Hugging Face";
link.style.cssText = "color:inherit;text-decoration:underline";
hint.append(link, document.createTextNode(" — the spirits answer the signed-in first"));
els.revealFailText.after(hint);
}
setTimeout(() => els.revealFail.classList.add("is-on"), 900);
}
function teardownReveal() {
try { state.revealOverlay?.destroy(); } catch { /* already gone */ }
state.revealOverlay = null;
els.seanceLayer.innerHTML = "";
els.revealScrim.classList.remove("is-on");
els.revealFail.classList.remove("is-on", "reveal__fail--busy");
els.revealFail.querySelector(".reveal__busy")?.remove();
els.revealFail.querySelector(".reveal__signin")?.remove();
els.revealImg.removeAttribute("src");
state.pending = null;
state.busy = null;
showScreen(null);
}
async function publishPending() {
const pending = state.pending;
if (!pending) return;
els.addBtn.disabled = true;
try {
let published = pending.record;
if (!MOCK) {
const res = await fetch("/api/menagerie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ record_token: pending.record_token }),
});
if (!res.ok) {
throw new Error(res.status === 429
? "the wall asks you to slow down — six souls an hour is plenty"
: "the wall would not take it — try again in a moment");
}
// the server returns the persisted record itself (id, ts, media URLs);
// using its id is what dedupes the SSE 'awakened' echo of our own publish
const body = await res.json();
published = body.record ?? body ?? published;
}
published.id = published.id || `local-${Date.now()}`;
published.image_url = published.image_url || pending.dataUrl;
teardownReveal();
addRecord(published, { fresh: true, prepend: true });
window.scrollTo({ top: 0, behavior: "smooth" });
const url = shareUrlFor(published);
toast(`${displayName(published)} has joined the menagerie`, {
label: "copy its link",
onClick: async () => {
const ok = await copyText(url);
toast(ok ? "link copied" : "the clipboard declined");
},
});
} catch (err) {
toast(err.message);
} finally {
els.addBtn.disabled = false;
}
}
// ---------------------------------------------------------------- boot
async function boot() {
await loadSiblings();
paintQr();
await loadWall();
startAmbientMutters();
startStream();
els.wakeBtn.addEventListener("click", openCapture);
els.wakeBtnFloat.addEventListener("click", openCapture);
els.captureCancel.addEventListener("click", closeCapture);
els.addBtn.addEventListener("click", publishPending);
els.restBtn.addEventListener("click", teardownReveal);
els.failBack.addEventListener("click", teardownReveal);
// a quiet hook for the screenshot harness
window.__pareidolia = {
get count() { return state.records.length; },
whisperFirst() {
const r = state.records[0];
if (r) whisper(state.cardEls.get(r.id), r.lines?.mutter, 60000);
},
};
}
boot();
|