pareidolia / web /seance.js
AndresCarreon's picture
Fix ZeroGPU 429/quota UX: classify as healthy busy, honest retryable panel
8382377 verified
Raw
History Blame Contribute Delete
13.4 kB
// 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;