docs: README (Italian) and MIT licence with font carve-out
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<boolean> {
|
||||
return new Promise<boolean>((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<boolean> {
|
||||
try {
|
||||
await access(p, FS.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isExecutable(p: string): Promise<boolean> {
|
||||
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<PidRecord | null> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(pidFile, "utf8"));
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const rec = parsed as Partial<PidRecord>;
|
||||
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<void> {
|
||||
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<typeof serverPaths>;
|
||||
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<StartResult> | 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<boolean> {
|
||||
return isPortOpen(this.port);
|
||||
}
|
||||
|
||||
async status(): Promise<ServerStatus> {
|
||||
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<HealthReport> {
|
||||
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<StartResult> {
|
||||
if (this.starting) return this.starting;
|
||||
this.starting = this.startOnce(opts).finally(() => { this.starting = null; });
|
||||
return this.starting;
|
||||
}
|
||||
|
||||
private async startOnce(opts: StartOptions): Promise<StartResult> {
|
||||
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<boolean> {
|
||||
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<StartResult> {
|
||||
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<StopResult> {
|
||||
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<EnsureBinaryResult> {
|
||||
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<number> {
|
||||
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<typeof Readable.fromWeb>[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<void> {
|
||||
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<void> {
|
||||
if (platform !== "darwin") return;
|
||||
await new Promise<void>((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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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 <input-image> <output.png> [--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 <immagine-input> <output.png> [--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<Float>.size
|
||||
for y in 0..<height {
|
||||
for x in 0..<width where floats[y * perRow + x] > 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)}")
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user