Scaffold: package, contracts (DesignSpec, config, backend, font registry)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
/tmp/
|
||||
/out/
|
||||
vendor/fonts/*/
|
||||
!vendor/fonts/.gitkeep
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["extensions/**/*.ts"]
|
||||
}
|
||||
Vendored
Reference in New Issue
Block a user