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,112 @@
|
||||
/**
|
||||
* Prepares the ITA ITO brand assets.
|
||||
*
|
||||
* npm run brand
|
||||
*
|
||||
* Downloads the shop logo once and writes two files into `public/brand`, both
|
||||
* of which are committed:
|
||||
*
|
||||
* logo.png the original, for the admin interface
|
||||
* logo-eink.png the wordmark alone, 1-bit and thresholded, for the panel
|
||||
*
|
||||
* The outputs are committed on purpose. Rendering must not depend on a
|
||||
* Shopify CDN being reachable, and the panel image feeds a content hash that
|
||||
* decides whether the screen redraws — so the bytes have to be stable.
|
||||
*
|
||||
* `sharp` is a devDependency used only here. Nothing at runtime needs it.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { encodePng, toBitmap } from '../lib/screen/encode';
|
||||
|
||||
const LOGO_URL =
|
||||
process.env.BRAND_LOGO_URL ?? 'https://ita-ito.com/cdn/shop/files/ITA_ITO_knitwear_logo.png';
|
||||
|
||||
/** Wide enough to read from the pavement, short enough for a 76px header. */
|
||||
const EINK_WIDTH = 260;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const directory = path.join(process.cwd(), 'public', 'brand');
|
||||
await mkdir(directory, { recursive: true });
|
||||
|
||||
const response = await fetch(LOGO_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch the logo: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const original = Buffer.from(await response.arrayBuffer());
|
||||
await writeFile(path.join(directory, 'logo.png'), original);
|
||||
|
||||
const band = await findWordmarkBand(original);
|
||||
const cropped = await sharp(original)
|
||||
.extract({ left: 0, top: band.top, width: band.width, height: band.height })
|
||||
.resize({ width: EINK_WIDTH })
|
||||
.flatten({ background: '#ffffff' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
// Reuse the panel's own encoder, so the logo is thresholded by exactly the
|
||||
// rule the rest of the screen is. A line drawing gets no dithering: it would
|
||||
// only fuzz the serifs.
|
||||
const bitmap = toBitmap(cropped.data, cropped.info.width, cropped.info.height);
|
||||
await writeFile(path.join(directory, 'logo-eink.png'), encodePng(bitmap));
|
||||
|
||||
console.log(`logo.png ${original.length} bytes (original)`);
|
||||
console.log(`logo-eink.png ${cropped.info.width}x${cropped.info.height}, 1-bit`);
|
||||
console.log('Both are committed; the CDN is never read at runtime.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo stacks a wordmark, a two-line tagline and a drawn motif. Only the
|
||||
* wordmark survives at panel size, so find its band rather than hard-coding
|
||||
* pixel offsets that a future logo revision would silently break.
|
||||
*/
|
||||
async function findWordmarkBand(
|
||||
source: Buffer,
|
||||
): Promise<{ top: number; height: number; width: number }> {
|
||||
const { data, info } = await sharp(source)
|
||||
.flatten({ background: '#ffffff' })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const bands: { top: number; bottom: number }[] = [];
|
||||
let start: number | null = null;
|
||||
|
||||
for (let y = 0; y < info.height; y += 1) {
|
||||
let ink = 0;
|
||||
for (let x = 0; x < info.width; x += 1) {
|
||||
if ((data[y * info.width + x] ?? 255) < 128) {
|
||||
ink += 1;
|
||||
}
|
||||
}
|
||||
if (ink > 0 && start === null) {
|
||||
start = y;
|
||||
} else if (ink === 0 && start !== null) {
|
||||
bands.push({ top: start, bottom: y - 1 });
|
||||
start = null;
|
||||
}
|
||||
}
|
||||
if (start !== null) {
|
||||
bands.push({ top: start, bottom: info.height - 1 });
|
||||
}
|
||||
|
||||
const wordmark = bands[0];
|
||||
if (!wordmark) {
|
||||
throw new Error('Found no ink in the logo; is the download correct?');
|
||||
}
|
||||
|
||||
// A couple of pixels of air, so the thresholding cannot clip a serif.
|
||||
const top = Math.max(0, wordmark.top - 2);
|
||||
const bottom = Math.min(info.height - 1, wordmark.bottom + 2);
|
||||
return { top, height: bottom - top + 1, width: info.width };
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Renders the panel locally, so the layout can be iterated on without a device.
|
||||
*
|
||||
* npm run screen:preview
|
||||
*
|
||||
* Writes `.preview/screen.svg`, `.preview/screen.bmp`, `.preview/screen.png`
|
||||
* and an `index.html` that shows the real 1-bit image at 1:1 next to a
|
||||
* magnified view, which is where clipping and thin strokes give themselves up.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { ScreenPayload } from '../lib/screen/contract';
|
||||
import { encodeScreen } from '../lib/screen/encode';
|
||||
import { SCREEN_HEIGHT, SCREEN_WIDTH } from '../lib/screen/layout';
|
||||
import { renderScreenSvg } from '../lib/screen/render';
|
||||
import { buildScreenPayload } from '../lib/screen/viewmodel';
|
||||
import type { ScheduleContext } from '../lib/schedule/types';
|
||||
|
||||
const ZURICH = 'Europe/Zurich';
|
||||
|
||||
/** A deliberately busy week: a lunch break, a late opening and a holiday. */
|
||||
const CONTEXT: ScheduleContext = {
|
||||
timezone: ZURICH,
|
||||
weekly: [
|
||||
{ dayOfWeek: 0, isClosed: true, slots: [] },
|
||||
{ dayOfWeek: 1, isClosed: true, slots: [] },
|
||||
{ dayOfWeek: 2, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||||
{
|
||||
dayOfWeek: 3,
|
||||
isClosed: false,
|
||||
slots: [
|
||||
{ open: '10:00', close: '13:00' },
|
||||
{ open: '14:00', close: '18:30' },
|
||||
],
|
||||
},
|
||||
{ dayOfWeek: 4, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||||
{ dayOfWeek: 5, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||||
{ dayOfWeek: 6, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||||
],
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-24',
|
||||
isClosed: false,
|
||||
slots: [{ open: '14:00', close: '18:30' }],
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: 'Ouverture retardée',
|
||||
noteEn: 'Late opening',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
vacations: [],
|
||||
holidays: [
|
||||
{ date: '2026-09-25', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', isAutoClosed: true },
|
||||
],
|
||||
};
|
||||
|
||||
function payload(): ScreenPayload {
|
||||
return buildScreenPayload({
|
||||
now: new Date('2026-09-23T10:30:00Z'),
|
||||
ctx: CONTEXT,
|
||||
shopName: 'ITA ITO',
|
||||
logoUrl: 'https://trmnl.loxi.ch/brand/logo-eink.png',
|
||||
messages: [
|
||||
{
|
||||
textFr: 'Fermeture exceptionnelle jeudi matin',
|
||||
textEn: 'Exceptionally closed on Thursday morning',
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const directory = path.join(process.cwd(), '.preview');
|
||||
await mkdir(directory, { recursive: true });
|
||||
|
||||
const screen = payload();
|
||||
const svg = await renderScreenSvg(screen);
|
||||
|
||||
await writeFile(path.join(directory, 'screen.svg'), svg);
|
||||
await writeFile(path.join(directory, 'screen.bmp'), encodeScreen(svg, 'bmp'));
|
||||
await writeFile(path.join(directory, 'screen.png'), encodeScreen(svg, 'png'));
|
||||
await writeFile(path.join(directory, 'payload.json'), `${JSON.stringify(screen, null, 2)}\n`);
|
||||
await writeFile(path.join(directory, 'index.html'), page());
|
||||
|
||||
console.log(`Preview written to ${path.relative(process.cwd(), directory)}/index.html`);
|
||||
console.log('Open it in a browser, or look at screen.png directly.');
|
||||
}
|
||||
|
||||
function page(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Aperçu écran — ITA ITO</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 32px; background: #f4f4f2; color: #141413;
|
||||
font: 15px/1.5 -apple-system, system-ui, sans-serif; }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
|
||||
p { margin: 0 0 24px; color: #5e5d59; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: #5e5d59; margin: 32px 0 8px; }
|
||||
img { display: block; background: #fff; border: 1px solid #d6d3c7;
|
||||
image-rendering: pixelated; }
|
||||
.one-to-one img { width: ${SCREEN_WIDTH}px; height: ${SCREEN_HEIGHT}px; }
|
||||
.magnified img { width: ${SCREEN_WIDTH * 2}px; height: ${SCREEN_HEIGHT * 2}px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Aperçu de l'écran — ${SCREEN_WIDTH} × ${SCREEN_HEIGHT}, 1 bit</h1>
|
||||
<p>Image réelle envoyée à l'appareil, après seuillage. Aucun gris n'est possible ici.</p>
|
||||
<h2>Taille réelle</h2>
|
||||
<div class="one-to-one"><img src="screen.png" alt="Aperçu à l'échelle 1:1"></div>
|
||||
<h2>Agrandi ×2 — pour repérer les coupures et les traits trop fins</h2>
|
||||
<div class="magnified"><img src="screen.png" alt="Aperçu agrandi"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user