Spaces:
Sleeping
Sleeping
pareidolia: post-snap geometry gate (one multi-turn feature retry) + eye readability halo + wall/img/SSE container-nap resilience
de59038 verified | // PAREIDOLIA — the LIFE engine. | |
| // | |
| // Takes a still photo and the awakening record, and makes the object's found | |
| // face *live* on it: eyes condense out of mist at the snapped feature points, | |
| // blink, glance, follow you around the room, and speak with an amplitude-driven | |
| // mouth. Everything renders as SVG layered over the photo — no WebGL, no | |
| // framework, deterministic per record (seeded RNG), 60fps on a phone. | |
| // | |
| // Contract (ARCHITECTURE.md §4): | |
| // createOverlay(containerEl, record, imgEl, opts = {}) | |
| // -> { playLine(audioUrlOrNull), setIdle(bool), destroy() } | |
| // | |
| // Constraints honored here: | |
| // - one rAF loop per overlay; pauses when the card is off-viewport | |
| // - transform/opacity mutations only after build (no layout thrash) | |
| // - iris colors are SAMPLED from the photo around each snap point, so the | |
| // eyes look OF the material — never cartoon-white | |
| // - all idle randomness flows from a seed derived from the record id, so a | |
| // persisted record replays identically forever | |
| // - no imports from main.js / capture.js | |
| // --------------------------------------------------------------------------- | |
| // deterministic randomness | |
| // --------------------------------------------------------------------------- | |
| /** Hash a string into a 32-bit seed (xmur3). */ | |
| function hashSeed(str) { | |
| let h = 1779033703 ^ str.length; | |
| for (let i = 0; i < str.length; i++) { | |
| h = Math.imul(h ^ str.charCodeAt(i), 3432918353); | |
| h = (h << 13) | (h >>> 19); | |
| } | |
| h = Math.imul(h ^ (h >>> 16), 2246822507); | |
| h = Math.imul(h ^ (h >>> 13), 3266489909); | |
| return (h ^= h >>> 16) >>> 0; | |
| } | |
| /** Tiny deterministic PRNG (mulberry32). Returns () => [0,1). */ | |
| function mulberry32(seed) { | |
| let a = seed >>> 0; | |
| return function () { | |
| a |= 0; | |
| a = (a + 0x6d2b79f5) | 0; | |
| let t = Math.imul(a ^ (a >>> 15), 1 | a); | |
| t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // color: sample the photo so the eye is made of the object's own material | |
| // --------------------------------------------------------------------------- | |
| function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } | |
| function lerp(a, b, t) { return a + (b - a) * t; } | |
| function rgbToHsl(r, g, b) { | |
| r /= 255; g /= 255; b /= 255; | |
| const max = Math.max(r, g, b), min = Math.min(r, g, b); | |
| let h = 0, s = 0; | |
| const l = (max + min) / 2; | |
| if (max !== min) { | |
| const d = max - min; | |
| s = l > 0.5 ? d / (2 - max - min) : d / (max + min); | |
| if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; | |
| else if (max === g) h = ((b - r) / d + 2) / 6; | |
| else h = ((r - g) / d + 4) / 6; | |
| } | |
| return [h, s, l]; | |
| } | |
| function hslToRgb(h, s, l) { | |
| if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; } | |
| const q = l < 0.5 ? l * (1 + s) : l + s - l * s; | |
| const p = 2 * l - q; | |
| const f = (t) => { | |
| t = ((t % 1) + 1) % 1; | |
| if (t < 1 / 6) return p + (q - p) * 6 * t; | |
| if (t < 1 / 2) return q; | |
| if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; | |
| return p; | |
| }; | |
| return [Math.round(f(h + 1 / 3) * 255), Math.round(f(h) * 255), Math.round(f(h - 1 / 3) * 255)]; | |
| } | |
| function css([r, g, b], a = 1) { | |
| return a >= 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${a})`; | |
| } | |
| function shade(rgb, dl, ds = 0) { | |
| const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]); | |
| return hslToRgb(h, clamp(s + ds, 0, 1), clamp(l + dl, 0.02, 0.97)); | |
| } | |
| function mixRgb(a, b, t) { | |
| return [Math.round(lerp(a[0], b[0], t)), Math.round(lerp(a[1], b[1], t)), Math.round(lerp(a[2], b[2], t))]; | |
| } | |
| const BONE = [239, 230, 212]; // #efe6d4 | |
| const FALLBACK_BASE = [110, 90, 72]; // warm cast iron — used if the canvas is tainted | |
| /** | |
| * Derive the full eye palette from one sampled patch color: a darker rim, a | |
| * lighter glint, a photo-toned lid. The eye must look carved from the photo — | |
| * and must always own one value-step DARKER than anything in the patch, so the | |
| * sampled base is clamped before deriving (bright/low-chroma materials used to | |
| * collapse the whole ramp into pastel rings: lavender-sticker eyes). | |
| */ | |
| function derivePalette(sampled) { | |
| const [h, s, l] = rgbToHsl(sampled[0], sampled[1], sampled[2]); | |
| let base = hslToRgb(h, s, clamp(l, 0.22, 0.58)); | |
| if (s < 0.12) base = mixRgb(base, FALLBACK_BASE, 0.4); | |
| return { | |
| base, | |
| rim: shade(base, -0.4, 0.06), | |
| mid: shade(base, -0.06, 0.12), | |
| inner: shade(base, +0.22, 0.1), | |
| glint: mixRgb(shade(base, +0.3), BONE, 0.65), | |
| lid: shade(base, -0.06, 0.02), | |
| lidEdge: shade(base, -0.28, 0.04), | |
| pupil: shade(base, -0.38, -0.05), | |
| pupilCore: shade(base, -0.6, -0.1), | |
| mist: mixRgb(base, BONE, 0.45), | |
| }; | |
| } | |
| /** | |
| * Draw the photo once to a small offscreen canvas; average a patch around each | |
| * normalized feature point. Returns Map(featureIndex -> palette) or null when | |
| * the image is cross-origin tainted (caller falls back to cast-iron tones). | |
| */ | |
| function samplePalettes(imgEl, features) { | |
| try { | |
| const nw = imgEl.naturalWidth, nh = imgEl.naturalHeight; | |
| if (!nw || !nh) return null; | |
| const scale = Math.min(1, 320 / Math.max(nw, nh)); | |
| const cw = Math.max(8, Math.round(nw * scale)); | |
| const ch = Math.max(8, Math.round(nh * scale)); | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = cw; canvas.height = ch; | |
| const ctx = canvas.getContext("2d", { willReadFrequently: true }); | |
| ctx.drawImage(imgEl, 0, 0, cw, ch); | |
| const out = new Map(); | |
| for (let i = 0; i < features.length; i++) { | |
| const f = features[i]; | |
| const px = clamp(Math.round(f.cx * cw), 0, cw - 1); | |
| const py = clamp(Math.round(f.cy * ch), 0, ch - 1); | |
| const pr = Math.max(2, Math.round((f.size || 0.06) * Math.min(cw, ch) * 0.45)); | |
| const x0 = clamp(px - pr, 0, cw - 1), y0 = clamp(py - pr, 0, ch - 1); | |
| const w = clamp(px + pr, 1, cw) - x0, h = clamp(py + pr, 1, ch) - y0; | |
| const data = ctx.getImageData(x0, y0, Math.max(1, w), Math.max(1, h)).data; | |
| let r = 0, g = 0, b = 0, n = 0; | |
| for (let k = 0; k < data.length; k += 4) { r += data[k]; g += data[k + 1]; b += data[k + 2]; n++; } | |
| out.set(i, derivePalette([Math.round(r / n), Math.round(g / n), Math.round(b / n)])); | |
| } | |
| return out; | |
| } catch (_taintedOrUndrawable) { | |
| return null; // the photo keeps its secrets; we fall back to iron | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // geometry: where the photo actually paints inside its element box | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * The box (relative to hostEl) where the image pixels are actually painted, | |
| * honoring object-fit contain/cover. Normalized record coords map into THIS | |
| * box, never the raw element box. | |
| */ | |
| function paintedBox(imgEl, hostEl) { | |
| const ir = imgEl.getBoundingClientRect(); | |
| const hr = hostEl.getBoundingClientRect(); | |
| let x = ir.left - hr.left, y = ir.top - hr.top, w = ir.width, h = ir.height; | |
| const nw = imgEl.naturalWidth, nh = imgEl.naturalHeight; | |
| const fit = (getComputedStyle(imgEl).objectFit || "fill"); | |
| if (nw > 0 && nh > 0 && w > 0 && h > 0 && (fit === "contain" || fit === "cover" || fit === "scale-down")) { | |
| let s = fit === "cover" ? Math.max(w / nw, h / nh) : Math.min(w / nw, h / nh); | |
| if (fit === "scale-down") s = Math.min(s, 1); | |
| const sw = nw * s, sh = nh * s; | |
| x += (w - sw) / 2; y += (h - sh) / 2; w = sw; h = sh; | |
| } | |
| return { x, y, w, h }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // shared gaze source — one set of listeners no matter how many overlays live | |
| // --------------------------------------------------------------------------- | |
| const gaze = { px: null, py: null, tx: 0, ty: 0, tiltActive: false }; | |
| let gazeRefs = 0, gazeBase = null; | |
| function onPointerMove(e) { gaze.px = e.clientX; gaze.py = e.clientY; } | |
| function onTilt(e) { | |
| if (e.beta == null || e.gamma == null) return; | |
| if (!gazeBase) gazeBase = { beta: e.beta, gamma: e.gamma }; | |
| gaze.tx = clamp((e.gamma - gazeBase.gamma) / 18, -1, 1); | |
| gaze.ty = clamp((e.beta - gazeBase.beta) / 18, -1, 1); | |
| gaze.tiltActive = true; | |
| } | |
| function acquireGaze() { | |
| if (gazeRefs++ === 0) { | |
| window.addEventListener("pointermove", onPointerMove, { passive: true }); | |
| window.addEventListener("deviceorientation", onTilt, { passive: true }); | |
| } | |
| } | |
| function releaseGaze() { | |
| if (--gazeRefs <= 0) { | |
| gazeRefs = 0; | |
| window.removeEventListener("pointermove", onPointerMove); | |
| window.removeEventListener("deviceorientation", onTilt); | |
| gazeBase = null; | |
| } | |
| } | |
| // One AudioContext for the whole menagerie (browsers cap concurrent contexts). | |
| let sharedAudioCtx = null; | |
| function audioCtx() { | |
| if (!sharedAudioCtx) { | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| sharedAudioCtx = new AC(); | |
| } | |
| if (sharedAudioCtx.state === "suspended") sharedAudioCtx.resume().catch(() => {}); | |
| return sharedAudioCtx; | |
| } | |
| /** | |
| * The glass breath — a tiny synthesized chime for the reveal's first blink. | |
| * Two sine partials (~620Hz + ~930Hz), fast attack, long decay, ~0.4s, master | |
| * gain ≤ 0.12 — quieter than a whisper. Skipped under prefers-reduced-motion; | |
| * never throws on browsers without an AudioContext (a silent breath is still | |
| * a breath). The reveal always follows a user gesture, so autoplay allows it. | |
| */ | |
| function playGlassBreath() { | |
| try { | |
| if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; | |
| const ctx = audioCtx(); | |
| const t0 = ctx.currentTime; | |
| const master = ctx.createGain(); | |
| master.gain.setValueAtTime(0.0001, t0); | |
| master.gain.exponentialRampToValueAtTime(0.11, t0 + 0.015); // fast attack | |
| master.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.42); // long decay | |
| master.connect(ctx.destination); | |
| for (const [freq, level] of [[622, 0.66], [932, 0.36]]) { | |
| const osc = ctx.createOscillator(); | |
| osc.type = "sine"; | |
| osc.frequency.setValueAtTime(freq, t0); | |
| const g = ctx.createGain(); | |
| g.gain.setValueAtTime(level, t0); | |
| osc.connect(g); | |
| g.connect(master); | |
| osc.start(t0); | |
| osc.stop(t0 + 0.5); | |
| } | |
| setTimeout(() => { try { master.disconnect(); } catch (_) { /* gone */ } }, 700); | |
| } catch (_noAudio) { /* the browser declined to breathe — fine */ } | |
| } | |
| // easing | |
| const easeOutQuad = (t) => 1 - (1 - t) * (1 - t); | |
| const easeInOutQuad = (t) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t)); | |
| const smoothstep = (t) => { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); }; | |
| /** back-out with slight overshoot — the snap of a real saccade. */ | |
| function easeBackOut(t) { | |
| const s = 1.35; | |
| const u = t - 1; | |
| return 1 + (s + 1) * u * u * u + s * u * u; | |
| } | |
| const SVG_NS = "http://www.w3.org/2000/svg"; | |
| function el(name, attrs = {}) { | |
| const n = document.createElementNS(SVG_NS, name); | |
| for (const k in attrs) n.setAttribute(k, attrs[k]); | |
| return n; | |
| } | |
| let uidCounter = 0; | |
| // --------------------------------------------------------------------------- | |
| // createOverlay — the contract entrypoint | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Bring a record's found face to life on top of `imgEl`. | |
| * | |
| * @param {HTMLElement} containerEl positioned ancestor the SVG is appended to | |
| * @param {object} record awakening record; reads `features` (cx/cy/size/role | |
| * normalized to the painted image), `id` (RNG seed), `persona` (unused here) | |
| * @param {HTMLImageElement} imgEl the photo the face was found in | |
| * @param {object} [opts] | |
| * @param {string} [opts.mode="reveal"] "card" = wall-card scale: eyes get a | |
| * minimum rendered radius, brighter glints, and a denser 3–5s blink cadence | |
| * so a static glance at the wall always catches life | |
| * @param {boolean} [opts.emerge=true] play the mist emergence (wall cards may skip) | |
| * @param {boolean} [opts.idle=true] start with idle life enabled | |
| * @param {string} [opts.seed] override the RNG seed | |
| * @param {number} [opts.timeScale=1] stretch time (dev) | |
| * @param {boolean} [opts.dev=false] expose `_dev` deterministic drive hooks | |
| * @returns {{playLine: (url: string|null) => Promise<void>, setIdle: (on: boolean) => void, destroy: () => void}} | |
| */ | |
| export function createOverlay(containerEl, record, imgEl, opts = {}) { | |
| const uid = `pdov${++uidCounter}`; | |
| const emerge = opts.emerge !== false; | |
| const isCard = opts.mode === "card"; | |
| const glintBoost = isCard ? 1.5 : 1; | |
| const timeScale = opts.timeScale || 1; | |
| const featuresRaw = record.features || record.candidate_features || []; | |
| const eyes = []; | |
| let mouthFeature = null; | |
| featuresRaw.forEach((f, i) => { | |
| if (typeof f.cx !== "number" || typeof f.cy !== "number") return; | |
| if ((f.role || "").startsWith("eye")) eyes.push({ ...f, index: i }); | |
| else if (f.role === "mouth" && !mouthFeature) mouthFeature = { ...f, index: i }; | |
| }); | |
| // G2 mitigation: a degenerate eye pair (cx nearly coincident) reads as one | |
| // smeared eye — spread the pair symmetrically about its midpoint to a 0.05 | |
| // gap. Render-only: we mutate our local copies, never the record. | |
| if (eyes.length === 2 && Math.abs(eyes[0].cx - eyes[1].cx) < 0.05) { | |
| const mid = (eyes[0].cx + eyes[1].cx) / 2; | |
| const left = eyes[0].cx <= eyes[1].cx ? eyes[0] : eyes[1]; | |
| const right = left === eyes[0] ? eyes[1] : eyes[0]; | |
| left.cx = clamp(mid - 0.025, 0, 1); | |
| right.cx = clamp(mid + 0.025, 0, 1); | |
| } | |
| const seedStr = opts.seed || record.id || record.record_token || | |
| `${record.object || "thing"}:${featuresRaw.map((f) => `${f.cx},${f.cy}`).join(";")}`; | |
| const rng = mulberry32(hashSeed(String(seedStr))); | |
| // ensure the container can host an absolutely-positioned layer | |
| if (getComputedStyle(containerEl).position === "static") containerEl.style.position = "relative"; | |
| const svg = el("svg", { "aria-hidden": "true" }); | |
| svg.style.cssText = "position:absolute;pointer-events:none;display:block;overflow:hidden;"; | |
| if (opts.zIndex != null) svg.style.zIndex = String(opts.zIndex); | |
| const defs = el("defs"); | |
| svg.appendChild(defs); | |
| containerEl.appendChild(svg); | |
| // ------------------------------------------------------------- state | |
| let destroyed = false; | |
| let clock = 0; // logical seconds since creation (deterministic) | |
| let lastMs = null; | |
| let rafId = 0; | |
| let visible = true; | |
| let idleEnabled = opts.idle !== false; | |
| let paused = false; // dev: freeze logical time | |
| let frameCount = 0; | |
| let box = { x: 0, y: 0, w: 1, h: 1 }; | |
| let svgRect = null, rectDirty = true; | |
| // emergence schedule (per feature) — generous mist: a wider, longer | |
| // condensation forgives the ±15% the CV snap may have missed by | |
| const EMERGE_DUR = 1.35; | |
| eyes.forEach((e, i) => { e.emergeStart = emerge ? i * (0.18 + rng() * 0.1) : -EMERGE_DUR; }); | |
| if (mouthFeature) mouthFeature.emergeStart = emerge ? 0.42 + rng() * 0.12 : -EMERGE_DUR; | |
| const allEyesEmergedAt = eyes.length | |
| ? Math.max(...eyes.map((e) => e.emergeStart + EMERGE_DUR)) | |
| : 0; | |
| // blink machine — the first blink is the money frame. Card mode blinks on a | |
| // dense 3–5s cadence with a per-record phase stagger (rng is id-seeded), so | |
| // a wall of cards always has someone mid-blink; reveal keeps the slow burn. | |
| const blinkGap = () => (isCard ? 3 + rng() * 2 : 5 + rng() * 6); | |
| const blinkDown = isCard ? 0.16 : 0.11; | |
| const blinkUp = isCard ? 0.13 : 0.09; | |
| let blink = null; // {start, amp, down, up, repeats, gap} | |
| let firstBlinkAt = allEyesEmergedAt + 0.7 + (isCard ? rng() * 2.8 : 0); | |
| let firstBlinkDone = false; | |
| let chimeAt = Infinity; // glass breath fires as the first blink completes | |
| let nextBlinkAt = Infinity; // set after the first blink | |
| let nextFlutterAt = Infinity; | |
| let lidOverride = null; // dev hook | |
| // saccade machine — both eyes jump together | |
| let sacc = { x: 0, y: 0 }; // current offset in eye-diameter units | |
| let saccAnim = null; // {fromX, fromY, toX, toY, start, dur} | |
| let nextSaccadeAt = allEyesEmergedAt + 1.2 + rng() * 2; | |
| // slow look-straight-at-camera | |
| let stareUntil = -1; | |
| let nextStareAt = allEyesEmergedAt + 12 + rng() * 14; | |
| // pupil follow smoothing | |
| const follow = { x: 0, y: 0 }; // smoothed, in [-1,1] gaze space | |
| // speech | |
| let speech = null; // {audio, srcNode, analyser, buf, resolve} | |
| let env = 0; // smoothed mouth amplitude [0,1] | |
| let ampOverride = null; // dev hook | |
| let whisperUntil = -1; | |
| let widen = 1; | |
| // -------------------------------------------------------- palettes | |
| let palettes = null; | |
| function paletteFor(featureIndex) { | |
| return (palettes && palettes.get(featureIndex)) || derivePalette(FALLBACK_BASE); | |
| } | |
| function resamplePalettes() { | |
| palettes = samplePalettes(imgEl, featuresRaw); | |
| applyPalettes(); | |
| } | |
| // -------------------------------------------------------- DOM build | |
| // Geometry is rebuilt (cheaply, rarely) whenever the painted box changes; | |
| // every per-frame mutation afterwards is transform/opacity only. | |
| const eyeNodes = []; // [{g, gAnim, gIris, gPupil, lid, lidEdge, mist, gMain, d, ex, ey, jitterP}] | |
| let mouthNodes = null; | |
| function grad(id, stops, opts2 = {}) { | |
| const g = el("radialGradient", { id, ...opts2 }); | |
| for (const [offset, color, opacity] of stops) { | |
| g.appendChild(el("stop", { offset, "stop-color": color, "stop-opacity": opacity == null ? 1 : opacity })); | |
| } | |
| return g; | |
| } | |
| function buildEye(slot) { | |
| const f = eyes[slot]; | |
| // card-scale floor: at wall-card sizes the proportional radius collapses | |
| // into invisible dots — never render an eye smaller than 8px radius | |
| let r = (f.size * Math.min(box.w, box.h)) / 2; | |
| if (isCard) r = Math.max(8, (f.size * box.w) / 2); | |
| const d = r * 2; | |
| const ex = f.cx * box.w, ey = f.cy * box.h; | |
| const p = paletteFor(f.index); | |
| const idp = `${uid}e${slot}`; | |
| defs.appendChild(grad(`${idp}-socket`, [ | |
| ["55%", css(p.rim), 0.0], ["82%", css(p.rim), 0.5], ["100%", css(p.rim), 0.0], | |
| ])); | |
| // readability grounding (June 12 live audit): correctly-placed eyes sank | |
| // into busy foliage and bright chrome. A soft near-black pool settles the | |
| // iris into a socket, and a thin blurred penumbra ring grounds its edge — | |
| // a shadow the eye sits IN, never a sticker outline (no pure black, no | |
| // hard edge). Card mode runs slightly stronger: a wall card gets one | |
| // glance, not a stare. | |
| // Tiny rendered eyes (small features, small cards, phone reveals) need | |
| // proportionally MORE grounding than big ones — at r≥16px the pool relaxes | |
| // to its quiet baseline so large reveal eyes never look ringed-on. | |
| const small = clamp((16 - r) / 10, 0, 1); | |
| const groundRingA = isCard ? 0.55 : 0.45; | |
| const groundPoolA = (isCard ? 0.4 : 0.3) + 0.12 * small; | |
| const groundPoolR = (isCard ? 1.5 : 1.4) + 0.15 * small; | |
| defs.appendChild(grad(`${idp}-ground`, [ | |
| ["0%", "rgb(4,3,2)", groundPoolA], | |
| ["55%", "rgb(4,3,2)", groundPoolA * 0.85], | |
| ["100%", "rgb(4,3,2)", 0], | |
| ])); | |
| const ringW = clamp(r * 0.12, isCard ? 1.8 : 1.5, 2.4); | |
| const soften = el("filter", { | |
| id: `${idp}-soften`, x: "-30%", y: "-30%", width: "160%", height: "160%", | |
| }); | |
| soften.appendChild(el("feGaussianBlur", { stdDeviation: (ringW * 0.42).toFixed(2) })); | |
| defs.appendChild(soften); | |
| defs.appendChild(grad(`${idp}-iris`, [ | |
| ["0%", css(p.inner), 0.96], ["34%", css(p.mid), 0.95], | |
| ["72%", css(p.rim), 0.97], ["100%", css(shade(p.rim, -0.1)), 0.9], | |
| ])); | |
| defs.appendChild(grad(`${idp}-pupil`, [ | |
| ["0%", css(p.pupilCore)], ["62%", css(p.pupil)], ["100%", css(p.pupil), 0.0], | |
| ])); | |
| defs.appendChild(grad(`${idp}-mist`, [ | |
| ["0%", css(p.mist), 0.5], ["55%", css(p.mist), 0.26], ["100%", css(p.mist), 0], | |
| ])); | |
| const clip = el("clipPath", { id: `${idp}-clip` }); | |
| clip.appendChild(el("ellipse", { cx: 0, cy: 0, rx: r * 1.04, ry: r * 1.0 })); | |
| defs.appendChild(clip); | |
| const g = el("g"); // positioned at eye center | |
| const gAnim = el("g"); // jitter + widen | |
| const gMain = el("g"); // fades in on emergence | |
| const gIris = el("g"); // small shift with saccade | |
| const gPupil = el("g"); // pupil-follow | |
| // socket shadow seats the eye INTO the material — first the near-black | |
| // grounding pool (normal blend: multiply vanishes on bright chrome), | |
| // slightly low, like weight settling… | |
| gMain.appendChild(el("ellipse", { | |
| cx: 0, cy: r * 0.1, rx: r * groundPoolR, ry: r * groundPoolR * 0.94, | |
| fill: `url(#${idp}-ground)`, | |
| })); | |
| // …then the palette-tinted socket that keeps it of the material | |
| gMain.appendChild(el("ellipse", { | |
| cx: 0, cy: 0, rx: r * 1.5, ry: r * 1.32, | |
| fill: `url(#${idp}-socket)`, style: "mix-blend-mode:multiply", opacity: 0.65, | |
| })); | |
| // iris: concentric translucent layers tinted from the photo | |
| gIris.appendChild(el("circle", { cx: 0, cy: 0, r: r, fill: `url(#${idp}-iris)` })); | |
| gIris.appendChild(el("circle", { | |
| cx: 0, cy: 0, r: r * 0.72, fill: "none", | |
| stroke: css(p.inner, 0.5), "stroke-width": Math.max(0.6, r * 0.06), | |
| })); | |
| // soft catchlight along the lower iris — light pooling in the material | |
| gIris.appendChild(el("ellipse", { | |
| cx: 0, cy: r * 0.55, rx: r * 0.44, ry: r * 0.13, fill: css(p.glint), | |
| opacity: Math.min(1, 0.2 * glintBoost), | |
| })); | |
| gPupil.appendChild(el("circle", { cx: 0, cy: 0, r: r * 0.4, fill: `url(#${idp}-pupil)` })); | |
| gPupil.appendChild(el("circle", { | |
| cx: -r * 0.15, cy: -r * 0.17, r: r * 0.12, fill: css(p.glint), | |
| opacity: Math.min(1, 0.95 * glintBoost), | |
| })); | |
| gPupil.appendChild(el("circle", { | |
| cx: r * 0.14, cy: r * 0.12, r: r * 0.05, fill: css(p.glint), | |
| opacity: Math.min(1, 0.4 * glintBoost), | |
| })); | |
| gIris.appendChild(gPupil); | |
| // clipped group: top shading crescent + the lid | |
| const gClipped = el("g", { "clip-path": `url(#${idp}-clip)` }); | |
| gClipped.appendChild(el("ellipse", { | |
| cx: 0, cy: -r * 0.62, rx: r * 1.05, ry: r * 0.78, | |
| fill: css(p.rim), opacity: 0.3, style: "mix-blend-mode:multiply", | |
| })); | |
| const lid = el("ellipse", { | |
| cx: 0, cy: -(r * 1.5) - r, rx: r * 1.6, ry: r * 1.5, | |
| fill: css(p.lid), stroke: css(p.lidEdge, 0.55), "stroke-width": Math.max(0.7, r * 0.07), | |
| }); | |
| gClipped.appendChild(lid); | |
| gMain.appendChild(gIris); | |
| gMain.appendChild(gClipped); | |
| // the penumbra ring rides the socket edge, just outside the iris — soft | |
| // (blurred), thin, and dark enough that foliage and chrome can't eat it | |
| gMain.appendChild(el("circle", { | |
| cx: 0, cy: 0, r: r + ringW * 0.45, fill: "none", | |
| stroke: `rgba(4,3,2,${groundRingA})`, "stroke-width": ringW.toFixed(2), | |
| filter: `url(#${idp}-soften)`, | |
| })); | |
| gMain.setAttribute("opacity", emerge ? 0 : 1); | |
| // emergence mist sits above everything for this eye — generously wide, | |
| // so a slightly-missed snap point still reads as condensation, not error | |
| const mist = el("circle", { cx: 0, cy: 0, r: r * 2.05, fill: `url(#${idp}-mist)`, opacity: 0 }); | |
| gAnim.appendChild(gMain); | |
| gAnim.appendChild(mist); | |
| g.appendChild(gAnim); | |
| g.setAttribute("transform", `translate(${ex} ${ey})`); | |
| svg.appendChild(g); | |
| return { | |
| f, g, gAnim, gMain, gIris, gPupil, lid, mist, d, r, ex, ey, | |
| jitterP: [rng() * 6.28, rng() * 6.28, 0.7 + rng() * 0.5, 1.3 + rng() * 0.8], | |
| }; | |
| } | |
| function buildMouth() { | |
| const f = mouthFeature; | |
| const d = f.size * Math.min(box.w, box.h); | |
| const mr = d / 2; | |
| const mx = f.cx * box.w, my = f.cy * box.h; | |
| const p = paletteFor(f.index); | |
| const idp = `${uid}m`; | |
| defs.appendChild(grad(`${idp}-shade`, [ | |
| ["40%", css(p.rim), 0.0], ["80%", css(p.rim), 0.45], ["100%", css(p.rim), 0.0], | |
| ])); | |
| defs.appendChild(grad(`${idp}-cavity`, [ | |
| ["0%", css(p.pupilCore), 0.95], ["70%", css(p.pupil), 0.9], ["100%", css(p.pupil), 0.0], | |
| ])); | |
| defs.appendChild(grad(`${idp}-mist`, [ | |
| ["0%", css(p.mist), 0.42], ["55%", css(p.mist), 0.22], ["100%", css(p.mist), 0], | |
| ])); | |
| const g = el("g"); | |
| const gMain = el("g"); | |
| const gAnim = el("g"); | |
| gMain.appendChild(el("ellipse", { | |
| cx: 0, cy: 0, rx: mr * 1.05, ry: mr * 0.5, | |
| fill: `url(#${idp}-shade)`, style: "mix-blend-mode:multiply", opacity: 0.38, | |
| })); | |
| const cavity = el("ellipse", { cx: 0, cy: 0, rx: mr * 0.72, ry: mr * 0.5, fill: `url(#${idp}-cavity)` }); | |
| const cavityG = el("g"); | |
| cavityG.appendChild(cavity); | |
| // at rest the mouth is a closed seam — a shadow crease across the material | |
| const seam = el("ellipse", { | |
| cx: 0, cy: 0, rx: mr * 0.82, ry: mr * 0.09, | |
| fill: css(p.pupil, 0.6), | |
| }); | |
| const lipGlint = el("ellipse", { | |
| cx: 0, cy: mr * 0.22, rx: mr * 0.56, ry: mr * 0.07, fill: css(p.glint), opacity: 0.16, | |
| }); | |
| gAnim.appendChild(cavityG); | |
| gAnim.appendChild(seam); | |
| gAnim.appendChild(lipGlint); | |
| gMain.appendChild(gAnim); | |
| gMain.setAttribute("opacity", emerge ? 0 : 1); | |
| const mist = el("circle", { cx: 0, cy: 0, r: mr * 1.8, fill: `url(#${idp}-mist)`, opacity: 0 }); | |
| g.appendChild(gMain); | |
| g.appendChild(mist); | |
| g.setAttribute("transform", `translate(${mx} ${my})`); | |
| svg.appendChild(g); | |
| return { f, g, gMain, gAnim, cavityG, cavity, seam, mist, mr, mx, my }; | |
| } | |
| function rebuild() { | |
| // wipe and rebuild geometry at current px box (rare: init / resize / palette) | |
| while (svg.lastChild !== defs && svg.lastChild) svg.removeChild(svg.lastChild); | |
| while (defs.firstChild) defs.removeChild(defs.firstChild); | |
| eyeNodes.length = 0; | |
| eyes.forEach((_, i) => eyeNodes.push(buildEye(i))); | |
| mouthNodes = mouthFeature ? buildMouth() : null; | |
| renderFrame(0); // re-apply current animation state immediately | |
| } | |
| function applyPalettes() { rebuild(); } | |
| function layout() { | |
| const b = paintedBox(imgEl, containerEl); | |
| if (b.w <= 1 || b.h <= 1) return; | |
| const changed = Math.abs(b.w - box.w) > 0.5 || Math.abs(b.h - box.h) > 0.5 || | |
| Math.abs(b.x - box.x) > 0.5 || Math.abs(b.y - box.y) > 0.5; | |
| box = b; | |
| svg.style.left = `${b.x}px`; | |
| svg.style.top = `${b.y}px`; | |
| svg.setAttribute("width", b.w); | |
| svg.setAttribute("height", b.h); | |
| svg.setAttribute("viewBox", `0 0 ${b.w} ${b.h}`); | |
| rectDirty = true; | |
| if (changed) rebuild(); | |
| } | |
| // ------------------------------------------------------------ animation | |
| function emergeT(f) { | |
| return clamp((clock - f.emergeStart) / EMERGE_DUR, 0, 1); | |
| } | |
| function startBlink(amp, down, up, repeats) { | |
| blink = { start: clock, amp, down, up, repeats: repeats || 1, gap: 0.07 }; | |
| } | |
| /** lid closure [0..1] from the blink machine (or dev override). */ | |
| function lidValue() { | |
| if (lidOverride != null) return lidOverride; | |
| if (!blink) return 0; | |
| const cycle = blink.down + blink.up + blink.gap; | |
| const t = clock - blink.start; | |
| const rep = Math.floor(t / cycle); | |
| if (rep >= blink.repeats) { blink = null; return 0; } | |
| const u = t - rep * cycle; | |
| if (u < blink.down) return blink.amp * easeOutQuad(u / blink.down); | |
| if (u < blink.down + blink.up) return blink.amp * (1 - easeInOutQuad((u - blink.down) / blink.up)); | |
| return 0; | |
| } | |
| function scheduleIdle() { | |
| // saccades — shared, sudden, slightly overshooting | |
| if (clock >= nextSaccadeAt) { | |
| const recenter = rng() < 0.4; | |
| const mag = recenter ? 0 : 0.05 + rng() * 0.09; | |
| const ang = rng() * Math.PI * 2; | |
| saccAnim = { | |
| fromX: sacc.x, fromY: sacc.y, | |
| toX: Math.cos(ang) * mag, toY: Math.sin(ang) * mag * 0.7, | |
| start: clock, dur: 0.06 + rng() * 0.03, | |
| }; | |
| nextSaccadeAt = clock + 2 + rng() * 4; | |
| } | |
| if (saccAnim) { | |
| const t = clamp((clock - saccAnim.start) / saccAnim.dur, 0, 1); | |
| const e = easeBackOut(t); | |
| sacc.x = lerp(saccAnim.fromX, saccAnim.toX, e); | |
| sacc.y = lerp(saccAnim.fromY, saccAnim.toY, e); | |
| if (t >= 1) saccAnim = null; | |
| } | |
| // the slow look-straight-at-camera | |
| if (clock >= nextStareAt) { | |
| stareUntil = clock + 4; | |
| nextStareAt = clock + 18 + rng() * 22; | |
| } | |
| // blinks + flutters | |
| if (firstBlinkDone && clock >= nextBlinkAt) { | |
| startBlink(1, blinkDown, blinkUp, 1); | |
| nextBlinkAt = clock + blinkGap(); | |
| } | |
| if (firstBlinkDone && clock >= nextFlutterAt) { | |
| startBlink(0.55, 0.07, 0.06, 2); | |
| nextFlutterAt = clock + 8 + rng() * 6; | |
| } | |
| } | |
| function gazeVector(node) { | |
| // normalized [-1,1] direction toward the pointer / device tilt | |
| if (gaze.tiltActive) return { x: gaze.tx, y: gaze.ty }; | |
| if (gaze.px == null) return { x: 0, y: 0 }; | |
| if (rectDirty || !svgRect) { svgRect = svg.getBoundingClientRect(); rectDirty = false; } | |
| const exs = svgRect.left + node.ex, eys = svgRect.top + node.ey; | |
| return { | |
| x: clamp((gaze.px - exs) / (window.innerWidth * 0.35), -1, 1), | |
| y: clamp((gaze.py - eys) / (window.innerHeight * 0.35), -1, 1), | |
| }; | |
| } | |
| function updateEnvelope(dt) { | |
| let target = 0; | |
| if (ampOverride != null) { env = ampOverride; return; } | |
| if (speech && speech.analyser) { | |
| speech.analyser.getByteTimeDomainData(speech.buf); | |
| let sum = 0; | |
| for (let i = 0; i < speech.buf.length; i++) { | |
| const v = (speech.buf[i] - 128) / 128; | |
| sum += v * v; | |
| } | |
| const rms = Math.sqrt(sum / speech.buf.length); | |
| target = clamp((rms - 0.015) * 5.5, 0, 1); | |
| } else if (clock < whisperUntil) { | |
| target = 0.06 + 0.05 * (0.5 + 0.5 * Math.sin(clock * 9 + 1.3)); | |
| } | |
| const tau = target > env ? 0.028 : 0.12; // fast attack, ~120ms release | |
| const k = dt <= 0 ? 0 : 1 - Math.exp(-dt / tau); | |
| env += (target - env) * k; | |
| } | |
| function renderFrame(dt) { | |
| frameCount++; | |
| if (idleEnabled && !destroyed) scheduleIdle(); | |
| if (!firstBlinkDone && clock >= firstBlinkAt) { | |
| startBlink(1, Math.max(0.12, blinkDown), blinkUp, 1); | |
| firstBlinkDone = true; | |
| // the glass breath belongs to the REVEAL's money frame only — wall | |
| // cards blink constantly and must never become a wind-chime shop | |
| if (!isCard) chimeAt = clock + Math.max(0.12, blinkDown) + blinkUp; | |
| nextBlinkAt = clock + blinkGap(); | |
| nextFlutterAt = clock + 8 + rng() * 6; | |
| } | |
| if (clock >= chimeAt) { | |
| chimeAt = Infinity; | |
| playGlassBreath(); | |
| } | |
| updateEnvelope(dt); | |
| const lid = lidValue(); | |
| const staring = clock < stareUntil; | |
| const speakingAloud = !!speech; | |
| const widenTarget = speakingAloud ? 1.08 : 1; | |
| const wk = dt <= 0 ? 0 : 1 - Math.exp(-dt / 0.12); | |
| widen += (widenTarget - widen) * wk; | |
| // shared gaze smoothing — eyes are near each other; one follow vector, | |
| // updated ONCE per frame (per-eye updates would double the smoothing rate) | |
| if (idleEnabled && eyeNodes.length) { | |
| const gz = staring ? { x: 0, y: 0 } : gazeVector(eyeNodes[0]); | |
| const fk = dt <= 0 ? 0 : 1 - Math.exp(-dt / (staring ? 0.45 : 0.09)); | |
| follow.x += (gz.x - follow.x) * fk; | |
| follow.y += (gz.y - follow.y) * fk; | |
| } | |
| for (const n of eyeNodes) { | |
| const e = emergeT(n.f); | |
| // emergence: mist swells then clears; the eye condenses underneath | |
| if (e < 1) { | |
| const mistA = e < 0.28 ? smoothstep(e / 0.28) : 1 - smoothstep((e - 0.28) / 0.72); | |
| n.mist.setAttribute("opacity", (mistA * 0.95).toFixed(3)); | |
| n.mist.setAttribute("transform", `scale(${(0.9 + e * 1.3).toFixed(3)})`); | |
| const reveal = smoothstep((e - 0.25) / 0.75); | |
| n.gMain.setAttribute("opacity", reveal.toFixed(3)); | |
| n.gMain.setAttribute("transform", `scale(${(1.12 - 0.12 * reveal).toFixed(4)})`); | |
| } else if (n.gMain.getAttribute("opacity") !== "1") { | |
| n.mist.setAttribute("opacity", "0"); | |
| n.gMain.setAttribute("opacity", "1"); | |
| n.gMain.removeAttribute("transform"); | |
| } | |
| // idle micro-jitter (≤0.3% of size) — independent per eye | |
| let jx = 0, jy = 0; | |
| if (idleEnabled) { | |
| const [p1, p2, f1, f2] = n.jitterP; | |
| const ja = 0.003 * n.d; | |
| jx = ja * (Math.sin(clock * 2.1 * f1 + p1) + 0.6 * Math.sin(clock * 4.7 * f2 + p2)); | |
| jy = ja * (Math.cos(clock * 1.7 * f2 + p2) + 0.6 * Math.cos(clock * 3.9 * f1 + p1)); | |
| } | |
| n.g.setAttribute("transform", `translate(${(n.ex + jx).toFixed(2)} ${(n.ey + jy).toFixed(2)})`); | |
| n.gAnim.setAttribute("transform", `scale(${widen.toFixed(4)})`); | |
| // pupil-follow + saccade, clamped to ±25% of eye size | |
| let px2 = 0, py2 = 0, ix = 0, iy = 0; | |
| if (idleEnabled) { | |
| const lim = 0.25 * n.d; | |
| px2 = clamp(follow.x * 0.22 * n.d + sacc.x * n.d, -lim, lim); | |
| py2 = clamp(follow.y * 0.18 * n.d + sacc.y * n.d, -lim, lim); | |
| ix = px2 * 0.35; iy = py2 * 0.35; | |
| } | |
| n.gPupil.setAttribute("transform", `translate(${(px2 - ix).toFixed(2)} ${(py2 - iy).toFixed(2)})`); | |
| n.gIris.setAttribute("transform", `translate(${ix.toFixed(2)} ${iy.toFixed(2)})`); | |
| // the lid sweep | |
| n.lid.setAttribute("transform", `translate(0 ${(lid * 2.04 * n.r).toFixed(2)})`); | |
| } | |
| if (mouthNodes) { | |
| const m = mouthNodes; | |
| const e = emergeT(m.f); | |
| if (e < 1) { | |
| const mistA = e < 0.3 ? smoothstep(e / 0.3) : 1 - smoothstep((e - 0.3) / 0.7); | |
| m.mist.setAttribute("opacity", (mistA * 0.7).toFixed(3)); | |
| m.mist.setAttribute("transform", `scale(${(0.85 + e * 1.0).toFixed(3)})`); | |
| m.gMain.setAttribute("opacity", smoothstep((e - 0.32) / 0.68).toFixed(3)); | |
| } else if (m.gMain.getAttribute("opacity") !== "1") { | |
| m.mist.setAttribute("opacity", "0"); | |
| m.gMain.setAttribute("opacity", "1"); | |
| } | |
| // squash/stretch with the envelope; sync beats viseme accuracy | |
| const sy = 1 + env * 0.85; | |
| const sx = 1 - env * 0.16; | |
| m.gAnim.setAttribute("transform", `scale(${sx.toFixed(4)} ${sy.toFixed(4)})`); | |
| m.cavityG.setAttribute("transform", `scale(1 ${(0.1 + env * 1.2).toFixed(4)})`); | |
| m.cavity.setAttribute("opacity", (0.08 + env * 0.92).toFixed(3)); | |
| m.seam.setAttribute("transform", `scale(1 ${(1 + env * 2.2).toFixed(4)})`); | |
| m.seam.setAttribute("opacity", (1 - env * 0.55).toFixed(3)); | |
| } | |
| } | |
| function frame(nowMs) { | |
| if (destroyed) return; | |
| if (!visible || document.hidden) { rafId = 0; lastMs = null; return; } // truly stop off-viewport | |
| rafId = requestAnimationFrame(frame); | |
| if (lastMs == null) lastMs = nowMs; | |
| let dt = Math.min(0.05, (nowMs - lastMs) / 1000); | |
| lastMs = nowMs; | |
| if (paused) dt = 0; | |
| dt *= timeScale; | |
| clock += dt; | |
| renderFrame(dt); | |
| } | |
| function ensureLoop() { | |
| if (!rafId && !destroyed) { lastMs = null; rafId = requestAnimationFrame(frame); } | |
| } | |
| // ------------------------------------------------------------ observers | |
| const ro = new ResizeObserver(() => layout()); | |
| ro.observe(imgEl); | |
| ro.observe(containerEl); | |
| const io = new IntersectionObserver((entries) => { | |
| for (const en of entries) visible = en.isIntersecting; | |
| if (visible) ensureLoop(); | |
| }); | |
| io.observe(containerEl); | |
| const onScroll = () => { rectDirty = true; }; | |
| window.addEventListener("scroll", onScroll, { passive: true }); | |
| const onVisibility = () => { if (!document.hidden) ensureLoop(); }; | |
| document.addEventListener("visibilitychange", onVisibility); | |
| acquireGaze(); | |
| if (imgEl.complete && imgEl.naturalWidth) { | |
| layout(); | |
| resamplePalettes(); | |
| } else { | |
| imgEl.addEventListener("load", () => { | |
| if (destroyed) return; | |
| layout(); | |
| resamplePalettes(); | |
| }, { once: true }); | |
| layout(); | |
| rebuild(); // fallback palette until pixels arrive | |
| } | |
| ensureLoop(); | |
| // ------------------------------------------------------------ speech | |
| function stopSpeech(resolveIt = true) { | |
| if (!speech) return; | |
| const s = speech; | |
| speech = null; | |
| try { s.audio.pause(); } catch (_) { /* already gone */ } | |
| try { s.srcNode.disconnect(); s.analyser.disconnect(); } catch (_) { /* fine */ } | |
| if (resolveIt && s.resolve) s.resolve(); | |
| } | |
| /** | |
| * Speak one line. With a wav URL: WebAudio analyser drives the mouth and the | |
| * eyes widen while it talks. With null (mutter-only cards): the mouth pulses | |
| * subtly for a few seconds while the CALLER shows the whisper-bubble caption. | |
| * Resolves when the line finishes (or quietly, if the audio fails to load — | |
| * never throws at the visitor). | |
| * @param {string|null} audioUrlOrNull | |
| * @returns {Promise<void>} | |
| */ | |
| function playLine(audioUrlOrNull) { | |
| if (destroyed) return Promise.resolve(); | |
| stopSpeech(); | |
| ensureLoop(); | |
| if (!audioUrlOrNull) { | |
| whisperUntil = clock + 2.8; | |
| return new Promise((res) => setTimeout(res, 2800 / timeScale)); | |
| } | |
| return new Promise((resolve) => { | |
| let ctx; | |
| try { ctx = audioCtx(); } catch (_noAudio) { resolve(); return; } | |
| const audio = new Audio(); | |
| audio.crossOrigin = "anonymous"; | |
| audio.src = audioUrlOrNull; | |
| let srcNode, analyser; | |
| try { | |
| srcNode = ctx.createMediaElementSource(audio); | |
| analyser = ctx.createAnalyser(); | |
| analyser.fftSize = 512; | |
| analyser.smoothingTimeConstant = 0.4; | |
| srcNode.connect(analyser); | |
| analyser.connect(ctx.destination); | |
| } catch (_graphFail) { resolve(); return; } | |
| speech = { audio, srcNode, analyser, buf: new Uint8Array(analyser.fftSize), resolve }; | |
| audio.addEventListener("ended", () => stopSpeech()); | |
| audio.addEventListener("error", () => stopSpeech()); | |
| audio.play().catch(() => stopSpeech()); // autoplay gates resolve quietly | |
| }); | |
| } | |
| /** | |
| * Toggle idle life (jitter/saccades/blinks/pupil-follow). Emergence and | |
| * speech still animate while idle is off. | |
| */ | |
| function setIdle(on) { | |
| idleEnabled = !!on; | |
| if (idleEnabled) ensureLoop(); | |
| } | |
| function destroy() { | |
| if (destroyed) return; | |
| destroyed = true; | |
| stopSpeech(false); | |
| cancelAnimationFrame(rafId); | |
| rafId = 0; | |
| ro.disconnect(); | |
| io.disconnect(); | |
| window.removeEventListener("scroll", onScroll); | |
| document.removeEventListener("visibilitychange", onVisibility); | |
| releaseGaze(); | |
| svg.remove(); | |
| } | |
| const handle = { playLine, setIdle, destroy }; | |
| if (opts.dev) { | |
| // Deterministic drive hooks for the screenshot harness — never shipped UI. | |
| handle._dev = { | |
| setPaused(b) { paused = !!b; }, | |
| /** advance logical time by ms and render one frame */ | |
| pump(ms) { clock += ms / 1000; renderFrame(ms / 1000); }, | |
| blink() { startBlink(1, 0.12, 0.09, 1); }, | |
| setLid(v) { lidOverride = v; renderFrame(0); }, | |
| forceAmp(v) { | |
| ampOverride = v; | |
| if (v != null && v > 0.2) widen = 1.08; // shot shows the speaking widen too | |
| renderFrame(0); | |
| }, | |
| state() { | |
| return { | |
| clock, frameCount, lid: lidValue(), env, widen, | |
| emerged: eyes.map((e2) => emergeT(e2)), firstBlinkDone, box, | |
| }; | |
| }, | |
| }; | |
| } | |
| return handle; | |
| } | |