| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { |
| createCaptureSession, awaken, retryAwaken, isMockMode, isBusyResult, |
| QUOTA_MESSAGE, QUOTA_MESSAGE_CROWDED, HF_LOGIN_URL, |
| } from "./capture.js"; |
|
|
| |
|
|
| let createOverlay = null; |
| let runSeance = null; |
|
|
| |
| |
| |
| 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"); } |
| } |
|
|
| |
|
|
| 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(), |
| cardEls: new Map(), |
| interacted: false, |
| muttering: null, |
| captureSession: null, |
| pending: null, |
| revealOverlay: null, |
| busy: null, |
| }; |
|
|
| |
|
|
| 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; |
| } |
|
|
| |
| const shareUrlFor = (record) => `${location.origin}/o/${record.id}`; |
|
|
| |
| |
| async function copyText(text) { |
| try { |
| if (navigator.clipboard?.writeText) { |
| await navigator.clipboard.writeText(text); |
| return true; |
| } |
| } catch { } |
| 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; |
| } |
| } |
|
|
| |
| 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; |
| |
| |
| |
| 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`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const RETRY_DELAYS = [2000, 6000, 15000]; |
|
|
| |
| |
| |
| 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; |
| img.src = `${url}${url.includes("?") ? "&" : "?"}r=${Date.now()}`; |
| }, RETRY_DELAYS[attempt++]); |
| }); |
| img.addEventListener("load", () => { |
| attempt = 0; |
| photoEl.classList.remove("is-waiting"); |
| }); |
| } |
|
|
| |
|
|
| 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); |
|
|
| |
| |
| 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(); |
| 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); |
| } |
|
|
| |
| |
| 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 { } |
| |
| |
| 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) { |
| |
| els.count.textContent = "the wall is waking…"; |
| setTimeout(loadWall, RETRY_DELAYS[wallRetry++]); |
| return; |
| } |
| setCount(state.records.length); |
| els.empty.hidden = state.records.length > 0; |
| return; |
| } |
| wallRetry = 0; |
| |
| |
| 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; |
| } |
|
|
| |
| |
| 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); |
| |
| |
| |
| if (card && r.mutter_audio) { |
| try { state.overlays.get(r.id)?.playLine(r.mutter_audio); } |
| catch { } |
| } |
| }, 20000); |
| window.addEventListener("pointerdown", () => { state.interacted = true; }, { once: true }); |
| } |
|
|
| |
| 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 { } |
| }); |
| es.onmessage = (e) => { |
| try { |
| const m = JSON.parse(e.data); |
| if (m.type === "awakened") onAwakened(m); |
| } catch { } |
| }; |
| es.onopen = () => { streamRetry = 0; }; |
| es.onerror = () => { |
| |
| |
| |
| if (es.readyState !== EventSource.CLOSED) return; |
| if (streamRetry >= RETRY_DELAYS.length) return; |
| els.count.textContent = "the wall is waking…"; |
| setTimeout(() => { |
| loadWall(); |
| open(); |
| }, RETRY_DELAYS[streamRetry++]); |
| }; |
| }; |
| open(); |
| } |
|
|
| |
| 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 { } |
| } |
|
|
| |
|
|
| 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); |
| } |
|
|
| |
|
|
| async function beginSeance(photo) { |
| state.captureSession?.destroy(); |
| state.captureSession = null; |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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"; |
|
|
| |
| |
| const whisperBeat = (runSeance && runSeance.WHISPER_BEAT_MS) || 2100; |
| setTimeout(() => els.seanceLayer.classList.add("is-done"), whisperBeat); |
|
|
| |
| |
| 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 { } |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function showBusy(reason, retries) { |
| els.revealTitle.textContent = "the spirits are overwhelmed"; |
| els.revealFail.classList.add("reveal__fail--busy"); |
|
|
| |
| const crowded = retries >= 2; |
| els.revealFailText.textContent = crowded |
| ? QUOTA_MESSAGE_CROWDED |
| : (reason && String(reason).trim()) || QUOTA_MESSAGE; |
|
|
| |
| 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); |
|
|
| |
| setTimeout(() => els.revealFail.classList.add("is-on"), 700); |
| } |
|
|
| |
| |
| |
| |
| 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) { |
| |
| 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; |
| } |
| |
| 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; |
| |
| |
| |
| 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 { } |
| 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"); |
| } |
| |
| |
| 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; |
| } |
| } |
|
|
| |
|
|
| 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); |
|
|
| |
| 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(); |
|
|