Files
gitlab-instance-0a899031_d2…/scripts/generate-branding.mjs
2026-04-05 01:06:05 -05:00

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);
});