302 lines
8.6 KiB
JavaScript
302 lines
8.6 KiB
JavaScript
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);
|
|
});
|
|
|