chore: npm scripts for typecheck/test/fonts; pin tsc as a devDependency
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
import { FORMATS_GEOMETRY } from "../render/formats.ts";
|
||||
import {
|
||||
ART_FILENAME,
|
||||
CAPTION_FILENAME,
|
||||
JobError,
|
||||
blockText,
|
||||
createJob,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
normaliseSlug,
|
||||
openInFinder,
|
||||
openJob,
|
||||
reEditionSlug,
|
||||
saveCaption,
|
||||
saveSpec,
|
||||
updateBlocks,
|
||||
@@ -221,7 +223,8 @@ export async function runSocial(
|
||||
): Promise<SocialResult> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const started = now();
|
||||
const formats = [...(req.formats?.length ? req.formats : SOCIAL_FORMATS)];
|
||||
const formats = [...(req.formats ?? SOCIAL_FORMATS)];
|
||||
if (formats.length === 0) throw new Error(errorText(S.errors.exportFailed(T.nothingRendered)));
|
||||
const warnings: string[] = [];
|
||||
|
||||
const slug = normaliseSlug(req.slug);
|
||||
@@ -241,7 +244,8 @@ export async function runSocial(
|
||||
let forked = false;
|
||||
|
||||
if (changed && req.fork !== false) {
|
||||
const editionSlug = reEditionSlugFor(patched, req.newDate ?? blockText(spec, "date"));
|
||||
// `sagra-castagna-12-set` becomes `sagra-castagna-11-set`: same title, new date.
|
||||
const editionSlug = reEditionSlug(patched, req.newDate ?? blockText(spec, "date") ?? null);
|
||||
target = createJob(deps.config, editionSlug);
|
||||
forked = true;
|
||||
|
||||
@@ -300,13 +304,10 @@ export async function runSocial(
|
||||
let caption: CaptionResult | undefined;
|
||||
let captionPath: string | undefined;
|
||||
if (req.caption) {
|
||||
if (!deps.director) throw new Error(errorText(S.errors.directorUnavailable));
|
||||
req.onProgress?.(S.progress.caption);
|
||||
caption = await deps.director.generateCaption(patched, briefFromSpec(patched), ctx, deps.config);
|
||||
captionPath = await withFileMutationQueue(
|
||||
`${target.dir}/${"caption.txt"}`,
|
||||
async () => saveCaption(target, caption!.full),
|
||||
);
|
||||
const written = await writeSocialCaption(target.slug, deps, ctx);
|
||||
caption = written.caption;
|
||||
captionPath = written.path;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -323,6 +324,26 @@ export async function runSocial(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption. Called ONLY after he has answered yes — never on our own initiative, and
|
||||
* never as a rewrite of copy he wrote himself. This is the one call in the file that
|
||||
* reaches a model.
|
||||
*/
|
||||
export async function writeSocialCaption(
|
||||
slug: string,
|
||||
deps: SocialDeps,
|
||||
ctx: DirectorContext = {},
|
||||
): Promise<{ path: string; caption: CaptionResult }> {
|
||||
if (!deps.director) throw new Error(errorText(S.errors.directorUnavailable));
|
||||
const job = openJob(deps.config, normaliseSlug(slug));
|
||||
const spec = loadSpec(job);
|
||||
const caption = await deps.director.generateCaption(spec, briefFromSpec(spec), ctx, deps.config);
|
||||
const path = await withFileMutationQueue(join(job.dir, CAPTION_FILENAME), async () =>
|
||||
saveCaption(job, caption.full),
|
||||
);
|
||||
return { path, caption };
|
||||
}
|
||||
|
||||
/** A Brief reconstructed from a spec — everything the caption writer needs, no model. */
|
||||
export function briefFromSpec(spec: DesignSpec): Brief {
|
||||
return {
|
||||
@@ -431,19 +452,16 @@ async function socialCommand(
|
||||
if (wants) {
|
||||
ctx.ui.setStatus(STATUS_KEY, S.progress.caption);
|
||||
try {
|
||||
const withCaption = await runSocial(
|
||||
{ slug: result.slug, formats: [], caption: true, onProgress: () => {} },
|
||||
deps,
|
||||
ctx,
|
||||
);
|
||||
result.captionPath = withCaption.captionPath;
|
||||
result.caption = withCaption.caption;
|
||||
if (withCaption.caption) {
|
||||
say(pi, [S.caption.ready, "", withCaption.caption.full, "", S.caption.hashtagsNote].join("\n"));
|
||||
say(pi, S.caption.savedTo);
|
||||
}
|
||||
// Only the caption is regenerated: the images are already on disk.
|
||||
const written = await writeSocialCaption(result.slug, deps, ctx);
|
||||
result.captionPath = written.path;
|
||||
result.caption = written.caption;
|
||||
result.usedModel = true;
|
||||
say(pi, [S.caption.ready, "", written.caption.full, "", S.caption.hashtagsNote].join("\n"));
|
||||
say(pi, S.caption.savedTo);
|
||||
} catch (e) {
|
||||
ctx.ui.notify(messageOf(e), "warning");
|
||||
// A failed caption must not lose the images that already succeeded.
|
||||
ctx.ui.notify(italianOf(e), "warning");
|
||||
}
|
||||
} else {
|
||||
say(pi, S.caption.declined);
|
||||
@@ -711,15 +729,6 @@ function titleOf(deps: SocialDeps, slug: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug for the new edition. Uses job.ts's helper, which keeps the title and swaps the
|
||||
* date part — `sagra-castagna-12-set` becomes `sagra-castagna-11-set`.
|
||||
*/
|
||||
function reEditionSlugFor(spec: DesignSpec, date: string | undefined): string {
|
||||
// Imported lazily by name to keep the dependency obvious at the call site.
|
||||
return reEdition(spec, date ?? null);
|
||||
}
|
||||
|
||||
function emptySummary(slug: string): JobSummary {
|
||||
return {
|
||||
slug,
|
||||
|
||||
@@ -265,6 +265,76 @@ export const S = {
|
||||
deleted: "Ho cancellato lo stile «{nome}».",
|
||||
applied: "Sto usando lo stile «{nome}».",
|
||||
defaultSet: "D'ora in poi parto da «{nome}».",
|
||||
|
||||
// --- /presets: il gestore degli stili salvati ---
|
||||
intro: "Uno stile salvato è l'insieme di colori, carattere e logo che riusi ogni volta.",
|
||||
actions: {
|
||||
list: "Guarda quelli che hai",
|
||||
create: "Crea uno stile nuovo",
|
||||
edit: "Modifica uno stile",
|
||||
remove: "Cancella uno stile",
|
||||
setDefault: "Scegli da quale partire",
|
||||
close: "Chiudi",
|
||||
},
|
||||
order: ["list", "create", "edit", "remove", "setDefault", "close"] as const,
|
||||
pick: "Quale stile?",
|
||||
listHeader: "I tuoi stili salvati:",
|
||||
listEmpty: "Non hai ancora salvato nessuno stile.",
|
||||
listHint: "Scrivi /presets e scegli «Crea uno stile nuovo».",
|
||||
isDefault: "— parto da questo",
|
||||
detailTitle: "Lo stile «{nome}»",
|
||||
fieldLabels: {
|
||||
palette: "Colori",
|
||||
fonts: "Carattere",
|
||||
logo: "Logo",
|
||||
tone: "Descrizione",
|
||||
},
|
||||
moodQuestion: "Che aria deve avere?",
|
||||
moodKeep: "Lascia com'è",
|
||||
fontsQuestion: "Che carattere usiamo?",
|
||||
fontsKeep: "Quello che viene con l'aria che hai scelto",
|
||||
coloursQuestion: "Vuoi mettere i colori tuoi?",
|
||||
colourAsk: "{campo} — scrivilo così: #B4121F",
|
||||
colourKeep: "Lascia vuoto per tenere quello che c'è.",
|
||||
colourFields: {
|
||||
bg: "Sfondo",
|
||||
ink: "Testo",
|
||||
accent: "Colore in evidenza",
|
||||
scrim: "Velo dietro le scritte",
|
||||
},
|
||||
colourInvalid: "«{valore}» non è un colore che so leggere: ci vuole qualcosa come #1A2B3C.",
|
||||
colourAdjusted: "Ho ritoccato qualche colore perché le scritte restassero leggibili:",
|
||||
logoQuestion: "Dov'è il file del logo?",
|
||||
logoPlaceholder: "~/Immagini/logo-band.png",
|
||||
logoHint: "Trascina qui il file. Lascia vuoto se non hai un logo.",
|
||||
logoNone: "Nessun logo",
|
||||
logoNotAFile: "«{percorso}» non è un file: controlla il percorso.",
|
||||
logoBadType: "Il logo dev'essere un PNG, un JPG o un SVG.",
|
||||
toneQuestion: "Come lo descriveresti in due parole?",
|
||||
tonePlaceholder: "caldo, artigianale, un po' anni Settanta",
|
||||
toneHint: "Mi serve per dare il tono alle immagini. Puoi lasciarlo vuoto.",
|
||||
summaryTitle: "Ecco com'è «{nome}»",
|
||||
confirmSave: "Lo salvo?",
|
||||
defaultQuestion: "Vuoi che d'ora in poi parta da questo?",
|
||||
defaultNone: "Nessuno: ogni volta parto da zero.",
|
||||
defaultCleared: "Va bene: d'ora in poi parto da zero ogni volta.",
|
||||
editWhat: "Cosa cambio di «{nome}»?",
|
||||
editFields: {
|
||||
label: "Il nome",
|
||||
mood: "L'aria: colori e carattere insieme",
|
||||
colours: "Solo i colori",
|
||||
fonts: "Solo il carattere",
|
||||
logo: "Il logo",
|
||||
tone: "La descrizione",
|
||||
done: "Ho finito",
|
||||
},
|
||||
editOrder: ["label", "mood", "colours", "fonts", "logo", "tone", "done"] as const,
|
||||
updated: "Ho aggiornato lo stile «{nome}».",
|
||||
nameMissing: "Mi serve un nome, altrimenti non so come richiamarlo.",
|
||||
nameBad: "Quel nome non va bene: usa lettere e numeri, per esempio «La mia band».",
|
||||
nothingChanged: "Non ho cambiato niente.",
|
||||
savedWhere: "L'ho scritto nelle tue impostazioni: {dove}",
|
||||
needsTerminal: "Per creare o modificare uno stile mi serve il terminale: apri pi e scrivi /presets.",
|
||||
},
|
||||
|
||||
progress: {
|
||||
@@ -537,6 +607,11 @@ export const S = {
|
||||
fix: "Scrivi /presets per vedere quelli che hai.",
|
||||
detail: name,
|
||||
}),
|
||||
configWriteFailed: (path: string, detail?: string): ErrorMessage => ({
|
||||
message: "Non riesco a salvare le tue impostazioni.",
|
||||
fix: fill("Controlla di poter scrivere in {dove}.", { dove: path }),
|
||||
detail,
|
||||
}),
|
||||
configBroken: (path: string, detail?: string): ErrorMessage => ({
|
||||
message: "Le tue impostazioni non si leggono, così vado avanti con quelle standard.",
|
||||
fix: fill("Se vuoi sistemarle, il file è {dove}.", { dove: path }),
|
||||
|
||||
+22
-3
@@ -2,14 +2,22 @@
|
||||
"name": "pi-imgen",
|
||||
"version": "0.1.0",
|
||||
"description": "Genera locandine, loghi e immagini per eventi - pi extension (local, Apple Silicon)",
|
||||
"keywords": ["pi-package", "pi-extension", "image-generation", "poster", "typst"],
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"image-generation",
|
||||
"poster",
|
||||
"typst"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.sal.giize.com/mozempk/pi-imgen.git"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": ["./extensions/imgen"]
|
||||
"extensions": [
|
||||
"./extensions/imgen"
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -22,5 +30,16 @@
|
||||
"@earendil-works/pi-tui": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"engines": { "node": ">=20" }
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "tests/render-fixtures.sh",
|
||||
"fonts": "scripts/fetch-fonts.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"@types/node": "^20.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +183,14 @@
|
||||
|
||||
// The footer band, reserved out of the stack's height before anything is measured.
|
||||
#let footer-style = block-style(spec, "footer")
|
||||
#let footer-line-box = lines-height(footer-style, _LINES.footer)
|
||||
// Glyphs paint a little past their line box — «@», an accented capital, a descender in a
|
||||
// mixed-case credit line — so the footer sits one small allowance ABOVE the safe line
|
||||
// instead of exactly on it. Measured: without it "INFO@EXAMPLE.IT" put 0.5 mm of ink
|
||||
// below the safe edge on A3.
|
||||
#let footer-descender = footer-style.size * 0.20
|
||||
#let footer-band = if footers.len() == 0 { 0pt } else {
|
||||
lines-height(footer-style, _LINES.footer) + gap * 1.2
|
||||
footer-line-box + gap * 1.2 + footer-descender
|
||||
}
|
||||
|
||||
#let avail = sa.height - 2 * lift - footer-band
|
||||
@@ -360,7 +366,7 @@
|
||||
dy: sa.safe + sa.height - footer-band,
|
||||
box(
|
||||
width: footer-width,
|
||||
height: footer-band,
|
||||
height: footer-band - footer-descender,
|
||||
align(center + bottom, footer-stack),
|
||||
),
|
||||
)
|
||||
|
||||
+16
-6
@@ -74,6 +74,15 @@
|
||||
#let GAP-FLOOR = 0.45
|
||||
#let MINOR-FLOOR = 0.70
|
||||
|
||||
/// Width of the coloured frame: the margin between the trim and everything inside it.
|
||||
/// Never narrower than the safe margin, never so wide that it eats the picture — 18% of
|
||||
/// the short edge per side is already a very deep mount. The logo is aligned to this
|
||||
/// same edge, which is why it is a function rather than a local.
|
||||
#let frame-of(spec) = {
|
||||
let sa = safe-area(spec)
|
||||
calc.min(calc.max(sa.safe, sa.short-edge * FRAME-RATIO), sa.short-edge * 0.18)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -393,9 +402,7 @@
|
||||
let tw = sa.trim-width
|
||||
let th = sa.trim-height
|
||||
|
||||
// The frame is never narrower than the safe margin and never so wide that it eats the
|
||||
// picture — 18% of the short edge per side is already a very deep mount.
|
||||
let frame = calc.min(calc.max(sa.safe, short * FRAME-RATIO), short * 0.18)
|
||||
let frame = frame-of(spec)
|
||||
let gap = short * GAP-RATIO
|
||||
let lg = logo-reserve(spec, short)
|
||||
let items = entries(spec)
|
||||
@@ -442,7 +449,7 @@
|
||||
// A logo in a top corner would land on the picture, so the top frame grows to clear
|
||||
// it; in a bottom corner it gets its own strip beneath the type band, which is how
|
||||
// civic posters carry their patron's mark anyway.
|
||||
let clearance = calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame)
|
||||
let clearance = lg.size + gap * 0.6
|
||||
let top-extra = if lg.corner in ("tl", "tr") { clearance } else { 0pt }
|
||||
let bottom-extra = if lg.corner in ("bl", "br") { clearance } else { 0pt }
|
||||
|
||||
@@ -454,7 +461,7 @@
|
||||
// The band gets what it asks for, capped so the picture keeps its floor. What it does
|
||||
// not use goes to the artwork rather than to slack: a short title means a big picture.
|
||||
let room = band-bottom - art-y - gap - th * ART-MIN
|
||||
let max-band = calc.min(th * BAND-MAX, calc.max(0pt, room))
|
||||
let max-band = calc.max(th * 0.12, calc.min(th * BAND-MAX, room))
|
||||
let plan = budget(items, art-w, gap, max-band)
|
||||
|
||||
let band-y = band-bottom - plan.height
|
||||
@@ -489,7 +496,10 @@
|
||||
fill: pal.bg,
|
||||
background: compose(spec),
|
||||
foreground: {
|
||||
logo-place(spec)
|
||||
// Aligned to the frame's inner edge, not to the safe corner: the logo then shares a
|
||||
// margin with the picture and the type instead of hanging out towards the trim. The
|
||||
// composition reserves exactly this much room for it.
|
||||
logo-place(spec, margin: sa.bleed + frame-of(spec))
|
||||
crop-marks(spec)
|
||||
},
|
||||
)
|
||||
|
||||
+9
-3
@@ -3,9 +3,15 @@
|
||||
//
|
||||
// The idea is editorial rather than pictorial. There is no soft blend, no text over a
|
||||
// photograph, no drop shadow doing the work a scrim should: a single straight seam cuts
|
||||
// the piece in two, the artwork bleeds off three edges of its half, and the type sits on
|
||||
// clean `palette.bg` where `ink_resolved` was contrast-checked against a flat colour and
|
||||
// is therefore actually true.
|
||||
// the piece in two, the artwork bleeds off three edges of its half, and every word sits
|
||||
// on flat `palette.bg`.
|
||||
//
|
||||
// That last point decides the colour handling. The ink the renderer measured
|
||||
// (`ink_on_art` / legacy `ink_resolved`) was chosen against the PICTURE, and no type
|
||||
// here ever touches the picture — so every call passes `surface: "bg"` and lets
|
||||
// `palette-of` / `block-style` re-derive the ink and the accent by contrast against the
|
||||
// flat half. Inheriting an ink picked for a photograph is the one colour bug this layout
|
||||
// is uniquely exposed to.
|
||||
//
|
||||
// The split axis follows the aspect, which is the only thing that changes between an A3
|
||||
// and a YouTube thumbnail:
|
||||
|
||||
Reference in New Issue
Block a user