115 lines
3.5 KiB
TypeScript
115 lines
3.5 KiB
TypeScript
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')}`;
|
||
}
|