Spaces:
Sleeping
Sleeping
| // 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<object>, 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<object>} 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; | |