Files
pi-imgen/extensions/imgen/commands/poster.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

1617 lines
57 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.
/**
* commands/poster.ts — `/poster`, the flagship command.
*
* THE SHAPE OF THIS FILE IS THE PRODUCT PITCH.
*
* There are exactly two lanes, and they are kept physically apart below:
*
* INSTANT LANE — colours, carattere, impaginazione, testo. Pure functions over the
* saved DesignSpec plus one Typst pass. No model, sub-second, free.
* SLOW LANE — the artwork. The ONLY place in this file that touches
* `deps.backend`. Costs minutes and a confirmation dialog.
*
* `applyInstantRefinement()` is typed to accept only `InstantAction`, so the compiler —
* not a comment — is what stops "rigenera l'immagine" from leaking into the fast path.
*
* The command itself stays thin: it gathers a brief, then orchestrates
* director -> backend -> renderer. Everything it does non-interactively lives in
* `runPoster()`, which a tool can call with the same arguments, so the command and the
* LLM-facing tool share one implementation.
*
* All user-visible text comes from ui/strings.ts. Nothing Italian is written here.
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import sharp from "sharp";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import {
Container,
Image,
Input,
Key,
Text,
getPngDimensions,
matchesKey,
truncateToWidth,
type Component,
type Focusable,
type TUI,
} from "@earendil-works/pi-tui";
import type { ImgenConfig, Preset } from "../config.ts";
import { BackendError, type Backend, type ProgressFn } from "../backends/types.ts";
import { BLOCK_ROLES, type DesignSpec, type Format, type TemplateName } from "../design/spec.ts";
import { PAIRINGS } from "../design/fonts.ts";
import { PALETTE_NAMES, mergePreset, paletteFor } from "../design/palettes.ts";
import type {
Brief,
CaptionResult,
DirectResult,
DirectorContext,
DirectorWarning,
ParsedBrief,
} from "../design/director.ts";
import {
ART_FILENAME,
blockText,
createJob,
expectedOutputs,
jobFile,
openInFinder,
outputFile,
saveCaption,
saveSpec,
slugify,
updateBlocks,
type Job,
} from "../job.ts";
import {
bleedPageMm,
effectiveDpi,
generationSize,
needsUpscale,
upscaleFactor,
type PixelSize,
type PrintTarget,
} from "../render/formats.ts";
import { S, bullets, duration, errorText, fileLabel, fill, menus } from "../ui/strings.ts";
import { previewImage } from "./retouch.ts";
// ---------------------------------------------------------------------------
// Injected collaborators
// ---------------------------------------------------------------------------
/**
* What the Typst stage is asked for.
*
* `render/typst.ts` satisfies this contract directly. What it guarantees, and what this
* command therefore relies on:
* - the renderer resolves the spec itself (contrast, geometry, font paths);
* - it writes into `outDir` under the names in job.ts's OUTPUT_BASENAMES;
* - it converts absolute paths to the root-relative form Typst requires — a leading
* "/" resolves against --root, NOT the filesystem (see docs/OPEN-DEFECTS.md §2).
*/
export interface RenderRequest {
spec: DesignSpec;
/** Absolute path to the artwork PNG to place. Never contains lettering. */
artPath: string;
/** The job folder. Outputs land here. */
outDir: string;
/** Formats to write. Defaults to `spec.format` alone. */
formats?: readonly Format[];
config: ImgenConfig;
/** False during the draft loop: a PDF nobody will open costs seconds. */
pdf?: boolean;
/** Override render resolution. The draft loop passes a low value for a fast preview. */
ppi?: number;
signal?: AbortSignal;
onProgress?: (message: string) => void;
}
export interface RenderOutcome {
/** Absolute paths actually written, in the order they were produced. */
files: string[];
/** Italian, ready to show: dropped blocks, added scrim, shrunk title. */
warnings?: string[];
}
export interface Renderer {
/** Typeset the formats in `req`. Pure with respect to the artwork: no model runs. */
render(req: RenderRequest): Promise<RenderOutcome>;
/** Typeset every requested format in one pass, reusing the resolved spec. */
renderAllFormats(req: RenderRequest): Promise<RenderOutcome>;
}
/** The art-director calls this command needs. Mirrors design/director.ts exactly. */
export interface Director {
direct(brief: Brief, cfg: ImgenConfig, ctx: DirectorContext): Promise<DirectResult>;
parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise<ParsedBrief>;
generateCaption(spec: DesignSpec, brief: Brief, ctx: DirectorContext, cfg?: ImgenConfig): Promise<CaptionResult>;
/** Folds an Italian note into the English art prompt. Falls back to a splice on failure. */
refineArtPrompt?(prompt: string, hintItalian: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise<string>;
/** Strips any request for lettering out of an art prompt. Pure. */
scrubArtPrompt(prompt: string, artHint: string | undefined, warnings: DirectorWarning[]): string;
}
export interface PosterDeps {
config: ImgenConfig;
backend: Backend;
renderer: Renderer;
director: Director;
/**
* Optional PDF check. index.ts wires this to `render/preflight.ts`'s `checkPdf()`,
* flattening its report to the failing/warning messages. When absent the PDF is simply
* not inspected — the files are still produced.
*/
preflight?: (pdfPath: string) => Promise<{ ok: boolean; problems: string[] }>;
/** Injected for tests. Defaults to job.ts's macOS-only Finder reveal. */
openFolder?: (dir: string) => boolean;
/** Injected for tests, so a regenerated artwork is reproducible. */
newSeed?: () => number;
now?: () => number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STATUS_KEY = "imgen-poster";
const PREVIEW_ENTRY = "imgen-poster-preview";
const RESULT_MESSAGE = "imgen-poster";
/**
* Draft artwork: small and few steps. Its only job is to let him judge composition and
* colour, and it must come back fast enough that the refinement loop feels like a
* conversation rather than a render farm.
*/
const DRAFT_LONG_EDGE = 512;
const DRAFT_STEPS = 4;
/**
* Final artwork: the full local cap (see render/formats.ts) and enough steps to hold up in
* print. Reached ONLY when there is no approved draft to enlarge — i.e. the headless
* pipeline. In the interactive command a second generation would be a different picture,
* so the final comes from the draft: see `PaintOptions.from`.
*/
const FINAL_STEPS = 8;
/** Preview render resolution during the draft loop. A terminal cell is not 300 dpi. */
const DRAFT_PPI = 72;
/** Working file the raw generation lands in before the print upscale replaces art.png. */
const RAW_ART_FILENAME = "art-raw.png";
const DRAFT_ART_FILENAME = "art-draft.png";
/**
* Scratch folder for everything the draft loop produces.
*
* Drafts must NEVER be written under a deliverable name: `poster-a3.png` means "the
* finished poster, ready for WhatsApp", and a 72 dpi preview parked there is a lie that
* survives him walking away from the refinement menu. Each iteration also gets its own
* immutable file, so an entry rendered three refinements ago still shows what it showed
* then instead of silently mutating into the newest render.
*/
const PREVIEW_DIRNAME = ".preview";
/** Longest edge of an image that goes inline in the terminal or into the context window. */
const PREVIEW_MAX_PX = 1200;
/** Above this, an image is downscaled before anything base64-encodes it. */
const MAX_INLINE_BYTES = 4 * 1024 * 1024;
/** Social sizes are always produced: he asked for finished files, not a starting point. */
const SOCIAL_FORMATS: readonly Format[] = ["ig-post", "ig-story", "fb-cover", "yt-thumb"];
/** Roles the guided form asks about, exhaustive by construction over the fixed contract. */
const FORM_ROLES = BLOCK_ROLES;
type Role = (typeof BLOCK_ROLES)[number];
// ---------------------------------------------------------------------------
// Refinement vocabulary — the fast/slow split, in the type system
// ---------------------------------------------------------------------------
type RefineAction = (typeof S.refine.order)[number];
/** Everything that is a pure spec edit plus one Typst pass. */
export const INSTANT_ACTIONS = ["colours", "fonts", "layout", "text"] as const;
export type InstantAction = (typeof INSTANT_ACTIONS)[number];
/** The one action that costs a diffusion run. */
export const MODEL_ACTIONS = ["art"] as const;
export type ModelAction = (typeof MODEL_ACTIONS)[number];
/**
* A type predicate on purpose: narrowing `action` here is what lets the compiler reject
* any attempt to hand "art" to `applyInstantRefinement`. Delete the predicate and the
* fast lane stops being guaranteed.
*/
export function touchesModel(action: RefineAction): action is ModelAction {
return (MODEL_ACTIONS as readonly string[]).includes(action);
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
/** Anything thrown by job.ts, the backend or the director carries `.italian`. */
function italianOf(e: unknown): string {
if (e && typeof e === "object" && "italian" in e) {
const it = (e as { italian?: unknown }).italian;
if (typeof it === "string" && it.trim()) return it;
}
const detail = e instanceof Error ? e.message : String(e);
return errorText(S.errors.unknown(detail));
}
/** Throws an Error whose message is Italian, keeping the technical text as the cause. */
function fail(e: unknown): never {
const err = new Error(italianOf(e));
if (e instanceof Error) (err as Error & { cause?: unknown }).cause = e;
throw err;
}
function isAbort(e: unknown): boolean {
return e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError");
}
/** Status line writer that never explodes in a mode without a footer. */
function statusWriter(ctx: ExtensionContext): (text: string | undefined) => void {
return (text) => {
try {
ctx.ui.setStatus(STATUS_KEY, text);
} catch {
/* headless surface without a status bar */
}
};
}
function notify(ctx: ExtensionContext, message: string, kind: "info" | "warning" | "error" = "info"): void {
try {
ctx.ui.notify(message, kind);
} catch {
/* headless */
}
}
function seedOf(deps: PosterDeps): number {
return deps.newSeed ? deps.newSeed() : Math.floor(Math.random() * 2_147_483_647);
}
/** Primary format first, then every social size that is not already it. */
export function exportFormats(primary: Format): Format[] {
return [primary, ...SOCIAL_FORMATS.filter((f) => f !== primary)];
}
function isPrint(format: Format): boolean {
return format === "a3-portrait" || format === "a4-portrait";
}
/** Where every draft render and every terminal thumbnail lives. Never a deliverable. */
function previewDir(job: Job): string {
return jobFile(job, PREVIEW_DIRNAME);
}
/**
* Removes everything the draft loop wrote. `spec.json` and any finished export stay:
* the only things that disappear are the working artwork and the scratch previews, so a
* folder is never left holding a 72 dpi preview under a finished-poster name.
*/
function cleanupDrafts(job: Job): void {
// Only ever our own working files: a photo he supplied is not ours to delete.
rmSync(jobFile(job, DRAFT_ART_FILENAME), { force: true });
rmSync(jobFile(job, RAW_ART_FILENAME), { force: true });
rmSync(previewDir(job), { recursive: true, force: true });
}
/** Real pixel size of a file on disk, or undefined when it cannot be read. */
async function imageSize(path: string): Promise<PixelSize | undefined> {
try {
const meta = await sharp(path).metadata();
return meta.width && meta.height ? { width: meta.width, height: meta.height } : undefined;
} catch {
return undefined;
}
}
/** The sharpness reality check: what dpi `art` actually delivers across the printed page. */
function printDpiOf(format: Format, art: PixelSize, print: PrintTarget): number {
const mm = bleedPageMm(format, print.bleedMm ?? 0);
return Math.round(Math.min(effectiveDpi(art.width, mm.widthMm), effectiveDpi(art.height, mm.heightMm)));
}
async function persistSpec(job: Job, spec: DesignSpec): Promise<DesignSpec> {
const path = jobFile(job, "spec.json");
await withFileMutationQueue(path, async () => saveSpec(job, spec));
return spec;
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTANT LANE
//
// Everything from here to the next banner is a pure function of the saved spec
// plus one Typst pass. NOTHING below may reference `deps.backend`. This is the
// whole reason the pipeline exists: changing a colour must not cost a model run.
// ═══════════════════════════════════════════════════════════════════════════
/** New palette, still respecting a brand preset's locked colours. */
export function withPalette(spec: DesignSpec, moodName: string, preset?: Preset): DesignSpec {
return { ...spec, palette: mergePreset(paletteFor(moodName), preset) };
}
/** New type pairing. The pairing table guarantees a caps-only face never lands on body. */
export function withFonts(spec: DesignSpec, display: string, body: string): DesignSpec {
return { ...spec, fonts: { display, body } };
}
/** New layout template. */
export function withTemplate(spec: DesignSpec, template: TemplateName): DesignSpec {
return { ...spec, template };
}
/**
* Applies an instant refinement. Typed against `InstantAction`, so passing "art" here
* is a compile error rather than a surprise two-minute wait.
*/
export function applyInstantRefinement(
spec: DesignSpec,
action: InstantAction,
choice: {
palette?: string;
fonts?: { display: string; body: string };
template?: TemplateName;
blocks?: Partial<Record<Role, string | null>>;
},
preset?: Preset,
): DesignSpec {
switch (action) {
case "colours":
return choice.palette ? withPalette(spec, choice.palette, preset) : spec;
case "fonts":
return choice.fonts ? withFonts(spec, choice.fonts.display, choice.fonts.body) : spec;
case "layout":
return choice.template ? withTemplate(spec, choice.template) : spec;
case "text":
return choice.blocks ? updateBlocks(spec, choice.blocks) : spec;
}
}
/**
* The Typst pass. This is the "instant" half of every refinement, and the only writer of
* the finished files. It never sees the backend.
*/
async function typeset(
deps: PosterDeps,
job: Job,
spec: DesignSpec,
opts: {
artPath: string;
formats?: readonly Format[];
pdf?: boolean;
ppi?: number;
all?: boolean;
/** Defaults to the job folder. The draft loop redirects to the scratch folder. */
outDir?: string;
signal?: AbortSignal;
onProgress?: (message: string) => void;
},
): Promise<RenderOutcome> {
const req: RenderRequest = {
spec,
artPath: opts.artPath,
outDir: opts.outDir ?? job.dir,
formats: opts.formats ?? [spec.format],
config: deps.config,
pdf: opts.pdf ?? true,
ppi: opts.ppi,
signal: opts.signal,
onProgress: opts.onProgress,
};
try {
return opts.all ? await deps.renderer.renderAllFormats(req) : await deps.renderer.render(req);
} catch (e) {
if (isAbort(e)) throw e;
if (e && typeof e === "object" && "italian" in e) fail(e);
fail(new Error(errorText(S.errors.renderFailed(e instanceof Error ? e.message : String(e)))));
}
}
/**
* A draft typeset: same Typst pass, but written to the scratch folder under a numbered
* name of its own.
*
* Two things depend on this and neither is cosmetic. The job folder must not acquire a
* file called `poster-a3.png` until the real export runs, and each preview entry must own
* an immutable file — entry components re-read their image on every resize, so a shared
* filename would rewrite the history of the refinement loop as it goes.
*/
async function typesetDraft(
deps: PosterDeps,
job: Job,
spec: DesignSpec,
artPath: string,
iteration: number,
signal?: AbortSignal,
): Promise<RenderOutcome> {
const dir = previewDir(job);
mkdirSync(dir, { recursive: true });
const outcome = await typeset(deps, job, spec, { artPath, outDir: dir, pdf: false, ppi: DRAFT_PPI, signal });
const produced = previewOf(outcome);
if (!produced.toLowerCase().endsWith(".png")) return outcome;
const stable = join(dir, `draft-${iteration}.png`);
try {
renameSync(produced, stable);
} catch {
return outcome; // The rename is a nicety; the render itself succeeded.
}
const files = [stable, ...outcome.files.filter((f) => f !== produced)];
return outcome.warnings?.length ? { files, warnings: outcome.warnings } : { files };
}
// ═══════════════════════════════════════════════════════════════════════════
// SLOW LANE
//
// The ONLY code in this file that invokes the diffusion model. Every entry point
// here is gated by a confirmation dialog and costs minutes, not milliseconds.
// ═══════════════════════════════════════════════════════════════════════════
/**
* A new artwork: fresh seed, plus his note folded into the prompt with lettering scrubbed.
*
* His note arrives in Italian and the models are trained on English, so the note goes
* through the director rather than being spliced in verbatim. `refineArtPrompt` falls back
* to the plain splice on any failure, and `scrubArtPrompt` guarantees — either way — that
* the result cannot ask for lettering.
*/
export async function withNewArtwork(
deps: PosterDeps,
spec: DesignSpec,
hint?: string,
ctx?: DirectorContext,
): Promise<DesignSpec> {
const note = hint?.trim() ?? "";
let prompt: string;
if (note && ctx && deps.director.refineArtPrompt) {
prompt = await deps.director.refineArtPrompt(spec.art.prompt, note, ctx, deps.config);
} else {
const merged = note ? `${spec.art.prompt}, ${note}` : spec.art.prompt;
prompt = deps.director.scrubArtPrompt(merged, undefined, []);
}
return { ...spec, art: { ...spec.art, prompt, seed: seedOf(deps) } };
}
export interface PaintOptions {
signal?: AbortSignal;
onProgress?: ProgressFn;
/**
* Italian notes worth showing but not worth failing over: an upscale that did not run, a
* photo too small to print. `paintArt` returns a path and nothing else, so without this
* channel a missing `models.upscale` produces ~62 dpi A3 PDFs forever and never says why.
*/
onWarning?: (message: string) => void;
/**
* The draft he already approved. When set for tier "final", the artwork is NOT painted
* again: it is enlarged.
*
* This is what makes S.draft.seedNote true. A pinned seed is not enough — the sampler
* draws different initial noise for a different latent shape and walks a different
* trajectory for a different step count, so re-generating at 728x1024/8 steps what he
* approved at 368x512/4 steps changes the composition, not just the sharpness. He would
* wait minutes for a different picture, with no second chance to say no.
*/
from?: string;
}
/** How the artwork that feeds the final render is settled into `art.png`. */
type Settle = "move" | "copy" | "keep";
interface EnlargeArgs {
/** Only changes which Italian sentence a failure gets. */
kind: "photo" | "art";
settle: Settle;
finalPath: string;
print: PrintTarget;
/** Size the producer already reported, when there is one. */
size?: PixelSize;
}
/** Puts the chosen artwork where the renderer expects it, without ever moving his photo. */
function settleArt(source: string, settle: Settle, finalPath: string): string {
if (settle === "keep") return source;
try {
if (settle === "copy") copyFileSync(source, finalPath);
else renameSync(source, finalPath);
return finalPath;
} catch {
// The file exists, under the other name. Rendering from it beats failing here.
return source;
}
}
/**
* Which Italian line a failed enlargement earns. A missing `models.upscale` is not a bad
* day, it is a broken install, and it must not read like the same transient failure every
* single time.
*/
function upscaleWarning(
e: unknown,
format: Format,
from: PixelSize,
print: PrintTarget,
kind: "photo" | "art",
): string {
const missingModel = e instanceof BackendError && /no upscale model configured/i.test(e.message);
if (!isPrint(format)) return missingModel ? S.warnings.upscaleMissingModel : S.warnings.upscaleFailed;
const dpi = printDpiOf(format, from, print);
if (missingModel) return fill(S.warnings.upscaleMissingModelPrint, { dpi });
return fill(kind === "photo" ? S.warnings.photoLowResPrint : S.warnings.upscaleFailedPrint, { dpi });
}
/**
* The resolution pass every final artwork goes through, whatever produced it.
*
* Print needs far more pixels than 16GB can generate in one go: see the warning on
* artTargetSize() in render/formats.ts. Failing here is not fatal — a slightly soft poster
* beats no poster — but it is never silent: the caller gets a sentence naming the dpi he
* will actually get.
*/
async function enlargeForFinal(
deps: PosterDeps,
spec: DesignSpec,
source: string,
args: EnlargeArgs,
opts: PaintOptions,
): Promise<string> {
const from = args.size ?? (await imageSize(source));
if (!from) {
// Unmeasurable input: place it, and still say something if it is going to be printed.
if (isPrint(spec.format)) {
opts.onWarning?.(args.kind === "photo" ? S.warnings.photoLowRes : S.warnings.upscaleFailed);
}
return settleArt(source, args.settle, args.finalPath);
}
if (!needsUpscale(spec.format, from, args.print)) return settleArt(source, args.settle, args.finalPath);
try {
await deps.backend.upscale(
source,
args.finalPath,
upscaleFactor(spec.format, args.print, from),
opts.onProgress,
opts.signal,
);
if (args.settle === "move") rmSync(source, { force: true });
return args.finalPath;
} catch (e) {
if (isAbort(e)) throw e;
opts.onWarning?.(upscaleWarning(e, spec.format, from, args.print, args.kind));
// A half-written art.png would be listed as a deliverable and used by nothing.
if (args.settle === "keep") rmSync(args.finalPath, { force: true });
return settleArt(source, args.settle, args.finalPath);
}
}
/**
* Paints the artwork.
*
* The final is the draft he approved, enlarged — see `PaintOptions.from`. Only a job with
* no draft behind it (the headless pipeline) actually generates at "final" tier.
*/
async function paintArt(
deps: PosterDeps,
job: Job,
spec: DesignSpec,
tier: "draft" | "final",
opts: PaintOptions = {},
): Promise<string> {
const print: PrintTarget = { bleedMm: deps.config.print.bleedMm, dpi: deps.config.print.dpi };
// -- his own photo ---------------------------------------------------------
// A phone photo is 1200 px on the long edge and A3-with-bleed wants 3579, so it goes
// through exactly the same resolution check as a generated artwork. Skipping it is how
// a poster ends up printed at ~100 dpi with nobody warned.
if (spec.art.source === "photo" && spec.art.photoPath) {
const photo = spec.art.photoPath;
if (!existsSync(photo)) fail(new Error(errorText(S.errors.photoMissing(photo))));
if (tier === "draft") return photo;
return await enlargeForFinal(deps, spec, photo, {
kind: "photo",
settle: "keep",
finalPath: job.artPath,
print,
}, opts);
}
// -- the picture he already said yes to ------------------------------------
if (tier === "final" && opts.from && existsSync(opts.from)) {
return await enlargeForFinal(deps, spec, opts.from, {
kind: "art",
settle: "copy",
finalPath: job.artPath,
print,
size: generationSize(spec.format, print, DRAFT_LONG_EDGE),
}, opts);
}
const size =
tier === "draft"
? generationSize(spec.format, print, DRAFT_LONG_EDGE)
: generationSize(spec.format, print);
const outName = tier === "draft" ? DRAFT_ART_FILENAME : RAW_ART_FILENAME;
const rawPath = jobFile(job, outName);
let produced;
try {
produced = await deps.backend.generate(
{
prompt: spec.art.prompt,
negative: spec.art.negative,
seed: spec.art.seed,
width: size.width,
height: size.height,
steps: tier === "draft" ? DRAFT_STEPS : FINAL_STEPS,
tier,
outPath: rawPath,
},
opts.onProgress,
opts.signal,
);
} catch (e) {
if (isAbort(e)) throw e;
fail(e);
}
if (tier === "draft") return produced.path;
return await enlargeForFinal(deps, spec, produced.path, {
kind: "art",
settle: "move",
finalPath: job.artPath,
print,
size: { width: produced.width, height: produced.height },
}, opts);
}
// ═══════════════════════════════════════════════════════════════════════════
// Shared implementation — one pipeline, used by the command AND by any tool
// ═══════════════════════════════════════════════════════════════════════════
export interface RunPosterOptions {
formats?: readonly Format[];
/** Reuse an existing job folder instead of suffixing a new one. */
reuse?: boolean;
signal?: AbortSignal;
/** Italian progress lines, already interpolated. */
onProgress?: (message: string) => void;
}
export interface PosterResult {
job: Job;
spec: DesignSpec;
/** Absolute paths of everything written, in listing order. */
files: string[];
/** Italian notes from the director and the renderer, worth showing but not fatal. */
warnings: string[];
elapsedMs: number;
}
/**
* Brief in, finished files out — no dialogs, no draft loop, no captions.
*
* This is the whole non-interactive pipeline, and it is what a registered tool should
* call so that the command and the LLM take exactly the same path. The interactive
* command below reuses the same primitives (`directSpec`, `paintArt`, `typeset`) but
* interleaves them with the refinement loop.
*/
export async function runPoster(
deps: PosterDeps,
brief: Brief,
ctx: DirectorContext,
opts: RunPosterOptions = {},
): Promise<PosterResult> {
const started = deps.now ? deps.now() : Date.now();
const progress = opts.onProgress ?? (() => {});
const warnings: string[] = [];
progress(S.progress.thinking);
const directed = await directSpec(deps, brief, ctx, opts.signal);
warnings.push(...directed.warnings.map((w) => w.italian));
const spec = directed.spec;
const job = createJob(deps.config, slugify(blockText(spec, "title") ?? "", blockText(spec, "date")), {
reuse: opts.reuse,
});
await persistSpec(job, spec);
progress(S.progress.finalArt);
const artPath = await paintArt(deps, job, spec, "final", {
signal: opts.signal,
onWarning: (w) => warnings.push(w),
});
progress(S.progress.exporting);
const outcome = await typeset(deps, job, spec, {
artPath,
formats: opts.formats ?? exportFormats(spec.format),
all: true,
signal: opts.signal,
onProgress: progress,
});
warnings.push(...(outcome.warnings ?? []));
if (deps.preflight && isPrint(spec.format)) {
const pdf = jobFile(job, outputFile(spec.format, "pdf"));
if (existsSync(pdf)) {
const check = await deps.preflight(pdf).catch(() => ({ ok: true, problems: [] }));
if (!check.ok) warnings.push(errorText(S.errors.preflightFailed(check.problems)));
}
}
return {
job,
spec,
files: listFiles(job, spec.format),
warnings,
elapsedMs: (deps.now ? deps.now() : Date.now()) - started,
};
}
async function directSpec(
deps: PosterDeps,
brief: Brief,
ctx: DirectorContext,
signal?: AbortSignal,
): Promise<DirectResult> {
try {
return await deps.director.direct({ ...brief, kind: "poster" }, deps.config, { ...ctx, signal: signal ?? ctx.signal });
} catch (e) {
if (isAbort(e)) throw e;
fail(e);
}
}
/** The PNG out of a render outcome — never the PDF, which a terminal cannot show. */
function previewOf(outcome: RenderOutcome): string {
return outcome.files.find((f) => f.toLowerCase().endsWith(".png")) ?? outcome.files[0] ?? "";
}
/** Everything worth listing in the closing summary, in the order a human reads it. */
function listFiles(job: Job, format: Format): string[] {
const names = [...expectedOutputs(format), ART_FILENAME, "caption.txt", "spec.json"];
const seen = new Set<string>();
const out: string[] = [];
for (const name of names) {
if (seen.has(name)) continue;
seen.add(name);
const path = jobFile(job, name);
if (existsSync(path)) out.push(path);
}
return out;
}
// ---------------------------------------------------------------------------
// Inline previews (TUI only, and deliberately outside the LLM context)
// ---------------------------------------------------------------------------
interface PreviewData {
path: string;
caption: string;
}
function registerPreviewRenderer(pi: ExtensionAPI): void {
pi.registerEntryRenderer<PreviewData>(PREVIEW_ENTRY, (entry, _options, theme) => {
const data = entry.data;
if (!data?.path || !existsSync(data.path)) return undefined;
const box = new Container();
if (data.caption) box.addChild(new Text(theme.fg("accent", data.caption), 0, 0));
try {
// Entry renderers are synchronous, so the downscale happened in showPreview(). This
// guard is the backstop: a 300 dpi A3 PNG must never be base64'd into the terminal.
if (statSync(data.path).size > MAX_INLINE_BYTES) {
box.addChild(new Text(theme.fg("dim", data.path), 0, 0));
return box;
}
const base64 = readFileSync(data.path).toString("base64");
const dims = getPngDimensions(base64);
box.addChild(
new Image(
base64,
"image/png",
{ fallbackColor: (s: string) => theme.fg("dim", s) },
{ maxHeightCells: 26, filename: basename(data.path) },
dims ?? undefined,
),
);
} catch {
box.addChild(new Text(theme.fg("dim", data.path), 0, 0));
}
return box;
});
}
/**
* A terminal-sized copy of `path`, written into the scratch folder. Returns `path` itself
* when it is already small enough, and on any sharp failure — a preview is a convenience,
* never a reason to lose the render.
*/
async function thumbnail(path: string, dir: string): Promise<string> {
try {
const size = await imageSize(path);
if (size && Math.max(size.width, size.height) <= PREVIEW_MAX_PX && statSync(path).size <= MAX_INLINE_BYTES) {
return path;
}
mkdirSync(dir, { recursive: true });
const out = join(dir, `anteprima-${basename(path, ".png")}.png`);
await sharp(path)
.resize({ width: PREVIEW_MAX_PX, height: PREVIEW_MAX_PX, fit: "inside", withoutEnlargement: true })
.png()
.toFile(out);
return out;
} catch {
return path;
}
}
/**
* Shows a render inline. Uses a custom ENTRY, not a message: previews are for his eyes,
* and a 4:5 poster repeated through a refinement loop has no business in the context
* window. The final result does go to the model — see `sendResult()`.
*/
async function showPreview(
pi: ExtensionAPI,
ctx: ExtensionContext,
job: Job,
path: string,
caption: string,
): Promise<void> {
if (ctx.mode !== "tui" || !path || !existsSync(path)) return;
pi.appendEntry<PreviewData>(PREVIEW_ENTRY, { path: await thumbnail(path, previewDir(job)), caption });
}
/**
* The closing hand-off: an image he can see, and a text block the model can act on.
*
* The image is DOWNSCALED first. This block goes into the session and therefore into the
* next provider request, and a 300 dpi A3 PNG is tens of megabytes before base64 adds a
* third — enough to be rejected outright or to eat the whole context window.
*/
async function sendResult(pi: ExtensionAPI, report: string, imagePath?: string): Promise<void> {
const content: Parameters<typeof pi.sendMessage>[0]["content"] = [];
if (imagePath && existsSync(imagePath)) {
const image = await previewImage(imagePath);
if (image) content.push(image);
}
content.push({ type: "text", text: report });
pi.sendMessage({ customType: RESULT_MESSAGE, content, display: true });
}
/** The most representative PNG for a preview: the social post first, then the poster. */
function pickShown(files: readonly string[], format: Format): string | undefined {
return (
files.find((f) => basename(f) === outputFile("ig-post", "png")) ??
files.find((f) => basename(f) === outputFile(format, "png"))
);
}
function doneReport(job: Job, files: string[], format: Format, cfg: ImgenConfig, elapsedMs: number): string {
const lines: string[] = [
S.done.header,
fill(S.done.folder, { cartella: job.dir }),
"",
S.done.filesHeader,
bullets(files.map((f) => `${basename(f)} — ${fileLabel(basename(f))}`)),
];
if (isPrint(format)) {
lines.push("", S.done.printNote);
if (!cfg.print.cropMarks) lines.push(S.done.printNoCropMarks);
}
lines.push("", fill(S.done.elapsed, { tempo: duration(elapsedMs) }), S.done.reopenNote);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// The guided form
// ---------------------------------------------------------------------------
export interface BriefFormValues {
fields: Record<Role, string>;
freeText: string;
format: Format;
/** Key into `cfg.presets`, or null for "parti da zero". */
preset: string | null;
}
interface PresetChoice {
key: string | null;
label: string;
}
function presetChoices(cfg: ImgenConfig): PresetChoice[] {
const entries = Object.entries(cfg.presets ?? {});
return [
{ key: null, label: S.presets.fresh },
...entries.map(([key, p]) => ({ key, label: fill(S.presets.use, { nome: p.label || key }) })),
];
}
function emptyFields(): Record<Role, string> {
return Object.fromEntries(FORM_ROLES.map((r) => [r, ""])) as Record<Role, string>;
}
function fieldsFromSpec(spec: DesignSpec): Record<Role, string> {
const out = emptyFields();
for (const role of FORM_ROLES) out[role] = blockText(spec, role) ?? "";
return out;
}
type FormRow =
| { kind: "text"; label: string; hint: string; placeholder: string; input: Input }
| { kind: "choice"; label: string; hint: string; options: string[]; index: number; key: "format" | "preset" };
/**
* The multi-field brief form. pi has no declarative form API, so this is hand-built:
* one `Input` per row, arrows/Tab to move, Enter to submit, Esc to give up.
*/
class BriefForm implements Component, Focusable {
private rows: FormRow[] = [];
private cursor = 0;
private error: string | undefined;
private _focused = false;
constructor(
private readonly opts: {
theme: Theme;
title: string;
intro?: string;
values: BriefFormValues;
presets: PresetChoice[];
/** Off when only the wording is being edited: format and preset are already settled. */
choices: boolean;
formatOptions: string[];
onChange(): void;
done(value: BriefFormValues | undefined): void;
},
) {
for (const role of FORM_ROLES) {
const input = new Input();
input.setValue(opts.values.fields[role] ?? "");
this.rows.push({
kind: "text",
label: S.brief.labels[role],
hint: S.brief.hints[role],
placeholder: S.brief.placeholders[role],
input,
});
}
const free = new Input();
free.setValue(opts.values.freeText);
this.rows.push({
kind: "text",
label: S.brief.freeTextLabel,
hint: S.brief.freeTextHint,
placeholder: S.brief.freeTextPlaceholder,
input: free,
});
if (opts.choices) {
const formatIndex = Math.max(0, S.formats.order.indexOf(opts.values.format));
this.rows.push({
kind: "choice",
key: "format",
label: S.formats.title,
hint: S.formats.allNote,
options: opts.formatOptions,
index: formatIndex,
});
const presetIndex = Math.max(0, opts.presets.findIndex((p) => p.key === opts.values.preset));
this.rows.push({
kind: "choice",
key: "preset",
label: S.presets.title,
hint: opts.presets.length > 1 ? S.presets.useHint : S.presets.none,
options: opts.presets.map((p) => p.label),
index: presetIndex,
});
}
this.syncFocus();
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.syncFocus();
}
private syncFocus(): void {
this.rows.forEach((row, i) => {
if (row.kind === "text") row.input.focused = this._focused && i === this.cursor;
});
}
invalidate(): void {
for (const row of this.rows) if (row.kind === "text") row.input.invalidate();
}
private move(delta: number): void {
this.cursor = (this.cursor + delta + this.rows.length) % this.rows.length;
this.syncFocus();
this.opts.onChange();
}
private cycle(delta: number): void {
const row = this.rows[this.cursor];
if (row?.kind !== "choice" || row.options.length === 0) return;
row.index = (row.index + delta + row.options.length) % row.options.length;
this.opts.onChange();
}
private values(): BriefFormValues {
const fields = emptyFields();
FORM_ROLES.forEach((role, i) => {
const row = this.rows[i];
if (row?.kind === "text") fields[role] = row.input.getValue().trim();
});
const freeRow = this.rows[FORM_ROLES.length];
const formatRow = this.rows.find((r) => r.kind === "choice" && r.key === "format");
const presetRow = this.rows.find((r) => r.kind === "choice" && r.key === "preset");
return {
fields,
freeText: freeRow?.kind === "text" ? freeRow.input.getValue().trim() : "",
format:
formatRow?.kind === "choice"
? (S.formats.order[formatRow.index] ?? this.opts.values.format)
: this.opts.values.format,
preset:
presetRow?.kind === "choice"
? (this.opts.presets[presetRow.index]?.key ?? null)
: this.opts.values.preset,
};
}
private submit(): void {
const values = this.values();
if (!values.fields.title) {
this.error = S.brief.needTitle;
this.cursor = 0;
this.syncFocus();
this.opts.onChange();
return;
}
this.opts.done(values);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.done(undefined);
return;
}
if (matchesKey(data, "shift+tab") || matchesKey(data, Key.up)) return this.move(-1);
if (matchesKey(data, Key.tab) || matchesKey(data, Key.down)) return this.move(1);
if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) return this.submit();
const row = this.rows[this.cursor];
if (!row) return;
if (row.kind === "choice") {
if (matchesKey(data, Key.left)) this.cycle(-1);
else if (matchesKey(data, Key.right) || matchesKey(data, Key.space)) this.cycle(1);
return;
}
this.error = undefined;
row.input.handleInput(data);
this.opts.onChange();
}
render(width: number): string[] {
const t = this.opts.theme;
const w = Math.max(20, width);
const clip = (s: string) => truncateToWidth(s, w);
const lines: string[] = [clip(t.bold(t.fg("accent", this.opts.title)))];
if (this.opts.intro) lines.push(clip(t.fg("dim", this.opts.intro)));
lines.push("");
this.rows.forEach((row, i) => {
const active = i === this.cursor;
const marker = active ? "› " : " ";
let label = `${marker}${row.label}`;
if (row.kind === "text" && !row.input.getValue()) label += ` «${row.placeholder}»`;
lines.push(clip(active ? t.fg("accent", label) : t.fg("muted", label)));
if (row.kind === "text") {
for (const line of row.input.render(Math.max(10, w - 2))) lines.push(` ${line}`);
} else {
const value = row.options[row.index] ?? S.common.empty;
lines.push(clip(` ‹ ${active ? t.fg("accent", value) : value} ›`));
}
if (active && row.hint) lines.push(clip(t.fg("dim", ` ${row.hint}`)));
});
lines.push("");
if (this.error) lines.push(clip(t.fg("error", this.error)));
lines.push(clip(t.fg("accent", `↩ ${S.brief.submit}`)));
lines.push(clip(t.fg("dim", S.common.escHint)));
return lines;
}
}
/** Opens the form. TUI only — the caller picks a fallback for every other surface. */
async function askForm(
ctx: ExtensionCommandContext,
cfg: ImgenConfig,
values: BriefFormValues,
opts: { title: string; intro?: string; choices: boolean },
): Promise<BriefFormValues | undefined> {
const formatOptions = menus.formats().options;
const presets = presetChoices(cfg);
return ctx.ui.custom<BriefFormValues | undefined>((tui: TUI, theme, _keys, done) =>
new BriefForm({
theme,
title: opts.title,
intro: opts.intro,
values,
presets,
choices: opts.choices,
formatOptions,
onChange: () => tui.requestRender(),
done,
}),
);
}
/**
* Dialog-by-dialog fallback for surfaces that have dialogs but no custom components
* (RPC). Same fields, same order, one question at a time.
*/
async function askSequential(
ctx: ExtensionCommandContext,
cfg: ImgenConfig,
values: BriefFormValues,
): Promise<BriefFormValues | undefined> {
const fields = { ...values.fields };
for (const role of FORM_ROLES) {
const answer = await ctx.ui.input(S.brief.labels[role], fields[role] || S.brief.placeholders[role]);
if (answer === undefined) return undefined;
fields[role] = answer.trim() || fields[role];
}
if (!fields.title) {
notify(ctx, S.brief.needTitle, "warning");
return undefined;
}
const free = await ctx.ui.input(S.brief.freeTextLabel, S.brief.freeTextPlaceholder);
if (free === undefined) return undefined;
const formatMenu = menus.formats();
const chosenFormat = await ctx.ui.select(S.formats.title, formatMenu.options);
const format = formatMenu.pick(chosenFormat) ?? values.format;
const presets = presetChoices(cfg);
let preset = values.preset;
if (presets.length > 1) {
const chosen = await ctx.ui.select(S.presets.title, presets.map((p) => p.label));
if (chosen === undefined) return undefined;
preset = presets.find((p) => p.label === chosen)?.key ?? null;
}
return { fields, freeText: free.trim() || values.freeText, format, preset };
}
function briefFrom(values: BriefFormValues): Brief {
return {
kind: "poster",
title: values.fields.title || undefined,
subtitle: values.fields.subtitle || undefined,
date: values.fields.date || undefined,
venue: values.fields.venue || undefined,
details: values.fields.details || undefined,
price: values.fields.price || undefined,
footer: values.fields.footer || undefined,
freeText: values.freeText || undefined,
format: values.format,
preset: values.preset ?? undefined,
};
}
// ---------------------------------------------------------------------------
// Refinement dialogs — each one returns a NEW spec, or undefined to go back
// ---------------------------------------------------------------------------
async function askInstantRefinement(
ctx: ExtensionCommandContext,
cfg: ImgenConfig,
spec: DesignSpec,
action: InstantAction,
preset: Preset | undefined,
): Promise<DesignSpec | undefined> {
switch (action) {
case "colours": {
const chosen = await ctx.ui.select(S.refine.coloursQuestion, [...PALETTE_NAMES]);
if (chosen === undefined) return undefined;
return applyInstantRefinement(spec, "colours", { palette: chosen }, preset);
}
case "fonts": {
const labels = PAIRINGS.map((p) => p.mood);
const chosen = await ctx.ui.select(S.refine.fontsQuestion, labels);
if (chosen === undefined) return undefined;
const pairing = PAIRINGS.find((p) => p.mood === chosen);
if (!pairing) return undefined;
return applyInstantRefinement(spec, "fonts", { fonts: { display: pairing.display, body: pairing.body } });
}
case "layout": {
const templates = menus.templates();
const chosen = await ctx.ui.select(S.refine.layoutQuestion, templates.options);
const template = templates.pick(chosen);
if (!template) return undefined;
return applyInstantRefinement(spec, "layout", { template });
}
case "text": {
const current: BriefFormValues = {
fields: fieldsFromSpec(spec),
freeText: "",
format: spec.format,
preset: null,
};
const edited =
ctx.mode === "tui"
? await askForm(ctx, cfg, current, { title: S.refine.textQuestion, intro: S.refine.textHint, choices: false })
: await askSequential(ctx, cfg, current);
if (!edited) return undefined;
const patch: Partial<Record<Role, string | null>> = {};
for (const role of FORM_ROLES) patch[role] = edited.fields[role] || null;
return applyInstantRefinement(spec, "text", { blocks: patch });
}
}
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
export function registerPoster(pi: ExtensionAPI, deps: PosterDeps): void {
registerPreviewRenderer(pi);
pi.registerCommand("poster", {
description: S.commands.poster.description,
handler: async (args, ctx) => {
const status = statusWriter(ctx);
try {
await posterCommand(pi, deps, args, ctx);
} finally {
// Never leave the footer claiming we are still working.
status(undefined);
}
},
});
}
async function posterCommand(
pi: ExtensionAPI,
deps: PosterDeps,
args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
const cfg = deps.config;
const status = statusWriter(ctx);
const started = deps.now ? deps.now() : Date.now();
const freeText = args.trim();
// ---- 1. Understand whatever he typed after the command --------------------
let parsed: ParsedBrief | undefined;
if (freeText) {
status(S.progress.thinking);
try {
parsed = await deps.director.parseFreeText(freeText, ctx, cfg);
} catch {
parsed = undefined; // parseFreeText already degrades offline; this is belt and braces.
}
status(undefined);
}
const prefill: BriefFormValues = {
fields: { ...emptyFields(), ...(parsed?.fields ?? {}) } as Record<Role, string>,
freeText: parsed?.freeText ?? (parsed ? "" : freeText),
format: parsed?.format ?? "a3-portrait",
preset: cfg.defaultPreset ?? null,
};
// ---- 2. Headless: no dialogs exist, so do the whole job in one shot -------
if (!ctx.hasUI) {
if (!prefill.fields.title && !prefill.freeText) {
throw new Error(errorText(S.errors.briefEmpty));
}
const result = await runPoster(deps, briefFrom(prefill), ctx, {
signal: ctx.signal,
onProgress: (m) => status(m),
});
// The social post, not the A3: same choice the interactive path makes, and the one
// that does not hand a 3508x4961 print export to the model.
const shown = pickShown(result.files, result.spec.format);
await sendResult(pi, [
...result.warnings,
doneReport(result.job, result.files, result.spec.format, cfg, result.elapsedMs),
].join("\n"), shown);
return;
}
// ---- 3. The guided form --------------------------------------------------
if (parsed) {
notify(ctx, parsed.confidence === "bassa" ? S.brief.prefilledNothing : S.brief.prefilled);
}
const values =
ctx.mode === "tui"
? await askForm(ctx, cfg, prefill, { title: S.brief.title, intro: S.brief.intro, choices: true })
: await askSequential(ctx, cfg, prefill);
if (!values) {
notify(ctx, S.common.cancelled);
return;
}
if (!values.fields.title.trim()) {
notify(ctx, S.brief.needTitle, "warning");
return;
}
const brief = briefFrom(values);
const preset = values.preset ? cfg.presets[values.preset] : undefined;
// ---- 4. Art direction ----------------------------------------------------
status(S.progress.thinking);
const directed = await directSpec(deps, brief, ctx, ctx.signal);
status(undefined);
let spec = directed.spec;
const notes = [directed.note, ...directed.warnings.map((w) => w.italian)].filter(Boolean) as string[];
if (notes.length) notify(ctx, `${S.brief.confirmTitle}\n${bullets(notes)}`);
const job = createJob(cfg, slugify(values.fields.title, values.fields.date));
await persistSpec(job, spec);
status(fill(S.progress.busyStatus, { titolo: values.fields.title }));
// Whatever the draft loop typesets over: the generated draft artwork, or his photo.
let draftArt = jobFile(job, DRAFT_ART_FILENAME);
/** Numbers the scratch renders, so every preview entry keeps its own immutable file. */
let iteration = 0;
// ---- 5. Fast draft -------------------------------------------------------
try {
status(S.progress.draft);
draftArt = await paintArt(deps, job, spec, "draft", {
signal: ctx.signal,
onProgress: (_m, fraction) => status(withPercent(S.progress.generating, fraction)),
onWarning: (w) => notify(ctx, w, "warning"),
});
status(S.progress.typesetting);
const drafted = await typesetDraft(deps, job, spec, draftArt, ++iteration, ctx.signal);
status(undefined);
await showPreview(pi, ctx, job, previewOf(drafted), `${S.draft.ready} ${S.draft.note} ${S.draft.seedNote}`);
notify(ctx, S.notify.draftReady);
} catch (e) {
status(undefined);
cleanupDrafts(job);
if (isAbort(e)) {
notify(ctx, S.common.cancelled);
return;
}
notify(ctx, italianOf(e), "error");
fail(e);
}
// ---- 6. The refinement loop ---------------------------------------------
//
// Exactly one branch below reaches the diffusion model, and it is the one that asks
// permission first. Everything else is a spec edit plus a Typst pass.
const refine = menus.refine();
let accepted = false;
while (!accepted) {
const chosen = await ctx.ui.select(S.refine.title, refine.options);
const action = refine.pick(chosen);
if (action === undefined) {
// Walking away is not destructive to anything that matters: spec.json stays, so the
// job can be picked up again. What goes is the scratch — a folder must never be left
// holding a 72 dpi preview where a finished poster is supposed to be.
notify(ctx, S.warnings.draftOnly, "warning");
status(undefined);
cleanupDrafts(job);
await sendResult(pi, `${S.warnings.draftOnly}\n${fill(S.done.folder, { cartella: job.dir })}`);
return;
}
if (action === "accept") {
accepted = true;
break;
}
if (touchesModel(action)) {
// ---- SLOW LANE ----
const ok = await ctx.ui.confirm(
S.confirm.regenerateArt.title,
`${S.confirm.regenerateArt.message}\n${S.refine.slow}`,
);
if (!ok) continue;
const hint = await ctx.ui.input(S.refine.artQuestion, S.refine.artPlaceholder);
if (hint === undefined) continue;
// If the new artwork never arrives, the spec must go back to describing the draft
// that is still on screen: step 7 enlarges that file, and it may not be a picture
// of a prompt he never got to see.
const beforeArt = spec;
let repainted = false;
try {
spec = await withNewArtwork(deps, spec, hint, { ...ctx, signal: ctx.signal });
await persistSpec(job, spec);
status(S.progress.generating);
draftArt = await paintArt(deps, job, spec, "draft", {
signal: ctx.signal,
onProgress: (_m, fraction) => status(withPercent(S.progress.generating, fraction)),
onWarning: (w) => notify(ctx, w, "warning"),
});
repainted = true;
status(S.progress.typesetting);
const out = await typesetDraft(deps, job, spec, draftArt, ++iteration, ctx.signal);
status(undefined);
await showPreview(pi, ctx, job, previewOf(out), S.refine.applied);
} catch (e) {
status(undefined);
if (!repainted) {
spec = beforeArt;
await persistSpec(job, spec).catch(() => undefined);
}
if (isAbort(e)) {
notify(ctx, S.common.cancelled);
return;
}
notify(ctx, italianOf(e), "error");
}
continue;
}
// ---- INSTANT LANE ----
notify(ctx, S.refine.instant);
const next = await askInstantRefinement(ctx, cfg, spec, action, preset);
if (!next) continue;
spec = next;
await persistSpec(job, spec);
status(S.progress.typesetting);
try {
const out = await typesetDraft(deps, job, spec, draftArt, ++iteration, ctx.signal);
status(undefined);
await showPreview(pi, ctx, job, previewOf(out), S.refine.applied);
for (const w of out.warnings ?? []) notify(ctx, w, "warning");
} catch (e) {
status(undefined);
if (isAbort(e)) return;
notify(ctx, italianOf(e), "error");
}
}
// ---- 7. One full-quality render -----------------------------------------
const go = await ctx.ui.confirm(S.confirm.finalRender.title, S.confirm.finalRender.message);
if (!go) {
notify(ctx, S.warnings.draftOnly, "warning");
cleanupDrafts(job);
return;
}
const warnings: string[] = [];
let artPath: string;
try {
status(S.progress.finalArt);
// `from` is what keeps the promise: the finished poster is the picture he approved,
// enlarged — not a second roll of the dice at a different size and step count.
artPath = await paintArt(deps, job, spec, "final", {
signal: ctx.signal,
from: draftArt,
onProgress: (_m, fraction) => status(withPercent(S.progress.finalArt, fraction)),
onWarning: (w) => warnings.push(w),
});
} catch (e) {
status(undefined);
if (isAbort(e)) {
notify(ctx, S.common.cancelled);
return;
}
notify(ctx, italianOf(e), "error");
fail(e);
}
let files: string[] = [];
try {
status(S.progress.exporting);
const outcome = await typeset(deps, job, spec, {
artPath,
formats: exportFormats(spec.format),
all: true,
signal: ctx.signal,
onProgress: (m) => status(m),
});
warnings.push(...(outcome.warnings ?? []));
if (deps.preflight && isPrint(spec.format)) {
status(S.progress.checkingPdf);
const pdf = jobFile(job, outputFile(spec.format, "pdf"));
if (existsSync(pdf)) {
const check = await deps.preflight(pdf).catch(() => ({ ok: true, problems: [] as string[] }));
if (!check.ok) warnings.push(errorText(S.errors.preflightFailed(check.problems)));
}
}
files = listFiles(job, spec.format);
} catch (e) {
status(undefined);
if (isAbort(e)) return;
notify(ctx, italianOf(e), "error");
fail(e);
} finally {
status(undefined);
// The exports are written: nothing in the scratch folder is worth keeping.
cleanupDrafts(job);
}
// ---- 8. Captions: ASKED FOR, never written on our own initiative ---------
const wantsCaption = await ctx.ui.confirm(S.confirm.wantCaption.title, S.confirm.wantCaption.message);
if (wantsCaption) {
try {
status(S.progress.caption);
const caption = await deps.director.generateCaption(spec, brief, ctx, cfg);
const path = jobFile(job, "caption.txt");
await withFileMutationQueue(path, async () => saveCaption(job, caption.full));
status(undefined);
notify(ctx, `${S.caption.ready}\n${caption.full}\n${S.caption.savedTo}\n${S.caption.hashtagsNote}`);
files = listFiles(job, spec.format);
} catch (e) {
status(undefined);
notify(ctx, italianOf(e), "warning");
}
} else {
notify(ctx, S.caption.declined);
}
// ---- 9. Hand it over -----------------------------------------------------
const elapsed = (deps.now ? deps.now() : Date.now()) - started;
const report = [...warnings, doneReport(job, files, spec.format, cfg, elapsed)].join("\n");
const shown = pickShown(files, spec.format);
await showPreview(pi, ctx, job, shown ?? "", S.done.header);
await sendResult(pi, report, shown);
notify(ctx, fill(S.notify.finishedNamed, { titolo: values.fields.title }));
const open = await ctx.ui.confirm(S.confirm.openFolder.title, S.confirm.openFolder.message);
if (open) {
status(S.progress.openingFolder);
const opener = deps.openFolder ?? openInFinder;
if (!opener(job.dir)) notify(ctx, errorText(S.errors.openFolderFailed(job.dir)), "warning");
status(undefined);
}
}
/** "Genero l'immagine… 40%" — the label stays Italian, the number is just a number. */
function withPercent(label: string, fraction?: number): string {
if (typeof fraction !== "number" || !Number.isFinite(fraction)) return label;
const pct = Math.max(0, Math.min(100, Math.round(fraction * 100)));
return `${label} ${pct}%`;
}
export default registerPoster;