The A of ITA had lost its diagonal: at 260px the hairlines of a Didone fall below a pixel, downscaling averages them into grey, and the panel's mid-grey cut then discarded them. What reached the shop window was a detached serif and something closer to a lambda. Letting more grey count as ink keeps them whole. 165 was not enough — the diagonal came back dotted — and 195 holds it solid without thickening the stems into mud. The number belongs to this particular drawing, so `npm run brand:variants` renders the grid of widths and thresholds it was chosen from, magnified, for the day the logo changes. Width stays at 260: the header shares its line with the date, and the main block is narrower now the week runs down the right-hand side. The panel will redraw once when it next wakes, since the image and therefore its content hash have changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
/**
|
|
* 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;
|
|
|
|
/**
|
|
* Where grey becomes ink.
|
|
*
|
|
* The wordmark is a Didone: its hairlines are a fraction of its stems, and at
|
|
* 260px they land below one pixel. Downscaling averages them into grey, and
|
|
* the panel's default mid-grey cut then threw them away — the diagonal of the
|
|
* A vanished entirely, leaving a detached serif and something closer to a
|
|
* lambda. Letting more grey count as ink keeps the thin strokes whole.
|
|
*
|
|
* 165 was still not enough: the diagonal came back dotted. 195 holds it solid
|
|
* without thickening the stems into mud. Re-check with `npm run brand:variants`
|
|
* if the logo is ever replaced — this number belongs to this drawing.
|
|
*/
|
|
const EINK_THRESHOLD = 195;
|
|
|
|
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, {
|
|
threshold: EINK_THRESHOLD,
|
|
});
|
|
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;
|
|
});
|