feat(screenshots): add constants and pixel-art bretzel generator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-20 22:32:16 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent d34b3a18bc
commit d577468b60
2 changed files with 329 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
import { deflateSync } from 'zlib';
// Colors: [R, G, B, A]
const TRANSPARENT = [0, 0, 0, 0];
const GOLD = [0xc8, 0x86, 0x0a, 255]; // #C8860A — bretzel body
const DARK = [0x8b, 0x69, 0x14, 255]; // #8B6914 — shading
const SALT = [0xf0, 0xe6, 0xd3, 255]; // #F0E6D3 — salt dots
const HIGHLIGHT = [0xe8, 0xa0, 0x20, 255]; // #E8A020 — highlight
// Salt shaker colors
const SHAKER_BODY = [0xfa, 0xf5, 0xeb, 255]; // #FAF5EB — white body
const SHAKER_CAP = [0x6b, 0x62, 0x72, 255]; // #6B6272 — gray cap
const SHAKER_DOTS = [0xd4, 0xca, 0xbb, 255]; // #D4CABB — holes
function encodePng(width: number, height: number, pixels: number[][]): Buffer {
// Build raw RGBA image data with filter byte per row
const rawData: number[] = [];
for (let y = 0; y < height; y++) {
rawData.push(0); // filter: None
for (let x = 0; x < width; x++) {
const color = pixels[y][x];
rawData.push(color[0], color[1], color[2], color[3]);
}
}
const compressed = deflateSync(Buffer.from(rawData));
// PNG signature
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
// IHDR chunk
const ihdr = createChunk('IHDR', () => {
const buf = Buffer.alloc(13);
buf.writeUInt32BE(width, 0);
buf.writeUInt32BE(height, 4);
buf[8] = 8; // bit depth
buf[9] = 6; // color type: RGBA
buf[10] = 0; // compression
buf[11] = 0; // filter
buf[12] = 0; // interlace
return buf;
});
// IDAT chunk
const idat = createChunk('IDAT', () => compressed);
// IEND chunk
const iend = createChunk('IEND', () => Buffer.alloc(0));
return Buffer.concat([signature, ihdr, idat, iend]);
}
function createChunk(type: string, dataFn: () => Buffer): Buffer {
const data = dataFn();
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuffer = Buffer.from(type, 'ascii');
const crcInput = Buffer.concat([typeBuffer, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(crcInput), 0);
return Buffer.concat([length, typeBuffer, data, crc]);
}
function crc32(buf: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i];
for (let j = 0; j < 8; j++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* 64x64 pixel-art bretzel in warm browns/golds.
* Shape: classic pretzel knot with salt dots.
*/
export function generateBretzelPng(): Buffer {
const S = 64;
const pixels: number[][][] = Array.from({ length: S }, () =>
Array.from({ length: S }, () => TRANSPARENT)
);
// Draw a pretzel shape using circles and arcs
// The bretzel is two loops connected by a twist in the center
function setPixel(x: number, y: number, color: number[]) {
if (x >= 0 && x < S && y >= 0 && y < S) {
pixels[y][x] = color;
}
}
function fillCircle(cx: number, cy: number, r: number, color: number[]) {
for (let y = -r; y <= r; y++) {
for (let x = -r; x <= r; x++) {
if (x * x + y * y <= r * r) {
setPixel(cx + x, cy + y, color);
}
}
}
}
function drawRing(cx: number, cy: number, outerR: number, innerR: number, color: number[]) {
for (let y = -outerR; y <= outerR; y++) {
for (let x = -outerR; x <= outerR; x++) {
const d = x * x + y * y;
if (d <= outerR * outerR && d >= innerR * innerR) {
setPixel(cx + x, cy + y, color);
}
}
}
}
// Main pretzel shape: two overlapping loops
// Left loop
drawRing(22, 28, 16, 11, GOLD);
// Right loop
drawRing(42, 28, 16, 11, GOLD);
// Top arm going up to center crossing
fillCircle(32, 14, 5, GOLD);
// Connect top to loops with thicker arms
for (let y = 12; y <= 22; y++) {
const spread = Math.floor((22 - y) * 0.6);
for (let x = -3; x <= 3; x++) {
setPixel(22 + spread + x, y, GOLD);
setPixel(42 - spread + x, y, GOLD);
}
}
// Cross-over: two diagonal arms crossing at center
for (let i = -8; i <= 8; i++) {
for (let w = -2; w <= 2; w++) {
// Left arm crosses to right bottom
setPixel(32 + i, 24 + Math.floor(i * 0.5) + w, GOLD);
// Right arm crosses to left bottom
setPixel(32 + i, 24 - Math.floor(i * 0.5) + w, GOLD);
}
}
// Bottom connection
for (let x = 18; x <= 46; x++) {
for (let y = 40; y <= 44; y++) {
const dx = x - 32;
const distFromCenter = Math.abs(dx);
if (distFromCenter <= 14) {
setPixel(x, y, GOLD);
}
}
}
// Add shading (darker on bottom-right edges)
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
if (pixels[y][x] === GOLD) {
// Check if near edge
let nearEdge = false;
for (const [dx, dy] of [
[1, 0],
[0, 1],
[1, 1]
]) {
const nx = x + dx;
const ny = y + dy;
if (nx >= S || ny >= S || pixels[ny][nx] === TRANSPARENT) {
nearEdge = true;
break;
}
}
if (nearEdge) {
setPixel(x, y, DARK);
}
}
}
}
// Add highlight on top-left edges
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
if (pixels[y][x] === GOLD) {
let nearTopEdge = false;
for (const [dx, dy] of [
[-1, 0],
[0, -1]
]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || pixels[ny][nx] === TRANSPARENT) {
nearTopEdge = true;
break;
}
}
if (nearTopEdge) {
setPixel(x, y, HIGHLIGHT);
}
}
}
}
// Salt dots scattered on the bretzel
const saltPositions = [
[20, 24],
[24, 20],
[28, 16],
[36, 16],
[40, 20],
[44, 24],
[24, 32],
[40, 32],
[30, 28],
[34, 28],
[26, 40],
[32, 42],
[38, 40]
];
for (const [sx, sy] of saltPositions) {
if (pixels[sy]?.[sx] && pixels[sy][sx] !== TRANSPARENT) {
setPixel(sx, sy, SALT);
setPixel(sx + 1, sy, SALT);
setPixel(sx, sy + 1, SALT);
}
}
return encodePng(S, S, pixels);
}
/**
* 32x32 pixel-art salt shaker.
*/
export function generateSaltShakerPng(): Buffer {
const S = 32;
const pixels: number[][][] = Array.from({ length: S }, () =>
Array.from({ length: S }, () => TRANSPARENT)
);
function setPixel(x: number, y: number, color: number[]) {
if (x >= 0 && x < S && y >= 0 && y < S) {
pixels[y][x] = color;
}
}
function fillRect(x1: number, y1: number, x2: number, y2: number, color: number[]) {
for (let y = y1; y <= y2; y++) {
for (let x = x1; x <= x2; x++) {
setPixel(x, y, color);
}
}
}
// Cap (top section)
fillRect(11, 4, 20, 8, SHAKER_CAP);
// Cap holes (salt comes out)
setPixel(13, 5, SHAKER_DOTS);
setPixel(16, 5, SHAKER_DOTS);
setPixel(19, 5, SHAKER_DOTS);
setPixel(14, 7, SHAKER_DOTS);
setPixel(17, 7, SHAKER_DOTS);
// Neck
fillRect(12, 9, 19, 10, SHAKER_BODY);
// Body (wider, tapered)
fillRect(10, 11, 21, 26, SHAKER_BODY);
// Body shading (right edge darker)
for (let y = 11; y <= 26; y++) {
setPixel(21, y, SHAKER_DOTS);
}
// Body outline
for (let y = 11; y <= 26; y++) {
setPixel(9, y, DARK);
setPixel(22, y, DARK);
}
for (let x = 10; x <= 21; x++) {
setPixel(x, 27, DARK);
}
// Bottom rim
fillRect(9, 27, 22, 28, SHAKER_CAP);
// "S" label on the body
const sPattern = [
[14, 16],
[15, 16],
[16, 16],
[13, 17],
[14, 18],
[15, 18],
[16, 19],
[13, 20],
[14, 20],
[15, 20]
];
for (const [x, y] of sPattern) {
setPixel(x, y, SHAKER_CAP);
}
// Falling salt particles
setPixel(14, 2, SALT);
setPixel(17, 1, SALT);
setPixel(19, 3, SALT);
setPixel(12, 1, SALT);
return encodePng(S, S, pixels);
}
+18
View File
@@ -0,0 +1,18 @@
export const BASE_URL = 'http://localhost:4173';
export const SCREENSHOT_DB = './data/screenshots.db';
export const OUTPUT_DIR = 'website/assets';
export const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
export const MOBILE_VIEWPORT = { width: 390, height: 844 };
export const ADMIN = {
email: 'admin@bretzel.app',
displayName: 'Bretzel Admin',
password: 'bretzel2026!'
};
export const COLLABORATOR = {
email: 'alice@bretzel.app',
displayName: 'Alice Chen',
password: 'alice2026!!'
};