// Place a crisp pose into the canonical 409x370 frame. // usage: yarn node - < normalise-pose.cjs // // The frame and the floor line are measured from the first twenty: content is centred on // x=204.5 and its baseline sits at y=341. Matching them is what lets a new pose drop into // Companion.tsx beside the existing set without its own viewBox. // // The floor is the bottom of the content, which is the shadow. Note the companion FLOATS, so // the shadow is its own connected component, not part of the body — anchoring on the largest // component instead puts the body's bottom on the floor and sinks the whole pose by the // shadow's height. // // A prop floating clear of the head (27-reach-sparkle's sparkle) can make the content taller // than the floor allows. Rescaling would break the uniform character size across the set and a // taller canvas would break the shared viewBox, so the prop is nudged down just far enough to // fit instead. The character never moves. const sharp = require('sharp'); const fs = require('node:fs'); const SRC = process.argv[2] + '/', OUT = process.argv[3] + '/'; const W = 409, H = 370, FLOOR = 341, TOP_PAD = 2; // A prop is small. Anything larger overflowing the top is the character itself, which means // the pose does not fit the canonical frame — that is a problem to look at, not to nudge away. // 18-flag trips this: its flag reaches ~400px, so a blind nudge would shunt the whole pose // down by 56px and silently break it. const MAX_PROP_PX = 8000; fs.mkdirSync(OUT, { recursive: true }); (async () => { for (const f of fs.readdirSync(SRC).filter(f => f.endsWith('.png')).sort()) { const { data, info } = await sharp(SRC + f).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const { width: w, height: h, channels: C } = info; const A = p => data[p * C + 3]; const seen = new Uint8Array(w * h); const comps = []; for (let p0 = 0; p0 < w * h; p0++) { if (seen[p0] || A(p0) <= 8) continue; const st = [p0]; seen[p0] = 1; const comp = []; while (st.length) { const p = st.pop(); comp.push(p); const x = p % w, y = (p / w) | 0; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]) { const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; const np = ny * w + nx; if (A(np) > 8 && !seen[np]) { seen[np] = 1; st.push(np); } } } comps.push(comp); } if (!comps.length) continue; comps.sort((a, b) => b.length - a.length); const box = (comp) => { let x0 = w, x1 = -1, y0 = h, y1 = -1; for (const p of comp) { const x = p % w, y = (p / w) | 0; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } return { x0, x1, y0, y1 }; }; const all = box(comps.flat()); const dy = FLOOR - all.y1; // baseline of the whole pose on the floor const dx = Math.round((W - (all.x1 - all.x0 + 1)) / 2) - all.x0; const out = Buffer.alloc(W * H * 4, 0); const place = (comp, extraDy) => { for (const p of comp) { const x = (p % w) + dx, y = ((p / w) | 0) + dy + extraDy; if (x < 0 || y < 0 || x >= W || y >= H) continue; const d = (y * W + x) * 4, s = p * C; out[d] = data[s]; out[d+1] = data[s+1]; out[d+2] = data[s+2]; out[d+3] = data[s+3]; } }; const nudged = []; let warn = ''; for (const comp of comps) { const b = box(comp); const over = TOP_PAD - (b.y0 + dy); let extra = over > 0 ? over : 0; if (extra && comp.length > MAX_PROP_PX) { warn = `OVERFLOWS TOP by ${extra}px and is too big (${comp.length}px) to be a prop — left in place, will clip`; extra = 0; } else if (extra) nudged.push(`${comp.length}px by ${extra}`); place(comp, extra); } await sharp(out, { raw: { width: W, height: H, channels: 4 } }).png().toFile(OUT + f); console.log(` ${f.padEnd(20)} floor ${all.y1}->${FLOOR}${nudged.length ? ' nudged prop ' + nudged.join(', ') : ''}${warn ? ' ** ' + warn : ''}`); } })();