// Port of scripts/derive_sprites.py: mechanical warps of existing sprites plus // procedural visemes from a closed-mouth stroke. No new artwork. deriveAll never // overwrites an existing canvas key (hand-made sprites win). Pixel work is on // typed arrays; canvas API only for whole-image ops (arc/ellipse draw, resize). import { CANVAS, DERIVE } from "./config.js?v=20260729.6"; import { newCanvas, getData, canvasFromData, hexToRgb, median, rad, bankersRound, bboxAlpha } from "./imageops.js?v=20260729.6"; const N = CANVAS * CANVAS; /** Port of make_procedural_closed: lower-half ellipse arc from manifest params. */ function makeProceduralClosed(manifest) { const [cx, cy] = manifest.mouthCenter; const halfW = Math.trunc(manifest.mouthStyle?.width ?? 20); const line = manifest.mouthStyle?.line ?? "#2e2e2e"; const c = newCanvas(CANVAS, CANVAS); const ctx = c.getContext("2d"); ctx.strokeStyle = line; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(cx, cy, halfW, Math.trunc(halfW * 0.55), 0, rad(25), rad(155), false); ctx.stroke(); return c; } /** Mark mask=1 where placed lip's raw alpha exceeds thr, clipped to canvas. */ function stampAlpha(mask, lip, xLeft, top, thr) { const { data, width: lw, height: lh } = lip; for (let ly = 0; ly < lh; ly++) { const dy = top + ly; if (dy < 0 || dy >= CANVAS) continue; for (let lx = 0; lx < lw; lx++) { const dx = xLeft + lx; if (dx < 0 || dx >= CANVAS) continue; if (data[(ly * lw + lx) * 4 + 3] > thr) mask[dy * CANVAS + dx] = 1; } } } /** Src-over lip onto out; blends all 4 channels like python _place. */ function compositeLip(out, lip, xLeft, top) { const { data, width: lw, height: lh } = lip; for (let ly = 0; ly < lh; ly++) { const dy = top + ly; if (dy < 0 || dy >= CANVAS) continue; for (let lx = 0; lx < lw; lx++) { const dx = xLeft + lx; if (dx < 0 || dx >= CANVAS) continue; const k = (ly * lw + lx) * 4; const a = data[k + 3] / 255; if (a === 0) continue; const i = (dy * CANVAS + dx) * 4; for (let ch = 0; ch < 4; ch++) out[i + ch] = data[k + ch] * a + out[i + ch] * (1 - a); } } } /** 3x3 max dilation, `iters` times. */ function dilate(mask, iters) { let cur = mask; for (let it = 0; it < iters; it++) { const next = new Uint8Array(N); for (let y = 0; y < CANVAS; y++) { for (let x = 0; x < CANVAS; x++) { let v = 0; for (let dy = -1; dy <= 1 && !v; dy++) { const ny = y + dy; if (ny < 0 || ny >= CANVAS) continue; for (let dx = -1; dx <= 1; dx++) { const nx = x + dx; if (nx >= 0 && nx < CANVAS && cur[ny * CANVAS + nx]) { v = 1; break; } } } next[y * CANVAS + x] = v; } } cur = next; } return cur; } function buildViseme(y0, mw, sh, cxf, strokeCanvas, colors, ink, thickness, p) { const lipW = Math.max(2, Math.trunc(mw * p.wx)); const lipCanvas = newCanvas(lipW, sh); const lctx = lipCanvas.getContext("2d"); lctx.imageSmoothingEnabled = true; lctx.imageSmoothingQuality = "high"; lctx.drawImage(strokeCanvas, 0, 0, mw, sh, 0, 0, lipW, sh); const lip = lctx.getImageData(0, 0, lipW, sh); const drop = Math.trunc(mw * p.drop); const xoff = bankersRound(cxf - lipW / 2); const out = new Uint8ClampedArray(N * 4); // interior: per column, fill between the bottom of the top lip and the top of // the bottom lip. Masks threshold raw lip alpha at >123 (python _place squares // alpha, so its `>60` on the placed layer equals raw alpha > 123.69). const topMask = new Uint8Array(N); const botMask = new Uint8Array(N); stampAlpha(topMask, lip, xoff, y0, 123); stampAlpha(botMask, lip, xoff, y0 + drop, 123); for (let x = 0; x < CANVAS; x++) { let tcLast = -1; for (let y = CANVAS - 1; y >= 0; y--) { if (topMask[y * CANVAS + x]) { tcLast = y; break; } } if (tcLast < 0) continue; let bcFirst = -1; for (let y = 0; y < CANVAS; y++) { if (botMask[y * CANVAS + x]) { bcFirst = y; break; } } if (bcFirst < 0 || bcFirst <= tcLast) continue; for (let y = tcLast; y <= bcFirst; y++) { const i = (y * CANVAS + x) * 4; out[i] = colors.fill[0]; out[i + 1] = colors.fill[1]; out[i + 2] = colors.fill[2]; out[i + 3] = 255; } } const inner = new Uint8Array(N); let anyInner = false, firstRow = -1, lastRow = -1; for (let y = 0; y < CANVAS; y++) { let rowHas = false; for (let x = 0; x < CANVAS; x++) { if (out[(y * CANVAS + x) * 4 + 3] > 0) { inner[y * CANVAS + x] = 1; rowHas = true; anyInner = true; } } if (rowHas) { if (firstRow < 0) firstRow = y; lastRow = y; } } if (p.teeth > 0 && anyInner) { const bandEnd = Math.min(CANVAS, firstRow + Math.max(2, Math.trunc(drop * p.teeth))); for (let y = firstRow; y < bandEnd; y++) { for (let x = 0; x < CANVAS; x++) { if (inner[y * CANVAS + x]) { const i = (y * CANVAS + x) * 4; out[i] = colors.teeth[0]; out[i + 1] = colors.teeth[1]; out[i + 2] = colors.teeth[2]; out[i + 3] = 255; } } } } if (p.tongue > 0 && anyInner) { // top half of an ellipse centered at the interior floor (cv2 angles 180..360). const tc = newCanvas(CANVAS, CANVAS); const tctx = tc.getContext("2d"); tctx.fillStyle = `rgb(${colors.tongue[0]},${colors.tongue[1]},${colors.tongue[2]})`; tctx.beginPath(); tctx.ellipse(Math.trunc(cxf), lastRow, Math.trunc(mw * 0.28), Math.max(2, Math.trunc(drop * p.tongue)), 0, Math.PI, 2 * Math.PI, false); tctx.fill(); const td = tctx.getImageData(0, 0, CANVAS, CANVAS).data; for (let idx = 0; idx < N; idx++) { if (td[idx * 4 + 3] > 127 && inner[idx]) { out[idx * 4] = colors.tongue[0]; out[idx * 4 + 1] = colors.tongue[1]; out[idx * 4 + 2] = colors.tongue[2]; out[idx * 4 + 3] = 255; } } } compositeLip(out, lip, xoff, y0); compositeLip(out, lip, xoff, y0 + drop); // ink outline: dilate the content mask and paint the new ring in ink under the // content (contract's equivalent of cv2.drawContours). const mask0 = new Uint8Array(N); for (let idx = 0; idx < N; idx++) mask0[idx] = out[idx * 4 + 3] > 60 ? 1 : 0; const dil = dilate(mask0, Math.round(thickness / 2) + 1); for (let idx = 0; idx < N; idx++) { if (dil[idx] && !mask0[idx]) { const ca = out[idx * 4 + 3] / 255; out[idx * 4] = out[idx * 4] * ca + ink[0] * (1 - ca); out[idx * 4 + 1] = out[idx * 4 + 1] * ca + ink[1] * (1 - ca); out[idx * 4 + 2] = out[idx * 4 + 2] * ca + ink[2] * (1 - ca); out[idx * 4 + 3] = 255; } } return canvasFromData(out, CANVAS, CANVAS); } /** Port of derive_mouth_set: A/E/I/O/U from the closed-mouth stroke + manifest. */ function deriveMouthSet(canvases, manifest, closed) { const closedData = getData(closed); const bb = bboxAlpha(closedData); if (!bb) return; const [x0, y0, x1, y1] = bb; const mw = x1 - x0, sh = y1 - y0, cxf = (x0 + x1) / 2; const strokeCanvas = newCanvas(mw, sh); strokeCanvas.getContext("2d").putImageData(closed.getContext("2d").getImageData(x0, y0, mw, sh), 0, 0); const style = manifest.mouthStyle ?? {}; const colors = { fill: hexToRgb(style.fill ?? "#8a3535").map((v) => Math.floor(v * DERIVE.fillDarken)), tongue: hexToRgb(style.tongue ?? "#d97b7b"), teeth: hexToRgb(style.teeth ?? "#ffffff"), }; // ink color = median of opaque stroke pixels; thickness = mean ink px/col * 0.9. const cd = closedData.data; const iR = [], iG = [], iB = []; for (let idx = 0; idx < N; idx++) { if (cd[idx * 4 + 3] > 200) { iR.push(cd[idx * 4]); iG.push(cd[idx * 4 + 1]); iB.push(cd[idx * 4 + 2]); } } const ink = iR.length ? [median(iR), median(iG), median(iB)] : [43, 43, 43]; let colSum = 0; for (let x = x0; x < x1; x++) { let cnt = 0; for (let y = 0; y < CANVAS; y++) if (cd[(y * CANVAS + x) * 4 + 3] > 60) cnt++; colSum += cnt; } const thickness = Math.max(2, bankersRound((colSum / (x1 - x0)) * 0.9)); for (const [name, p] of Object.entries(DERIVE.visemes)) { const key = `mouth_${name}.png`; if (key in canvases) continue; canvases[key] = buildViseme(y0, mw, sh, cxf, strokeCanvas, colors, ink, thickness, p); } } /** Port of derive_half_eye: vertical squash of the open eye, anchored at its bbox bottom. */ function deriveHalfEye(open) { const bb = bboxAlpha(getData(open)); if (!bb) return canvasFromData(new Uint8ClampedArray(N * 4), CANVAS, CANVAS); const [x0, y0, x1, y1] = bb; const cw = x1 - x0, chh = y1 - y0; const newH = Math.max(2, bankersRound(chh * DERIVE.squash)); const src = newCanvas(cw, chh); src.getContext("2d").putImageData(open.getContext("2d").getImageData(x0, y0, cw, chh), 0, 0); const out = newCanvas(CANVAS, CANVAS); const octx = out.getContext("2d"); octx.imageSmoothingEnabled = true; octx.imageSmoothingQuality = "high"; octx.drawImage(src, 0, 0, cw, chh, x0, y1 - newH, cw, newH); return out; } /** Port of derive_smile: per-column integer lift (corners rise, center still). */ function deriveSmile(closed) { const d = getData(closed); const data = d.data; const bb = bboxAlpha(d); if (!bb) return canvasFromData(new Uint8ClampedArray(N * 4), CANVAS, CANVAS); const [x0, y0, x1, y1] = bb; const cxf = (x0 + x1) / 2, halfW = Math.max(1, (x1 - x0) / 2); const amp = (x1 - x0) * DERIVE.smileAmpFrac; const out = new Uint8ClampedArray(N * 4); for (let x = x0; x < x1; x++) { const t = Math.min(1, Math.abs(x - cxf) / halfW); const shift = bankersRound(amp * t * t); for (let y = 0; y < CANVAS; y++) { if (shift > 0 && y >= CANVAS - shift) continue; const si = ((y + shift) * CANVAS + x) * 4, di = (y * CANVAS + x) * 4; out[di] = data[si]; out[di + 1] = data[si + 1]; out[di + 2] = data[si + 2]; out[di + 3] = data[si + 3]; } } return canvasFromData(out, CANVAS, CANVAS); } /** Adds derived sprites to `canvases` in place; never overwrites an existing key. */ export function deriveAll(canvases, manifest) { if (!("mouth_closed.png" in canvases) && manifest.proceduralMouth) { canvases["mouth_closed.png"] = makeProceduralClosed(manifest); } const closed = canvases["mouth_closed.png"]; if (closed) { deriveMouthSet(canvases, manifest, closed); if (!("mouth_smile.png" in canvases)) canvases["mouth_smile.png"] = deriveSmile(closed); } for (const side of ["L", "R"]) { const open = canvases[`eye_${side}_open.png`]; const key = `eye_${side}_half.png`; if (open && !(key in canvases)) canvases[key] = deriveHalfEye(open); } }