feat: render the panel image as a 1-bit 800x480 BMP or PNG
With the move to BYOS there is no TRMNL cloud to interpret a Liquid template, so the application draws the screen itself. The pipeline is satori (flexbox to SVG) then resvg (SVG to pixels) then a hand-written 1-bit encoder. Chromium was the alternative and was rejected: half a gigabyte and a real memory appetite in the runtime image, for a screen of eight blocks. Playwright still handles the E2E tests, in its own pinned image, never in the application one. The payoff is testability. The layout is asserted on the element tree and the geometry on the SVG text, so a rendering regression shows up as a readable diff instead of a pixel comparison. The BMP and PNG encoders are verified field by field against their specifications, including an independent CRC-32 for the PNG: a device rejecting a malformed image is expensive to debug from a shop window. Two properties are pinned because the battery depends on them: the same payload must produce byte-identical output, and changed hours must produce different output. The filename handed to the device is a hash of these bytes, and the firmware skips the redraw when it is unchanged. The fonts are vendored into public/fonts and the logo into public/brand, both committed. Rendering must not depend on an install tree, a CDN or the network, or the bytes drift and the panel wakes for nothing. `npm run screen:preview` writes a real 1-bit image plus a magnified view, which is where clipping and thin strokes give themselves up. That is how the week strip was caught clipping and condensed. `npm run brand` rebuilds the assets: it locates the wordmark band in the shop logo rather than hard-coding offsets a future revision would break, and thresholds it with the panel's own encoder. sharp is a devDependency used only there; nothing at runtime needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Turns the rendered SVG into what the panel actually consumes: a 1-bit
|
||||
* 800x480 image.
|
||||
*
|
||||
* The firmware on this kit references `.bmp`, but the documentation is not
|
||||
* explicit, so both encoders live here and `DEVICE_IMAGE_FORMAT` chooses. The
|
||||
* bit-packing is shared, so whichever format wins shows identical pixels.
|
||||
*
|
||||
* There is deliberately no dithering. The source is flat black on flat white;
|
||||
* dithering would only fuzz the text edges and cost contrast on a panel that
|
||||
* has none to spare.
|
||||
*/
|
||||
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
|
||||
import { SCREEN_HEIGHT, SCREEN_WIDTH } from './layout';
|
||||
|
||||
export type Bitmap = {
|
||||
width: number;
|
||||
height: number;
|
||||
/** One bit per pixel, MSB first, row-major. A set bit is white. */
|
||||
bits: Uint8Array;
|
||||
};
|
||||
|
||||
/** Mid-grey. Anti-aliased glyph edges fall either side of it. */
|
||||
export const DEFAULT_THRESHOLD = 127;
|
||||
|
||||
export type EncodeOptions = {
|
||||
threshold?: number;
|
||||
/**
|
||||
* Flip black and white. The knob to reach for if the panel ever comes out
|
||||
* as a negative; the pixels are otherwise correct.
|
||||
*/
|
||||
invert?: boolean;
|
||||
};
|
||||
|
||||
/** Rasterises the SVG at its natural size and returns RGBA pixels. */
|
||||
export function rasterise(svg: string): { width: number; height: number; rgba: Buffer } {
|
||||
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: SCREEN_WIDTH } });
|
||||
const rendered = resvg.render();
|
||||
return { width: rendered.width, height: rendered.height, rgba: rendered.pixels };
|
||||
}
|
||||
|
||||
/** Thresholds RGBA pixels into one bit each, no dithering. */
|
||||
export function toBitmap(
|
||||
rgba: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
{ threshold = DEFAULT_THRESHOLD, invert = false }: EncodeOptions = {},
|
||||
): Bitmap {
|
||||
const bytesPerRow = Math.ceil(width / 8);
|
||||
const bits = new Uint8Array(bytesPerRow * height);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
const red = rgba[offset] ?? 0;
|
||||
const green = rgba[offset + 1] ?? 0;
|
||||
const blue = rgba[offset + 2] ?? 0;
|
||||
const alpha = rgba[offset + 3] ?? 255;
|
||||
|
||||
// Composite over white first: the panel is paper, so anything
|
||||
// transparent is paper too, not ink.
|
||||
const opacity = alpha / 255;
|
||||
const over = (channel: number): number => channel * opacity + 255 * (1 - opacity);
|
||||
const luminance = 0.299 * over(red) + 0.587 * over(green) + 0.114 * over(blue);
|
||||
|
||||
const isWhite = invert ? luminance <= threshold : luminance > threshold;
|
||||
if (isWhite) {
|
||||
const index = y * bytesPerRow + (x >> 3);
|
||||
bits[index] = (bits[index] ?? 0) | (0b1000_0000 >> (x & 7));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, bits };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 1-bit BMP: 14-byte file header, 40-byte info header, a two-colour palette,
|
||||
* then bottom-up rows padded to four bytes. Uncompressed on purpose — the
|
||||
* firmware reads it straight through.
|
||||
*/
|
||||
export function encodeBmp(bitmap: Bitmap): Buffer {
|
||||
const { width, height, bits } = bitmap;
|
||||
const sourceRowBytes = Math.ceil(width / 8);
|
||||
const rowBytes = Math.ceil(width / 32) * 4;
|
||||
const pixelBytes = rowBytes * height;
|
||||
const offset = 14 + 40 + 8;
|
||||
|
||||
const buffer = Buffer.alloc(offset + pixelBytes);
|
||||
|
||||
buffer.write('BM', 0, 'ascii');
|
||||
buffer.writeUInt32LE(buffer.length, 2);
|
||||
buffer.writeUInt32LE(0, 6);
|
||||
buffer.writeUInt32LE(offset, 10);
|
||||
|
||||
buffer.writeUInt32LE(40, 14);
|
||||
buffer.writeInt32LE(width, 18);
|
||||
buffer.writeInt32LE(height, 22);
|
||||
buffer.writeUInt16LE(1, 26);
|
||||
buffer.writeUInt16LE(1, 28);
|
||||
buffer.writeUInt32LE(0, 30);
|
||||
buffer.writeUInt32LE(pixelBytes, 34);
|
||||
buffer.writeInt32LE(2835, 38);
|
||||
buffer.writeInt32LE(2835, 42);
|
||||
buffer.writeUInt32LE(2, 46);
|
||||
buffer.writeUInt32LE(2, 50);
|
||||
|
||||
// Palette, BGRA. Index 0 is black, index 1 is white.
|
||||
buffer.writeUInt32LE(0x00000000, 54);
|
||||
buffer.writeUInt32LE(0x00ffffff, 58);
|
||||
|
||||
// BMP rows run bottom to top.
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const source = (height - 1 - y) * sourceRowBytes;
|
||||
const target = offset + y * rowBytes;
|
||||
for (let byte = 0; byte < sourceRowBytes; byte += 1) {
|
||||
buffer[target + byte] = bits[source + byte] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/** A 1-bit greyscale PNG, for firmwares that prefer it to BMP. */
|
||||
export function encodePng(bitmap: Bitmap): Buffer {
|
||||
const { width, height, bits } = bitmap;
|
||||
const rowBytes = Math.ceil(width / 8);
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header.writeUInt8(1, 8); // bit depth
|
||||
header.writeUInt8(0, 9); // greyscale
|
||||
header.writeUInt8(0, 10);
|
||||
header.writeUInt8(0, 11);
|
||||
header.writeUInt8(0, 12);
|
||||
|
||||
// Every scanline carries a leading filter byte; "none" keeps it readable.
|
||||
const raw = Buffer.alloc((rowBytes + 1) * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const target = y * (rowBytes + 1);
|
||||
raw[target] = 0;
|
||||
for (let byte = 0; byte < rowBytes; byte += 1) {
|
||||
raw[target + 1 + byte] = bits[y * rowBytes + byte] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk('IHDR', header),
|
||||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([length, body, crc]);
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/** The whole pipeline: SVG in, panel-ready bytes out. */
|
||||
export function encodeScreen(
|
||||
svg: string,
|
||||
format: 'bmp' | 'png',
|
||||
options: EncodeOptions = {},
|
||||
): Buffer {
|
||||
const { width, height, rgba } = rasterise(svg);
|
||||
const bitmap = toBitmap(rgba, width, height, options);
|
||||
if (bitmap.width !== SCREEN_WIDTH || bitmap.height !== SCREEN_HEIGHT) {
|
||||
throw new Error(
|
||||
`Rendered ${bitmap.width}x${bitmap.height}, expected ${SCREEN_WIDTH}x${SCREEN_HEIGHT}`,
|
||||
);
|
||||
}
|
||||
return format === 'png' ? encodePng(bitmap) : encodeBmp(bitmap);
|
||||
}
|
||||
Reference in New Issue
Block a user