import fs from "node:fs/promises"; import path from "node:path"; import { PNG } from "pngjs"; const root = path.resolve(import.meta.dirname, ".."); const outDir = path.join(root, "assets", "images"); const COLORS = { indigo: [0x2d, 0x2a, 0xf6, 0xff], cyan: [0x27, 0xd7, 0xff, 0xff], white: [0xff, 0xff, 0xff, 0xff], red: [0xff, 0x3b, 0x30, 0xff], transparent: [0x00, 0x00, 0x00, 0x00], }; function clamp01(x) { return Math.max(0, Math.min(1, x)); } function lerp(a, b, t) { return a + (b - a) * t; } function mixRGBA(c1, c2, t) { return [ Math.round(lerp(c1[0], c2[0], t)), Math.round(lerp(c1[1], c2[1], t)), Math.round(lerp(c1[2], c2[2], t)), Math.round(lerp(c1[3], c2[3], t)), ]; } function putPixel(img, x, y, rgba) { const idx = (img.width * y + x) << 2; img.data[idx] = rgba[0]; img.data[idx + 1] = rgba[1]; img.data[idx + 2] = rgba[2]; img.data[idx + 3] = rgba[3]; } function over(dst, src) { const sa = src[3] / 255; const da = dst[3] / 255; const outA = sa + da * (1 - sa); if (outA <= 0) return [0, 0, 0, 0]; const outR = (src[0] * sa + dst[0] * da * (1 - sa)) / outA; const outG = (src[1] * sa + dst[1] * da * (1 - sa)) / outA; const outB = (src[2] * sa + dst[2] * da * (1 - sa)) / outA; return [Math.round(outR), Math.round(outG), Math.round(outB), Math.round(outA * 255)]; } function sampleGradient(x, y, size) { const t = clamp01((x + y) / (2 * (size - 1))); return mixRGBA(COLORS.indigo, COLORS.cyan, t); } function sdRoundRect(px, py, cx, cy, hw, hh, r) { const x = Math.abs(px - cx) - hw; const y = Math.abs(py - cy) - hh; const ax = Math.max(x, 0); const ay = Math.max(y, 0); const outside = Math.hypot(ax, ay) - r; const inside = Math.min(Math.max(x, y), 0); return outside + inside; } function sdCircle(px, py, cx, cy, r) { return Math.hypot(px - cx, py - cy) - r; } function aaCoverage(sd, feather) { // Signed distance -> coverage (1 inside, 0 outside) with soft edge. return clamp01(0.5 - sd / feather); } function render({ size, supersample = 3, draw, background = "gradient" }) { const hi = size * supersample; const img = new PNG({ width: hi, height: hi }); for (let y = 0; y < hi; y++) { for (let x = 0; x < hi; x++) { const bx = background === "transparent" ? COLORS.transparent : sampleGradient(x / supersample, y / supersample, size); putPixel(img, x, y, bx); } } draw(img, supersample); // Downsample (box filter). const out = new PNG({ width: size, height: size }); const n = supersample * supersample; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { let r = 0, g = 0, b = 0, a = 0; for (let sy = 0; sy < supersample; sy++) { for (let sx = 0; sx < supersample; sx++) { const hx = x * supersample + sx; const hy = y * supersample + sy; const idx = (hi * hy + hx) << 2; r += img.data[idx]; g += img.data[idx + 1]; b += img.data[idx + 2]; a += img.data[idx + 3]; } } putPixel(out, x, y, [ Math.round(r / n), Math.round(g / n), Math.round(b / n), Math.round(a / n), ]); } } return out; } function drawCamera({ includeRedDot, strokePx, glow = false, silhouette = false, roundedIcon = false }) { return (img, ss) => { const size = img.width / ss; const hi = img.width; const cx = size / 2; const cy = size / 2; const bodyW = size * 0.56; const bodyH = size * 0.36; const r = size * 0.06; const lensR = size * 0.10; const topBumpW = size * 0.18; const topBumpH = size * 0.08; const bodyCx = cx; const bodyCy = cy; const bumpCx = cx - bodyW * 0.38 + topBumpW / 2; const bumpCy = cy - bodyH / 2 - topBumpH * 0.05; const dotCx = cx + bodyW * 0.36; const dotCy = cy - bodyH * 0.28; const dotR = size * 0.04; const feather = 0.9; // in hi-res pixels const stroke = strokePx; function applyOver(x, y, color) { const idx = (img.width * y + x) << 2; const dst = [img.data[idx], img.data[idx + 1], img.data[idx + 2], img.data[idx + 3]]; const out = over(dst, color); img.data[idx] = out[0]; img.data[idx + 1] = out[1]; img.data[idx + 2] = out[2]; img.data[idx + 3] = out[3]; } for (let y = 0; y < hi; y++) { for (let x = 0; x < hi; x++) { const px = (x + 0.5) / ss; const py = (y + 0.5) / ss; // Optional rounded icon mask (for 1024 icon and favicon). if (roundedIcon) { const sdMask = sdRoundRect(px, py, cx, cy, size / 2, size / 2, size * 0.215); const m = aaCoverage(sdMask, 1.25 / ss); if (m < 1) { // apply alpha mask by scaling current alpha const idx = (img.width * y + x) << 2; img.data[idx + 3] = Math.round(img.data[idx + 3] * m); } } if (silhouette) { const sdBody = sdRoundRect(px, py, bodyCx, bodyCy, bodyW / 2, bodyH / 2, r); const sdBump = sdRoundRect(px, py, bumpCx, bumpCy, topBumpW / 2, topBumpH / 2, r * 0.6); const inside = Math.min(sdBody, sdBump); const hole = sdCircle(px, py, cx, cy, lensR * 0.62); const sd = Math.max(inside, -hole); // even-odd hole const cov = aaCoverage(sd, feather / ss); if (cov > 0) applyOver(x, y, [COLORS.white[0], COLORS.white[1], COLORS.white[2], Math.round(255 * cov)]); continue; } // Outline stroke using distance to shapes. const sdBody = sdRoundRect(px, py, bodyCx, bodyCy, bodyW / 2, bodyH / 2, r); const sdBump = sdRoundRect(px, py, bumpCx, bumpCy, topBumpW / 2, topBumpH / 2, r * 0.6); const sdLens = sdCircle(px, py, cx, cy, lensR); const d = Math.min(Math.abs(sdBody), Math.abs(sdBump), Math.abs(sdLens)); const cov = aaCoverage(d - stroke / 2, feather / ss); if (cov > 0) { const alpha = Math.round(255 * cov); applyOver(x, y, [COLORS.white[0], COLORS.white[1], COLORS.white[2], alpha]); } if (includeRedDot) { const sdDot = sdCircle(px, py, dotCx, dotCy, dotR); const covDot = aaCoverage(sdDot, feather / ss); if (covDot > 0) { applyOver(x, y, [COLORS.red[0], COLORS.red[1], COLORS.red[2], Math.round(255 * covDot)]); } } if (glow) { // soft radial glow behind the logo area const glowR = size * 0.38; const gd = sdCircle(px, py, cx, cy, glowR); const gCov = clamp01(1 - Math.max(gd, 0) / (size * 0.22)); if (gCov > 0) { applyOver(x, y, [255, 255, 255, Math.round(38 * gCov)]); } } } } }; } async function writePng(filePath, png) { const buf = PNG.sync.write(png); await fs.writeFile(filePath, buf); } async function main() { await fs.mkdir(outDir, { recursive: true }); // icon.png (1024) await writePng( path.join(outDir, "icon.png"), render({ size: 1024, supersample: 3, background: "gradient", draw: drawCamera({ includeRedDot: true, strokePx: 18, roundedIcon: true }), }) ); // splash-icon.png (2048) await writePng( path.join(outDir, "splash-icon.png"), render({ size: 2048, supersample: 2, background: "gradient", draw: drawCamera({ includeRedDot: true, strokePx: 26, glow: true }), }) ); // android-icon-background.png (1024) await writePng( path.join(outDir, "android-icon-background.png"), render({ size: 1024, supersample: 2, background: "gradient", draw: () => {} }) ); // android-icon-foreground.png (1024, transparent bg) await writePng( path.join(outDir, "android-icon-foreground.png"), render({ size: 1024, supersample: 3, background: "transparent", draw: drawCamera({ includeRedDot: true, strokePx: 20 }), }) ); // android-icon-monochrome.png (1024, transparent bg) await writePng( path.join(outDir, "android-icon-monochrome.png"), render({ size: 1024, supersample: 3, background: "transparent", draw: drawCamera({ includeRedDot: false, strokePx: 0, silhouette: true }), }) ); // favicon.png (48) await writePng( path.join(outDir, "favicon.png"), render({ size: 48, supersample: 6, background: "gradient", draw: drawCamera({ includeRedDot: true, strokePx: 2, roundedIcon: true }), }) ); // eslint-disable-next-line no-console console.log("Branding assets generated in assets/images/"); } main().catch((err) => { // eslint-disable-next-line no-console console.error(err); process.exit(1); });