Scaffold: package, contracts (DesignSpec, config, backend, font registry)

This commit is contained in:
mozempk
2026-08-27 08:58:57 +02:00
commit 5a76acdfbc
8 changed files with 441 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
*.log
.DS_Store
/tmp/
/out/
vendor/fonts/*/
!vendor/fonts/.gitkeep
+46
View File
@@ -0,0 +1,46 @@
/**
* Backend contract. Implemented by drawthings.ts today; kept narrow enough that an
* mflux backend could be dropped in (mflux has more explicit tiling control, which
* matters if Draw Things issue #121 blocks img2img on 16GB).
*/
export interface GenerateOptions {
prompt: string;
negative: string;
seed: number;
width: number;
height: number;
steps: number;
/** "draft" keeps the model warm via the resident server; "final" may run cold. */
tier: "draft" | "final";
/** img2img / edit input. NOTE: draw-things-cli auto-resizes (aspect + centre crop). */
initImage?: string;
strength?: number;
outPath: string;
}
export interface GenerateResult {
path: string;
width: number;
height: number;
seed: number;
model: string;
elapsedMs: number;
}
export type ProgressFn = (msg: string, fraction?: number) => void;
export interface Backend {
readonly name: string;
/** Cheap check used by `imgen doctor` and session_start. Never throws. */
probe(): Promise<{ ok: boolean; problems: string[] }>;
generate(opts: GenerateOptions, onProgress?: ProgressFn, signal?: AbortSignal): Promise<GenerateResult>;
upscale(input: string, outPath: string, factor: number, onProgress?: ProgressFn, signal?: AbortSignal): Promise<GenerateResult>;
}
export class BackendError extends Error {
constructor(message: string, readonly italian: string, readonly detail?: string) {
super(message);
this.name = "BackendError";
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Config — everything machine-specific. NOTHING personal ever lives in the repo:
* model disk path, output directory and brand presets all come from here.
* A missing or partial file must never crash a command.
*/
import { homedir } from "node:os";
import { join, isAbsolute } from "node:path";
import { readFileSync, existsSync } from "node:fs";
export const CONFIG_FILENAME = "pi-imgen.json";
export interface TilingConfig {
/** The single highest-payoff memory fix on 16GB: 14.03GB -> 7.18GB peak at 1024². */
tiledDecoding: boolean;
decodingTileWidth: number;
decodingTileHeight: number;
decodingTileOverlap: number;
}
export interface ModelsConfig {
draft: string;
final: string;
alt?: string;
edit?: string;
upscale?: string;
}
export interface PrintConfig {
bleedMm: number;
safeMm: number;
/** Web-to-print services REJECT crop marks; offset houses want them. Default off. */
cropMarks: boolean;
dpi: number;
}
export interface ServerConfig {
/** Resident gRPCServerCLI keeps the model warm for the draft loop. */
enabled: boolean;
port: number;
binary?: string;
/** "offload some weights to CPU during inference" - likely vital at 16GB. */
cpuOffload: boolean;
}
export interface Preset {
label: string;
logo?: string;
palette?: Partial<Record<"bg" | "ink" | "accent" | "scrim", string>>;
fonts?: { display?: string; body?: string };
tone?: string;
}
export interface ImgenConfig {
modelsPath: string;
outputDir: string;
models: ModelsConfig;
tiling: TilingConfig;
print: PrintConfig;
server: ServerConfig;
defaultPreset: string | null;
presets: Record<string, Preset>;
/** Provider id registered in pi that the art director should use. */
directorProvider?: string;
directorModel?: string;
}
export const DEFAULTS: ImgenConfig = {
modelsPath: join(homedir(), "Documents", "Models"),
outputDir: join(homedir(), "Immagini", "generated"),
models: {
// Z-Image Turbo: Apache-2.0, 5.90GB @Q4, and the best OPEN model at text-in-image
// (OneIG EN 0.994 vs FLUX.1-dev 0.497). Fast enough to be the draft model too.
draft: "z_image_turbo_1.0_i8x.ckpt",
final: "z_image_turbo_1.0_i8x.ckpt",
alt: "flux_2_klein_4b_q8p.ckpt",
edit: "flux_2_klein_4b_q8p.ckpt",
upscale: "seedvr2_3b_q8p.ckpt",
},
tiling: {
tiledDecoding: true,
decodingTileWidth: 512,
decodingTileHeight: 512,
decodingTileOverlap: 64,
},
print: { bleedMm: 3, safeMm: 5, cropMarks: false, dpi: 300 },
server: { enabled: true, port: 7859, cpuOffload: true },
defaultPreset: null,
presets: {},
};
export function expandTilde(p: string): string {
if (p === "~") return homedir();
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
return p;
}
export function configPath(piConfigDir: string): string {
return join(piConfigDir, CONFIG_FILENAME);
}
/** Deep-merges over DEFAULTS. Never throws: a broken config degrades to defaults. */
export function loadConfig(piConfigDir: string): { config: ImgenConfig; problems: string[] } {
const problems: string[] = [];
const path = configPath(piConfigDir);
let raw: Partial<ImgenConfig> = {};
if (existsSync(path)) {
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (e) {
problems.push(`Configurazione illeggibile (${path}): ${(e as Error).message}`);
}
}
const config: ImgenConfig = {
...DEFAULTS,
...raw,
models: { ...DEFAULTS.models, ...raw.models },
tiling: { ...DEFAULTS.tiling, ...raw.tiling },
print: { ...DEFAULTS.print, ...raw.print },
server: { ...DEFAULTS.server, ...raw.server },
presets: { ...DEFAULTS.presets, ...raw.presets },
};
config.modelsPath = expandTilde(config.modelsPath);
config.outputDir = expandTilde(config.outputDir);
if (!isAbsolute(config.modelsPath)) problems.push("modelsPath deve essere un percorso assoluto.");
if (!isAbsolute(config.outputDir)) problems.push("outputDir deve essere un percorso assoluto.");
return { config, problems };
}
+105
View File
@@ -0,0 +1,105 @@
/**
* The bundled font set. The art director may ONLY choose from this list, which is what
* guarantees a chosen family is present on disk and legally redistributable.
*
* Coverage was audited by parsing each family's TrueType `cmap` against the full
* Italian set plus « » " " ' ' – — ° € . Orbitron was the ONLY failure in ~90 families
* (missing « ») and is replaced here by Unbounded.
*/
export type Mood =
| "grotesque" | "condensed" | "elegant-serif" | "didone" | "old-style-serif"
| "geometric-sans" | "handwritten" | "retro" | "distressed" | "stencil"
| "mono" | "techno";
export interface FontEntry {
family: string;
mood: Mood;
license: "OFL-1.1" | "Apache-2.0";
/** Fontsource id — `api.fontsource.org/v1/download/{id}` ships TTF **and** LICENSE. */
id: string;
/** google/fonts directory slug, for the licence fallback. Bucket is resolved, never hardcoded. */
slug: string;
/** Variable axes usable at render time (Typst 0.15 supports these). */
axes?: Partial<Record<"wght" | "wdth" | "opsz" | "slnt" | "ital" | "SOFT" | "WONK", [number, number]>>;
/** Draws capitals at lowercase codepoints. NEVER assign to a body block. */
capsOnly?: boolean;
/** Suitable as a display/headline face. */
display: boolean;
/** Suitable as a body/detail face. */
body: boolean;
note: string;
}
export const FONTS: FontEntry[] = [
{ family: "Archivo", mood: "grotesque", license: "OFL-1.1", id: "archivo", slug: "archivo",
axes: { wght: [100, 900], wdth: [62, 125] }, display: true, body: true,
note: "Auto-fit workhorse - the wdth axis lets a long title narrow instead of shrink." },
{ family: "Anton", mood: "grotesque", license: "OFL-1.1", id: "anton", slug: "anton",
display: true, body: false, note: "Default Italian poster headline." },
{ family: "Oswald", mood: "condensed", license: "OFL-1.1", id: "oswald", slug: "oswald",
axes: { wght: [200, 700] }, display: true, body: false,
note: "Safe condensed headline, legible at distance." },
{ family: "Bebas Neue", mood: "condensed", license: "OFL-1.1", id: "bebas-neue", slug: "bebasneue",
capsOnly: true, display: true, body: false, note: "Gig/ticket caps." },
{ family: "Big Shoulders", mood: "condensed", license: "OFL-1.1", id: "big-shoulders", slug: "bigshoulders",
axes: { wght: [100, 900], opsz: [10, 72] }, display: true, body: false,
note: "Tall compressed. NB: 5 distinct Big Shoulders families exist - this is not the Display one." },
{ family: "Playfair Display", mood: "elegant-serif", license: "OFL-1.1", id: "playfair-display", slug: "playfairdisplay",
axes: { wght: [400, 900] }, display: true, body: false, note: "Teatro, classica. Reserved Font Name - ship unmodified." },
{ family: "Bodoni Moda", mood: "didone", license: "OFL-1.1", id: "bodoni-moda", slug: "bodonimoda",
axes: { wght: [400, 900], opsz: [6, 96] }, display: true, body: true,
note: "True didone - hairlines only survive at large opsz." },
{ family: "DM Serif Display", mood: "elegant-serif", license: "OFL-1.1", id: "dm-serif-display", slug: "dmserifdisplay",
display: true, body: false, note: "High-contrast headline serif. Reserved Font Name." },
{ family: "Instrument Serif", mood: "elegant-serif", license: "OFL-1.1", id: "instrument-serif", slug: "instrumentserif",
display: true, body: true, note: "Mostre, gallerie - restrained editorial." },
{ family: "Fraunces", mood: "old-style-serif", license: "OFL-1.1", id: "fraunces", slug: "fraunces",
axes: { wght: [100, 900], opsz: [9, 144], SOFT: [0, 100], WONK: [0, 1] }, display: true, body: true,
note: "Quirky old-style; WONK toggles the odd glyphs." },
{ family: "Outfit", mood: "geometric-sans", license: "OFL-1.1", id: "outfit", slug: "outfit",
axes: { wght: [100, 900] }, display: true, body: true, note: "Clean geometric headline/subhead." },
{ family: "Inter", mood: "geometric-sans", license: "OFL-1.1", id: "inter", slug: "inter",
axes: { wght: [100, 900], opsz: [14, 32] }, display: false, body: true,
note: "Safest text face in the set - default body." },
{ family: "Caveat", mood: "handwritten", license: "OFL-1.1", id: "caveat", slug: "caveat",
axes: { wght: [400, 700] }, display: true, body: false, note: "'A mano' accents." },
{ family: "Permanent Marker", mood: "handwritten", license: "Apache-2.0", id: "permanent-marker", slug: "permanentmarker",
display: true, body: false, note: "Sagre, feste, street events." },
{ family: "Bungee", mood: "retro", license: "OFL-1.1", id: "bungee", slug: "bungee",
capsOnly: true, display: true, body: false, note: "Pop, loud, saturated." },
{ family: "Alfa Slab One", mood: "retro", license: "OFL-1.1", id: "alfa-slab-one", slug: "alfaslabone",
display: true, body: false, note: "Fat slab, circus/fairground - very Italian-poster. Reserved Font Name." },
{ family: "Special Elite", mood: "distressed", license: "Apache-2.0", id: "special-elite", slug: "specialelite",
display: true, body: true, note: "Vintage typewriter, distressed without a stencil." },
{ family: "Big Shoulders Stencil Display", mood: "stencil", license: "OFL-1.1", id: "big-shoulders-stencil-display", slug: "bigshouldersstencildisplay",
axes: { wght: [100, 900] }, display: true, body: false, note: "Best stencil option, and variable." },
{ family: "Saira Stencil One", mood: "stencil", license: "OFL-1.1", id: "saira-stencil-one", slug: "sairastencilone",
display: true, body: false, note: "Second stencil voice." },
{ family: "Space Mono", mood: "mono", license: "OFL-1.1", id: "space-mono", slug: "spacemono",
display: false, body: true, note: "Date/time/price lines with character." },
{ family: "JetBrains Mono", mood: "mono", license: "OFL-1.1", id: "jetbrains-mono", slug: "jetbrainsmono",
axes: { wght: [100, 800] }, display: false, body: true, note: "Clean mono detail lines." },
{ family: "Unbounded", mood: "techno", license: "OFL-1.1", id: "unbounded", slug: "unbounded",
axes: { wght: [200, 900] }, display: true, body: true, note: "Club/techno. Use INSTEAD of Orbitron (which lacks « »)." },
];
/** Curated pairings. Each is a named mood - the vocabulary an LLM picks well between. */
export const PAIRINGS: { display: string; body: string; mood: string }[] = [
{ display: "Anton", body: "Inter", mood: "Manifesto civico - istituzionale, diretto" },
{ display: "Bodoni Moda", body: "Instrument Serif", mood: "Teatro / opera lirica - formale, classico" },
{ display: "Instrument Serif", body: "Inter", mood: "Mostra d'arte - minimalismo da galleria" },
{ display: "Big Shoulders Stencil Display", body: "Space Mono", mood: "Concerto rock - industriale, DIY" },
{ display: "Bungee", body: "Outfit", mood: "Festival estivo - pop, chiassoso" },
{ display: "Alfa Slab One", body: "Outfit", mood: "Sagra paesana - calore da luna park" },
{ display: "Special Elite", body: "Space Mono", mood: "Mercatino vintage - da macchina da scrivere" },
{ display: "Unbounded", body: "JetBrains Mono", mood: "Notte techno - flyer da club" },
{ display: "Permanent Marker", body: "Archivo", mood: "Festa in piazza - fatto a mano, spontaneo" },
{ display: "Fraunces", body: "Archivo", mood: "Cinema all'aperto - editoriale bizzarro, d'essai" },
];
export const byFamily = (name: string): FontEntry | undefined =>
FONTS.find((f) => f.family.toLowerCase() === name.toLowerCase());
export const displayFamilies = () => FONTS.filter((f) => f.display).map((f) => f.family);
export const bodyFamilies = () => FONTS.filter((f) => f.body && !f.capsOnly).map((f) => f.family);
+112
View File
@@ -0,0 +1,112 @@
/**
* DesignSpec — the single object the whole pipeline revolves around.
*
* The art director (LLM) emits exactly this, validated. Every downstream stage is a
* pure function of it: the diffusion backend reads `art`, the Typst renderer reads
* everything else. Text lives here as DATA, never as pixels — which is why accents,
* dates and venue names are always correct, and why changing a font is a sub-second
* re-render rather than a regeneration.
*/
import { Type, type Static } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
/** Output formats. Page geometry lives in render/formats.ts, keyed by these names. */
export const FORMATS = [
"a3-portrait",
"a4-portrait",
"ig-post", // 1080x1350 (4:5)
"ig-story", // 1080x1920 (9:16)
"fb-cover", // 1640x664
"yt-thumb", // 1280x720
] as const;
export type Format = (typeof FORMATS)[number];
/** Layout templates. Each is a reviewed templates/<name>.typ, never LLM-authored. */
export const TEMPLATES = [
"hero-bottom", // art full-bleed, text band bottom
"banded", // solid colour band across the middle
"framed", // art inset inside a coloured frame
"centred-stack", // centred type stack over art
"split", // hard split: art half, colour half
] as const;
export type TemplateName = (typeof TEMPLATES)[number];
/** Roles are ordered by visual priority; the renderer knows how to treat each. */
export const BLOCK_ROLES = [
"title", "subtitle", "date", "venue", "details", "price", "footer",
] as const;
const Hex = Type.String({ pattern: "^#[0-9a-fA-F]{6}$" });
export const PaletteSchema = Type.Object({
bg: Hex,
ink: Hex,
accent: Hex,
scrim: Hex,
}, { description: "Hex colours. `scrim` is the overlay drawn behind text over busy art." });
export const FontsSchema = Type.Object({
display: Type.String({ description: "Family name from the bundled set (headlines)." }),
body: Type.String({ description: "Family name from the bundled set (everything else)." }),
});
export const BlockSchema = Type.Object({
role: StringEnum([...BLOCK_ROLES]),
text: Type.String(),
optional: Type.Optional(Type.Boolean({
description: "If true the renderer may drop this block when space runs short.",
})),
});
export const ArtSchema = Type.Object({
prompt: Type.String({
description: "ENGLISH. Describes the ARTWORK ONLY. Must never ask for text, words or lettering.",
}),
negative: Type.String({
description: "Always includes: text, letters, words, typography, watermark, signature.",
}),
seed: Type.Integer({ description: "Pinned so draft and final are the same image." }),
source: StringEnum(["generate", "photo"]),
photoPath: Type.Optional(Type.String({ description: "Set when source==='photo'." })),
});
export const LogoSchema = Type.Object({
path: Type.String(),
corner: StringEnum(["tl", "tr", "bl", "br"]),
scale: Type.Number({ minimum: 0.02, maximum: 0.4, description: "Fraction of the short edge." }),
});
export const DesignSpecSchema = Type.Object({
slug: Type.String({ pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" }),
format: StringEnum([...FORMATS]),
template: StringEnum([...TEMPLATES]),
palette: PaletteSchema,
fonts: FontsSchema,
art: ArtSchema,
blocks: Type.Array(BlockSchema, { minItems: 1 }),
logo: Type.Optional(LogoSchema),
}, { $id: "DesignSpec" });
export type DesignSpec = Static<typeof DesignSpecSchema>;
export type Palette = Static<typeof PaletteSchema>;
export type Block = Static<typeof BlockSchema>;
/**
* Resolved spec — what actually reaches Typst. The renderer adds computed values the
* LLM must not be trusted with: measured contrast decisions, page geometry in mm, and
* absolute paths. Templates read THIS, not DesignSpec.
*/
export interface ResolvedSpec extends DesignSpec {
page: {
widthMm: number;
heightMm: number;
bleedMm: number;
safeMm: number;
dpi: number;
cropMarks: boolean;
};
art_file: string; // absolute path to the artwork actually placed
ink_resolved: string; // contrast-checked, may override palette.ink
needs_scrim: boolean; // from luminance AND variance of the region behind text
font_files: Record<string, string>; // family name -> absolute .ttf path
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pi-imgen",
"version": "0.1.0",
"description": "Genera locandine, loghi e immagini per eventi - pi extension (local, Apple Silicon)",
"keywords": ["pi-package", "pi-extension", "image-generation", "poster", "typst"],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.sal.giize.com/mozempk/pi-imgen.git"
},
"pi": {
"extensions": ["./extensions/imgen"]
},
"type": "module",
"dependencies": {
"sharp": "^0.34.2"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-agent-core": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"engines": { "node": ">=20" }
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["extensions/**/*.ts"]
}
View File