Spaces:
Running on Zero
Running on Zero
| // 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(); | |