// 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, 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} */ 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; }