scripts/chickpea-art.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Renders Chickpea art from the kit in assets/chickpea: a master body in any
// palette color, with a face from the kit's face set. See docs/chickpea-art.md.
//
// npm run art:chickpea -- <master> <color> <out.png|out.webp> [--face NAME|none] [--size N] [--shadow]
import { readFile, writeFile } from 'node:fs/promises';
import { extname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
// sRGB <-> CIELAB (D65).
const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const gamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
const labF = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
const labFInverse = (t) => (t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27));
const clamp01 = (value) => Math.min(1, Math.max(0, value));
export function toLab(r, g, b) {
const R = linear(r / 255), G = linear(g / 255), B = linear(b / 255);
const X = (0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047;
const Y = 0.2126 * R + 0.7152 * G + 0.0722 * B;
const Z = (0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883;
return [116 * labF(Y) - 16, 500 * (labF(X) - labF(Y)), 200 * (labF(Y) - labF(Z))];
}
export function toRgb(L, a, b) {
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const X = labFInverse(fx) * 0.95047, Y = labFInverse(fy), Z = labFInverse(fz) * 1.08883;
const R = 3.2406 * X - 1.5372 * Y - 0.4986 * Z;
const G = -0.9689 * X + 1.8758 * Y + 0.0415 * Z;
const B = 0.0557 * X - 0.204 * Y + 1.057 * Z;
return [R, G, B].map((c) => Math.round(255 * clamp01(gamma(clamp01(c)))));
}
export const labToHex = (lab) => `#${toRgb(...lab).map((c) => c.toString(16).padStart(2, '0')).join('')}`;
// Lightness quantiles each palette entry and body records, by probability.
export const LIGHTNESS_QUANTILES = [0.02, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.98];
const KIT = fileURLToPath(new URL('../assets/chickpea/', import.meta.url));
const PNG = { compressionLevel: 9, adaptiveFiltering: true, palette: false };
const WEBP = { quality: 90, alphaQuality: 100, effort: 6 };
let kit;
export function loadKit() {
kit ??= Promise.all(['palette.json', 'manifest.json'].map(async (file) => JSON.parse(await readFile(join(KIT, file), 'utf8'))))
.then(([palette, manifest]) => ({ palette, manifest }));
return kit;
}
async function plane(file, channels) {
const image = sharp(join(KIT, file));
const { data, info } = await (channels === 1 ? image.extractChannel(0) : image.ensureAlpha()).raw().toBuffer({ resolveWithObject: true });
return { data, width: info.width, height: info.height };
}
// Map lightness by rank: piecewise-linear between the source body's quantiles
// and the target color's. Past the ends, follow the outer segment, never
// steeper than 1, so the tails never stretch.
export function toneMap(L, from, to) {
const last = from.length - 1;
const edge = (span, target) => (span > 0 ? Math.min(1, target / span) : 1);
if (L <= from[0]) return to[0] + (L - from[0]) * edge(from[1] - from[0], to[1] - to[0]);
if (L >= from[last]) return to[last] + (L - from[last]) * edge(from[last] - from[last - 1], to[last] - to[last - 1]);
let k = 0;
while (L > from[k + 1]) k++;
const span = from[k + 1] - from[k];
return span > 0 ? to[k] + ((L - from[k]) / span) * (to[k + 1] - to[k]) : to[k];
}
// 1px inner shadow of a face feature: how much of a pixel's 3x3 neighborhood
// the feature does not cover, weighted toward the feature's lower edge.
function creamShade(coverage, width, height, x, y) {
const at = (sx, sy) => (sx < 0 || sy < 0 || sx >= width || sy >= height ? 0 : coverage[sy * width + sx] / 255);
let lowest = 1;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) lowest = Math.min(lowest, at(x + dx, y + dy));
const rim = at(x, y) - lowest;
const below = clamp01(at(x, y) - at(x, y + 1));
return clamp01(0.5 * rim + 0.5 * below);
}
// The fill of a face feature pixel: flat ink, or cream that darkens toward
// its rim like the real deep-moss and midnight-indigo renders.
function faceFill(kind, faces, shade) {
if (kind === 'ink') return faces.ink.lab;
const [L, a, b] = faces.cream.lab;
return [L - 38 * shade, a + 4 * shade, b - 6 * shade];
}
function recolor(rgba, mask, face, entry, target, faces) {
const out = Buffer.from(rgba);
const sourceChroma = Math.hypot(entry.body[1], entry.body[2]);
const [, ta, tb] = target.body.lab;
const ink = faces.ink.lab;
for (let i = 0; i < mask.length; i++) {
if (!mask[i]) continue;
const lab = toLab(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]);
const w = face ? face.data[i] / 255 : 0;
// Under a pose's own face, recover the felt by unmixing the ink out.
const felt = w === 0 ? lab : w > 0.95 ? entry.body : lab.map((v, c) => (v - w * ink[c]) / (1 - w));
// A weak-chroma source's chroma ratio is mostly noise, which a
// saturated target would turn into mottling; damp it toward 1.
const k = sourceChroma > 1 ? 1 + (Math.hypot(felt[1], felt[2]) / sourceChroma - 1) * Math.min(1, sourceChroma / 50) : 1;
let result = [toneMap(felt[0], entry.lightness, target.lightness), ta * k, tb * k];
if (w > 0) {
const fill = faceFill(target.face, faces, creamShade(face.data, face.width, face.height, i % face.width, (i / face.width) | 0));
result = result.map((v, c) => v * (1 - w) + fill[c] * w);
}
const rgb = toRgb(...result);
const m = mask[i] / 255;
for (let c = 0; c < 3; c++) out[i * 4 + c] = Math.round(rgba[i * 4 + c] * (1 - m) + rgb[c] * m);
}
return out;
}
// Composite a face matte onto a body so its anchor lands exactly on the
// body's: the matte and its cream shading are sampled bilinearly at the
// subpixel offset.
function placeFace(body, size, matte, offset, kind, faces) {
const shade = Float32Array.from(matte.data, (_, i) => creamShade(matte.data, matte.width, matte.height, i % matte.width, (i / matte.width) | 0));
const sample = (plane, x, y) => {
const x0 = Math.floor(x), y0 = Math.floor(y), tx = x - x0, ty = y - y0;
const at = (sx, sy) => (sx < 0 || sy < 0 || sx >= matte.width || sy >= matte.height ? 0 : plane[sy * matte.width + sx]);
return (at(x0, y0) * (1 - tx) + at(x0 + 1, y0) * tx) * (1 - ty) + (at(x0, y0 + 1) * (1 - tx) + at(x0 + 1, y0 + 1) * tx) * ty;
};
const out = Buffer.from(body);
const left = Math.floor(offset[0]), top = Math.floor(offset[1]);
for (let ty = top; ty <= top + matte.height; ty++) for (let tx = left; tx <= left + matte.width; tx++) {
if (tx < 0 || ty < 0 || tx >= size || ty >= size) continue;
const i = ty * size + tx;
const w = (sample(matte.data, tx - offset[0], ty - offset[1]) / 255) * (body[i * 4 + 3] / 255);
if (!w) continue;
const fill = toRgb(...faceFill(kind, faces, sample(shade, tx - offset[0], ty - offset[1])));
for (let c = 0; c < 3; c++) out[i * 4 + c] = Math.round(body[i * 4 + c] * (1 - w) + fill[c] * w);
}
return out;
}
// A soft contact shadow under the body, behind it.
async function withShadow(rgba, size, mask, groundY) {
let x0 = size, x1 = -1;
for (let i = 0; i < mask.length; i++) {
if (mask[i] !== 255) continue;
x0 = Math.min(x0, i % size); x1 = Math.max(x1, i % size);
}
const rx = (x1 - x0) * 0.42, cx = (x0 + x1) / 2;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><defs><filter id="s" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${rx * 0.06}"/></filter></defs><ellipse cx="${cx}" cy="${groundY - rx * 0.02}" rx="${rx}" ry="${rx * 0.07}" fill="#000" fill-opacity="0.32" filter="url(#s)"/></svg>`;
return sharp(Buffer.from(svg)).composite([{ input: rgba, raw: { width: size, height: size, channels: 4 } }]).raw().toBuffer();
}
// Renders `master` in palette `color` as a transparent PNG (or WebP with
// format 'webp'). A standing body takes `face` from the kit's face set
// (default: the face its render had; 'none' for the bare body); a pose
// carries its own face. In the master's native color with no face change the
// master's pixels come back unchanged; outside the body mask nothing changes.
export async function renderChickpea({ master, color, face, size, format = 'png', shadow = false }) {
if (format !== 'png' && format !== 'webp') throw new Error(`format must be 'png' or 'webp', not ${JSON.stringify(format)}`);
if (size !== undefined && !(Number.isInteger(size) && size > 0)) throw new Error(`size must be a positive integer, not ${JSON.stringify(size)}`);
const { palette, manifest } = await loadKit();
const entry = manifest.masters[master];
if (!entry) throw new Error(`Unknown master "${master}". Masters: ${Object.keys(manifest.masters).join(', ')}`);
const target = palette.colors[color];
if (!target) throw new Error(`Unknown color "${color}". Colors: ${Object.keys(palette.colors).join(', ')}`);
if (color !== entry.color && !entry.recolor) throw new Error(`${master} is not recolorable: ${entry.reason}`);
const isStand = entry.kind === 'stand';
if (!isStand && face !== undefined) throw new Error(`${master} is a pose and carries its own face`);
const faceName = isStand ? face ?? entry.face.native : undefined;
if (faceName !== undefined && faceName !== 'none' && !manifest.faces[faceName]) {
throw new Error(`Unknown face "${faceName}". Faces: none, ${Object.keys(manifest.faces).join(', ')}`);
}
const native = manifest.size;
const body = await plane(entry.file, 4);
const mask = (await plane(entry.mask, 1)).data;
let rgba = body.data;
if (color !== entry.color) {
const poseFace = isStand ? undefined : await plane(entry.face.matte, 1);
rgba = recolor(rgba, mask, poseFace, entry, target, palette.faces);
}
if (faceName !== undefined && faceName !== 'none') {
const matte = await plane(manifest.faces[faceName].file, 1);
const anchor = manifest.faces[faceName].anchor;
const offset = [entry.face.anchor[0] - anchor[0], entry.face.anchor[1] - anchor[1]];
rgba = placeFace(rgba, native, matte, offset, target.face, palette.faces);
}
if (shadow) rgba = await withShadow(rgba, native, mask, entry.groundY);
let image = sharp(rgba, { raw: { width: native, height: native, channels: 4 } });
if (size !== undefined && size !== native) image = image.resize(size, size, { kernel: 'lanczos3' });
return format === 'webp' ? image.webp(WEBP).toBuffer() : image.png(PNG).toBuffer();
}
function option(args, name) {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value`);
args.splice(index, 2);
return value;
}
async function main(argv) {
const args = [...argv];
const face = option(args, '--face');
const size = option(args, '--size');
const shadow = args.includes('--shadow');
const [master, color, out, ...rest] = args.filter((arg) => arg !== '--shadow');
if (!out || rest.length) {
throw new Error('Usage: npm run art:chickpea -- <master> <color> <out.png|out.webp> [--face NAME|none] [--size N] [--shadow]');
}
const format = { '.png': 'png', '.webp': 'webp' }[extname(out).toLowerCase()];
if (!format) throw new Error(`${out} must end in .png or .webp`);
if (size !== undefined && !/^[1-9]\d*$/.test(size)) throw new Error('--size must be a positive integer');
const bytes = await renderChickpea({ master, color, face, format, shadow, ...(size ? { size: Number(size) } : {}) });
await writeFile(resolve(out), bytes);
console.log(`Wrote ${out}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}