- Create shared PNG encoder module (src/media/png-encode.ts) - Refactor qr-image.ts and live-image-probe.ts to use shared encoder - Add safeParseJson to utils.ts and plugin-sdk exports - Update msteams and pairing-store to use centralized safeParseJson
91 lines
2.5 KiB
TypeScript
91 lines
2.5 KiB
TypeScript
/**
|
|
* Minimal PNG encoder for generating simple RGBA images without native dependencies.
|
|
* Used for QR codes, live probes, and other programmatic image generation.
|
|
*/
|
|
import { deflateSync } from "node:zlib";
|
|
|
|
const CRC_TABLE = (() => {
|
|
const table = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i += 1) {
|
|
let c = i;
|
|
for (let k = 0; k < 8; k += 1) {
|
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
}
|
|
table[i] = c >>> 0;
|
|
}
|
|
return table;
|
|
})();
|
|
|
|
/** Compute CRC32 checksum for a buffer (used in PNG chunk encoding). */
|
|
export function crc32(buf: Buffer): number {
|
|
let crc = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i += 1) {
|
|
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
/** Create a PNG chunk with type, data, and CRC. */
|
|
export function pngChunk(type: string, data: Buffer): Buffer {
|
|
const typeBuf = Buffer.from(type, "ascii");
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.length, 0);
|
|
const crc = crc32(Buffer.concat([typeBuf, data]));
|
|
const crcBuf = Buffer.alloc(4);
|
|
crcBuf.writeUInt32BE(crc, 0);
|
|
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
|
}
|
|
|
|
/** Write a pixel to an RGBA buffer. Ignores out-of-bounds writes. */
|
|
export function fillPixel(
|
|
buf: Buffer,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
r: number,
|
|
g: number,
|
|
b: number,
|
|
a = 255,
|
|
): void {
|
|
if (x < 0 || y < 0 || x >= width) {
|
|
return;
|
|
}
|
|
const idx = (y * width + x) * 4;
|
|
if (idx < 0 || idx + 3 >= buf.length) {
|
|
return;
|
|
}
|
|
buf[idx] = r;
|
|
buf[idx + 1] = g;
|
|
buf[idx + 2] = b;
|
|
buf[idx + 3] = a;
|
|
}
|
|
|
|
/** Encode an RGBA buffer as a PNG image. */
|
|
export function encodePngRgba(buffer: Buffer, width: number, height: number): Buffer {
|
|
const stride = width * 4;
|
|
const raw = Buffer.alloc((stride + 1) * height);
|
|
for (let row = 0; row < height; row += 1) {
|
|
const rawOffset = row * (stride + 1);
|
|
raw[rawOffset] = 0; // filter: none
|
|
buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride);
|
|
}
|
|
const compressed = deflateSync(raw);
|
|
|
|
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 6; // color type RGBA
|
|
ihdr[10] = 0; // compression
|
|
ihdr[11] = 0; // filter
|
|
ihdr[12] = 0; // interlace
|
|
|
|
return Buffer.concat([
|
|
signature,
|
|
pngChunk("IHDR", ihdr),
|
|
pngChunk("IDAT", compressed),
|
|
pngChunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
}
|