// Separate a crisp pose into character and shadow layers, ready for two vtracer runs. // usage: yarn node - < split-shadow.cjs // // companion.md is explicit that the character and the shadow are traced separately: vtracer // blends a sentinel-coloured shadow with neighbouring ink and leaves purple fringing along // the shadow edge. It has no alpha output either, which is the only reason two runs exist. // // convert-shadow.cjs has already turned the opaque pale shadow into translucent peacock.90 // capped at alpha 118, so the layers are separable by peak alpha per connected component: // the character reaches 255, the shadow never exceeds the cap. Splitting by a flat alpha // threshold instead would eat the character's anti-aliased rim, which refine-edges.cjs // deliberately ramps between 70 and 205. const sharp = require('sharp'); const fs = require('node:fs'); const SRC = process.argv[2] + '/', OUT = process.argv[3] + '/'; const SHADOW_PEAK = 150; // above convert-shadow's 118 cap, far below the character's 255 fs.mkdirSync(OUT + 'char', { recursive: true }); fs.mkdirSync(OUT + 'shadow', { 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), isShadow = new Uint8Array(W * H); for (let p0 = 0; p0 < W * H; p0++) { if (seen[p0] || A(p0) === 0) continue; const st = [p0]; seen[p0] = 1; const comp = []; let peak = 0; while (st.length) { const p = st.pop(); comp.push(p); if (A(p) > peak) peak = A(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) > 0 && !seen[np]) { seen[np] = 1; st.push(np); } } } if (peak < SHADOW_PEAK) for (const p of comp) isShadow[p] = 1; } // vtracer has no alpha input semantics for a mask, so the shadow goes out as solid ink // on white and is re-tinted at assembly time with the authored fill-opacity. const charBuf = Buffer.from(data), shadBuf = Buffer.alloc(W * H * 3, 255); let shadPx = 0; for (let p = 0; p < W * H; p++) { if (isShadow[p]) { charBuf[p * C + 3] = 0; shadBuf[p * 3] = 17; shadBuf[p * 3 + 1] = 56; shadBuf[p * 3 + 2] = 51; shadPx++; } } await sharp(charBuf, { raw: { width: W, height: H, channels: C } }).png().toFile(OUT + 'char/' + f); await sharp(shadBuf, { raw: { width: W, height: H, channels: 3 } }).png().toFile(OUT + 'shadow/' + f); console.log(` ${f.padEnd(16)} shadow ${shadPx} px`); } })();