From 5a76acdfbce0655571702623da7f6d3e53115e47 Mon Sep 17 00:00:00 2001 From: mozempk Date: Thu, 27 Aug 2026 08:58:57 +0200 Subject: [PATCH] Scaffold: package, contracts (DesignSpec, config, backend, font registry) --- .gitignore | 7 ++ extensions/imgen/backends/types.ts | 46 ++++++++++ extensions/imgen/config.ts | 132 +++++++++++++++++++++++++++++ extensions/imgen/design/fonts.ts | 105 +++++++++++++++++++++++ extensions/imgen/design/spec.ts | 112 ++++++++++++++++++++++++ package.json | 26 ++++++ tsconfig.json | 13 +++ vendor/fonts/.gitkeep | 0 8 files changed, 441 insertions(+) create mode 100644 .gitignore create mode 100644 extensions/imgen/backends/types.ts create mode 100644 extensions/imgen/config.ts create mode 100644 extensions/imgen/design/fonts.ts create mode 100644 extensions/imgen/design/spec.ts create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 vendor/fonts/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0c9204 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +*.log +.DS_Store +/tmp/ +/out/ +vendor/fonts/*/ +!vendor/fonts/.gitkeep diff --git a/extensions/imgen/backends/types.ts b/extensions/imgen/backends/types.ts new file mode 100644 index 0000000..3344565 --- /dev/null +++ b/extensions/imgen/backends/types.ts @@ -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; + upscale(input: string, outPath: string, factor: number, onProgress?: ProgressFn, signal?: AbortSignal): Promise; +} + +export class BackendError extends Error { + constructor(message: string, readonly italian: string, readonly detail?: string) { + super(message); + this.name = "BackendError"; + } +} diff --git a/extensions/imgen/config.ts b/extensions/imgen/config.ts new file mode 100644 index 0000000..c45808a --- /dev/null +++ b/extensions/imgen/config.ts @@ -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>; + 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; + /** 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 = {}; + + 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 }; +} diff --git a/extensions/imgen/design/fonts.ts b/extensions/imgen/design/fonts.ts new file mode 100644 index 0000000..4a2e8e1 --- /dev/null +++ b/extensions/imgen/design/fonts.ts @@ -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>; + /** 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); diff --git a/extensions/imgen/design/spec.ts b/extensions/imgen/design/spec.ts new file mode 100644 index 0000000..e809cf5 --- /dev/null +++ b/extensions/imgen/design/spec.ts @@ -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/.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; +export type Palette = Static; +export type Block = Static; + +/** + * 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; // family name -> absolute .ttf path +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..66cf114 --- /dev/null +++ b/package.json @@ -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" } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5035dae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["extensions/**/*.ts"] +} diff --git a/vendor/fonts/.gitkeep b/vendor/fonts/.gitkeep new file mode 100644 index 0000000..e69de29