pareidolia / web /overlay.js
AndresCarreon's picture
pareidolia: post-snap geometry gate (one multi-turn feature retry) + eye readability halo + wall/img/SSE container-nap resilience
de59038 verified
Raw
History Blame Contribute Delete
39.4 kB
// 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;
}