Files
pi-imgen/extensions/imgen/commands/logo.ts
T
mozempk aee92d284c cleanup: clear stale TODOs, tighten types, translate art-prompt hints
Every 'being written in parallel' / 'ASSUMED SIGNATURE' TODO is gone: each
referenced module now exists and each assumption was checked against it
rather than the comment simply deleted.

- logo/presets/retouch: renderer typed as the real Renderer contract instead
  of unknown / (...args: never[]).
- director: the ModelRegistry assumption is VERIFIED against the installed
  @earendil-works types (find() on ModelRegistry, getModel() on ModelRuntime).
- strings: the img2imgBug TODO had it backwards -- drawthings raises a graded
  message a static string cannot express, and this is the fallback.
- poster: an Italian hint was being spliced into an English art prompt, which
  degrades these models. New director.refineArtPrompt() folds the note in via
  the LLM and falls back to the old splice on any failure, so it can only
  improve on the previous behaviour. scrubArtPrompt still runs either way.

Remaining TODOs are one category only (Italian strings living in per-file
tables rather than ui/strings.ts) and are accurate, not stale.
2026-08-27 10:30:54 +02:00

1070 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /logo — un marchio semplice per un evento o per la sua band.
*
* The pipeline here is deliberately NOT the poster pipeline. A logo has no typesetting
* stage at all: the diffusion model paints one bold mark on a plain background, the
* cutout takes the background away, vtracer turns what is left into curves, and sharp
* writes the icon sizes. Typst is never invoked, which is why `renderer` is carried in
* `LogoDeps` but never called.
*
* The three things that make or break the result, in order:
*
* 1. THE ART PROMPT MUST ASK FOR A PLAIN BACKGROUND. Everything downstream is a cutout,
* and a cutout of a mark sitting in a painted landscape is a mark with a painted
* landscape stuck to it. `LOGO_ART_SUFFIX` is appended deterministically AFTER the
* director has spoken, because this is too important to leave to an LLM that was
* asked nicely.
* 2. THE CUTOUT MUST BE CHECKED, NOT TRUSTED. `removeBackground` returns happily when
* it has erased the subject and kept the sky. The alpha coverage check below is the
* only thing standing between that and a "logo" that is 4000 transparent pixels.
* 3. VECTORISATION IS OPTIONAL. vtracer may be absent or the wrong version; that costs
* him the SVG and nothing else, so it is caught and downgraded to a warning. The
* PNGs are the deliverable that must always exist.
*
* Command and tool share one implementation: `runLogo()`. The command gathers input from
* dialogs, the tool gathers it from parameters, and both then call the same function.
*/
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { Type } from "typebox";
import sharp from "sharp";
import {
defineTool,
withFileMutationQueue,
type ExtensionAPI,
type ExtensionCommandContext,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { configPath, type ImgenConfig, type Preset } from "../config.ts";
import type { Backend, ProgressFn } from "../backends/types.ts";
import { removeBackground, vectorize, type CpuOpResult } from "../backends/cpu.ts";
import {
direct as directImpl,
parseFreeText as parseFreeTextImpl,
ensureNegative,
scrubArtPrompt,
type Brief,
type DirectResult,
type DirectorContext,
type DirectorWarning,
type ParsedBrief,
} from "../design/director.ts";
import type { DesignSpec } from "../design/spec.ts";
import { createJob, openInFinder, saveSpec, slugify, type Job } from "../job.ts";
import { S, bullets, duration, errorText, fill, menu, type ErrorMessage } from "../ui/strings.ts";
import type { Renderer } from "./poster.ts";
// ---------------------------------------------------------------------------
// Italian, local — see the TODO
// ---------------------------------------------------------------------------
/**
* TODO: these belong in `S.logo` in ui/strings.ts. They are kept here, together and
* nowhere else, exactly as director.ts keeps its own `WARNING_TEXT`, so that moving them
* into the string table is one cut-and-paste. Nothing else in this file contains Italian.
*/
const LOGO_S = {
intro: "Facciamo un marchio: una forma sola, forte, che si riconosca anche piccolissima.",
nameQuestion: "Come si chiama?",
namePlaceholder: "La mia band",
claimQuestion: "C'è una riga sotto? (facoltativa)",
claimPlaceholder: "folk d'osteria dal 2011",
needName: "Mi serve almeno il nome del marchio.",
drafting: "Provo una forma…",
finalising: "Disegno il marchio…",
cuttingOut: "Stacco il marchio dallo sfondo…",
sizes: "Salvo le versioni piccole e grandi…",
draftQuestion: "Ti piace questa forma?",
markReady: "Ecco la forma.",
/** Deliberately NOT S.draft.note / S.draft.seedNote: nothing is redrawn after this. */
markIsFinal:
"Questa è già l'immagine buona, alla risoluzione giusta: se ti piace la uso esattamente così, senza rifarla.",
outOfRounds:
"Abbiamo fatto {n} prove e la forma non ci è ancora venuta. Mi fermo qui e non salvo nessun marchio: " +
"riscrivi /logo e raccontami una forma più semplice.",
toolLabel: "Disegno il marchio",
pngSetLabel: "le versioni PNG del marchio",
savePresetTitle: "Stile salvato",
savePresetQuestion:
"Vuoi che me lo ricordi come stile? Così lo metto da solo sulle prossime locandine.",
presetNeedsConfigDir:
"Non so dove sono le tue impostazioni, quindi lo stile non l'ho salvato: il marchio però è nella cartella.",
cutoutTooEmpty:
"Del marchio è rimasto pochissimo: probabilmente lo sfondo si è mangiato la forma. Prova a rifarlo dicendomi una forma più semplice e più scura.",
cutoutNotRemoved:
"Lo sfondo non è stato tolto: il marchio è finito su un fondo pieno. Prova a rifarlo chiedendo una forma sola su fondo chiaro e uniforme.",
cutoutThin:
"Il ritaglio è venuto un po' sottile: guarda le versioni piccole prima di usarlo, e se non ti convince rifacciamolo.",
transparentNote: "Tutti i file hanno lo sfondo trasparente: puoi metterli sopra qualsiasi colore.",
svgNote: "L'SVG lo puoi ingrandire quanto vuoi senza che si sgrani: è quello da dare al tipografo.",
files: {
"logo.png": "Il marchio alla massima risoluzione, con sfondo trasparente",
"logo-1024.png": "Marchio grande (1024 px), per le stampe e il web",
"logo-512.png": "Marchio medio (512 px), per i social",
"logo-256.png": "Marchio piccolo (256 px), per le icone",
"logo.svg": "Il marchio a linee, ingrandibile all'infinito",
"art.png": "Il disegno di partenza, prima del ritaglio",
"spec.json": "Le scelte di questo lavoro, per rifarlo uguale",
} as Record<string, string>,
} as const;
// ---------------------------------------------------------------------------
// Fixed numbers
// ---------------------------------------------------------------------------
/** A logo is square. No `Format` is, which is why generation size is set here directly. */
const LOGO_PX = 1024;
/**
* Z-Image Turbo is a few-step model: 20+ steps buys nothing and costs minutes. This is the
* number to move if the marks come out mushy.
*
* There is deliberately NO cheaper draft pair of numbers here, unlike /poster. A logo IS
* its shape, and a shape judged small, with few steps, through the draft model is simply a
* different shape from the one the final model would paint at 1024 — the seed does not pin
* it. Since a 1024 square is the cheapest thing this extension ever generates, the draft
* loop can afford to paint the real thing every pass, which is what makes the mark he
* approves the mark that gets cut out, sized, vectorised and possibly saved as a preset.
*/
const FINAL_STEPS = 12;
/** How many times he may say "rigenera" before we stop looping. */
const MAX_DRAFT_ROUNDS = 6;
/** Icon sizes exported alongside the master and the SVG. */
const ICON_SIZES = [256, 512, 1024] as const;
/**
* Appended to the art prompt AFTER the director, and after scrubbing, so no LLM output
* and no free text can weaken it. None of these words is in director.ts's lettering
* blacklist, so the scrub never eats this suffix.
*/
const LOGO_ART_SUFFIX =
"single centred emblem, one subject only, bold simple silhouette, thick clean shapes, " +
"strong high contrast, flat graphic illustration, isolated on a plain uniform flat " +
"background, generous empty margin all around, even lighting, no cast shadow";
/** Everything that makes a mark impossible to cut out cleanly. */
const LOGO_NEGATIVE_EXTRA =
"busy background, scenery, landscape background, textured background, gradient background, " +
"patterned background, photographic depth of field, bokeh, clutter, multiple subjects, " +
"drop shadow, reflection, frame, border, mockup, paper texture, film grain, noise";
/**
* Alpha coverage thresholds. `coverage` is the mean alpha of the UNCROPPED cutout, 0..1 —
* the fraction of the square canvas the mark still occupies once the background is gone.
* Below `EMPTY` the cutout ate the subject; above `OPAQUE` it removed nothing at all.
*
* Measuring before the crop is the whole point: a mark trimmed to its own bounding box is
* legitimately 70–100% opaque, so the same test after cropping would call every solid
* mark a failed cutout.
*/
const ALPHA_EMPTY = 0.04;
const ALPHA_THIN = 0.10;
const ALPHA_OPAQUE = 0.95;
// ---------------------------------------------------------------------------
// Dependencies
// ---------------------------------------------------------------------------
/** The art-director calls /logo makes. Injected so the command is testable without a model. */
export interface DirectorPort {
direct(brief: Brief, cfg: ImgenConfig, ctx: DirectorContext): Promise<DirectResult>;
parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise<ParsedBrief>;
}
/** The two CPU helpers /logo needs. Injected for the same reason. */
export interface CpuPort {
removeBackground(
input: string,
out: string,
opts?: {
crop?: boolean;
largestOnly?: boolean;
signal?: AbortSignal;
onProgress?: ProgressFn;
config?: ImgenConfig;
},
): Promise<CpuOpResult>;
vectorize(
input: string,
out: string,
opts?: {
mode?: "color" | "bw";
curve?: "spline" | "polygon" | "pixel";
filterSpeckle?: number;
colorPrecision?: number;
pathPrecision?: number;
maxColors?: number;
simplify?: number;
optimize?: 0 | 1 | 2;
signal?: AbortSignal;
onProgress?: ProgressFn;
config?: ImgenConfig;
},
): Promise<CpuOpResult>;
}
export interface LogoDeps {
/**
* The live config. A function when it can change under us — /presets rewrites it, and
* a logo run that saves a preset must not be looking at a stale copy.
*/
config: ImgenConfig | (() => ImgenConfig);
/** The diffusion backend (backends/drawthings.ts). */
backend: Backend;
/**
* The Typst renderer (render/typst.ts). A logo carries no typeset text, so /logo never
* calls it; it is in `Deps` only so index.ts can build ONE dependency object shared by
* every command.
* Typed against the same `Renderer` contract index.ts builds and every other command
* receives, so a signature change is caught here rather than at runtime.
*/
renderer?: Renderer;
/** Defaults to design/director.ts. */
director?: DirectorPort;
/** Defaults to backends/cpu.ts. */
cpu?: CpuPort;
/**
* pi's config directory (`~/.pi/agent` unless overridden), where pi-imgen.json lives.
* Without it a preset cannot be persisted, and /logo says so instead of pretending.
*/
piConfigDir?: string;
/**
* Persists a brand preset. index.ts should wire this to whatever /presets uses so the
* two never disagree about the file format; when absent, the local writer below is used.
*/
savePreset?: (key: string, preset: Preset) => Promise<void> | void;
/**
* Renders a block of content in the transcript. `registerLogo` wires this to
* `pi.sendMessage`, which is how an image reaches the terminal from a command — a
* command handler returns void and has no result channel of its own. When absent
* (a unit test, a surface with nothing to draw on) the text falls back to a notification.
*/
present?: (content: (TextContent | ImageContent)[], details?: unknown) => void;
}
// ---------------------------------------------------------------------------
// Request / result — the shared surface of command and tool
// ---------------------------------------------------------------------------
export interface LogoRequest {
/** Free text typed after the command, e.g. "/logo per la mia band folk, tono caldo". */
text?: string;
/** The name of the mark, when already known. */
name?: string;
/** An optional second line (claim). */
claim?: string;
/** Extra context: tone, audience, what must NOT appear. */
context?: string;
/** Key of an existing preset in `config.presets` to start from. */
preset?: string;
/** Pin the seed to reproduce a mark exactly. */
seed?: number;
/** Open dialogs. False in tool mode and whenever the surface has no UI. */
interactive?: boolean;
/** Save the finished mark as a preset under this name, skipping the question. */
savePresetAs?: string;
/** Reveal the folder when done. Defaults to asking (interactive) or false. */
openFolder?: boolean;
}
export interface LogoResult {
slug: string;
dir: string;
specPath: string;
/** The raw generated painting, before the cutout. */
artPath: string;
/** Full-resolution transparent master. */
logoPath: string;
/** 256 / 512 / 1024 transparent PNGs, keyed by size. */
pngPaths: Record<number, string>;
/** Absent when vtracer was missing or failed — never fatal. */
svgPath?: string;
/** Mean alpha of the cutout, 0..1. Low means the mark is nearly gone. */
alphaCoverage: number;
/** Italian, user-visible: what went less than perfectly. */
warnings: string[];
/** Preset key actually written, when he asked for one. */
presetSaved?: string;
seed: number;
elapsedMs: number;
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function resolveConfig(deps: LogoDeps): ImgenConfig {
return typeof deps.config === "function" ? deps.config() : deps.config;
}
/** `setStatus` is a no-op on some surfaces; guarded anyway — belt and braces. */
function status(ctx: ExtensionContext, text: string | undefined): void {
try {
ctx.ui.setStatus("imgen", text);
} catch {
/* a status line is never worth failing a job over */
}
}
function notify(ctx: ExtensionContext, message: string, kind: "info" | "warning" | "error" = "info"): void {
try {
ctx.ui.notify(message, kind);
} catch {
/* same */
}
}
/** Wraps anything thrown downstream in an Error whose message is Italian. */
function italianError(e: unknown, fallback: ErrorMessage): Error {
const italian = (e as { italian?: unknown } | null)?.italian;
const message = typeof italian === "string" && italian.trim() ? italian : errorText(fallback);
return new Error(message, { cause: e });
}
/** 31-bit, so it survives every backend's integer handling. Same rule as director.ts. */
function randomSeed(): number {
return Math.floor(Math.random() * 0x7fffffff);
}
function abortIfCancelled(ctx: ExtensionContext): void {
if (ctx.signal?.aborted) throw new Error(errorText(S.errors.cancelled));
}
/** True when dialogs are actually available on this surface. */
function canAsk(ctx: ExtensionContext, req: LogoRequest): boolean {
return req.interactive !== false && ctx.hasUI;
}
/** Marks an abandoned dialog. `runLogo` returns undefined rather than throwing on cancel. */
const CANCELLED = Symbol("cancelled");
type Cancellable<T> = T | typeof CANCELLED;
async function ask(
ctx: ExtensionContext,
title: string,
placeholder: string,
): Promise<Cancellable<string>> {
const answer = await ctx.ui.input(title, placeholder);
return answer === undefined ? CANCELLED : answer.trim();
}
// ---------------------------------------------------------------------------
// The brief
// ---------------------------------------------------------------------------
interface LogoBrief {
/** Optional: headless, a mark can be described without ever being named. */
name?: string;
claim?: string;
context?: string;
preset?: string;
}
/**
* Guided AND free-form: whatever he typed after the command is parsed first and used to
* pre-fill, then the form asks for what is still missing. Headless, the parse is all
* there is — and if it produced nothing, that is an error rather than a hang.
*/
async function gatherBrief(
req: LogoRequest,
ctx: ExtensionContext,
cfg: ImgenConfig,
director: DirectorPort,
): Promise<Cancellable<LogoBrief>> {
let name = req.name?.trim() ?? "";
let claim = req.claim?.trim() ?? "";
let context = req.context?.trim() ?? "";
let preset = req.preset;
const free = req.text?.trim();
if (free) {
status(ctx, S.progress.thinking);
let parsed: ParsedBrief | undefined;
try {
parsed = await director.parseFreeText(free, ctx, cfg);
} catch {
// parseFreeText already degrades to a local parse internally; if even that threw,
// the free text simply becomes context and the form asks for the rest.
parsed = undefined;
}
if (parsed) {
if (!name) name = parsed.fields.title?.trim() ?? "";
if (!claim) claim = parsed.fields.subtitle?.trim() ?? "";
if (!context) context = parsed.freeText.trim();
}
if (!context) context = free;
status(ctx, undefined);
}
if (!canAsk(ctx, req)) {
// Headless: no dialogs, no waiting, no hanging. The parse is all there is; if it and
// the parameters together said nothing, that is an error rather than a prompt. The
// name may legitimately stay empty — the director can work from context alone, and
// it names the folder from whatever it decides the mark is.
if (!name && !context) throw new Error(errorText(S.errors.briefEmpty));
return {
...(name ? { name } : {}),
...(claim ? { claim } : {}),
...(context ? { context } : {}),
...(preset ? { preset } : {}),
};
}
notify(ctx, free ? S.brief.prefilled : LOGO_S.intro);
// Preset first: it changes the palette and fonts the director is allowed to pick.
const presetKeys = Object.keys(cfg.presets);
if (preset === undefined && presetKeys.length > 0) {
const labels: Record<string, string> = { __fresh: S.presets.fresh };
for (const key of presetKeys) {
labels[key] = fill(S.presets.use, { nome: cfg.presets[key]?.label ?? key });
}
const m = menu(labels, ["__fresh", ...presetKeys]);
const picked = m.pick(await ctx.ui.select(S.presets.title, m.options));
if (picked === undefined) return CANCELLED;
if (picked !== "__fresh") preset = picked;
}
const askedName = await ask(ctx, LOGO_S.nameQuestion, LOGO_S.namePlaceholder);
if (askedName === CANCELLED) return CANCELLED;
if (askedName) name = askedName;
if (!name) {
notify(ctx, LOGO_S.needName, "warning");
return CANCELLED;
}
const askedClaim = await ask(ctx, LOGO_S.claimQuestion, LOGO_S.claimPlaceholder);
if (askedClaim === CANCELLED) return CANCELLED;
if (askedClaim) claim = askedClaim;
const askedContext = await ask(ctx, S.brief.freeTextLabel, S.brief.freeTextPlaceholder);
if (askedContext === CANCELLED) return CANCELLED;
if (askedContext) context = context ? `${context}. ${askedContext}` : askedContext;
return {
name,
...(claim ? { claim } : {}),
...(context ? { context } : {}),
...(preset ? { preset } : {}),
};
}
// ---------------------------------------------------------------------------
// Art direction
// ---------------------------------------------------------------------------
/**
* Hardens the director's art prompt for cutting out. Deterministic on purpose: the plain
* background is a mechanical requirement of the pipeline, not a stylistic preference, so
* it is not left to the model that was merely asked for it.
*/
function hardenForCutout(spec: DesignSpec, extraDirection?: string): DesignSpec {
const dropped: DirectorWarning[] = [];
const base = scrubArtPrompt(spec.art.prompt, undefined, dropped);
const extra = extraDirection?.trim()
? `, ${scrubArtPrompt(extraDirection, undefined, dropped)}`
: "";
return {
...spec,
art: {
...spec.art,
prompt: `${base}${extra}, ${LOGO_ART_SUFFIX}`,
negative: ensureNegative(`${spec.art.negative}, ${LOGO_NEGATIVE_EXTRA}`, dropped),
},
};
}
// ---------------------------------------------------------------------------
// Cutout quality
// ---------------------------------------------------------------------------
/**
* Mean alpha of the cutout, 0..1, plus the Italian warnings that mean coverage implies.
*
* This is the check the whole command hangs on: `removeBackground` reports success just
* as cheerfully when it has kept the sky and deleted the mark.
*/
async function inspectCutout(path: string): Promise<{ coverage: number; warnings: string[] }> {
const warnings: string[] = [];
let coverage = 1;
try {
const stats = await sharp(path).stats();
const alpha = stats.channels[3];
// No alpha channel at all means nothing was cut out: treat as fully opaque.
coverage = alpha ? alpha.mean / 255 : 1;
} catch {
// An unreadable cutout is a real failure, but it will surface on the resize below
// with a better message than a guess made here.
return { coverage: 1, warnings };
}
if (coverage < ALPHA_EMPTY) warnings.push(LOGO_S.cutoutTooEmpty);
else if (coverage < ALPHA_THIN) warnings.push(LOGO_S.cutoutThin);
else if (coverage > ALPHA_OPAQUE) warnings.push(LOGO_S.cutoutNotRemoved);
return { coverage, warnings };
}
/**
* Trims the transparent border off the master, in place.
*
* `removeBackground` is deliberately called WITHOUT its own `crop`, so that the coverage
* check above sees the full canvas; the crop a logo actually wants happens here instead.
* A trim that fails (a uniform image, an old sharp) leaves the untrimmed master, which is
* correct, just less tidy.
*/
async function trimInPlace(path: string): Promise<void> {
const tmp = `${path}.trim.png`;
try {
await sharp(path).trim().png({ compressionLevel: 9 }).toFile(tmp);
await rename(tmp, path);
} catch {
/* keep the untrimmed master */
}
}
/**
* Writes the square icon set. `fit: "contain"` on a transparent canvas keeps the mark's
* proportions and centres it, which is what every downstream use (avatar, favicon, the
* `logo` slot on a poster) expects.
*/
async function writeIconSet(source: string, job: Job): Promise<Record<number, string>> {
const out: Record<number, string> = {};
for (const size of ICON_SIZES) {
const target = join(job.dir, `logo-${size}.png`);
await sharp(source)
.resize(size, size, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png({ compressionLevel: 9 })
.toFile(target);
out[size] = target;
}
return out;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
/**
* Merges one preset into pi-imgen.json without touching anything else in it.
*
* Reads the RAW file rather than `loadConfig()`'s merged view: writing the merged view
* back would bake today's DEFAULTS into his file for ever. A file that exists but does
* not parse is left alone — overwriting it would silently destroy his settings.
*/
async function writePresetToConfig(piConfigDir: string, key: string, preset: Preset): Promise<string> {
const path = configPath(piConfigDir);
return withFileMutationQueue(path, async () => {
let raw: Record<string, unknown> = {};
let existing: string | undefined;
try {
existing = await readFile(path, "utf8");
} catch {
existing = undefined;
}
if (existing !== undefined) {
try {
const parsed: unknown = JSON.parse(existing);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
raw = parsed as Record<string, unknown>;
}
} catch (e) {
throw new Error(errorText(S.errors.configBroken(path, (e as Error).message)), { cause: e });
}
}
const current = raw.presets;
const presets: Record<string, Preset> =
current && typeof current === "object" && !Array.isArray(current)
? { ...(current as Record<string, Preset>) }
: {};
presets[key] = preset;
raw.presets = presets;
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
await writeFile(tmp, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
await rename(tmp, path);
return path;
});
}
/**
* Offers to remember the mark as a brand preset, so it lands on future posters without
* being asked for again. Returns the preset key actually written.
*/
async function maybeSavePreset(
req: LogoRequest,
ctx: ExtensionContext,
deps: LogoDeps,
cfg: ImgenConfig,
spec: DesignSpec,
brief: LogoBrief,
logoPath: string,
warnings: string[],
): Promise<string | undefined> {
let name = req.savePresetAs?.trim();
if (!name && canAsk(ctx, req)) {
const wanted = await ctx.ui.confirm(LOGO_S.savePresetTitle, LOGO_S.savePresetQuestion);
if (!wanted) return undefined;
const asked = await ask(ctx, S.presets.nameQuestion, S.presets.namePlaceholder);
if (asked === CANCELLED || !asked) return undefined;
name = asked;
}
if (!name) return undefined;
const key = slugify(name);
if (cfg.presets[key] && canAsk(ctx, req)) {
const replace = await ctx.ui.confirm(
S.presets.manageTitle,
fill(S.presets.overwriteQuestion, { nome: cfg.presets[key]?.label ?? name }),
);
if (!replace) return undefined;
}
const preset: Preset = {
label: name,
logo: logoPath,
palette: spec.palette,
fonts: spec.fonts,
...(brief.context ? { tone: brief.context } : {}),
};
if (deps.savePreset) {
await deps.savePreset(key, preset);
} else if (deps.piConfigDir) {
await writePresetToConfig(deps.piConfigDir, key, preset);
} else {
warnings.push(LOGO_S.presetNeedsConfigDir);
return undefined;
}
// Keep the in-memory config honest for the rest of this session.
cfg.presets[key] = preset;
notify(ctx, fill(S.presets.saved, { nome: name }));
return key;
}
// ---------------------------------------------------------------------------
// runLogo — the one implementation the command and the tool share
// ---------------------------------------------------------------------------
/**
* Returns `undefined` when he abandoned a dialog; throws (with an Italian message) when
* something actually went wrong. Never leaves the status line set.
*/
export async function runLogo(
req: LogoRequest,
ctx: ExtensionContext,
deps: LogoDeps,
): Promise<LogoResult | undefined> {
const started = Date.now();
const cfg = resolveConfig(deps);
const director: DirectorPort = deps.director ?? { direct: directImpl, parseFreeText: parseFreeTextImpl };
const cpu: CpuPort = deps.cpu ?? { removeBackground, vectorize };
const warnings: string[] = [];
try {
// --- 1. brief --------------------------------------------------------
const brief = await gatherBrief(req, ctx, cfg, director);
if (brief === CANCELLED) return undefined;
abortIfCancelled(ctx);
// --- 2. art direction ------------------------------------------------
status(ctx, S.progress.thinking);
const directorBrief: Brief = {
kind: "logo",
// `format` and `template` exist only because DesignSpec demands them. A logo is
// square and is never typeset, so both are pinned rather than left to the model.
format: "ig-post",
template: "centred-stack",
...(brief.name ? { title: brief.name } : {}),
...(brief.claim ? { subtitle: brief.claim } : {}),
...(brief.context ? { freeText: brief.context } : {}),
...(brief.preset ? { preset: brief.preset } : {}),
...(req.seed !== undefined ? { seed: req.seed } : {}),
};
let directed: DirectResult;
try {
directed = await director.direct(directorBrief, cfg, ctx);
} catch (e) {
throw italianError(e, S.errors.directorFailed((e as Error)?.message));
}
for (const w of directed.warnings) warnings.push(w.italian);
let spec = hardenForCutout(directed.spec);
abortIfCancelled(ctx);
// --- 3. folder -------------------------------------------------------
// Created only now: a director failure must not leave an empty folder behind.
// `slugify` never returns an empty string, so the director's slug is always usable.
const job = createJob(cfg, spec.slug);
spec = { ...spec, slug: job.slug };
saveSpec(job, spec);
if (directed.note && canAsk(ctx, req)) notify(ctx, `${S.brief.confirmTitle}: ${directed.note}`);
const onProgress: ProgressFn = (msg) => status(ctx, msg);
/**
* The one description of what painting the mark means. Both callers below use it, so
* the image he is shown and the image the headless path writes cannot drift apart —
* which is the whole reason the mark is never painted twice at two different sizes.
* Reads `spec` at call time, so a reworked prompt and its new seed are picked up.
*/
const paintMark = async (): Promise<void> => {
try {
await deps.backend.generate(
{
prompt: spec.art.prompt,
negative: spec.art.negative,
seed: spec.art.seed,
width: LOGO_PX,
height: LOGO_PX,
steps: FINAL_STEPS,
tier: "final",
outPath: job.artPath,
},
onProgress,
ctx.signal,
);
} catch (e) {
throw italianError(e, S.errors.generationFailed((e as Error)?.message));
}
};
// --- 4. draft loop ---------------------------------------------------
// Interactive only, and every pass paints the REAL mark: full size, full steps, final
// model, straight into `art.png`. The shape on screen is therefore the shape that gets
// cut out below, with no second generation to change it behind his back. In
// tool/headless mode there is nobody to ask, so section 5 does the one generation.
let artApproved = false;
if (canAsk(ctx, req)) {
const draftMenu = menu(
{ accept: S.refine.actions.accept, art: S.refine.actions.art, cancel: S.common.cancel },
["accept", "art", "cancel"] as const,
);
for (let round = 0; ; round++) {
if (round >= MAX_DRAFT_ROUNDS) {
// The retry budget is spent on a mark he has just asked to change, so nothing
// here is approved — and an unapproved mark must not be cut out, sized,
// vectorised or saved as a preset. Say the loop ended, and cancel.
notify(ctx, fill(LOGO_S.outOfRounds, { n: MAX_DRAFT_ROUNDS }), "warning");
return undefined;
}
status(ctx, LOGO_S.drafting);
await paintMark();
status(ctx, undefined);
await showImage(ctx, deps, job.artPath, `${LOGO_S.markReady}\n${LOGO_S.markIsFinal}`);
const choice = draftMenu.pick(await ctx.ui.select(LOGO_S.draftQuestion, draftMenu.options));
if (choice === undefined || choice === "cancel") return undefined;
if (choice === "accept") {
// What he just looked at IS the artwork: nothing is regenerated below.
artApproved = true;
break;
}
// "Rigenera l'immagine": take his words, add them to the direction, new seed.
const change = await ask(ctx, S.refine.artQuestion, S.refine.artPlaceholder);
if (change === CANCELLED) return undefined;
const reworked = hardenForCutout(directed.spec, change || undefined);
spec = {
...reworked,
slug: job.slug,
// A new seed, because the same seed with a slightly different prompt gives him
// the same mark again and he will think nothing happened.
art: { ...reworked.art, seed: randomSeed() },
};
saveSpec(job, spec);
}
}
abortIfCancelled(ctx);
// --- 5. the render ----------------------------------------------------
// Only when nobody could be shown a mark to approve: the loop above already painted
// and had the approved one accepted, and repainting it here would hand him a different
// mark from the one he said yes to.
if (!artApproved) {
status(ctx, LOGO_S.finalising);
await paintMark();
}
abortIfCancelled(ctx);
// --- 6. cutout -------------------------------------------------------
status(ctx, LOGO_S.cuttingOut);
const logoPath = join(job.dir, "logo.png");
let cutout: CpuOpResult;
try {
cutout = await cpu.removeBackground(job.artPath, logoPath, {
// NOT cropped here: the coverage check below has to see the whole canvas to tell
// "the background survived" from "this mark simply fills its bounding box".
// `trimInPlace` does the cropping a logo wants, straight afterwards.
crop: false,
largestOnly: true,
config: cfg,
onProgress,
...(ctx.signal ? { signal: ctx.signal } : {}),
});
} catch (e) {
throw italianError(e, S.errors.backgroundRemovalFailed);
}
for (const note of cutout.notes) warnings.push(note);
const inspection = await inspectCutout(logoPath);
warnings.push(...inspection.warnings);
for (const w of inspection.warnings) notify(ctx, w, "warning");
await trimInPlace(logoPath);
// --- 7. exports ------------------------------------------------------
status(ctx, LOGO_S.sizes);
let pngPaths: Record<number, string>;
try {
pngPaths = await writeIconSet(logoPath, job);
} catch (e) {
throw italianError(e, S.errors.exportFailed(LOGO_S.pngSetLabel));
}
status(ctx, S.progress.vectorizing);
const svgTarget = join(job.dir, "logo.svg");
let svgPath: string | undefined;
try {
const traced = await cpu.vectorize(logoPath, svgTarget, {
mode: "color",
curve: "spline",
// A traced logo wants few, clean, closed shapes: speckles and colour steps are
// what make a traced mark look like a scan of a mark.
filterSpeckle: 8,
colorPrecision: 6,
pathPrecision: 2,
maxColors: 8,
simplify: 1.5,
optimize: 1,
config: cfg,
onProgress,
...(ctx.signal ? { signal: ctx.signal } : {}),
});
svgPath = traced.path;
for (const note of traced.notes) warnings.push(note);
} catch (e) {
// vtracer missing or the wrong version costs him the SVG and nothing else.
const italian = (e as { italian?: unknown })?.italian;
warnings.push(typeof italian === "string" ? italian : errorText(S.errors.vectorizeFailed));
}
// --- 8. preset -------------------------------------------------------
const presetSaved = await maybeSavePreset(req, ctx, deps, cfg, spec, brief, logoPath, warnings);
// --- 9. done ---------------------------------------------------------
const result: LogoResult = {
slug: job.slug,
dir: job.dir,
specPath: job.specPath,
artPath: job.artPath,
logoPath,
pngPaths,
...(svgPath ? { svgPath } : {}),
alphaCoverage: inspection.coverage,
warnings,
...(presetSaved ? { presetSaved } : {}),
seed: spec.art.seed,
elapsedMs: Date.now() - started,
};
const shouldOpen =
req.openFolder ??
(canAsk(ctx, req)
? await ctx.ui.confirm(S.confirm.openFolder.title, S.confirm.openFolder.message)
: false);
if (shouldOpen && !openInFinder(job.dir)) {
warnings.push(errorText(S.errors.openFolderFailed(job.dir)));
}
return result;
} finally {
// The status line outlives the command otherwise, and a stale "sto lavorando" is a
// lie the user has no way to clear.
status(ctx, undefined);
}
}
// ---------------------------------------------------------------------------
// Presentation
// ---------------------------------------------------------------------------
/** Shows an image inline where the surface allows it, with the text beside it. */
async function showImage(
ctx: ExtensionContext,
deps: LogoDeps,
path: string,
text: string,
): Promise<void> {
if (!deps.present) {
notify(ctx, text);
return;
}
deps.present(await imageContent(path, text));
}
/** Builds the `[image, text]` pair. The image renders inline; the text carries the path. */
async function imageContent(path: string, text: string): Promise<(TextContent | ImageContent)[]> {
const blocks: (TextContent | ImageContent)[] = [];
try {
const data = await readFile(path);
blocks.push({ type: "image", data: data.toString("base64"), mimeType: "image/png" });
} catch {
/* no preview is survivable; the text below still names the file */
}
blocks.push({ type: "text", text });
return blocks;
}
/** The closing report: what was made, where it is, and what went less than perfectly. */
function summarise(result: LogoResult): string {
const names = [
"logo.png",
"logo-1024.png",
"logo-512.png",
"logo-256.png",
...(result.svgPath ? ["logo.svg"] : []),
"art.png",
"spec.json",
];
const lines = [
S.done.header,
fill(S.done.folder, { cartella: result.dir }),
"",
S.done.filesHeader,
bullets(names.map((n) => `${n} — ${LOGO_S.files[n] ?? n}`)),
"",
LOGO_S.transparentNote,
...(result.svgPath ? [LOGO_S.svgNote] : []),
fill(S.done.elapsed, { tempo: duration(result.elapsedMs) }),
];
if (result.presetSaved) lines.push(fill(S.presets.saved, { nome: result.presetSaved }));
if (result.warnings.length) lines.push("", bullets(result.warnings));
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
const LogoParams = Type.Object({
name: Type.Optional(
Type.String({ description: "The name of the mark, e.g. the band or the event. Italian, verbatim." }),
),
brief: Type.Optional(
Type.String({
description:
"Free-form Italian description: tone, audience, what the mark should evoke, what must NOT appear.",
}),
),
claim: Type.Optional(Type.String({ description: "Optional second line under the name. Italian, verbatim." })),
preset: Type.Optional(Type.String({ description: "Key of an existing brand preset to start from." })),
seed: Type.Optional(Type.Integer({ description: "Pin the artwork seed to reproduce a mark exactly." })),
savePresetAs: Type.Optional(
Type.String({ description: "Save the finished mark as a brand preset under this name." }),
),
});
/**
* Registers `/logo` and the `imgen_logo` tool. Both funnel into `runLogo`, so there is
* exactly one description of what making a logo means.
*
* Nothing here starts a process, a timer or a socket: the factory may run in an
* invocation that never opens a session.
*/
export function registerLogo(pi: ExtensionAPI, deps: LogoDeps): void {
// `pi.sendMessage` is the only way an image reaches the terminal from a command
// handler, and it lives on the API rather than the context — so it is bound here once
// and handed to `runLogo` like any other dependency.
const wired: LogoDeps = {
...deps,
present:
deps.present ??
((content, details) => {
pi.sendMessage({ customType: "imgen-logo", content, display: true, details });
}),
};
pi.registerCommand("logo", {
description: S.commands.logo.description,
handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
const req: LogoRequest = {
interactive: ctx.hasUI,
...(args.trim() ? { text: args.trim() } : {}),
};
const result = await runLogo(req, ctx, wired);
if (!result) {
notify(ctx, S.common.cancelled);
return;
}
wired.present?.(
await imageContent(result.pngPaths[512] ?? result.logoPath, summarise(result)),
result,
);
notify(ctx, fill(S.notify.finishedNamed, { titolo: result.slug }));
},
});
pi.registerTool(
defineTool({
name: "imgen_logo",
label: LOGO_S.toolLabel,
description:
"Create a simple, bold logo mark for a band or an event: the local diffusion model paints " +
"the mark on a plain background, the background is cut out, the result is vectorised to SVG, " +
"and a transparent PNG set (256/512/1024) plus the SVG are written to the job folder. " +
"The model never draws lettering — any name or claim is metadata, not pixels. " +
"Runs entirely locally and takes a couple of minutes.",
promptSnippet: "imgen_logo — generate a transparent logo mark (PNG set + SVG) from an Italian brief.",
parameters: LogoParams,
// Two concurrent diffusion runs OOM a 16GB machine. Never parallel.
executionMode: "sequential",
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const req: LogoRequest = {
// The model is driving: no dialogs, no draft loop, no Finder window.
interactive: false,
openFolder: false,
...(params.name ? { name: params.name } : {}),
...(params.brief ? { text: params.brief } : {}),
...(params.claim ? { claim: params.claim } : {}),
...(params.preset ? { preset: params.preset } : {}),
...(params.seed !== undefined ? { seed: params.seed } : {}),
...(params.savePresetAs ? { savePresetAs: params.savePresetAs } : {}),
};
const result = await runLogo(req, ctx, wired);
// Only a cancelled dialog returns undefined, and this path opens none — but a
// returned error-shaped object would never set isError, so throw.
if (!result) throw new Error(errorText(S.errors.cancelled));
return {
content: await imageContent(result.pngPaths[512] ?? result.logoPath, summarise(result)),
details: result,
};
},
}),
);
}
export default registerLogo;