diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2f18b3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2026 mozempk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Note: bundled fonts under vendor/fonts/ are NOT covered by this licence. Each +retains its own (SIL Open Font License 1.1 or Apache 2.0); see THIRD-PARTY-FONTS.md +and the LICENSE file shipped alongside each family. diff --git a/README.md b/README.md new file mode 100644 index 0000000..771dc8a --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# pi-imgen + +Genera **locandine, loghi e immagini per i social** per eventi, direttamente da +[pi](https://pi.dev). Tutto in locale, senza abbonamenti a servizi di immagini. + +Pensato per chi organizza eventi e non è un grafico: rispondi a qualche domanda in +italiano e ottieni i file finiti, incluso il **PDF pronto per la tipografia**. + +``` +/poster una locandina completa, dal titolo al PDF per la stampa +/logo un marchio semplice, con sfondo trasparente e SVG +/social le stesse grafiche in tutti i formati social +/retouch ritocca un'immagine che hai già +/presets salva i tuoi colori, caratteri e logo +``` + +## Come funziona + +Il trucco è **non far scrivere il testo al modello di immagini**. I modelli di +diffusione sbagliano le lettere, e sbagliano quasi sempre le accentate italiane. + +``` +brief ──► direttore artistico (LLM) ──► modello locale ──► impaginazione (Typst) ──► export + palette, caratteri, SOLO l'immagine, caratteri veri, PDF A3 + impaginazione, prompt nessuna scritta accenti giusti, IG post + data e luogo esatti IG story +``` + +Il modello dipinge soltanto l'illustrazione. Il testo viene composto dopo, con caratteri +veri. Due conseguenze pratiche: + +- **à è é ì ò ù, date e nomi dei luoghi sono sempre corretti.** Non sono pixel, sono dati. +- **Cambiare un colore o un carattere è istantaneo.** Non serve rigenerare l'immagine: + si ricompone soltanto il testo, in meno di un secondo. + +## Requisiti + +- **macOS su Apple Silicon.** Sviluppato per un Mac mini M4 base con 16 GB. +- [`draw-things-cli`](https://github.com/drawthingsai/draw-things-community) e + [`typst`](https://typst.app) — installati da `install.sh`. +- Spazio per i modelli (~10 GB). Possono stare su un **disco esterno**: il percorso è + configurabile e non deve stare nella home. +- Un modello di testo per il direttore artistico, tramite un provider già configurato in + pi. Nessun servizio di generazione immagini è richiesto o usato. + +## Installazione + +```bash +pi install git:git.sal.giize.com/mozempk/pi-imgen +cd ~/.pi/agent/git/git.sal.giize.com/mozempk/pi-imgen && ./install.sh +``` + +`install.sh` è ri-eseguibile: chiede dove tenere i modelli e dove salvare i file, +scarica i caratteri, e scrive `~/.pi/agent/pi-imgen.json`. Se lo rilanci, ripara invece +di duplicare, e **non sovrascrive i tuoi preset**. + +Per aggiornare: `pi update --extensions`. + +## Configurazione + +Tutto ciò che è specifico della tua macchina sta in `~/.pi/agent/pi-imgen.json` — mai nel +repository. Percorso dei modelli, cartella di destinazione, e i tuoi preset di marca +(logo, colori, caratteri) restano sul tuo computer. + +## Stampa + +L'output per la tipografia è un PDF singolo A3 o A4 con **3 mm di abbondanza** e un +**TrimBox** corretto, caratteri incorporati, in RGB. Va bene per la grande maggioranza +delle copisterie. I crocini di taglio sono disattivati di default: i servizi di stampa +online li rifiutano, le tipografie tradizionali li chiedono — si attivano dalla +configurazione. + +## Sotto il cofano + +| Pezzo | Scelta | +|---|---| +| Generazione | `draw-things-cli` + `gRPCServerCLI` residente (modello caldo per le bozze) | +| Modello | Z-Image Turbo (Apache-2.0) — piccolo, veloce, e il migliore fra gli open sul testo | +| Impaginazione | Typst 0.15 — un solo binario, PDF di stampa nativo, sillabazione italiana | +| Caratteri | 22 famiglie OFL/Apache incluse nel pacchetto ([THIRD-PARTY-FONTS.md](THIRD-PARTY-FONTS.md)) | + +Dettagli verificati sperimentalmente in [`docs/typst-verified.md`](docs/typst-verified.md). + +## Licenza + +MIT per il codice. I caratteri inclusi restano sotto le loro licenze originali (OFL 1.1 o +Apache 2.0), elencate in `THIRD-PARTY-FONTS.md` con il file di licenza di ciascuno. diff --git a/extensions/imgen/backends/drawthings.ts b/extensions/imgen/backends/drawthings.ts new file mode 100644 index 0000000..d1dc7a7 --- /dev/null +++ b/extensions/imgen/backends/drawthings.ts @@ -0,0 +1,1012 @@ +/** + * Draw Things backend — `draw-things-cli` driving a resident `gRPCServerCLI` daemon. + * + * Why this and not the alternatives (do NOT "improve" on this): + * - The built-in HTTP API is 4 routes, 2 of them stubs; it MERGES your JSON into + * whatever the GUI has loaded, so the model cannot be selected, and unknown keys + * give "Unrecognized keys" -> 422. Unusable. + * - Raw gRPC carries `configuration` as a FlatBuffer (config.fbs) with no Node SDK. + * - The in-app JS scripting engine cannot be triggered from outside. + * + * Two invariants this file exists to protect: + * 1. `--output` is MANDATORY. Without it the CLI paints an inline terminal preview and + * INTENTIONALLY WRITES NO FILE — the classic silently-empty result. We always pass + * it, and we verify afterwards that a non-empty file really appeared. + * 2. `--disable-preview` is always passed, otherwise every run draws into the terminal. + * + * Memory: peak RAM at VAE DECODE — not weight size — is what kills a 16GB machine + * (FLUX.2 klein @1024²: 14.03GB untiled vs 7.18GB tiled). Tiling therefore travels in + * every `--config-json` payload we build. + */ + +import { spawn } from "node:child_process"; +import { access, mkdir, stat } from "node:fs/promises"; +import { constants as FS_CONST } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { connect } from "node:net"; + +import { BackendError } from "./types.ts"; +import type { Backend, GenerateOptions, GenerateResult, ProgressFn } from "./types.ts"; +import type { ImgenConfig } from "../config.ts"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Homebrew core formula installs this on PATH: `brew install draw-things-cli`. */ +const DEFAULT_CLI = "draw-things-cli"; + +/** The resident daemon is always local; we never expose it on the network. */ +const REMOTE_HOST = "127.0.0.1"; + +/** How much child output we keep for error reports (bytes of text, per stream). */ +const MAX_LOG_CHARS = 8_000; + +/** Grace period between SIGTERM and SIGKILL when an AbortSignal fires. */ +const KILL_GRACE_MS = 3_000; + +/** + * Draw Things works in 64px units (the UI only offers multiples of 64). Requested + * sizes are snapped down to this grid; the REAL size is read back from the produced + * file, so callers are never lied to about geometry. + */ +const SIZE_GRID = 64; + +/** Above this long edge we force tiled decoding during upscale even if config says off. */ +const FORCE_TILING_ABOVE_PX = 1536; + +/** Upscale is deterministic in intent; there is no user-facing seed for it. */ +const UPSCALE_SEED = 0; + +/** + * Fatal-signal names and the 128+n exit codes that stand in for them. A run that ends + * this way did not "fail", it crashed. + */ +const CRASH_SIGNALS = new Set(["SIGTRAP", "SIGABRT", "SIGSEGV", "SIGBUS", "SIGILL", "SIGFPE"]); +const CRASH_EXIT_CODES = new Set([132, 133, 134, 136, 138, 139]); + +/** Textual crash fingerprints seen in macOS/Swift crash output. */ +const CRASH_MARKERS = [ + "exc_breakpoint", + "exc_bad_access", + "exc_bad_instruction", + "trace/bpt trap", + "abort trap", + "illegal instruction", + "segmentation fault", + "fatal error", + "swift runtime failure", + "crashed", +]; + +/** + * When the run used `--image`, a daemon that vanishes mid-request is the same bug seen + * from the client side: the server process crashed, so the RPC dies in transport. + * Heuristic, deliberately narrow — only consulted for img2img runs. + */ +const REMOTE_DEATH_MARKERS = [ + "unavailable", + "connection refused", + "transport became inactive", + "broken pipe", + "connection reset", + "socket is not connected", +]; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface DrawThingsOptions { + /** Override the CLI binary (absolute path or PATH name). Default `draw-things-cli`. */ + cliBinary?: string; + /** Pass `--offline` to every run: never touch the network. Useful at a venue with no wifi. */ + offline?: boolean; + /** Hard timeout per run, ms. 0 (default) = no timeout: a cold model load can be slow. */ + timeoutMs?: number; + /** Steps used by the generative upscale pass. */ + upscaleSteps?: number; + /** + * img2img strength for the upscale pass. Low on purpose: we are restoring detail, + * not reinventing the poster. + */ + upscaleStrength?: number; +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +/** `--model` also accepts `hf://owner/repo` and HuggingFace URLs; those aren't on disk. */ +function isRemoteModelRef(model: string): boolean { + return /^(hf:\/\/|https?:\/\/)/i.test(model); +} + +function snapToGrid(value: number): number { + if (!Number.isFinite(value) || value <= 0) return SIZE_GRID; + return Math.max(SIZE_GRID, Math.floor(value / SIZE_GRID) * SIZE_GRID); +} + +function tail(text: string, max = MAX_LOG_CHARS): string { + return text.length <= max ? text : `…${text.slice(text.length - max)}`; +} + +async function isReadableDir(path: string): Promise { + try { + const st = await stat(path); + if (!st.isDirectory()) return false; + // X_OK on a directory is what actually allows traversal; R_OK allows listing. + await access(path, FS_CONST.R_OK | FS_CONST.X_OK); + return true; + } catch { + return false; + } +} + +async function isNonEmptyFile(path: string): Promise { + try { + const st = await stat(path); + return st.isFile() && st.size > 0; + } catch { + return false; + } +} + +/** + * Real dimensions of a produced file. sharp is loaded lazily so a broken native build + * degrades to "we report what we asked for" instead of taking the whole backend down. + */ +async function readImageSize(path: string): Promise<{ width: number; height: number } | null> { + try { + const sharpMod = await import("sharp"); + const sharp = (sharpMod as { default: typeof import("sharp") }).default ?? sharpMod; + const meta = await sharp(path).metadata(); + if (meta.width && meta.height) return { width: meta.width, height: meta.height }; + } catch { + /* ignore: dimensions are informational */ + } + return null; +} + +/** Is the resident daemon actually accepting connections on this port? */ +function probePort(port: number, timeoutMs = 800): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (ok: boolean) => { + if (settled) return; + settled = true; + try { + sock.destroy(); + } catch { + /* ignore */ + } + resolve(ok); + }; + const sock = connect({ host: REMOTE_HOST, port }); + sock.setTimeout(timeoutMs); + sock.once("connect", () => done(true)); + sock.once("timeout", () => done(false)); + sock.once("error", () => done(false)); + }); +} + +// --------------------------------------------------------------------------- +// Progress parsing +// --------------------------------------------------------------------------- + +/** + * Liberal in what it accepts, silent about what it doesn't understand. The CLI's exact + * progress format is not part of any contract, so every rule here is best-effort and a + * parse failure must never affect the run. + */ +class ProgressParser { + private buffer = ""; + private lastMessage = ""; + private lastFraction = -1; + + constructor(private readonly onProgress?: ProgressFn) {} + + feed(chunk: string): void { + if (!this.onProgress) return; + try { + this.buffer += chunk; + // Progress bars overwrite with \r, so treat it as a line break too. + const parts = this.buffer.split(/[\r\n]+/); + this.buffer = parts.pop() ?? ""; + for (const line of parts) this.line(line); + // A bar that never emits a terminator would otherwise stall: peek at the partial. + if (this.buffer.length > 240) { + this.line(this.buffer); + this.buffer = ""; + } + } catch { + /* never let log parsing break a generation */ + } + } + + /** Flush whatever is left when the stream closes. */ + end(): void { + if (this.buffer) { + try { + this.line(this.buffer); + } catch { + /* ignore */ + } + this.buffer = ""; + } + } + + note(message: string, fraction?: number): void { + this.emit(message, fraction); + } + + private line(raw: string): void { + const line = raw.trim(); + if (!line) return; + const low = line.toLowerCase(); + + // 1. Explicit step counters: "step 3/8", "Step: 3 of 8", "3/8". + const step = + /\bstep[s]?\s*[:#]?\s*(\d+)\s*(?:\/|of|su|di)\s*(\d+)/i.exec(line) ?? + /^(\d+)\s*\/\s*(\d+)\b/.exec(line); + if (step) { + const done = Number(step[1]); + const total = Number(step[2]); + if (Number.isFinite(done) && Number.isFinite(total) && total > 0) { + this.emit(`Generazione: passo ${done} di ${total}`, Math.min(1, done / total)); + return; + } + } + + // 2. Bare percentages, including inside an ASCII progress bar. + const pct = /(\d{1,3}(?:[.,]\d+)?)\s*%/.exec(line); + if (pct) { + const value = Number(pct[1].replace(",", ".")); + if (Number.isFinite(value) && value >= 0 && value <= 100) { + this.emit(`Generazione al ${Math.round(value)}%`, value / 100); + return; + } + } + + // 3. Phase keywords. Cheap, and the only signal during a cold model load. + if (low.includes("download")) return this.emit("Scaricamento del modello…"); + if (low.includes("loading") || low.includes("load model") || low.includes("loaded")) { + return this.emit("Caricamento del modello…"); + } + if (low.includes("compil") || low.includes("warm")) { + return this.emit("Preparazione del modello…"); + } + if (low.includes("tokeniz") || low.includes("text encoder") || low.includes("encoding")) { + return this.emit("Analisi del prompt…"); + } + if (low.includes("vae") || low.includes("decod")) { + return this.emit("Decodifica dell'immagine…", 0.95); + } + if (low.includes("upscal")) return this.emit("Ingrandimento in corso…"); + if (low.includes("saving") || low.includes("saved") || low.includes("written")) { + return this.emit("Salvataggio dell'immagine…", 0.99); + } + } + + private emit(message: string, fraction?: number): void { + if (!this.onProgress) return; + // De-duplicate: identical text with no meaningful advance is noise. + if (message === this.lastMessage) { + if (fraction === undefined) return; + if (fraction <= this.lastFraction + 0.005) return; + } + this.lastMessage = message; + if (fraction !== undefined) this.lastFraction = fraction; + try { + this.onProgress(message, fraction); + } catch { + /* a misbehaving consumer must not abort the generation */ + } + } +} + +// --------------------------------------------------------------------------- +// Child process runner +// --------------------------------------------------------------------------- + +interface RunOutcome { + stdout: string; + stderr: string; + code: number | null; + signal: string | null; + elapsedMs: number; +} + +interface RunInput { + binary: string; + args: string[]; + /** True when the argv contained `--image` — the precondition for the issue #121 check. */ + usesImage: boolean; + onProgress?: ProgressFn; + signal?: AbortSignal; + timeoutMs: number; +} + +function crashLooksLikeIssue121(outcome: RunOutcome): boolean { + if (outcome.signal && CRASH_SIGNALS.has(outcome.signal)) return true; + if (outcome.code !== null && CRASH_EXIT_CODES.has(outcome.code)) return true; + const blob = `${outcome.stdout}\n${outcome.stderr}`.toLowerCase(); + if (CRASH_MARKERS.some((m) => blob.includes(m))) return true; + // Client-side view of a daemon that died mid-request. + return REMOTE_DEATH_MARKERS.some((m) => blob.includes(m)); +} + +function issue121Error(detail: string): BackendError { + return new BackendError( + "draw-things img2img crashed (upstream issue #121, 16GB Macs)", + [ + "Modifica generativa non riuscita: è un bug noto di Draw Things (issue #121).", + "Sulla build attuale la modalità img2img/edit va in crash (EXC_BREAKPOINT) sui Mac da 16GB,", + "sia dall'API sia dall'interfaccia. Non dipende dalla tua richiesta né dal prompt.", + "La generazione da solo testo (txt2img) funziona regolarmente: puoi rigenerare senza", + "immagine di partenza, oppure usare la foto così com'è come sfondo della locandina,", + "senza editing generativo.", + ].join(" "), + detail, + ); +} + +function abortedError(): BackendError { + return new BackendError( + "generation aborted", + "Operazione annullata: la generazione è stata interrotta prima di produrre un'immagine.", + ); +} + +function runCli(input: RunInput): Promise { + const { binary, args, onProgress, signal, timeoutMs } = input; + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortedError()); + return; + } + + const started = Date.now(); + const parser = new ProgressParser(onProgress); + parser.note("Avvio di draw-things-cli…", 0); + + let child; + try { + // No shell: every argv element (the --config-json blob especially) is passed + // verbatim, so it needs no quoting and cannot be re-parsed by a shell. + child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env }); + } catch (e) { + reject( + new BackendError( + `failed to spawn ${binary}`, + `Impossibile avviare «${binary}». Installalo con: brew install draw-things-cli`, + (e as Error).message, + ), + ); + return; + } + + let stdout = ""; + let stderr = ""; + let aborted = false; + let timedOut = false; + let killTimer: NodeJS.Timeout | undefined; + let runTimer: NodeJS.Timeout | undefined; + let settled = false; + + const hardKill = () => { + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + }, KILL_GRACE_MS); + killTimer.unref?.(); + }; + + const onAbort = () => { + aborted = true; + try { + child.kill("SIGTERM"); + } catch { + /* already gone */ + } + hardKill(); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + + if (timeoutMs > 0) { + runTimer = setTimeout(() => { + timedOut = true; + try { + child.kill("SIGTERM"); + } catch { + /* already gone */ + } + hardKill(); + }, timeoutMs); + runTimer.unref?.(); + } + + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + if (killTimer) clearTimeout(killTimer); + if (runTimer) clearTimeout(runTimer); + }; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + child.stdout?.on("data", (d: string) => { + stdout = tail(stdout + d, MAX_LOG_CHARS); + parser.feed(d); + }); + // The CLI writes progress to stderr at least as often as to stdout; parse both. + child.stderr?.on("data", (d: string) => { + stderr = tail(stderr + d, MAX_LOG_CHARS); + parser.feed(d); + }); + + child.on("error", (e: NodeJS.ErrnoException) => { + if (settled) return; + settled = true; + cleanup(); + if (e.code === "ENOENT") { + reject( + new BackendError( + `${binary} not found on PATH`, + `Comando «${binary}» non trovato. Installalo con: brew install draw-things-cli ` + + "(formula Homebrew CORE, non un tap personalizzato e non --HEAD).", + e.message, + ), + ); + return; + } + reject( + new BackendError( + `spawn error for ${binary}`, + `Errore nell'esecuzione di «${binary}»: ${e.message}`, + e.message, + ), + ); + }); + + child.on("close", (code, sig) => { + if (settled) return; + settled = true; + cleanup(); + parser.end(); + + if (aborted) { + reject(abortedError()); + return; + } + if (timedOut) { + reject( + new BackendError( + "generation timed out", + `Tempo scaduto: la generazione ha superato ${Math.round(timeoutMs / 1000)} secondi ed è stata interrotta.`, + tail(stderr, 2_000), + ), + ); + return; + } + + resolve({ + stdout, + stderr, + code, + signal: sig, + elapsedMs: Date.now() - started, + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Backend +// --------------------------------------------------------------------------- + +export class DrawThingsBackend implements Backend { + readonly name = "drawthings"; + + private readonly cli: string; + private readonly offline: boolean; + private readonly timeoutMs: number; + private readonly upscaleSteps: number; + private readonly upscaleStrength: number; + + constructor( + private readonly cfg: ImgenConfig, + opts: DrawThingsOptions = {}, + ) { + this.cli = opts.cliBinary ?? process.env.IMGEN_DT_CLI ?? DEFAULT_CLI; + this.offline = opts.offline ?? process.env.IMGEN_OFFLINE === "1"; + this.timeoutMs = opts.timeoutMs ?? 0; + this.upscaleSteps = opts.upscaleSteps ?? 12; + this.upscaleStrength = opts.upscaleStrength ?? 0.25; + } + + // -- probe --------------------------------------------------------------- + + /** + * Never throws. `ok` is false only for blocking problems (missing CLI, unusable + * models dir, missing draft/final model); everything else is reported but survivable. + */ + async probe(): Promise<{ ok: boolean; problems: string[] }> { + const problems: string[] = []; + let ok = true; + + // 1. The binary. + const cliOk = await this.cliAvailable(); + if (!cliOk) { + ok = false; + problems.push( + `Comando «${this.cli}» non trovato o non eseguibile. Installalo con: brew install draw-things-cli`, + ); + } + + // 2. The models directory. + const modelsPath = this.cfg.modelsPath; + if (!isAbsolute(modelsPath)) { + ok = false; + problems.push(`La cartella dei modelli deve essere un percorso assoluto: «${modelsPath}».`); + } else if (!(await isReadableDir(modelsPath))) { + ok = false; + problems.push(`Cartella dei modelli assente o non leggibile: «${modelsPath}».`); + } else { + // 3. The model files. draft/final are blocking, the rest degrade gracefully. + const roles: Array<[keyof typeof this.cfg.models, string, boolean]> = [ + ["draft", "bozza", true], + ["final", "finale", true], + ["alt", "stile alternativo", false], + ["edit", "modifica immagine", false], + ["upscale", "ingrandimento per la stampa", false], + ]; + for (const [key, label, blocking] of roles) { + const model = this.cfg.models[key]; + if (!model) { + if (blocking) { + ok = false; + problems.push(`Nessun modello configurato per «${label}».`); + } + continue; + } + if (isRemoteModelRef(model)) continue; // hf:// or URL: resolved at run time + const full = join(modelsPath, model); + if (!(await isNonEmptyFile(full))) { + if (blocking) ok = false; + problems.push( + `Modello «${model}» (${label}) non trovato in «${modelsPath}».` + + (blocking ? "" : " La funzione relativa non sarà disponibile."), + ); + } + } + } + + // 4. The resident daemon. Non-blocking: without it every run reloads the model, + // which is slow but correct. + if (this.cfg.server.enabled) { + const up = await probePort(this.cfg.server.port); + if (!up) { + problems.push( + `Server residente non raggiungibile su ${REMOTE_HOST}:${this.cfg.server.port}. ` + + "Senza di esso ogni generazione ricarica il modello da zero (molto più lenta). " + + `Avvialo con: gRPCServerCLI-macOS "${modelsPath}" --no-tls --port ${this.cfg.server.port}` + + (this.cfg.server.cpuOffload ? " --cpu-offload" : ""), + ); + } + } + + return { ok, problems }; + } + + private cliAvailable(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (v: boolean) => { + if (!settled) { + settled = true; + resolve(v); + } + }; + try { + const child = spawn(this.cli, ["--help"], { stdio: "ignore", env: process.env }); + const t = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + // It answered slowly, but it exists. + done(true); + }, 10_000); + t.unref?.(); + child.on("error", () => { + clearTimeout(t); + done(false); + }); + // Any exit code counts: some CLIs return non-zero for --help. + child.on("close", () => { + clearTimeout(t); + done(true); + }); + } catch { + done(false); + } + }); + } + + // -- generate ------------------------------------------------------------ + + async generate( + opts: GenerateOptions, + onProgress?: ProgressFn, + signal?: AbortSignal, + ): Promise { + if (!opts.outPath || !isAbsolute(opts.outPath)) { + throw new BackendError( + "outPath must be absolute", + "Percorso di destinazione non valido: serve un percorso assoluto per l'immagine.", + ); + } + + const usesImage = Boolean(opts.initImage); + const model = this.pickModel(opts.tier, usesImage); + await this.assertModelPresent(model); + + if (usesImage) { + const src = opts.initImage as string; + if (!(await isNonEmptyFile(src))) { + throw new BackendError( + `init image not found: ${src}`, + `Immagine di partenza non trovata o vuota: «${src}».`, + ); + } + } + + const width = snapToGrid(opts.width); + const height = snapToGrid(opts.height); + if ((width !== opts.width || height !== opts.height) && onProgress) { + onProgress( + `Dimensioni adattate alla griglia di ${SIZE_GRID}px: ${width}×${height} ` + + `(richiesti ${opts.width}×${opts.height}).`, + ); + } + + await mkdir(dirname(opts.outPath), { recursive: true }); + + const config: Record = { ...this.tilingPayload() }; + // `strength` belongs to JSGenerationConfiguration; there is no verified `--strength` + // flag on the CLI, so it travels in the merged partial config instead. + if (usesImage && typeof opts.strength === "number" && Number.isFinite(opts.strength)) { + config.strength = Math.min(1, Math.max(0, opts.strength)); + } + + const args = [ + "generate", + "--model", model, + "--prompt", opts.prompt, + "--negative-prompt", opts.negative ?? "", + "--width", String(width), + "--height", String(height), + "--steps", String(Math.max(1, Math.round(opts.steps))), + "--seed", String(Math.trunc(opts.seed)), + "--models-dir", this.cfg.modelsPath, + "--config-json", JSON.stringify(config), + // Non-negotiable: without --output nothing is written to disk at all. + "--output", opts.outPath, + "--disable-preview", + ]; + if (usesImage) args.push("--image", opts.initImage as string); + if (this.offline) args.push("--offline"); + args.push(...this.remoteArgs()); + + const outcome = await runCli({ + binary: this.cli, + args, + usesImage, + onProgress, + signal, + timeoutMs: this.timeoutMs, + }); + + await this.assertSuccess(outcome, opts.outPath, usesImage); + + const real = await readImageSize(opts.outPath); + return { + path: opts.outPath, + width: real?.width ?? width, + height: real?.height ?? height, + seed: Math.trunc(opts.seed), + model, + elapsedMs: outcome.elapsedMs, + }; + } + + // -- upscale ------------------------------------------------------------- + + /** + * Mandatory for the print path: a 1024px generation is roughly 62dpi across A3's long + * edge, i.e. unprintable. + * + * The configured upscale model is driven as the MAIN model over an `--image` input at + * low strength (restore detail, don't reinvent the poster). If the model name looks + * like a classic ESRGAN-family upscaler, we additionally set the `upscaler` / + * `upscalerScaleFactor` keys, which are that family's own lever in Draw Things. + * UNVERIFIED: which of the two routes a given catalogue entry expects; both keys merge + * harmlessly onto the model's recommended settings. + * + * Because this run uses `--image`, it is exposed to upstream issue #121. Print must + * not die on that bug, so a crash falls back to a high-quality Lanczos resample via + * sharp — announced in Italian through onProgress, never silently. + */ + async upscale( + input: string, + outPath: string, + factor: number, + onProgress?: ProgressFn, + signal?: AbortSignal, + ): Promise { + if (!Number.isFinite(factor) || factor <= 1) { + throw new BackendError( + `invalid upscale factor: ${factor}`, + `Fattore di ingrandimento non valido (${factor}): deve essere maggiore di 1.`, + ); + } + if (!isAbsolute(outPath)) { + throw new BackendError( + "outPath must be absolute", + "Percorso di destinazione non valido: serve un percorso assoluto per l'immagine ingrandita.", + ); + } + if (!(await isNonEmptyFile(input))) { + throw new BackendError( + `upscale input not found: ${input}`, + `Immagine da ingrandire non trovata o vuota: «${input}».`, + ); + } + + const model = this.cfg.models.upscale; + if (!model) { + throw new BackendError( + "no upscale model configured", + "Nessun modello di ingrandimento configurato: senza di esso la stampa resterebbe a bassa " + + "risoluzione. Imposta «models.upscale» nella configurazione.", + ); + } + await this.assertModelPresent(model); + + const src = await readImageSize(input); + if (!src) { + throw new BackendError( + `cannot read image size: ${input}`, + `Impossibile leggere le dimensioni di «${input}»: l'ingrandimento richiede un'immagine valida.`, + ); + } + + const targetW = snapToGrid(src.width * factor); + const targetH = snapToGrid(src.height * factor); + const longEdge = Math.max(targetW, targetH); + + await mkdir(dirname(outPath), { recursive: true }); + + onProgress?.( + `Ingrandimento ${src.width}×${src.height} → ${targetW}×${targetH} (×${factor})…`, + 0, + ); + + const config: Record = { ...this.tilingPayload() }; + // Memory safety: at 4× the VAE decode is what OOMs a 16GB machine, so tiling is + // forced on for large targets regardless of configuration. + if (longEdge > FORCE_TILING_ABOVE_PX && config.tiledDecoding !== true) { + config.tiledDecoding = true; + onProgress?.("Decodifica a tasselli attivata automaticamente per non saturare la memoria."); + } + config.strength = this.upscaleStrength; + if (/(esrgan|ultrasharp|remacri|swinir|\b[248]x\b)/i.test(model)) { + config.upscaler = model; + config.upscalerScaleFactor = Math.round(factor); + } + + const args = [ + "generate", + "--model", model, + // An upscaler needs no creative direction; an empty prompt keeps it faithful. + "--prompt", "", + "--negative-prompt", "", + "--width", String(targetW), + "--height", String(targetH), + "--steps", String(this.upscaleSteps), + "--seed", String(UPSCALE_SEED), + "--models-dir", this.cfg.modelsPath, + "--config-json", JSON.stringify(config), + "--image", input, + "--output", outPath, + "--disable-preview", + ]; + if (this.offline) args.push("--offline"); + args.push(...this.remoteArgs()); + + const started = Date.now(); + let outcome: RunOutcome; + try { + outcome = await runCli({ + binary: this.cli, + args, + usesImage: true, + onProgress, + signal, + timeoutMs: this.timeoutMs, + }); + await this.assertSuccess(outcome, outPath, true); + } catch (e) { + // An abort is the user's decision, not a failure to work around. + if (signal?.aborted) throw e; + const isKnownBug = e instanceof BackendError && /issue #121/.test(e.message); + if (!isKnownBug) throw e; + + onProgress?.( + "Ingrandimento generativo non disponibile (bug noto #121 sui Mac da 16GB): " + + "procedo con un ridimensionamento Lanczos di alta qualità, senza ricostruzione del dettaglio.", + ); + await resampleWithSharp(input, outPath, targetW, targetH); + const fallbackSize = await readImageSize(outPath); + return { + path: outPath, + width: fallbackSize?.width ?? targetW, + height: fallbackSize?.height ?? targetH, + seed: UPSCALE_SEED, + model: "lanczos(sharp)", + elapsedMs: Date.now() - started, + }; + } + + const real = await readImageSize(outPath); + return { + path: outPath, + width: real?.width ?? targetW, + height: real?.height ?? targetH, + seed: UPSCALE_SEED, + model, + elapsedMs: outcome.elapsedMs, + }; + } + + // -- internals ----------------------------------------------------------- + + /** Edit runs prefer the dedicated edit model; otherwise tier decides. */ + private pickModel(tier: GenerateOptions["tier"], usesImage: boolean): string { + if (usesImage && this.cfg.models.edit) return this.cfg.models.edit; + return tier === "draft" ? this.cfg.models.draft : this.cfg.models.final; + } + + private async assertModelPresent(model: string): Promise { + if (!model) { + throw new BackendError( + "no model configured", + "Nessun modello configurato per questa operazione.", + ); + } + if (isRemoteModelRef(model)) return; + const full = join(this.cfg.modelsPath, model); + if (!(await isNonEmptyFile(full))) { + throw new BackendError( + `model not found: ${full}`, + `Modello «${model}» non trovato in «${this.cfg.modelsPath}». ` + + "Scaricalo da Draw Things oppure correggi «modelsPath» nella configurazione.", + full, + ); + } + } + + /** + * The tiling lever: a PARTIAL JSGenerationConfiguration merged onto the model's + * recommended settings — never a complete config. + * + * UNVERIFIED: the units of decodingTileWidth / decodingTileHeight. Pixels are assumed + * here (the values in config default to 512/512 with 64 overlap, which reads as px); + * if they turn out to be latent units, these would each cover 8× the area. + */ + private tilingPayload(): Record { + const t = this.cfg.tiling; + if (!t?.tiledDecoding) return { tiledDecoding: false }; + return { + tiledDecoding: true, + decodingTileWidth: t.decodingTileWidth, + decodingTileHeight: t.decodingTileHeight, + decodingTileOverlap: t.decodingTileOverlap, + }; + } + + /** Flags that route the run through the resident, already-warm daemon. */ + private remoteArgs(): string[] { + if (!this.cfg.server?.enabled) return []; + return [ + "--remote", + "--remote-url", REMOTE_HOST, + "--remote-port", String(this.cfg.server.port), + "--no-remote-tls", + ]; + } + + /** + * Turns a finished child into either silence or a good Italian error. Order matters: + * the crash check comes first, because a crashed img2img run also has no output file + * and would otherwise be reported as the generic "no image produced". + */ + private async assertSuccess( + outcome: RunOutcome, + outPath: string, + usesImage: boolean, + ): Promise { + const failed = outcome.code !== 0 || outcome.signal !== null; + + if (failed && usesImage && crashLooksLikeIssue121(outcome)) { + throw issue121Error( + `exit=${outcome.code} signal=${outcome.signal}\n${tail(outcome.stderr, 2_000)}`, + ); + } + + if (failed) { + const how = + outcome.signal !== null + ? `interrotto dal segnale ${outcome.signal}` + : `uscito con codice ${outcome.code}`; + throw new BackendError( + `draw-things-cli failed (exit=${outcome.code} signal=${outcome.signal})`, + `draw-things-cli è ${how} senza completare la generazione.` + + (this.cfg.server?.enabled + ? ` Verifica che il server residente sia attivo su ${REMOTE_HOST}:${this.cfg.server.port}.` + : ""), + tail(outcome.stderr || outcome.stdout, 2_000), + ); + } + + // A zero exit code is not proof of a file: this is exactly the failure mode that a + // missing --output produces, so it is always checked. + if (!(await isNonEmptyFile(outPath))) { + if (usesImage && crashLooksLikeIssue121(outcome)) { + throw issue121Error(tail(outcome.stderr, 2_000)); + } + throw new BackendError( + `no output produced at ${outPath}`, + `Nessuna immagine prodotta in «${outPath}», nonostante il comando sia terminato senza errori.`, + tail(outcome.stderr || outcome.stdout, 2_000), + ); + } + } +} + +/** Non-generative fallback resize. Lanczos3 is the best of the cheap options. */ +async function resampleWithSharp( + input: string, + outPath: string, + width: number, + height: number, +): Promise { + try { + const sharpMod = await import("sharp"); + const sharp = (sharpMod as { default: typeof import("sharp") }).default ?? sharpMod; + await sharp(input).resize(width, height, { kernel: "lanczos3", fit: "fill" }).toFile(outPath); + } catch (e) { + throw new BackendError( + "sharp fallback resize failed", + "Ingrandimento non riuscito: né il modello generativo né il ridimensionamento di riserva " + + "hanno prodotto un'immagine utilizzabile per la stampa.", + (e as Error).message, + ); + } +} + +export function createDrawThingsBackend( + cfg: ImgenConfig, + opts?: DrawThingsOptions, +): DrawThingsBackend { + return new DrawThingsBackend(cfg, opts); +} + +export default DrawThingsBackend; diff --git a/extensions/imgen/backends/server.ts b/extensions/imgen/backends/server.ts new file mode 100644 index 0000000..2a57c6a --- /dev/null +++ b/extensions/imgen/backends/server.ts @@ -0,0 +1,811 @@ +/** + * Lifecycle for the resident `gRPCServerCLI-macOS` daemon. + * + * WHY A DAEMON AT ALL: `draw-things-cli` has no daemon mode — every invocation reloads + * the model from disk. The resident gRPC server keeps weights warm, which is the whole + * difference between a usable draft loop and a 40s-per-thumbnail one. The CLI talks to + * it with `--remote --remote-url 127.0.0.1 --remote-port N --no-remote-tls` + * (see `remoteFlags()` — the single place that knowledge lives). + * + * HARD RULE: exactly ONE daemon per machine. Two diffusion processes will OOM a 16GB + * Mac mini before either finishes a VAE decode. Every path here therefore prefers + * ADOPTING whatever already listens on the port over spawning a rival. + * + * pi RULE: nothing in this module runs at import time. `start()` is called from + * session_start, `stop()` from session_shutdown, and both are safe to call twice. + */ +import { spawn } from "node:child_process"; +import { createWriteStream, constants as FS } from "node:fs"; +import { + access, + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { createConnection } from "node:net"; +import { arch, platform } from "node:process"; +import { dirname, join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { setTimeout as delay } from "node:timers/promises"; + +import type { ImgenConfig } from "../config.ts"; +import { BackendError, type ProgressFn } from "./types.ts"; + +/* -------------------------------------------------------------------------- */ +/* Constants */ +/* -------------------------------------------------------------------------- */ + +/** + * Pinned release. gRPCServerCLI ships as a GitHub RELEASE ASSET only — it is NOT in the + * `draw-things-cli` Homebrew formula, so `brew install draw-things-cli` gives you the + * client and nothing else. + */ +export const GRPC_SERVER_VERSION = "v1.20260716.0"; +export const GRPC_SERVER_BINARY = "gRPCServerCLI-macOS"; +export const GRPC_SERVER_URL = + `https://github.com/drawthingsai/draw-things-community/releases/download/${GRPC_SERVER_VERSION}/${GRPC_SERVER_BINARY}`; + +/** Subdirectory of the pi config dir holding log, pid and lock files. */ +const STATE_DIR = "imgen"; +const LOG_NAME = "server.log"; +const PID_NAME = "server.pid"; +const LOCK_NAME = "server.lock"; + +/** Rotate at 2 MiB, keep 3 old generations. The daemon is chatty about model loads. */ +const MAX_LOG_BYTES = 2 * 1024 * 1024; +const LOG_GENERATIONS = 3; + +/** A start lock older than this is assumed to be from a crashed session. */ +const LOCK_STALE_MS = 60_000; + +const DEFAULT_READY_TIMEOUT_MS = 60_000; +const DEFAULT_GRACE_MS = 5_000; +const POLL_INTERVAL_MS = 250; +const CONNECT_TIMEOUT_MS = 750; + +/** Mach-O / universal-binary magic numbers, read big-endian. Guards against a + * download that is really an HTML error page or a Git LFS pointer. */ +const MACHO_MAGIC = new Set([0xfeedfacf, 0xcffaedfe, 0xfeedface, 0xcefaedfe, 0xcafebabe, 0xbebafeca]); + +/* -------------------------------------------------------------------------- */ +/* Public types */ +/* -------------------------------------------------------------------------- */ + +/** + * How the daemon on our port came to be. + * - `spawned` — this process started it; ours to kill. + * - `adopted-pidfile` — a previous pi session started it and left a pid file; ours. + * - `adopted-foreign` — something else listens there (the Draw Things GUI, a manual + * run). Usable, but `stop()` leaves it alone unless forced. + */ +export type Ownership = "spawned" | "adopted-pidfile" | "adopted-foreign"; + +export interface ServerStatus { + /** Something accepts TCP on the configured port. */ + running: boolean; + port: number; + ownership: Ownership | null; + pid: number | null; + binary: string; + logFile: string; + /** True when config.server.enabled is false — the daemon is deliberately off. */ + disabled: boolean; +} + +export interface StartResult { + status: ServerStatus; + /** What `start()` actually did. `disabled` and `already-running` do no work. */ + action: "spawned" | "adopted" | "already-running" | "disabled"; + /** Human-visible, Italian. Safe to show in a session banner. */ + message: string; +} + +export interface StopResult { + stopped: boolean; + /** Italian. Explains a `stopped: false` (nothing running / not ours). */ + message: string; +} + +export interface HealthReport { + ok: boolean; + /** All Italian; ready to print under `imgen doctor`. */ + problems: string[]; + status: ServerStatus; +} + +export interface EnsureBinaryOptions { + /** + * Downloads are NEVER silent: the caller must pass `true` after asking the user. + * With `false` a missing binary raises a BackendError explaining how to get it. + */ + allowDownload?: boolean; + onProgress?: ProgressFn; + signal?: AbortSignal; +} + +export interface EnsureBinaryResult { + path: string; + action: "present" | "downloaded"; + bytes?: number; +} + +export interface StartOptions { + /** Total budget for spawn + first TCP accept. */ + readyTimeoutMs?: number; + /** Passed through to `ensureBinary()`. Default false: never download unasked. */ + allowDownload?: boolean; + onProgress?: ProgressFn; +} + +/* -------------------------------------------------------------------------- */ +/* Small helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * CLI flags that point `draw-things-cli` at the warm daemon. Exported so the backend + * never has to re-derive them (and never forgets `--no-remote-tls`, which is required + * because we start the server with `--no-tls`). + */ +export function remoteFlags(cfg: ImgenConfig, host = "127.0.0.1"): string[] { + return [ + "--remote", + "--remote-url", host, + "--remote-port", String(cfg.server.port), + "--no-remote-tls", + ]; +} + +/** Resolved on-disk locations for a given config. Pure; touches nothing. */ +export function serverPaths(cfg: ImgenConfig, piConfigDir: string): { + stateDir: string; + binary: string; + logFile: string; + pidFile: string; + lockFile: string; +} { + const stateDir = join(piConfigDir, STATE_DIR); + return { + stateDir, + // A configured binary wins; otherwise we keep our own copy beside the state files + // rather than writing into a Homebrew prefix we do not own. + binary: cfg.server.binary ?? join(stateDir, GRPC_SERVER_BINARY), + logFile: join(stateDir, LOG_NAME), + pidFile: join(stateDir, PID_NAME), + lockFile: join(stateDir, LOCK_NAME), + }; +} + +/** True if a TCP connection to the port is accepted. Never throws. */ +export function isPortOpen(port: number, host = "127.0.0.1", timeoutMs = CONNECT_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port, host }); + let settled = false; + const finish = (ok: boolean): void => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + +/** `kill(pid, 0)`: EPERM means alive but not ours, which still counts as alive. */ +function pidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 1) return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function exists(p: string): Promise { + try { + await access(p, FS.F_OK); + return true; + } catch { + return false; + } +} + +async function isExecutable(p: string): Promise { + try { + await access(p, FS.X_OK); + return (await stat(p)).isFile(); + } catch { + return false; + } +} + +interface PidRecord { + pid: number; + port: number; + binary: string; + startedAt: string; +} + +async function readPidFile(pidFile: string): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(pidFile, "utf8")); + if (typeof parsed !== "object" || parsed === null) return null; + const rec = parsed as Partial; + if (typeof rec.pid !== "number" || typeof rec.port !== "number") return null; + return { + pid: rec.pid, + port: rec.port, + binary: typeof rec.binary === "string" ? rec.binary : "", + startedAt: typeof rec.startedAt === "string" ? rec.startedAt : "", + }; + } catch { + return null; + } +} + +/** Rotate server.log -> .1 -> .2 -> .3 once it grows past MAX_LOG_BYTES. */ +async function rotateLog(logFile: string): Promise { + try { + const st = await stat(logFile); + if (st.size < MAX_LOG_BYTES) return; + } catch { + return; // no log yet + } + await rm(`${logFile}.${LOG_GENERATIONS}`, { force: true }); + for (let i = LOG_GENERATIONS - 1; i >= 1; i--) { + try { + await rename(`${logFile}.${i}`, `${logFile}.${i + 1}`); + } catch { /* generation absent */ } + } + try { + await rename(logFile, `${logFile}.1`); + } catch { /* raced with another session; harmless */ } +} + +/* -------------------------------------------------------------------------- */ +/* DrawThingsServer */ +/* -------------------------------------------------------------------------- */ + +/** + * One instance per pi session. Construct it in session_start, keep it on the extension + * state, call `stop()` in session_shutdown. Construction itself does no I/O. + */ +export class DrawThingsServer { + readonly port: number; + private readonly paths: ReturnType; + private readonly cfg: ImgenConfig; + + /** Set only when THIS process spawned the daemon. */ + private childPid: number | null = null; + private ownership: Ownership | null = null; + /** In-process de-duplication: concurrent `start()` calls share one attempt. */ + private starting: Promise | null = null; + /** Flipped by the child's own `exit` event so `waitUntilReady` can fail fast. */ + private childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + + constructor(cfg: ImgenConfig, piConfigDir: string) { + this.cfg = cfg; + this.port = cfg.server.port; + this.paths = serverPaths(cfg, piConfigDir); + } + + get binaryPath(): string { return this.paths.binary; } + get logFile(): string { return this.paths.logFile; } + + /* ---------------------------------------------------------------- status */ + + /** Is a daemon accepting connections on our port? Says nothing about ownership. */ + async isRunning(): Promise { + return isPortOpen(this.port); + } + + async status(): Promise { + const running = await this.isRunning(); + let ownership = this.ownership; + let pid = this.childPid; + + if (running && ownership === null) { + const rec = await readPidFile(this.paths.pidFile); + if (rec && rec.port === this.port && pidAlive(rec.pid)) { + ownership = "adopted-pidfile"; + pid = rec.pid; + } else { + ownership = "adopted-foreign"; + } + } + if (!running) { ownership = null; pid = null; } + + return { + running, + port: this.port, + ownership, + pid, + binary: this.paths.binary, + logFile: this.paths.logFile, + disabled: !this.cfg.server.enabled, + }; + } + + /** + * Health check for `imgen doctor` and for session_start. Never throws. + * + * NOTE: readiness is a TCP accept, nothing more. Without the FlatBuffer schema we + * cannot speak gRPC to ask the daemon anything. UNVERIFIED: whether the daemon binds + * the port before or after it finishes scanning the models directory — in practice it + * binds early and loads weights lazily per request, so a first generation after a + * cold start is still slow. Treat "porta aperta" as "reachable", not "warm". + */ + async health(): Promise { + const problems: string[] = []; + // Only environment/binary faults are fatal. A daemon that is merely off is a + // slowdown, not a failure: draw-things-cli still runs cold. + let fatal = false; + const fail = (msg: string): void => { fatal = true; problems.push(msg); }; + const status = await this.status(); + + if (platform !== "darwin") { + fail("Il server Draw Things gira solo su macOS (Apple Silicon)."); + } else if (arch !== "arm64") { + fail("Serve un Mac Apple Silicon (arm64): non esiste una build Intel del server."); + } + + if (!(await exists(this.paths.binary))) { + fail(`Binario del server assente: ${this.paths.binary}. Scaricalo con \`imgen setup --download\` (${GRPC_SERVER_URL}).`); + } else if (!(await isExecutable(this.paths.binary))) { + fail(`Binario del server non eseguibile: ${this.paths.binary} (manca il permesso di esecuzione).`); + } + + if (!(await exists(this.cfg.modelsPath))) { + fail(`Cartella dei modelli inesistente: ${this.cfg.modelsPath}.`); + } + + if (status.disabled) { + problems.push("Server residente disattivato in configurazione: ogni generazione ricaricherà il modello da zero."); + } else if (!status.running) { + problems.push(`Nessun server in ascolto sulla porta ${this.port}: le generazioni partiranno a freddo.`); + } else if (status.ownership === "adopted-foreign") { + problems.push( + `Sulla porta ${this.port} risponde un processo che non abbiamo avviato noi: lo useremo così com'è e non lo fermeremo.`, + ); + } + + return { ok: !fatal, problems, status }; + } + + /* ----------------------------------------------------------------- start */ + + /** + * Idempotent. Adopts an existing listener, otherwise spawns one detached. + * Returns rather than throws for the "already fine" cases; throws BackendError only + * when it genuinely could not get a daemon up. + */ + async start(opts: StartOptions = {}): Promise { + if (this.starting) return this.starting; + this.starting = this.startOnce(opts).finally(() => { this.starting = null; }); + return this.starting; + } + + private async startOnce(opts: StartOptions): Promise { + if (!this.cfg.server.enabled) { + return { + status: await this.status(), + action: "disabled", + message: "Server residente disattivato in configurazione: si procede senza modello caldo.", + }; + } + + // Fast path: someone is already there. Adopt, never duplicate. + if (await this.isRunning()) return this.adoptResult("already-running"); + + if (platform !== "darwin") { + throw new BackendError( + "gRPCServerCLI requires macOS", + "Il server Draw Things gira solo su macOS: qui non può essere avviato.", + `platform=${platform} arch=${arch}`, + ); + } + + await mkdir(this.paths.stateDir, { recursive: true }); + const binary = (await this.ensureBinary({ + allowDownload: opts.allowDownload ?? false, + onProgress: opts.onProgress, + })).path; + + // Cross-session lock: two pi sessions opening at once must not both spawn. + const lockHeld = await this.acquireLock(); + try { + // Re-check under the lock: the other session may have just won the race. + if (await this.isRunning()) return this.adoptResult("already-running"); + if (!lockHeld) { + // Someone else holds a fresh lock and is mid-spawn. Wait for their daemon. + opts.onProgress?.("Un'altra sessione sta avviando il server, attendo…"); + const ready = await this.waitUntilReady(opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + if (ready) return this.adoptResult("adopted"); + throw new BackendError( + "another session holds the start lock but no server appeared", + `Un'altra sessione risulta stia avviando il server, ma la porta ${this.port} non ha mai risposto. ` + + `Se il problema persiste elimina ${this.paths.lockFile}.`, + ); + } + + await rotateLog(this.paths.logFile); + const args = this.spawnArgs(); + const logFd = await open(this.paths.logFile, "a"); + + this.childExit = null; + const child = spawn(binary, args, { + detached: true, // own process group: survives the terminal, and + stdio: ["ignore", logFd.fd, logFd.fd], // lets us signal the whole group on stop + }); + + child.once("error", (err) => { + this.childExit = { code: -1, signal: null }; + // Surfaced through waitUntilReady's log tail; nothing to do here. + void err; + }); + child.once("exit", (code, signal) => { this.childExit = { code, signal }; }); + + const pid = child.pid ?? null; + child.unref(); + await logFd.close(); + + if (pid === null) { + throw new BackendError( + "spawn returned no pid", + "Avvio del server fallito: il processo non è partito.", + `${binary} ${args.join(" ")}`, + ); + } + + this.childPid = pid; + this.ownership = "spawned"; + await writeFile( + this.paths.pidFile, + JSON.stringify({ pid, port: this.port, binary, startedAt: new Date().toISOString() } satisfies PidRecord, null, 2), + "utf8", + ); + + const ready = await this.waitUntilReady(opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + if (!ready) { + const tail = await this.tailLog(20); + await this.stop({ force: true }).catch(() => undefined); + throw new BackendError( + "gRPCServerCLI did not start listening in time", + `Il server non ha risposto sulla porta ${this.port} entro il tempo previsto. ` + + `Log: ${this.paths.logFile}`, + tail, + ); + } + + return { + status: await this.status(), + action: "spawned", + message: `Server Draw Things avviato sulla porta ${this.port} (pid ${pid}).`, + }; + } finally { + if (lockHeld) await this.releaseLock(); + } + } + + /** Exact argv for the daemon. Kept separate so the doctor can print it verbatim. */ + spawnArgs(): string[] { + const args = [this.cfg.modelsPath, "--no-tls", "--port", String(this.port)]; + // "offload some weights to CPU during inference" — undocumented in the README and + // effectively mandatory at 16GB. + if (this.cfg.server.cpuOffload) args.push("--cpu-offload"); + // Deliberately NOT passing -w/--weights-cache: the default of 0 GiB is correct on a + // 16GB machine, and raising it trades away exactly the RAM the VAE decode needs. + return args; + } + + /** Poll until the port accepts a connection, the child dies, or the budget expires. */ + async waitUntilReady(timeoutMs = DEFAULT_READY_TIMEOUT_MS, signal?: AbortSignal): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (signal?.aborted) return false; + if (await isPortOpen(this.port)) return true; + // A child that already exited will never open the port; stop waiting 60s for it. + if (this.childExit !== null) return false; + if (Date.now() >= deadline) return false; + await delay(POLL_INTERVAL_MS); + } + } + + private async adoptResult(action: "already-running" | "adopted"): Promise { + const status = await this.status(); + if (status.ownership === "adopted-pidfile" || status.ownership === "adopted-foreign") { + this.ownership = status.ownership; + this.childPid = status.pid; + } + const who = status.ownership === "adopted-foreign" ? " (avviato da un altro processo)" : ""; + return { + status, + action, + message: `Server Draw Things già attivo sulla porta ${this.port}${who}: riutilizzato.`, + }; + } + + /* ------------------------------------------------------------------ stop */ + + /** + * Graceful SIGTERM, then SIGKILL. Safe when nothing is running. + * A foreign daemon (GUI, manual run) is left alone unless `force` is set — killing + * the user's own Draw Things session out from under them would be rude. + */ + async stop(opts: { graceMs?: number; force?: boolean } = {}): Promise { + const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS; + const status = await this.status(); + + if (!status.running) { + await rm(this.paths.pidFile, { force: true }); + this.childPid = null; + this.ownership = null; + this.childExit = null; + return { stopped: false, message: "Nessun server da fermare." }; + } + + if (status.ownership === "adopted-foreign" && !opts.force) { + return { + stopped: false, + message: `Il server sulla porta ${this.port} non è stato avviato da noi: lasciato in esecuzione.`, + }; + } + + const pid = status.pid; + if (pid === null) { + return { + stopped: false, + message: `Server attivo sulla porta ${this.port} ma senza pid noto: fermalo a mano.`, + }; + } + + this.signal(pid, "SIGTERM"); + const deadline = Date.now() + graceMs; + while (Date.now() < deadline) { + if (!pidAlive(pid) && !(await isPortOpen(this.port))) break; + await delay(POLL_INTERVAL_MS); + } + + let forced = false; + if (pidAlive(pid)) { + this.signal(pid, "SIGKILL"); + forced = true; + // Give the kernel a moment to reap before we report success. + for (let i = 0; i < 8 && pidAlive(pid); i++) await delay(POLL_INTERVAL_MS); + } + + await rm(this.paths.pidFile, { force: true }); + this.childPid = null; + this.ownership = null; + this.childExit = null; + + return { + stopped: true, + message: forced + ? `Server Draw Things terminato forzatamente (pid ${pid}).` + : `Server Draw Things fermato (pid ${pid}).`, + }; + } + + /** + * Signal the process GROUP first — we spawn with `detached: true`, so the daemon is a + * group leader and any helper it forked dies with it. Falls back to the bare pid for + * an adopted daemon that may not lead its own group. + */ + private signal(pid: number, sig: NodeJS.Signals): void { + try { + process.kill(-pid, sig); + return; + } catch { /* not a group leader, or already gone */ } + try { + process.kill(pid, sig); + } catch { /* already gone */ } + } + + /* -------------------------------------------------------------- binaries */ + + /** + * Make sure the server binary exists and is executable. NEVER downloads unless the + * caller passes `allowDownload: true` — that flag is the user's answer to a prompt, + * not a default. + */ + async ensureBinary(opts: EnsureBinaryOptions = {}): Promise { + const target = this.paths.binary; + + if (await exists(target)) { + if (!(await isExecutable(target))) { + // Most common cause: a manual `curl -O` that never chmod'ed. Fix it in place. + await chmod(target, 0o755).catch(() => undefined); + } + if (!(await isExecutable(target))) { + throw new BackendError( + `server binary is not executable: ${target}`, + `Il binario del server non è eseguibile: ${target}. Prova con \`chmod +x "${target}"\`.`, + ); + } + return { path: target, action: "present" }; + } + + if (!opts.allowDownload) { + throw new BackendError( + `server binary missing: ${target}`, + `Manca il binario del server (${GRPC_SERVER_BINARY}). Non lo scarico senza il tuo consenso: ` + + `esegui \`imgen setup --download\` oppure scaricalo a mano da ${GRPC_SERVER_URL} e mettilo in ${target}. ` + + `Nota: \`brew install draw-things-cli\` installa solo il client, non il server.`, + ); + } + + const bytes = await this.downloadBinary(target, opts.onProgress, opts.signal); + return { path: target, action: "downloaded", bytes }; + } + + private async downloadBinary(target: string, onProgress?: ProgressFn, signal?: AbortSignal): Promise { + await mkdir(dirname(target), { recursive: true }); + const tmp = `${target}.part-${process.pid}`; + onProgress?.(`Scarico ${GRPC_SERVER_BINARY} ${GRPC_SERVER_VERSION}…`, 0); + + let written = 0; + try { + const res = await fetch(GRPC_SERVER_URL, { redirect: "follow", signal }); + if (!res.ok || res.body === null) { + throw new BackendError( + `download failed: HTTP ${res.status}`, + `Scaricamento del server fallito (HTTP ${res.status}). Controlla la connessione o scarica a mano da ${GRPC_SERVER_URL}.`, + ); + } + const totalHeader = res.headers.get("content-length"); + const total = totalHeader === null ? 0 : Number.parseInt(totalHeader, 10); + + const source = Readable.fromWeb(res.body as Parameters[0]); + source.on("data", (chunk: Buffer) => { + written += chunk.length; + if (total > 0) { + onProgress?.(`Scarico ${GRPC_SERVER_BINARY}…`, Math.min(1, written / total)); + } + }); + await pipeline(source, createWriteStream(tmp, { mode: 0o755 })); + + await this.verifyMachO(tmp); + await chmod(tmp, 0o755); + await rename(tmp, target); + } catch (e) { + await unlink(tmp).catch(() => undefined); + if (e instanceof BackendError) throw e; + throw new BackendError( + `download failed: ${(e as Error).message}`, + `Scaricamento del server fallito. Scaricalo a mano da ${GRPC_SERVER_URL} e mettilo in ${target}.`, + (e as Error).message, + ); + } + + // ASSUMPTION (unverified): a file fetched by Node is not flagged with + // com.apple.quarantine the way a browser download is, so Gatekeeper should not + // block it. We clear the attribute best-effort anyway; failure is not fatal. + await this.clearQuarantine(target); + + if (!(await isExecutable(target))) { + throw new BackendError( + "downloaded binary is not executable", + `Il binario scaricato non risulta eseguibile: ${target}.`, + ); + } + onProgress?.(`${GRPC_SERVER_BINARY} installato in ${target}.`, 1); + return written; + } + + /** Reject an HTML error page or LFS pointer masquerading as a 200 OK. */ + private async verifyMachO(file: string): Promise { + const st = await stat(file); + if (st.size < 1024 * 1024) { + throw new BackendError( + `downloaded file is implausibly small (${st.size} bytes)`, + `Il file scaricato è troppo piccolo (${st.size} byte) per essere il server: probabilmente è una pagina di errore.`, + ); + } + const fh = await open(file, "r"); + try { + const head = Buffer.alloc(4); + await fh.read(head, 0, 4, 0); + if (!MACHO_MAGIC.has(head.readUInt32BE(0))) { + throw new BackendError( + "downloaded file is not a Mach-O binary", + "Il file scaricato non è un eseguibile macOS valido: scaricamento annullato.", + `magic=0x${head.readUInt32BE(0).toString(16)}`, + ); + } + } finally { + await fh.close(); + } + } + + private async clearQuarantine(target: string): Promise { + if (platform !== "darwin") return; + await new Promise((resolve) => { + const p = spawn("/usr/bin/xattr", ["-d", "com.apple.quarantine", target], { stdio: "ignore" }); + p.once("error", () => resolve()); + p.once("exit", () => resolve()); + }); + } + + /* ------------------------------------------------------------------ misc */ + + /** Last `lines` lines of the daemon log — the only diagnostics gRPC denies us. */ + async tailLog(lines = 40): Promise { + try { + const text = await readFile(this.paths.logFile, "utf8"); + return text.split("\n").slice(-lines).join("\n").trim(); + } catch { + return ""; + } + } + + /* ------------------------------------------------------------------ lock */ + + /** O_EXCL lock file. Returns false when a FRESH lock is held elsewhere. */ + private async acquireLock(): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const fh = await open(this.paths.lockFile, "wx"); + await fh.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); + await fh.close(); + return true; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") return false; + let stale = true; + try { + stale = Date.now() - (await stat(this.paths.lockFile)).mtimeMs > LOCK_STALE_MS; + } catch { + stale = true; // vanished between calls: try again + } + if (!stale) return false; + await rm(this.paths.lockFile, { force: true }); + } + } + return false; + } + + private async releaseLock(): Promise { + await rm(this.paths.lockFile, { force: true }); + } +} + +/* -------------------------------------------------------------------------- */ +/* Convenience wrappers */ +/* -------------------------------------------------------------------------- */ + +/** + * One-shot helpers for callers that do not want to hold an instance (e.g. a `doctor` + * command). Constructing a DrawThingsServer does no I/O, so this is cheap. + */ +export function createServer(cfg: ImgenConfig, piConfigDir: string): DrawThingsServer { + return new DrawThingsServer(cfg, piConfigDir); +} + +/** session_start helper: never throws, returns an Italian line to show the user. */ +export async function startForSession( + cfg: ImgenConfig, + piConfigDir: string, + opts: StartOptions = {}, +): Promise<{ server: DrawThingsServer; message: string; ok: boolean }> { + const server = createServer(cfg, piConfigDir); + try { + const res = await server.start(opts); + return { server, message: res.message, ok: res.action !== "disabled" }; + } catch (e) { + const message = e instanceof BackendError + ? e.italian + : `Avvio del server non riuscito: ${(e as Error).message}`; + // A dead daemon is not fatal: draw-things-cli still runs cold, just slowly. + return { server, message: `${message} Si continuerà senza modello caldo (più lento).`, ok: false }; + } +} diff --git a/native/cutout.swift b/native/cutout.swift new file mode 100644 index 0000000..888ab89 --- /dev/null +++ b/native/cutout.swift @@ -0,0 +1,132 @@ +// +// cutout.swift — subject cutout (background removal) via Apple Vision. +// +// Why this and not rembg/U^2-Net: VNGenerateForegroundInstanceMaskRequest is built into +// macOS, runs on the ANE/GPU, downloads nothing, adds no Python, and carries no licence +// question at all. rembg is only the fallback (see backends/cpu.ts) because its DEFAULT +// model, bria-rmbg, is CC BY-NC 4.0 and therefore unusable for a paying client's poster. +// +// BUILD (universal-arm64, ~1s): +// +// swiftc -O -whole-module-optimization \ +// -target arm64-apple-macos14.0 \ +// -framework Vision -framework CoreImage -framework ImageIO -framework CoreGraphics \ +// -o vendor/bin/cutout native/cutout.swift +// +// backends/cpu.ts runs exactly that command for you (buildCutout()) whenever the binary +// is missing and `swiftc` is on PATH — Xcode Command Line Tools are enough, no full Xcode. +// +// USAGE +// cutout [--crop] [--largest] +// +// --crop crop the result to the subjects' bounding box instead of keeping the +// original canvas size (handy for logos, wrong for compositing). +// --largest keep only the biggest instance instead of every detected subject. +// +// EXIT CODES (backends/cpu.ts maps these to Italian messages — keep them in sync) +// 0 ok · 2 bad usage · 3 macOS too old (needs 14) · 4 no subject found +// 5 cannot read input · 6 Vision request failed · 7 cannot write output +// +// NOTE: the input is expected to be already upright. cpu.ts normalises EXIF orientation +// with sharp before calling, because it is NOT verified here whether the pixel buffer +// returned by generateMaskedImage is in original or in oriented coordinates. +// + +import Foundation +import Vision +import CoreImage +import CoreGraphics + +func fail(_ message: String, _ code: Int32) -> Never { + FileHandle.standardError.write(Data(("cutout: " + message + "\n").utf8)) + exit(code) +} + +let argv = CommandLine.arguments +let positional = argv.dropFirst().filter { !$0.hasPrefix("--") } +let flags = Set(argv.dropFirst().filter { $0.hasPrefix("--") }) + +guard positional.count == 2 else { + fail("uso: cutout [--crop] [--largest]", 2) +} + +let inputURL = URL(fileURLWithPath: positional[positional.startIndex]) +let outputURL = URL(fileURLWithPath: positional[positional.index(after: positional.startIndex)]) +let cropToExtent = flags.contains("--crop") +let onlyLargest = flags.contains("--largest") + +guard #available(macOS 14.0, *) else { + fail("serve macOS 14 o superiore per il ritaglio automatico del soggetto.", 3) +} + +guard FileManager.default.isReadableFile(atPath: inputURL.path) else { + fail("immagine non leggibile: \(inputURL.path)", 5) +} + +let handler = VNImageRequestHandler(url: inputURL, options: [:]) +let request = VNGenerateForegroundInstanceMaskRequest() + +do { + try handler.perform([request]) +} catch { + fail("analisi Vision fallita: \(error.localizedDescription)", 6) +} + +guard let observation = request.results?.first, !observation.allInstances.isEmpty else { + fail("nessun soggetto riconosciuto nell'immagine.", 4) +} + +var instances = observation.allInstances + +// --largest: Vision numbers instances from 1; instance 0 is the background and is never +// part of allInstances. Pick the one whose mask covers the most pixels. +if onlyLargest && instances.count > 1 { + var best = instances.first! + var bestArea = -1.0 + for index in instances { + guard let buffer = try? observation.generateScaledMaskForImage(forInstances: IndexSet(integer: index), + from: handler) else { continue } + CVPixelBufferLockBaseAddress(buffer, .readOnly) + let width = CVPixelBufferGetWidth(buffer) + let height = CVPixelBufferGetHeight(buffer) + let stride = CVPixelBufferGetBytesPerRow(buffer) + var area = 0.0 + if let base = CVPixelBufferGetBaseAddress(buffer) { + let floats = base.assumingMemoryBound(to: Float.self) + let perRow = stride / MemoryLayout.size + for y in 0.. 0.5 { area += 1 } + } + } + CVPixelBufferUnlockBaseAddress(buffer, .readOnly) + if area > bestArea { bestArea = area; best = index } + } + instances = IndexSet(integer: best) +} + +var masked: CVPixelBuffer +do { + masked = try observation.generateMaskedImage(ofInstances: instances, + from: handler, + croppedToInstancesExtent: cropToExtent) +} catch { + fail("creazione della maschera fallita: \(error.localizedDescription)", 6) +} + +let image = CIImage(cvPixelBuffer: masked) +let context = CIContext(options: [.useSoftwareRenderer: false]) +guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + fail("spazio colore sRGB non disponibile.", 7) +} + +do { + try context.writePNGRepresentation(of: image, + to: outputURL, + format: .RGBA8, + colorSpace: colorSpace) +} catch { + fail("impossibile scrivere \(outputURL.path): \(error.localizedDescription)", 7) +} + +// Machine-readable one-liner for the caller. +print("{\"instances\":\(instances.count),\"cropped\":\(cropToExtent)}") diff --git a/templates/lib.typ b/templates/lib.typ new file mode 100644 index 0000000..4f33727 --- /dev/null +++ b/templates/lib.typ @@ -0,0 +1,482 @@ +// lib.typ — shared kernel imported by every template in templates/. +// +// Templates are fixed and reviewed; the LLM never writes Typst. Everything here reads +// the ResolvedSpec (see extensions/imgen/design/spec.ts) as DATA, loaded once by the +// template with: +// +// #let spec = json(sys.inputs.specfile) +// +// Two invariants this file must never break: +// 1. Determinism. No randomness, no dates, no system fonts, no wall-clock. Combined +// with --ignore-system-fonts + SOURCE_DATE_EPOCH + `#set document(date: none)` +// the output is byte-identical across runs, which is what makes the golden-file +// tests meaningful. +// 2. Resolution independence. Sizes are expressed as fractions of the trim, so the +// same template is correct at 1080 px and at 300 dpi A3. +// +// Colour note: this is an RGB pipeline on purpose. Never introduce cmyk() — its +// gradients render wrong (typst#4422) and CMYK ICC images produce PDFs Acrobat +// refuses to open (typst#3781). + +// --------------------------------------------------------------------------- +// Internal helpers — tolerant readers for a spec whose optional fields may be +// missing entirely, or present but JSON `null` (which reaches Typst as `none`). +// --------------------------------------------------------------------------- + +/// Read `key` from dictionary `d`, falling back to `default` when the dictionary is +/// not a dictionary, the key is absent, or the value is `none`. +#let _get(d, key, default) = { + if type(d) != dictionary { return default } + let v = d.at(key, default: default) + if v == none { default } else { v } +} + +/// Read a nested path, e.g. `_dig(spec, ("page", "bleedMm"), 0)`. Same tolerance as +/// `_get` at every level, so a missing `page` object degrades instead of panicking. +#let _dig(d, path, default) = { + let cur = d + for key in path { + if type(cur) != dictionary { return default } + cur = cur.at(key, default: none) + if cur == none { return default } + } + cur +} + +/// Parse a spec hex string into a colour. Anything unparseable falls back rather than +/// killing the render — a wrong colour is recoverable, a failed poster is not. +#let hex(v, fallback: black) = { + if type(v) == color { return v } + if type(v) != str { return fallback } + let s = v.trim() + if not s.starts-with("#") { return fallback } + if s.len() in (4, 7, 9) { rgb(s) } else { fallback } +} + +/// The four spec colours, always complete. `ink_resolved` is the contrast-checked ink +/// the renderer computed; it wins over the art director's `palette.ink`. +#let palette-of(spec) = { + let p = _get(spec, "palette", (:)) + let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111")) + ( + bg: hex(_get(p, "bg", none), fallback: rgb("#ffffff")), + ink: ink, + accent: hex(_get(p, "accent", none), fallback: ink), + scrim: hex(_get(p, "scrim", none), fallback: rgb("#000000")), + ) +} + +/// Font family for a role group: "display" for headlines, "body" for everything else. +/// Returns `none` when the spec omits it, which callers pass straight through (Typst +/// then keeps the inherited family instead of erroring on `font: none`). +#let font-of(spec, kind) = { + let v = _dig(spec, ("fonts", kind), none) + if type(v) == str and v.trim() != "" { v } else { none } +} + +/// Variable-font axes usable for `family`, as `(wdth: (62, 125), wght: (100, 900))`. +/// Optional: the renderer may inject `spec.font_axes` (family -> axis ranges) from +/// design/fonts.ts. Absent, this returns `(:)` and `fit` simply bisects on size. +#let font-axes(spec, family) = { + if type(family) != str { return (:) } + let table = _get(spec, "font_axes", (:)) + if type(table) != dictionary { return (:) } + let a = table.at(family, default: (:)) + if type(a) == dictionary { a } else { (:) } +} + +/// Trim size in mm, as plain numbers. Bleed deliberately excluded: every ratio in this +/// file is a fraction of the TRIM, so adding bleed never changes how big the title is. +#let trim-mm(spec) = ( + width: _dig(spec, ("page", "widthMm"), 210), + height: _dig(spec, ("page", "heightMm"), 297), +) + +/// Short edge of the trim, in mm. The scale reference for every type ratio and for the +/// logo, so a portrait and a landscape poster get proportionally the same headline. +#let short-edge-mm(spec) = { + let t = trim-mm(spec) + calc.min(t.width, t.height) +} + +/// Normalise an axis range from the spec: `(62, 125)`, `(min: 62, max: 125)` or a bare +/// number all become a `(lo, hi)` pair; anything else becomes `none`. +#let _axis-range(a) = { + if a == none { return none } + if type(a) == array and a.len() >= 2 { return (a.at(0), a.at(1)) } + if type(a) == dictionary { + let lo = a.at("min", default: a.at("lo", default: none)) + let hi = a.at("max", default: a.at("hi", default: none)) + if lo != none and hi != none { return (lo, hi) } + } + if type(a) in (int, float) { return (a, a) } + none +} + +/// Build the `text()` arguments dictionary, skipping every key the caller left `none` +/// so we never hand Typst `font: none` or an empty `variations`. +#let _text-args(family, size, weight, tracking, fill, wdth) = { + let args = (size: size) + if family != none { args.insert("font", family) } + if weight != none { args.insert("weight", weight) } + if tracking != none { args.insert("tracking", tracking) } + if fill != none { args.insert("fill", fill) } + if wdth != none { args.insert("variations", (wdth: wdth)) } + args +} + +// --------------------------------------------------------------------------- +// fit — auto-fit text into a box +// --------------------------------------------------------------------------- + +/// Fit `body` into a `w` x `h` box, returning the styled content. +/// +/// `w` and `h` MUST be absolute lengths (mm/pt). Inside `page(background:)` or a +/// `layout()` block use `safe-area(spec)`'s mm keys; percentages cannot be measured. +/// +/// Strategy, and the whole reason this helper exists: when `family` exposes a `wdth` +/// axis we bisect on WIDTH FIRST, keeping the requested size and narrowing the letters +/// until the line fits. Narrowing preserves a poster's optical weight — the headline +/// still fills its band and still reads from across the piazza. Shrinking destroys it: +/// a 40 % smaller title is a 40 % quieter poster. Only once the axis bottoms out at its +/// minimum do we fall back to bisecting the size. +/// +/// - body: content to typeset +/// - w, h: absolute box size +/// - family: font family string, or `none` to inherit +/// - axes: axis ranges for that family, e.g. `(wdth: (62, 125))` — `none`/`(:)` is fine +/// - size: the ideal (maximum) size; the fit never grows past it +/// - min-size: hard floor; defaults to 28 % of `size`, never below 6pt +/// - steps: bisection iterations (~12 is well past visual convergence) +#let fit( + body, + w, + h, + family, + axes, + size: 72pt, + min-size: none, + weight: none, + tracking: none, + fill: none, + leading: 0.34em, + justify: false, + align-to: left, + steps: 12, +) = context { + let axes = if type(axes) == dictionary { axes } else { (:) } + let wdth = _axis-range(axes.at("wdth", default: none)) + let floor = if min-size != none { min-size } else { + calc.max(6pt, size * 0.28) + } + let floor = calc.min(floor, size) + + // One trial layout, measured inside the real box. `measure` with a region makes the + // paragraph wrap exactly as it will on the page, so multi-line titles are handled. + let trial(sz, wd) = { + set par(leading: leading, linebreaks: "optimized", justify: justify) + set align(align-to) + text(.._text-args(family, sz, weight, tracking, fill, wd), body) + } + let fits(sz, wd) = { + let m = measure(width: w, height: h, trial(sz, wd)) + // 0.01pt slack absorbs the last bit of floating-point noise in the bisection. + m.width <= w + 0.01pt and m.height <= h + 0.01pt + } + + let final-size = size + let final-wdth = none + + if wdth != none and wdth.at(0) < wdth.at(1) { + let lo = calc.min(wdth.at(0), wdth.at(1)) // narrowest, most likely to fit + let hi = calc.max(wdth.at(0), wdth.at(1)) // widest, what we would rather keep + if fits(size, hi) { + final-wdth = hi + } else if not fits(size, lo) { + // The axis bottomed out: stay narrow and buy the rest back by shrinking. + final-wdth = lo + let slo = floor + let shi = size + let i = 0 + while i < steps { + let mid = (slo + shi) / 2 + if fits(mid, lo) { slo = mid } else { shi = mid } + i += 1 + } + final-size = slo + } else { + // Widest that still fits at full size. + let i = 0 + while i < steps { + let mid = (lo + hi) / 2 + if fits(size, mid) { lo = mid } else { hi = mid } + i += 1 + } + final-wdth = lo + } + } else { + if wdth != none { final-wdth = wdth.at(0) } + if not fits(size, final-wdth) { + let slo = floor + let shi = size + let i = 0 + while i < steps { + let mid = (slo + shi) / 2 + if fits(mid, final-wdth) { slo = mid } else { shi = mid } + i += 1 + } + final-size = slo + } + } + + trial(final-size, final-wdth) +} + +// --------------------------------------------------------------------------- +// scrim — the gradient wash that buys legibility over busy artwork +// --------------------------------------------------------------------------- + +/// A gradient rectangle drawn BEHIND a text band: opaque at the band's outer edge, +/// fading to nothing over the artwork. Draw it, then the text, then nothing else. +/// +/// - height: band height (absolute length or a ratio of the container) +/// - colour: the wash colour — normally `palette.scrim`; `none` falls back to black +/// - angle: gradient direction. `none` -> 90deg, i.e. transparent at top, solid at the +/// bottom, which is what a bottom-anchored text band wants. Use 270deg for a top band. +/// +/// The mid stop is deliberately not linear: a straight ramp reads as a grey haze across +/// the whole image, whereas holding the fade back until ~45 % keeps the artwork clean. +#let scrim(height, colour, angle, width: 100%, strength: 100%) = { + let c = if colour == none { black } else { hex(colour, fallback: black) } + let a = if angle == none { 90deg } else { angle } + let solid = if strength >= 100% { c } else { c.transparentize(100% - strength) } + rect( + width: width, + height: height, + stroke: none, + fill: gradient.linear( + (c.transparentize(100%), 0%), + (c.transparentize(78%), 45%), + (c.transparentize(30%), 75%), + (solid, 100%), + angle: a, + ), + ) +} + +// --------------------------------------------------------------------------- +// safe-area — the usable rect +// --------------------------------------------------------------------------- + +/// The rect that content may occupy: the trim, inset by the safe margin. +/// +/// Returns absolute mm lengths AND fractions, because the two live in different +/// coordinate systems and mixing them is the classic bleed bug: +/// * `x`/`y`/`width`/`height` are offsets from the FULL page origin (top-left of the +/// bleed), which is exactly what `page(background:)` and `page(foreground:)` +/// resolve against. +/// * `fx`/`fy`/`fw`/`fh` are the same rect as fractions of the FULL page, for +/// percentage-based placement. +/// * `tw`/`th` are the safe rect as fractions of the TRIM — the resolution-independent +/// numbers to build layout ratios from. +#let safe-area(spec) = { + let t = trim-mm(spec) + let bleed = _dig(spec, ("page", "bleedMm"), 0) + let safe = _dig(spec, ("page", "safeMm"), 0) + + // A safe margin can never eat more than 40 % of an edge, whatever the config says. + let safe = calc.max(0, calc.min(safe, calc.min(t.width, t.height) * 0.4)) + let bleed = calc.max(0, bleed) + + let full-w = t.width + 2 * bleed + let full-h = t.height + 2 * bleed + let inset = bleed + safe + let usable-w = t.width - 2 * safe + let usable-h = t.height - 2 * safe + + ( + // absolute, from the full-page origin + x: inset * 1mm, + y: inset * 1mm, + width: usable-w * 1mm, + height: usable-h * 1mm, + // the boxes this rect sits inside + trim-width: t.width * 1mm, + trim-height: t.height * 1mm, + full-width: full-w * 1mm, + full-height: full-h * 1mm, + bleed: bleed * 1mm, + safe: safe * 1mm, + short-edge: calc.min(t.width, t.height) * 1mm, + // fractions of the full page (bleed included) + fx: inset / full-w * 100%, + fy: inset / full-h * 100%, + fw: usable-w / full-w * 100%, + fh: usable-h / full-h * 100%, + // fractions of the trim — the stable numbers for layout ratios + tw: usable-w / t.width * 100%, + th: usable-h / t.height * 100%, + ) +} + +// --------------------------------------------------------------------------- +// crop-marks +// --------------------------------------------------------------------------- + +/// Eight short rules marking the trim corners, for `page(foreground: ...)`. +/// +/// Off by default (`spec.page.cropMarks`), because web-to-print services reject them; +/// offset houses want them. Each mark runs from a trim corner outward to the page edge, +/// so it lives entirely in the bleed and never touches the trimmed piece. With no bleed +/// there is nowhere to put them and nothing is drawn. +/// +/// Drawn in plain black: this is an RGB pipeline, and cmyk() registration black is +/// exactly the kind of colour that breaks the PDF (see the header note). +#let crop-marks(spec) = { + if not _dig(spec, ("page", "cropMarks"), false) { return none } + let bleed = _dig(spec, ("page", "bleedMm"), 0) + if bleed <= 0 { return none } + + let t = trim-mm(spec) + let b = bleed * 1mm + let tw = t.width * 1mm + let th = t.height * 1mm + let s = 0.25pt + black + let hmark(x, y) = place(dx: x, dy: y, line(length: b, angle: 0deg, stroke: s)) + let vmark(x, y) = place(dx: x, dy: y, line(length: b, angle: 90deg, stroke: s)) + + hmark(0pt, b) // top-left, horizontal + vmark(b, 0pt) // top-left, vertical + hmark(b + tw, b) // top-right, horizontal + vmark(b + tw, 0pt) // top-right, vertical + hmark(0pt, b + th) // bottom-left, horizontal + vmark(b, b + th) // bottom-left, vertical + hmark(b + tw, b + th) // bottom-right, horizontal + vmark(b + tw, b + th) // bottom-right, vertical +} + +// --------------------------------------------------------------------------- +// block-style — role to typography +// --------------------------------------------------------------------------- + +// Ratios are fractions of the TRIM'S SHORT EDGE, so a headline is the same physical +// fraction of the poster at any format or dpi. The title dominates by roughly 8x the +// footer, which is what makes a poster readable at distance and legal up close. +#let _ROLE-STYLES = ( + title: (ratio: 0.130, kind: "display", weight: 800, tracking: -0.015em, leading: 0.30em, upper: true, ink: "ink"), + subtitle: (ratio: 0.052, kind: "display", weight: 500, tracking: 0.000em, leading: 0.42em, upper: false, ink: "ink"), + date: (ratio: 0.046, kind: "body", weight: 700, tracking: 0.020em, leading: 0.40em, upper: true, ink: "accent"), + venue: (ratio: 0.036, kind: "body", weight: 500, tracking: 0.030em, leading: 0.45em, upper: true, ink: "ink"), + details: (ratio: 0.024, kind: "body", weight: 400, tracking: 0.010em, leading: 0.60em, upper: false, ink: "ink"), + price: (ratio: 0.032, kind: "body", weight: 700, tracking: 0.020em, leading: 0.45em, upper: false, ink: "accent"), + footer: (ratio: 0.016, kind: "body", weight: 400, tracking: 0.060em, leading: 0.70em, upper: true, ink: "muted"), +) + +/// Typography for a block role. Unknown roles degrade to `details` rather than failing. +/// +/// Returns: +/// ratio fraction of the short edge used for `size` +/// size absolute ideal size (the maximum `fit` will use) +/// min-size floor for the auto-fit +/// family resolved family name, or `none` to inherit +/// axes that family's variable axes, ready for `fit` +/// upper whether the template should uppercase the text +/// args spreadable straight into `fit`: `fit(body, w, h, st.family, st.axes, ..st.args)` +#let block-style(spec, role) = { + let key = if type(role) == str and role in _ROLE-STYLES { role } else { "details" } + let s = _ROLE-STYLES.at(key) + let pal = palette-of(spec) + let fill = if s.ink == "accent" { pal.accent } else if s.ink == "muted" { + pal.ink.transparentize(30%) + } else { pal.ink } + + let family = font-of(spec, s.kind) + let size = short-edge-mm(spec) * s.ratio * 1mm + // Titles may shrink hard; small roles must stay legible, so their floor is tight. + let min-size = if key == "title" { size * 0.32 } else if key == "subtitle" { + size * 0.55 + } else { size * 0.8 } + + ( + role: key, + ratio: s.ratio, + size: size, + min-size: calc.max(5pt, min-size), + family: family, + axes: font-axes(spec, family), + weight: s.weight, + tracking: s.tracking, + leading: s.leading, + fill: fill, + upper: s.upper, + args: ( + size: size, + min-size: calc.max(5pt, min-size), + weight: s.weight, + tracking: s.tracking, + leading: s.leading, + fill: fill, + ), + ) +} + +/// Every block with the given role, in spec order. Returns `()` when there are none — +/// templates should treat an absent role as "draw nothing", not as an error. +#let blocks-of(spec, role) = { + let bs = _get(spec, "blocks", ()) + if type(bs) != array { return () } + bs.filter(b => type(b) == dictionary and _get(b, "role", none) == role) +} + +/// The text of the first block with `role`, uppercased when the role calls for it. +/// `none` when the role is absent. +#let block-text(spec, role, upper: auto) = { + let bs = blocks-of(spec, role) + if bs.len() == 0 { return none } + let t = _get(bs.at(0), "text", "") + if type(t) != str or t.trim() == "" { return none } + let up = if upper == auto { block-style(spec, role).upper } else { upper } + if up { upper(t) } else { t } +} + +// --------------------------------------------------------------------------- +// logo-place +// --------------------------------------------------------------------------- + +/// Place `spec.logo` in its corner, sized to `spec.logo.scale` of the trim's short +/// edge, inset to the safe area. For `page(foreground: ...)`, whose origin is the full +/// page including bleed — which is why the inset is bleed + safe, not just safe. +/// +/// Optional throughout: no `logo`, no `path`, or an empty path all draw nothing. +/// NOTE: `path` must be root-relative (leading "/") — Typst resolves image paths +/// against `--root`, not against the filesystem root, so the renderer rewrites it. +#let logo-place(spec, margin: none) = { + let logo = _get(spec, "logo", none) + if type(logo) != dictionary { return none } + let path = _get(logo, "path", none) + if type(path) != str or path.trim() == "" { return none } + + let sa = safe-area(spec) + let corner = _get(logo, "corner", "br") + let scale = _get(logo, "scale", 0.12) + if type(scale) not in (int, float) { scale = 0.12 } + let scale = calc.max(0.02, calc.min(0.4, scale)) + let w = sa.short-edge * scale + + let inset = if margin != none { margin } else { sa.x } + let insety = if margin != none { margin } else { sa.y } + + let spot = ( + tl: (top + left, inset, insety), + tr: (top + right, -inset, insety), + bl: (bottom + left, inset, -insety), + br: (bottom + right, -inset, -insety), + ).at(corner, default: (bottom + right, -inset, -insety)) + + place( + spot.at(0), + dx: spot.at(1), + dy: spot.at(2), + image(path, width: w, fit: "contain"), + ) +}