Files
2026-08-13 23:20:22 +07:00

115 lines
3.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')}`;
}