// PAREIDOLIA — the séance. // // Latency theater for the awakening call (ARCHITECTURE.md §2): the moment the // photo is taken, it dims to candlelight, mist drifts across it, a scanning // shimmer passes, and whispered captions keep the visitor leaning in for the // 45–60 seconds a cold ZeroGPU awakening can take. Nothing here spins; nothing // here says "loading". // // Contract: // runSeance(containerEl, imgUrl) -> { resolve(record), fail(message) } // // resolve(record) cross-fades the candlelight away and hands the photo to // the LIFE engine; returns a Promise of the overlay handle. // fail(message) disperses the mist, shows the message in serif, and // dispatches a bubbling 'seance:failed' CustomEvent on // containerEl so the shell can offer "try again". // busy(message) a DISTINCT, retryable state for a transient ZeroGPU // quota/429 wall (the backend is healthy; this visitor is // just out of free GPU seconds). Mist settles like fail() // but it dispatches 'seance:busy' so the shell renders an // honest 'try again / sign in' panel, NOT the generic veil. // // No imports from main.js / capture.js. Vanilla ES module, CSS injected once. import { createOverlay } from "./overlay.js"; const WHISPERS = [ "holding it to the light…", "there is something here…", "it has seen things…", "listening for a pulse…", "naming what it sees…", "do not startle it…", ]; const STYLE_ID = "pd-seance-style"; const CSS = ` .pd-seance { position: absolute; inset: 0; overflow: hidden; background: #0b0a09; font-family: "Iowan Old Style", "Palatino", Georgia, serif; } .pd-seance__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; filter: brightness(0.42) saturate(0.72) sepia(0.18); transition: filter 1.8s ease; } .pd-seance.is-resolving .pd-seance__photo { filter: brightness(0.97) saturate(0.97) sepia(0.04); } .pd-seance.is-failed .pd-seance__photo { filter: brightness(0.2) saturate(0.45) sepia(0.25); } /* busy is NOT a break — the photo waits, dimmed-but-warm, ready to try again */ .pd-seance.is-busy .pd-seance__photo { filter: brightness(0.32) saturate(0.7) sepia(0.2); } .pd-seance__candle { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(to bottom, transparent 72%, rgba(7,5,3,0.66) 96%), radial-gradient(42% 34% at 50% 42%, rgba(244,194,108,0.16), transparent 70%), radial-gradient(85% 72% at 50% 46%, rgba(232,168,73,0.22), transparent 64%), radial-gradient(130% 110% at 50% 50%, transparent 46%, rgba(7,5,3,0.82) 100%); animation: pd-candle 5.6s ease-in-out infinite; transition: opacity 1.8s ease; } .pd-seance.is-resolving .pd-seance__candle, .pd-seance.is-busy .pd-seance__candle { opacity: 0.35; animation: none; } @keyframes pd-candle { 0%, 100% { opacity: 1; } 43% { opacity: 0.82; } 58% { opacity: 0.94; } 71% { opacity: 0.86; } } .pd-seance__mist { position: absolute; width: 75%; height: 60%; pointer-events: none; border-radius: 50%; background: radial-gradient(closest-side, rgba(239,230,212,0.19), rgba(232,168,73,0.08) 58%, transparent 75%); filter: blur(26px); mix-blend-mode: screen; transition: opacity 1.5s ease, transform 1.5s ease; } .pd-seance__mist.pd-m1 { top: -8%; left: -12%; animation: pd-drift1 13s ease-in-out infinite alternate; } .pd-seance__mist.pd-m2 { top: 34%; right: -18%; animation: pd-drift2 17s ease-in-out infinite alternate; } .pd-seance__mist.pd-m3 { bottom: -14%; left: 8%; width: 85%; animation: pd-drift3 11s ease-in-out infinite alternate; } @keyframes pd-drift1 { from { transform: translate(0,0) scale(1); } to { transform: translate(9%, 6%) scale(1.18); } } @keyframes pd-drift2 { from { transform: translate(0,0) scale(1.1); } to { transform: translate(-11%, -5%) scale(0.92); } } @keyframes pd-drift3 { from { transform: translate(0,0) scale(1.05); } to { transform: translate(7%, -8%) scale(1.22); } } .pd-seance.is-resolving .pd-seance__mist, .pd-seance.is-failed .pd-seance__mist, .pd-seance.is-busy .pd-seance__mist { opacity: 0 !important; transform: scale(1.9) !important; animation: none; } .pd-seance__shimmer { position: absolute; left: 0; right: 0; top: 0; height: 20%; pointer-events: none; mix-blend-mode: screen; background: linear-gradient(to bottom, transparent, rgba(232,168,73,0.1) 42%, rgba(239,230,212,0.16) 50%, rgba(232,168,73,0.1) 58%, transparent); filter: blur(5px); animation: pd-scan 6.8s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; transition: opacity 1s ease; } @keyframes pd-scan { 0% { transform: translateY(-110%); } 62% { transform: translateY(520%); } 100% { transform: translateY(520%); } } .pd-seance.is-resolving .pd-seance__shimmer, .pd-seance.is-failed .pd-seance__shimmer, .pd-seance.is-busy .pd-seance__shimmer { opacity: 0; animation-play-state: paused; } .pd-seance__grain { position: absolute; inset: -55%; pointer-events: none; opacity: 0.055; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)' opacity='0.9'/%3E%3C/svg%3E"); animation: pd-grain 1.7s steps(4) infinite; } @keyframes pd-grain { 0% { transform: translate(0,0); } 25% { transform: translate(-2%,1.5%); } 50% { transform: translate(1.7%,-1%); } 75% { transform: translate(-1%,-1.9%); } 100% { transform: translate(0,0); } } .pd-seance__caption { position: absolute; left: 8%; right: 8%; bottom: 7%; text-align: center; pointer-events: none; color: rgba(239,230,212,0.86); font-style: italic; font-size: clamp(16px, 2.6vmin, 21px); letter-spacing: 0.05em; text-transform: lowercase; text-shadow: 0 1px 14px rgba(0,0,0,0.9), 0 0 30px rgba(232,168,73,0.18); opacity: 0; transition: opacity 0.7s ease; } .pd-seance__caption.is-shown { opacity: 1; } .pd-seance__fail { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 8%; text-align: center; pointer-events: none; opacity: 0; transition: opacity 1.1s ease 0.5s; } .pd-seance.is-failed .pd-seance__fail { opacity: 1; } .pd-seance__fail-msg { color: #efe6d4; font-style: italic; font-size: clamp(17px, 3.2vmin, 26px); letter-spacing: 0.04em; text-shadow: 0 1px 16px rgba(0,0,0,0.95); } .pd-seance__fail-hint { color: rgba(232,168,73,0.75); font-size: clamp(12px, 1.9vmin, 15px); letter-spacing: 0.14em; text-transform: lowercase; } `; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const s = document.createElement("style"); s.id = STYLE_ID; s.textContent = CSS; document.head.appendChild(s); } function div(cls) { const d = document.createElement("div"); d.className = cls; return d; } /** * One whisper built from the record's own named features — the medium says * what it found ("two bonnet bolts… symmetric…") right before the eyes open. * Falls back to a generic line when the record keeps its features to itself. */ function featureWhisper(record) { const feats = record?.candidate_features || record?.features || []; const eyeNames = feats .filter((f) => (f.role || "").startsWith("eye") && f.name) .map((f) => String(f.name).toLowerCase().replace(/^(left|right|upper|lower)\s+/, "").trim()); if (eyeNames.length >= 2 && eyeNames[0] && eyeNames[0] === eyeNames[1]) { const plural = /s$/.test(eyeNames[0]) ? eyeNames[0] : eyeNames[0] + "s"; return `two ${plural}… symmetric…`; } if (eyeNames.length >= 2) return `${eyeNames[0]}… and ${eyeNames[1]}… level…`; if (eyeNames.length === 1) return `${eyeNames[0]}… watching…`; return "something looked back…"; } /** How long resolve() holds its record-whisper before the eye handoff. The * shell reads this off the export to stage its own cross-fade around it. */ const WHISPER_BEAT_MS = 2100; /** * Begin the séance over a freshly captured photo. * * @param {HTMLElement} containerEl positioned element the séance fills * @param {string} imgUrl the captured photo (object URL / data URL / path) * @param {object} [extra] non-contractual: `{ overlayOpts }` forwarded to * createOverlay on resolve (the dev harness uses this for deterministic shots) * @returns {{resolve: (record: object) => Promise, fail: (message: string) => void}} */ export function runSeance(containerEl, imgUrl, extra = {}) { injectStyle(); if (getComputedStyle(containerEl).position === "static") { containerEl.style.position = "relative"; } const wrap = div("pd-seance"); const img = document.createElement("img"); img.className = "pd-seance__photo"; img.alt = ""; img.decoding = "async"; img.src = imgUrl; wrap.appendChild(img); wrap.appendChild(div("pd-seance__candle")); wrap.appendChild(div("pd-seance__mist pd-m1")); wrap.appendChild(div("pd-seance__mist pd-m2")); wrap.appendChild(div("pd-seance__mist pd-m3")); wrap.appendChild(div("pd-seance__shimmer")); wrap.appendChild(div("pd-seance__grain")); const caption = div("pd-seance__caption"); wrap.appendChild(caption); const failBox = div("pd-seance__fail"); const failMsg = div("pd-seance__fail-msg"); const failHint = div("pd-seance__fail-hint"); failHint.textContent = "let the mist settle, then try again"; failBox.appendChild(failMsg); failBox.appendChild(failHint); wrap.appendChild(failBox); containerEl.appendChild(wrap); // ---- whispered captions: fade in, hold, fade out, ~4.2s cadence, loop ---- let settled = false; // resolve()/fail() called let whisperIdx = 0; let timer = 0; function whisperCycle() { if (settled) return; caption.textContent = WHISPERS[whisperIdx % WHISPERS.length]; whisperIdx++; caption.classList.add("is-shown"); timer = setTimeout(() => { caption.classList.remove("is-shown"); timer = setTimeout(whisperCycle, 1050); // fade-out + breath }, 3150); } timer = setTimeout(whisperCycle, 800); function settle() { if (settled) return false; settled = true; clearTimeout(timer); caption.classList.remove("is-shown"); return true; } /** * The spirits answered. First beat: ONE whisper derived from the record's * own named features (the medium says what it found). Then the candlelight * lifts, mist disperses, and the LIFE engine takes over with its emergence * — the cross-fade IS the reveal. * @param {object} record the awakening record * @returns {Promise} the overlay handle from createOverlay */ function resolve(record) { if (!settle()) return Promise.resolve(null); // the record-whisper beat — still under candlelight, before the eyes caption.textContent = featureWhisper(record); caption.classList.add("is-shown"); const overlayOpts = extra.overlayOpts || {}; return new Promise((done) => { setTimeout(() => { caption.classList.remove("is-shown"); wrap.classList.add("is-resolving"); const begin = () => { // let the light lift for a beat before the eyes condense setTimeout(() => { const overlay = createOverlay(wrap, record, img, { emerge: true, ...overlayOpts }); containerEl.dispatchEvent(new CustomEvent("seance:resolved", { detail: { record, overlay }, bubbles: true, composed: true, })); done(overlay); }, 900); }; if (img.complete && img.naturalWidth) begin(); else img.decode().then(begin, begin); }, WHISPER_BEAT_MS - 300); // the caption's 0.7s fade overlaps the lift }); } /** * The spirits declined (refusal, timeout, quota). Disperse the mist, speak * the message gently, and tell the shell so it can offer another attempt. * @param {string} message poetic, user-facing; never a stack trace */ function fail(message) { if (!settle()) return; failMsg.textContent = message || "the spirits decline."; wrap.classList.add("is-failed"); containerEl.dispatchEvent(new CustomEvent("seance:failed", { detail: { message: failMsg.textContent }, bubbles: true, composed: true, })); } /** * The spirits are OVERWHELMED, not absent — a transient ZeroGPU quota/429 * wall. Settle the mist (like fail) but keep the photo warm-dimmed and tell * the shell to offer an honest, retryable 'try again / sign in' panel rather * than the generic veil. The reason is the server's own busy line. * @param {string} message poetic, user-facing; never a stack trace */ function busy(message) { if (!settle()) return; wrap.classList.add("is-busy"); containerEl.dispatchEvent(new CustomEvent("seance:busy", { detail: { message: message || "" }, bubbles: true, composed: true, })); } /** Non-contractual cleanup for the shell (removes the séance DOM). */ function destroy() { settle(); wrap.remove(); } return { resolve, fail, busy, destroy, element: wrap }; } // the shell stages its cross-fade around the record-whisper beat runSeance.WHISPER_BEAT_MS = WHISPER_BEAT_MS;