mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
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]>
133 lines
5.3 KiB
JavaScript
133 lines
5.3 KiB
JavaScript
// 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`);
|