// Cut a 4-pose companion sheet into individual pose files. // usage: yarn node < split-poses.cjs (or pass ) // See ../references/companion.md — 'The pose pipeline, end to end'. const sharp = require('sharp'); const fs = require('node:fs'); const SRC = process.argv[2] || '/home/ubuntu/generated-images/character-moodboard/ai'; const OUT = process.argv[3] || '/home/ubuntu/generated-images/character-moodboard/poses'; fs.mkdirSync(OUT, { recursive: true }); const NAMES = { sheet1: ['01-wave','02-idle','03-celebrate','04-hello'], sheet2: ['05-carry-box','06-hold-card','07-push-trolley','08-beside-stack'], sheet3: ['09-confused','10-sad','11-asleep','12-surprised'], }; // The sheets carry a subtle vignette, so a single corner reference reads gradient as ink. // Compare each pixel to its OWN column's background instead. Shadows merge between poses, // so cuts are found in the character band only, then applied over the full height. async function profile(file) { const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const { width: W, height: H, channels: C } = info; const YMAX = Math.round(H * 0.80); const ink = new Array(W).fill(0), rowInk = new Array(H).fill(0); for (let x = 0; x < W; x++) { const t = (2 * W + x) * C, b = ((H - 3) * W + x) * C; const ref = [(data[t]+data[b])/2, (data[t+1]+data[b+1])/2, (data[t+2]+data[b+2])/2]; for (let y = 0; y < H; y++) { const i = (y * W + x) * C; if (Math.abs(data[i]-ref[0]) + Math.abs(data[i+1]-ref[1]) + Math.abs(data[i+2]-ref[2]) > 30) { rowInk[y]++; if (y < YMAX) ink[x]++; } } } return { ink, rowInk, W, H }; } function cutsFor(ink, W) { let L = 0, R = W - 1; while (L < W && ink[L] <= 2) L++; while (R > 0 && ink[R] <= 2) R--; const span = (R - L) / 4, cuts = [], quality = []; for (let k = 1; k <= 3; k++) { const centre = Math.round(L + k * span), win = Math.round(span * 0.38); let best = centre, bestScore = Infinity; for (let x = Math.max(L+1, centre-win); x <= Math.min(R-1, centre+win); x++) { const score = ink[x] * 10000 + Math.abs(x - centre); if (score < bestScore) { bestScore = score; best = x; } } cuts.push(best); quality.push(ink[best]); } return { cuts, quality, L, R }; } // Only true slivers go — never a wheel, a prop or a limb. async function despeckle(path, coreL, coreR) { const { data, info } = await sharp(path).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const { width: W, height: H, channels: C } = info; // Per-column background, so both detection and the erase fill follow the vignette // instead of leaving a flat ghost rectangle behind. const colRef = new Array(W); for (let x = 0; x < W; x++) { const t = (2 * W + x) * C, b = ((H - 3) * W + x) * C; colRef[x] = [(data[t]+data[b])/2 | 0, (data[t+1]+data[b+1])/2 | 0, (data[t+2]+data[b+2])/2 | 0]; } const mask = new Uint8Array(W * H); for (let p = 0, i = 0; p < W * H; p++, i += C) { const r = colRef[p % W]; if (Math.abs(data[i]-r[0]) + Math.abs(data[i+1]-r[1]) + Math.abs(data[i+2]-r[2]) > 30) mask[p] = 1; } const seen = new Uint8Array(W * H); let erased = 0; for (let p0 = 0; p0 < W * H; p0++) { if (!mask[p0] || seen[p0]) continue; const st = [p0]; seen[p0] = 1; const comp = []; let minX = W, maxX = 0, edge = false, sumX = 0; while (st.length) { const p = st.pop(); comp.push(p); const x = p % W, y = (p / W) | 0; sumX += x; if (x <= 1 || x >= W - 2) edge = true; if (x < minX) minX = x; if (x > maxX) maxX = x; 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 (mask[np] && !seen[np]) { seen[np]=1; st.push(np); } } } // A neighbour pulled in by the overlap margin never reaches this tile's core band. // The subject always does — even a subject that merges with its neighbour, which is // why this keeps rather than deletes in the ambiguous case. // Centroid, not mere overlap: a neighbour's wheel can clip the core band by a few // pixels, but its centre of mass always sits out in the margin. const cx = sumX / comp.length; const touchesCore = cx >= coreL && cx <= coreR; const sliver = edge && (maxX-minX) < W*0.07 && comp.length < 1400; if (!touchesCore || sliver) { for (const p of comp) { const i=p*C, r=colRef[p % W]; data[i]=r[0]; data[i+1]=r[1]; data[i+2]=r[2]; } erased += comp.length; } } if (erased) { await sharp(data, { raw: { width: W, height: H, channels: C } }).png().toFile(path + '.t'); fs.renameSync(path + '.t', path); } return erased; } (async () => { const sheets = {}; let top = Infinity, bot = -Infinity; for (const s of Object.keys(NAMES)) { const p = await profile(`${SRC}/${s}.png`); const rows = p.rowInk.map((v,i)=>[v,i]).filter(([v])=>v>2).map(([,i])=>i); top = Math.min(top, rows[0]); bot = Math.max(bot, rows[rows.length-1]); sheets[s] = p; } const PAD = 26; top = Math.max(0, top - PAD); for (const [s, names] of Object.entries(NAMES)) { const { ink, W, H } = sheets[s]; const { cuts, quality, L, R } = cutsFor(ink, W); // Windows OVERLAP rather than sharing one hard cut. Where the gap is clean the extra // is just background; where two poses overlap it is the only way to keep both subjects // whole — a shared cut necessarily clips one of them. const M = 18, OVER = 26; const edges = [Math.max(0, L-M), ...cuts, Math.min(W, R+M)]; const h = Math.min(H, bot + PAD) - top; for (let i = 0; i < 4; i++) { const left = Math.max(0, edges[i] - (i === 0 ? 0 : OVER)); const right = Math.min(W, edges[i+1] + (i === 3 ? 0 : OVER)); const f = `${OUT}/${names[i]}.png`; await sharp(`${SRC}/${s}.png`) .extract({ left, top, width: right - left, height: h }) .png().toFile(f); await despeckle(f, edges[i] - left, edges[i+1] - left); } console.log(`${s}: cuts ${cuts.join(', ')} ink-at-cut ${quality.join('/')} ${quality.some(q=>q>5)?'<- OVERLAP, not a clean gap':''}`); } console.log('\n12 poses rewritten'); })();