File size: 13,406 Bytes
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
772074b
 
 
 
 
 
 
 
 
 
8382377
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772074b
 
 
 
 
 
8382377
772074b
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
// 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;