This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
+114
View File
@@ -0,0 +1,114 @@
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')}`;
}