import { deflateSync } from 'node:zlib'; /** * A dependency-free PNG encoder for seed placeholder imagery. * * Why not just point MediaAsset rows at URLs that do not exist: the seed should * exercise the real media path — bytes in object storage, only a key in * PostgreSQL (ADR-0009). Broken images would hide a misconfigured bucket, a * wrong public URL or a missing Next.js `remotePatterns` entry until much later. * * Why not `sharp`: a native dependency for solid-colour rectangles is not a * trade worth making. A solid PNG is ~60 lines of well-specified format. */ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let c = n; for (let k = 0; k < 8; k += 1) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } table[n] = c >>> 0; } return table; })(); function crc32(buffer: Buffer): number { let crc = 0xffffffff; for (const byte of buffer) { crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function chunk(type: string, data: Buffer): Buffer { const length = Buffer.alloc(4); length.writeUInt32BE(data.length, 0); const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(typeAndData), 0); return Buffer.concat([length, typeAndData, crc]); } export interface Rgb { r: number; g: number; b: number; } /** `#1a2b3c` → { r, g, b }. Falls back to mid-grey on anything unparseable. */ export function hexToRgb(hex: string): Rgb { const match = /^#?([\da-f]{6})$/i.exec(hex.trim()); if (!match?.[1]) return { r: 128, g: 128, b: 128 }; const value = Number.parseInt(match[1], 16); return { r: (value >> 16) & 0xff, g: (value >> 8) & 0xff, b: value & 0xff }; } /** * Renders a solid PNG with a subtle vertical gradient, which reads as a studio * backdrop rather than a flat colour swatch in a product grid. */ export function solidPng(width: number, height: number, base: Rgb): Buffer { const bytesPerRow = width * 3 + 1; // +1 for the per-row filter byte const raw = Buffer.alloc(bytesPerRow * height); for (let y = 0; y < height; y += 1) { const rowStart = y * bytesPerRow; raw[rowStart] = 0; // filter type 0 (None) // -8%..+8% luminance across the height. const factor = 0.92 + (y / Math.max(height - 1, 1)) * 0.16; for (let x = 0; x < width; x += 1) { const offset = rowStart + 1 + x * 3; raw[offset] = clampByte(base.r * factor); raw[offset + 1] = clampByte(base.g * factor); raw[offset + 2] = clampByte(base.b * factor); } } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr.writeUInt8(8, 8); // bit depth ihdr.writeUInt8(2, 9); // colour type 2 = truecolour RGB ihdr.writeUInt8(0, 10); // compression ihdr.writeUInt8(0, 11); // filter ihdr.writeUInt8(0, 12); // interlace return Buffer.concat([ PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ]); } function clampByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } /** * A 1×1 PNG of the same colour, base64-encoded, used as the LQIP blur * placeholder so grids never flash empty while images load. */ export function blurDataUrl(base: Rgb): string { return `data:image/png;base64,${solidPng(1, 1, base).toString('base64')}`; }