Files
hanzo.ai/scripts/template-shots.mjs
zooqueenandHanzo Dev 4f362d5c08 The builder's pictures live with the builder
hanzo.ai/dev is the canonical build surface, so a template's picture is
served from this origin and drawn here: scripts/template-schematic decides
what a slug looks like, scripts/template-shots.mjs renders it to
public/templates/<slug>.webp, and lib/catalog-shots.json says which files
are drawings. The store holds one file per catalog slug and nothing else —
a template that leaves the catalog takes its picture with it, and a card
names one address.

(cherry picked from commit c5636e4bfb5fc8d378e72901b54f4000ed762aba)

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-03 12:17:47 -07:00

133 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Draws a shot for every template the catalog carries and has no photograph of.
//
// node scripts/template-shots.mjs
//
// A card needs a picture. Where a template has been photographed, the capture
// at public/templates/<slug>.webp is it and this script leaves it alone; where
// it has not, `lib/template-schematic` decides what to draw — a tinted canvas,
// a bloom, and the rectangles of the kind of screen the slug names — and this
// renders that decision to a file, so /dev draws every card with one <img>.
//
// The catalog is the API's, so a template added there gets a picture here on the
// next run, and one that leaves takes its drawing with it. A slug the generator
// did not draw is a photograph, and nothing downstream tells them apart.
import { createRequire } from "node:module";
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { cellsOf, kindOf, paletteOf } from "./template-schematic.ts";
const require = createRequire(import.meta.url);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const shots = path.join(root, "public/templates");
const manifest = path.join(root, "lib/catalog-shots.json");
const CATALOG = process.env.TEMPLATES_URL || "https://api.hanzo.ai/v1/templates";
const WIDTH = 1280;
const HEIGHT = 800;
// sharp is a transitive dependency (next/image), so it is present but not
// hoisted under pnpm. Resolve it the normal way first and fall through to the
// store only if that fails.
function loadSharp() {
try {
return require("sharp");
} catch {
const store = path.join(root, "node_modules/.pnpm");
const dir = readdirSync(store).find((d) => /^sharp@/.test(d));
if (!dir) throw new Error("sharp not installed — run pnpm install");
return require(path.join(store, dir, "node_modules/sharp"));
}
}
// The palette speaks CSS. librsvg reads a narrower colour grammar than a
// browser, so the one conversion happens here rather than being trusted to it.
function hex(css) {
const m = /^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/.exec(css);
if (!m) return css;
const h = Number(m[1]) / 360;
const s = Number(m[2]) / 100;
const l = Number(m[3]) / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h * 6) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] = [
[c, x, 0],
[x, c, 0],
[0, c, x],
[0, x, c],
[x, 0, c],
[c, 0, x],
][Math.floor(h * 6) % 6];
const byte = (v) =>
Math.round((v + m0) * 255)
.toString(16)
.padStart(2, "0");
return `#${byte(r)}${byte(g)}${byte(b)}`;
}
/** The drawn screen as SVG, in the same 100×100 frame the component uses. */
function svg(slug, category) {
const p = paletteOf(slug);
const fill = {
surface: hex(p.surface),
line: hex(p.line),
ink: hex(p.ink),
accent: hex(p.accent),
accent2: hex(p.accent2),
};
// The bloom is the component's `radial-gradient(120% 88% at x% y%, glow,
// transparent 62%)`: a circle of the frame's width, squashed to 88/120 of it
// vertically, fading out at 62% of the way.
const squash = 88 / 120;
const bloom = `
<radialGradient id="bloom" gradientUnits="userSpaceOnUse"
cx="${p.glowX}" cy="${p.glowY}" r="120"
gradientTransform="translate(0 ${p.glowY * (1 - squash)}) scale(1 ${squash})">
<stop offset="0" stop-color="${hex(p.glow)}" stop-opacity="${p.light ? 0.26 : 0.4}"/>
<stop offset="0.62" stop-color="${hex(p.glow)}" stop-opacity="0"/>
</radialGradient>`;
const rects = cellsOf(kindOf(slug, category), slug)
.map(
(c) =>
// `r` is px against the 150px-tall card the component draws in, which is
// 1.5px to the frame unit this grid counts in.
`<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="${(c.r ?? 2) / 1.5}" fill="${fill[c.tone]}"/>`,
)
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 100 100" preserveAspectRatio="none"><defs>${bloom}</defs><rect width="100" height="100" fill="${hex(p.canvas)}"/><rect width="100" height="100" fill="url(#bloom)"/>${rects}</svg>`;
}
const sharp = loadSharp();
const res = await fetch(CATALOG);
if (!res.ok) throw new Error(`${CATALOG} answered ${res.status}`);
const rows = (await res.json()).data ?? [];
const wanted = new Map(rows.map((r) => [r.slug, r.category]));
// The pictures this script drew. The list is how a drawing is withdrawn when its
// template leaves the catalog.
const owned = new Set(JSON.parse(readFileSync(manifest, "utf8")));
let drew = 0;
for (const [slug, category] of wanted) {
const out = path.join(shots, `${slug}.webp`);
if (existsSync(out) && !owned.has(slug)) continue;
const png = await sharp(Buffer.from(svg(slug, category))).png().toBuffer();
writeFileSync(out, await sharp(png).webp({ quality: 88 }).toBuffer());
owned.add(slug);
drew++;
}
// A template that leaves the catalog takes its drawing with it.
for (const slug of [...owned]) {
if (wanted.has(slug)) continue;
owned.delete(slug);
rmSync(path.join(shots, `${slug}.webp`), { force: true });
}
writeFileSync(manifest, `${JSON.stringify([...owned].sort(), null, 2)}\n`);
console.log(`${wanted.size} templates — ${wanted.size - owned.size} photographed, ${drew} drawn`);