@hiper2d/ai-agents 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/images.d.mts +326 -0
- package/dist/images.d.ts +326 -0
- package/dist/images.js +412 -0
- package/dist/images.js.map +1 -0
- package/dist/images.mjs +352 -0
- package/dist/images.mjs.map +1 -0
- package/dist/index.js +3 -3
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -74,6 +74,32 @@ Transcribe (`VOICE_MODEL_CONSTANTS`, prices in `VOICE_MODEL_PRICING`). The `voic
|
|
|
74
74
|
direction works for both providers: OpenAI takes it as instructions, Gemini gets it folded
|
|
75
75
|
into the prompt ("Say gravely: …").
|
|
76
76
|
|
|
77
|
+
### Images and portrait sheets (`@hiper2d/ai-agents/images`)
|
|
78
|
+
|
|
79
|
+
A separate entry for hosts that draw pictures. `generateImage` is one Gemini image call
|
|
80
|
+
that reports its cost; `drawPortraitSheet` draws a whole cast as one grid of bust
|
|
81
|
+
portraits and cuts it into one 3:4 card per character — one image call whether the cast
|
|
82
|
+
is three or sixteen, all in one consistent style. The divider lines the model draws are
|
|
83
|
+
read off the pixels (rows drift), and the kept sheet plus per-card framing let the host
|
|
84
|
+
re-cut any card later at a new crop. The library never imports sharp: pass your own
|
|
85
|
+
instance in, so text-only consumers pull in nothing native.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { drawPortraitSheet, cutCard } from '@hiper2d/ai-agents/images';
|
|
89
|
+
|
|
90
|
+
const sharp = (await import('sharp')).default;
|
|
91
|
+
const { portraits, sheet, costUSD } = await drawPortraitSheet(googleKey, sharp, {
|
|
92
|
+
purpose: 'a social deduction game',
|
|
93
|
+
setting: { title: 'Harbor of Glass', description: 'A rain-soaked port city of lantern-lit canals.' },
|
|
94
|
+
artStyle: 'ink and watercolor',
|
|
95
|
+
cells: cast.map(c => ({ key: c.id, label: c.name, prompt: `(${c.gender}) "${c.name}" — ${c.look}` })),
|
|
96
|
+
});
|
|
97
|
+
// portraits[i].jpeg is a 600x800 card, portraits[i].framing says where it sits on sheet.jpeg
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The pure geometry (`fitFraming`, `circleFocus`, `focusToBackground`, …) is safe in a
|
|
101
|
+
browser bundle for reframe editors and avatar renderers.
|
|
102
|
+
|
|
77
103
|
### Budget control
|
|
78
104
|
|
|
79
105
|
Per-subject spend caps (a user, a tenant, a job) over UTC day and month windows. Pure and
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless
|
|
3
|
+
* stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.
|
|
4
|
+
*/
|
|
5
|
+
/** A rectangle in image pixels (sharp's `extract` shape). */
|
|
6
|
+
interface ImageRect {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
interface ImageSize {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
/** The round avatar inside a card. `x` and `d` are fractions of the card's width,
|
|
17
|
+
* `y` a fraction of its height. */
|
|
18
|
+
interface AvatarCircle {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
d: number;
|
|
22
|
+
}
|
|
23
|
+
/** Where a portrait comes from on its sheet: the card cut and the circle in it. */
|
|
24
|
+
interface AvatarFraming {
|
|
25
|
+
card: ImageRect;
|
|
26
|
+
circle: AvatarCircle;
|
|
27
|
+
}
|
|
28
|
+
/** The portion of an image to display, as fractions of its width/height. */
|
|
29
|
+
interface ImageFocus {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
w: number;
|
|
33
|
+
h: number;
|
|
34
|
+
}
|
|
35
|
+
declare const CARD_ASPECT: number;
|
|
36
|
+
declare const CARD_WIDTH_PX = 600;
|
|
37
|
+
declare const CARD_HEIGHT_PX = 800;
|
|
38
|
+
declare const DEFAULT_AVATAR_CIRCLE: AvatarCircle;
|
|
39
|
+
declare const MIN_CARD_HEIGHT_FRACTION = 0.12;
|
|
40
|
+
/** One character on a portrait sheet. */
|
|
41
|
+
interface PortraitCell {
|
|
42
|
+
/** Stable id the caller stores the crop under; never drawn into the image. */
|
|
43
|
+
key: string;
|
|
44
|
+
/** Character name, for logs and prompt guidance; never drawn into the image. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** One-line visual description for this cell (gender, name, appearance). */
|
|
47
|
+
prompt: string;
|
|
48
|
+
}
|
|
49
|
+
/** A card cut from a drawn sheet. */
|
|
50
|
+
interface PortraitSlice {
|
|
51
|
+
key: string;
|
|
52
|
+
label: string;
|
|
53
|
+
jpeg: Buffer;
|
|
54
|
+
/** The framing the slicer chose: the card on the sheet and the default circle. */
|
|
55
|
+
framing: AvatarFraming;
|
|
56
|
+
}
|
|
57
|
+
/** The kept sheet: the grid image itself (re-encoded), its size and the cells
|
|
58
|
+
* the cards were cut from — enough to re-cut any card later at a new framing. */
|
|
59
|
+
interface DrawnSheet {
|
|
60
|
+
jpeg: Buffer;
|
|
61
|
+
width: number;
|
|
62
|
+
height: number;
|
|
63
|
+
cells: ImageRect[];
|
|
64
|
+
/** false = no divider lines were found and the cells are the equal split of the
|
|
65
|
+
* requested grid; true = the cells between the lines the model actually drew. */
|
|
66
|
+
detected: boolean;
|
|
67
|
+
}
|
|
68
|
+
interface GeneratedImage {
|
|
69
|
+
buffer: Buffer;
|
|
70
|
+
costUSD: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The slice of sharp's API the pipeline uses. The library never imports sharp — a
|
|
74
|
+
* native module the host installs and passes in (`(await import('sharp')).default`) —
|
|
75
|
+
* so text-only consumers pull in nothing new.
|
|
76
|
+
*/
|
|
77
|
+
type SharpLike = (input: Buffer) => {
|
|
78
|
+
resize(options: {
|
|
79
|
+
width: number;
|
|
80
|
+
withoutEnlargement?: boolean;
|
|
81
|
+
}): SharpPipeline;
|
|
82
|
+
resize(width: number, height: number): SharpPipeline;
|
|
83
|
+
greyscale(): SharpPipeline;
|
|
84
|
+
extract(rect: ImageRect): SharpPipeline;
|
|
85
|
+
jpeg(options: {
|
|
86
|
+
quality: number;
|
|
87
|
+
mozjpeg?: boolean;
|
|
88
|
+
}): SharpPipeline;
|
|
89
|
+
raw(): SharpPipeline;
|
|
90
|
+
toBuffer(): Promise<Buffer>;
|
|
91
|
+
toBuffer(options: {
|
|
92
|
+
resolveWithObject: true;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
data: Buffer;
|
|
95
|
+
info: {
|
|
96
|
+
width: number;
|
|
97
|
+
height: number;
|
|
98
|
+
};
|
|
99
|
+
}>;
|
|
100
|
+
metadata(): Promise<{
|
|
101
|
+
width?: number;
|
|
102
|
+
height?: number;
|
|
103
|
+
}>;
|
|
104
|
+
};
|
|
105
|
+
type SharpPipeline = ReturnType<SharpLike>;
|
|
106
|
+
|
|
107
|
+
/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:
|
|
108
|
+
* portraits are busts with the head near the top, so the top edge is the
|
|
109
|
+
* anchor that keeps the face in. */
|
|
110
|
+
declare function cardInCell(cell: ImageRect): ImageRect;
|
|
111
|
+
declare function defaultFraming(cell: ImageRect): AvatarFraming;
|
|
112
|
+
/** Keeps the circle inside its card. */
|
|
113
|
+
declare function fitCircle(circle: AvatarCircle): AvatarCircle;
|
|
114
|
+
/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the
|
|
115
|
+
* minimum size. Width is the free variable; height follows the aspect. */
|
|
116
|
+
declare function fitCard(card: ImageRect, sheet: ImageSize): ImageRect;
|
|
117
|
+
declare function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming;
|
|
118
|
+
/** True when the framing is a well-formed object with finite numbers. Shape
|
|
119
|
+
* only — call fitFraming for the geometric bounds. */
|
|
120
|
+
declare function isFramingShape(value: unknown): value is AvatarFraming;
|
|
121
|
+
/** The circle as a focus on the CARD image itself (a stored card cut). */
|
|
122
|
+
declare function circleFocus(circle: AvatarCircle): ImageFocus;
|
|
123
|
+
/** The card as a focus on its sheet. */
|
|
124
|
+
declare function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus;
|
|
125
|
+
/** The circle as a focus on the SHEET (card and circle composed) — how a preset
|
|
126
|
+
* mannequin renders straight off a static sheet. */
|
|
127
|
+
declare function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus;
|
|
128
|
+
/** CSS `background-size` / `background-position` that shows exactly `focus`
|
|
129
|
+
* of an image inside a box. */
|
|
130
|
+
declare function focusToBackground(focus: ImageFocus): {
|
|
131
|
+
backgroundSize: string;
|
|
132
|
+
backgroundPosition: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Finds the cells of a drawn portrait sheet from its pixels.
|
|
137
|
+
*
|
|
138
|
+
* The prompt asks for equal cells separated by thin dark divider lines, but
|
|
139
|
+
* the model draws the lines where it likes: on a correctly drawn 4x4 sheet
|
|
140
|
+
* the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448
|
|
141
|
+
* (2026-09-02), so fixed-interval slicing cut lower rows through the previous
|
|
142
|
+
* character's chest. The lines themselves are crisp — near-black across the
|
|
143
|
+
* full width — so they are easy to find, and the cells between them are the
|
|
144
|
+
* real ones. Columns have always measured exact; rows drift.
|
|
145
|
+
*
|
|
146
|
+
* Pure over a greyscale byte plane so it is testable without sharp.
|
|
147
|
+
*/
|
|
148
|
+
interface GreyPlane {
|
|
149
|
+
width: number;
|
|
150
|
+
height: number;
|
|
151
|
+
data: Uint8Array;
|
|
152
|
+
}
|
|
153
|
+
interface SheetGrid {
|
|
154
|
+
cells: ImageRect[];
|
|
155
|
+
cols: number;
|
|
156
|
+
rows: number;
|
|
157
|
+
detected: boolean;
|
|
158
|
+
}
|
|
159
|
+
interface Run {
|
|
160
|
+
start: number;
|
|
161
|
+
end: number;
|
|
162
|
+
}
|
|
163
|
+
/** Divider runs across the whole plane: row lines over the full width,
|
|
164
|
+
* column lines over the full height. */
|
|
165
|
+
declare function findDividers(plane: GreyPlane): {
|
|
166
|
+
rows: Run[];
|
|
167
|
+
cols: Run[];
|
|
168
|
+
};
|
|
169
|
+
declare function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid;
|
|
170
|
+
/**
|
|
171
|
+
* The sheet's cells. Read off the divider lines the model drew whenever they
|
|
172
|
+
* form a plausible grid, even when that grid differs from the cols x rows
|
|
173
|
+
* that were asked for — the model keeps row-major order, so the drawn cells
|
|
174
|
+
* still map to the characters in sequence. Row lines are found over the
|
|
175
|
+
* full width; column lines are found PER ROW BAND, because the model has
|
|
176
|
+
* drawn rows with different column layouts (and a column line that stops
|
|
177
|
+
* short of the last row). A band without lines of its own borrows the
|
|
178
|
+
* layout of the band above it. The equal split of the requested grid is the
|
|
179
|
+
* last resort when no lines are found at all (the caller logs it; the kept
|
|
180
|
+
* sheet lets the owner reframe by hand either way).
|
|
181
|
+
*/
|
|
182
|
+
declare function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid;
|
|
183
|
+
/** What findDividers saw, for the mismatch log line. */
|
|
184
|
+
declare function describeDividers(plane: GreyPlane): string;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Image-generation models: platform-side, never user-selected. Prices per 1M tokens
|
|
188
|
+
* from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens
|
|
189
|
+
* per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower
|
|
190
|
+
* resolution, is what minimizes cost.
|
|
191
|
+
*/
|
|
192
|
+
declare const IMAGE_MODEL_CONSTANTS: {
|
|
193
|
+
readonly GEMINI_FLASH_IMAGE: "gemini-3.1-flash-image";
|
|
194
|
+
};
|
|
195
|
+
type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];
|
|
196
|
+
interface ImageModelPricing {
|
|
197
|
+
imageOutputPricePerM: number;
|
|
198
|
+
textInputPricePerM: number;
|
|
199
|
+
}
|
|
200
|
+
declare const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing>;
|
|
201
|
+
declare const DEFAULT_IMAGE_MODEL: ImageModelId;
|
|
202
|
+
/** Cost of one image call from the usage Gemini reports. */
|
|
203
|
+
declare function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number;
|
|
204
|
+
|
|
205
|
+
type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';
|
|
206
|
+
interface GenerateImageOptions {
|
|
207
|
+
/** Labeled reference JPEGs (an established scene, character portraits) that
|
|
208
|
+
* anchor the drawing to a known style, place or faces. */
|
|
209
|
+
references?: {
|
|
210
|
+
label: string;
|
|
211
|
+
jpeg: Buffer;
|
|
212
|
+
}[];
|
|
213
|
+
imageSize?: '1K' | '2K';
|
|
214
|
+
model?: ImageModelId;
|
|
215
|
+
/** Test seam / custom transport; defaults to the global fetch. */
|
|
216
|
+
fetchImpl?: typeof fetch;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* One image-model call on Gemini's Interactions API. Pure — no auth, no billing —
|
|
220
|
+
* and the result reports its cost so the host decides whom to charge. Cost is per
|
|
221
|
+
* IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).
|
|
222
|
+
*/
|
|
223
|
+
declare function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts?: GenerateImageOptions): Promise<GeneratedImage>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Portrait sheets: one image-model call draws a whole cast as a grid of bust
|
|
227
|
+
* portraits, and the grid is cut into one card per character. One call costs the
|
|
228
|
+
* same whether it draws one face or sixteen, so this is the cheap way to give
|
|
229
|
+
* every character in a game a portrait in a single consistent style.
|
|
230
|
+
*
|
|
231
|
+
* Nothing here touches storage or billing: the host stores the cards and the kept
|
|
232
|
+
* sheet (so a card can be re-cut later at a new framing) and charges whoever it
|
|
233
|
+
* likes for `costUSD`. sharp is passed in, never imported.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* Grid dimensions by cell count on the 4:3 canvas. Cells must come out
|
|
237
|
+
* square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the
|
|
238
|
+
* sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards
|
|
239
|
+
* taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,
|
|
240
|
+
* one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell
|
|
241
|
+
* cast to 16.
|
|
242
|
+
*/
|
|
243
|
+
declare function gridFor(cells: number): {
|
|
244
|
+
cols: number;
|
|
245
|
+
rows: number;
|
|
246
|
+
};
|
|
247
|
+
declare const DEFAULT_FILLER_PROMPT = "\"Stranger\" \u2014 an anonymous hooded figure fitting the setting, face hidden in shadow";
|
|
248
|
+
/**
|
|
249
|
+
* Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the
|
|
250
|
+
* spare ones get an anonymous filler that is drawn and discarded.
|
|
251
|
+
*/
|
|
252
|
+
declare function padCells(cells: PortraitCell[], fillerPrompt?: string): {
|
|
253
|
+
cells: PortraitCell[];
|
|
254
|
+
cols: number;
|
|
255
|
+
rows: number;
|
|
256
|
+
realCount: number;
|
|
257
|
+
};
|
|
258
|
+
interface PortraitSheetPrompt {
|
|
259
|
+
cells: PortraitCell[];
|
|
260
|
+
cols: number;
|
|
261
|
+
rows: number;
|
|
262
|
+
/** What the sheet is for, e.g. "a social deduction game" — one noun phrase. */
|
|
263
|
+
purpose: string;
|
|
264
|
+
/** The world the characters live in: a title and a short description. */
|
|
265
|
+
setting: {
|
|
266
|
+
title: string;
|
|
267
|
+
description: string;
|
|
268
|
+
};
|
|
269
|
+
/** Player-chosen art direction, already sanitized by the host. Replaces the
|
|
270
|
+
* model's free choice of style. */
|
|
271
|
+
artStyle?: string;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The sheet prompt. The wording is load-bearing: equal cells with thin dark
|
|
275
|
+
* divider lines are what the slicer finds, one flat muted background per cell keeps
|
|
276
|
+
* the lines detectable in any art style, and the no-text rule is repeated because
|
|
277
|
+
* the model otherwise typesets names and bios into the image.
|
|
278
|
+
*/
|
|
279
|
+
declare function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string;
|
|
280
|
+
declare const SHEET_MAX_WIDTH = 2400;
|
|
281
|
+
declare const SHEET_JPEG_QUALITY = 85;
|
|
282
|
+
declare const SHEET_MAX_BASE64_BYTES = 900000;
|
|
283
|
+
/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */
|
|
284
|
+
interface SheetMismatch {
|
|
285
|
+
kind: 'no-dividers' | 'different-grid';
|
|
286
|
+
message: string;
|
|
287
|
+
detail: Record<string, unknown>;
|
|
288
|
+
}
|
|
289
|
+
interface SliceSheetOptions {
|
|
290
|
+
onMismatch?: (mismatch: SheetMismatch) => void;
|
|
291
|
+
/** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */
|
|
292
|
+
maxWidth?: number;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the
|
|
296
|
+
* divider lines the model drew (equal split as a last resort); each card is the
|
|
297
|
+
* largest 3:4 rectangle in its cell, top-anchored, with the default circle — the
|
|
298
|
+
* framing the host can let a user move later.
|
|
299
|
+
*/
|
|
300
|
+
declare function sliceSheet(sharp: SharpLike, raw: Buffer, cells: PortraitCell[], count: number, cols: number, rows: number, opts?: SliceSheetOptions): Promise<{
|
|
301
|
+
slices: PortraitSlice[];
|
|
302
|
+
sheet: DrawnSheet;
|
|
303
|
+
}>;
|
|
304
|
+
/** Cuts one card out of a stored sheet at the given framing. */
|
|
305
|
+
declare function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer>;
|
|
306
|
+
interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {
|
|
307
|
+
/** Prompt for the cells that pad the cast out to a full grid. */
|
|
308
|
+
fillerPrompt?: string;
|
|
309
|
+
}
|
|
310
|
+
interface DrawPortraitSheetOptions extends SliceSheetOptions {
|
|
311
|
+
image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;
|
|
312
|
+
}
|
|
313
|
+
interface DrawnPortraitSheet {
|
|
314
|
+
portraits: PortraitSlice[];
|
|
315
|
+
sheet: DrawnSheet;
|
|
316
|
+
/** What the image call cost, reported for the host's billing. */
|
|
317
|
+
costUSD: number;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The whole pipeline: lay the cast out on a grid, draw it in one call, cut the
|
|
321
|
+
* cards. Filler cells are drawn and discarded; `portraits` has exactly one entry
|
|
322
|
+
* per input cell, in order.
|
|
323
|
+
*/
|
|
324
|
+
declare function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts?: DrawPortraitSheetOptions): Promise<DrawnPortraitSheet>;
|
|
325
|
+
|
|
326
|
+
export { type AvatarCircle, type AvatarFraming, CARD_ASPECT, CARD_HEIGHT_PX, CARD_WIDTH_PX, DEFAULT_AVATAR_CIRCLE, DEFAULT_FILLER_PROMPT, DEFAULT_IMAGE_MODEL, type DrawPortraitSheetOptions, type DrawPortraitSheetSpec, type DrawnPortraitSheet, type DrawnSheet, type GenerateImageOptions, type GeneratedImage, type GreyPlane, IMAGE_MODEL_CONSTANTS, IMAGE_MODEL_PRICING, type ImageAspectRatio, type ImageFocus, type ImageModelId, type ImageModelPricing, type ImageRect, type ImageSize, MIN_CARD_HEIGHT_FRACTION, type PortraitCell, type PortraitSheetPrompt, type PortraitSlice, SHEET_JPEG_QUALITY, SHEET_MAX_BASE64_BYTES, SHEET_MAX_WIDTH, type SharpLike, type SharpPipeline, type SheetGrid, type SheetMismatch, type SliceSheetOptions, buildPortraitSheetPrompt, calculateImageCost, cardFocus, cardInCell, circleFocus, circleFocusOnSheet, cutCard, defaultFraming, describeDividers, detectSheetGrid, drawPortraitSheet, equalSplitGrid, findDividers, fitCard, fitCircle, fitFraming, focusToBackground, generateImage, gridFor, isFramingShape, padCells, sliceSheet };
|
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless
|
|
3
|
+
* stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.
|
|
4
|
+
*/
|
|
5
|
+
/** A rectangle in image pixels (sharp's `extract` shape). */
|
|
6
|
+
interface ImageRect {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
interface ImageSize {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
/** The round avatar inside a card. `x` and `d` are fractions of the card's width,
|
|
17
|
+
* `y` a fraction of its height. */
|
|
18
|
+
interface AvatarCircle {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
d: number;
|
|
22
|
+
}
|
|
23
|
+
/** Where a portrait comes from on its sheet: the card cut and the circle in it. */
|
|
24
|
+
interface AvatarFraming {
|
|
25
|
+
card: ImageRect;
|
|
26
|
+
circle: AvatarCircle;
|
|
27
|
+
}
|
|
28
|
+
/** The portion of an image to display, as fractions of its width/height. */
|
|
29
|
+
interface ImageFocus {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
w: number;
|
|
33
|
+
h: number;
|
|
34
|
+
}
|
|
35
|
+
declare const CARD_ASPECT: number;
|
|
36
|
+
declare const CARD_WIDTH_PX = 600;
|
|
37
|
+
declare const CARD_HEIGHT_PX = 800;
|
|
38
|
+
declare const DEFAULT_AVATAR_CIRCLE: AvatarCircle;
|
|
39
|
+
declare const MIN_CARD_HEIGHT_FRACTION = 0.12;
|
|
40
|
+
/** One character on a portrait sheet. */
|
|
41
|
+
interface PortraitCell {
|
|
42
|
+
/** Stable id the caller stores the crop under; never drawn into the image. */
|
|
43
|
+
key: string;
|
|
44
|
+
/** Character name, for logs and prompt guidance; never drawn into the image. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** One-line visual description for this cell (gender, name, appearance). */
|
|
47
|
+
prompt: string;
|
|
48
|
+
}
|
|
49
|
+
/** A card cut from a drawn sheet. */
|
|
50
|
+
interface PortraitSlice {
|
|
51
|
+
key: string;
|
|
52
|
+
label: string;
|
|
53
|
+
jpeg: Buffer;
|
|
54
|
+
/** The framing the slicer chose: the card on the sheet and the default circle. */
|
|
55
|
+
framing: AvatarFraming;
|
|
56
|
+
}
|
|
57
|
+
/** The kept sheet: the grid image itself (re-encoded), its size and the cells
|
|
58
|
+
* the cards were cut from — enough to re-cut any card later at a new framing. */
|
|
59
|
+
interface DrawnSheet {
|
|
60
|
+
jpeg: Buffer;
|
|
61
|
+
width: number;
|
|
62
|
+
height: number;
|
|
63
|
+
cells: ImageRect[];
|
|
64
|
+
/** false = no divider lines were found and the cells are the equal split of the
|
|
65
|
+
* requested grid; true = the cells between the lines the model actually drew. */
|
|
66
|
+
detected: boolean;
|
|
67
|
+
}
|
|
68
|
+
interface GeneratedImage {
|
|
69
|
+
buffer: Buffer;
|
|
70
|
+
costUSD: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The slice of sharp's API the pipeline uses. The library never imports sharp — a
|
|
74
|
+
* native module the host installs and passes in (`(await import('sharp')).default`) —
|
|
75
|
+
* so text-only consumers pull in nothing new.
|
|
76
|
+
*/
|
|
77
|
+
type SharpLike = (input: Buffer) => {
|
|
78
|
+
resize(options: {
|
|
79
|
+
width: number;
|
|
80
|
+
withoutEnlargement?: boolean;
|
|
81
|
+
}): SharpPipeline;
|
|
82
|
+
resize(width: number, height: number): SharpPipeline;
|
|
83
|
+
greyscale(): SharpPipeline;
|
|
84
|
+
extract(rect: ImageRect): SharpPipeline;
|
|
85
|
+
jpeg(options: {
|
|
86
|
+
quality: number;
|
|
87
|
+
mozjpeg?: boolean;
|
|
88
|
+
}): SharpPipeline;
|
|
89
|
+
raw(): SharpPipeline;
|
|
90
|
+
toBuffer(): Promise<Buffer>;
|
|
91
|
+
toBuffer(options: {
|
|
92
|
+
resolveWithObject: true;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
data: Buffer;
|
|
95
|
+
info: {
|
|
96
|
+
width: number;
|
|
97
|
+
height: number;
|
|
98
|
+
};
|
|
99
|
+
}>;
|
|
100
|
+
metadata(): Promise<{
|
|
101
|
+
width?: number;
|
|
102
|
+
height?: number;
|
|
103
|
+
}>;
|
|
104
|
+
};
|
|
105
|
+
type SharpPipeline = ReturnType<SharpLike>;
|
|
106
|
+
|
|
107
|
+
/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:
|
|
108
|
+
* portraits are busts with the head near the top, so the top edge is the
|
|
109
|
+
* anchor that keeps the face in. */
|
|
110
|
+
declare function cardInCell(cell: ImageRect): ImageRect;
|
|
111
|
+
declare function defaultFraming(cell: ImageRect): AvatarFraming;
|
|
112
|
+
/** Keeps the circle inside its card. */
|
|
113
|
+
declare function fitCircle(circle: AvatarCircle): AvatarCircle;
|
|
114
|
+
/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the
|
|
115
|
+
* minimum size. Width is the free variable; height follows the aspect. */
|
|
116
|
+
declare function fitCard(card: ImageRect, sheet: ImageSize): ImageRect;
|
|
117
|
+
declare function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming;
|
|
118
|
+
/** True when the framing is a well-formed object with finite numbers. Shape
|
|
119
|
+
* only — call fitFraming for the geometric bounds. */
|
|
120
|
+
declare function isFramingShape(value: unknown): value is AvatarFraming;
|
|
121
|
+
/** The circle as a focus on the CARD image itself (a stored card cut). */
|
|
122
|
+
declare function circleFocus(circle: AvatarCircle): ImageFocus;
|
|
123
|
+
/** The card as a focus on its sheet. */
|
|
124
|
+
declare function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus;
|
|
125
|
+
/** The circle as a focus on the SHEET (card and circle composed) — how a preset
|
|
126
|
+
* mannequin renders straight off a static sheet. */
|
|
127
|
+
declare function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus;
|
|
128
|
+
/** CSS `background-size` / `background-position` that shows exactly `focus`
|
|
129
|
+
* of an image inside a box. */
|
|
130
|
+
declare function focusToBackground(focus: ImageFocus): {
|
|
131
|
+
backgroundSize: string;
|
|
132
|
+
backgroundPosition: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Finds the cells of a drawn portrait sheet from its pixels.
|
|
137
|
+
*
|
|
138
|
+
* The prompt asks for equal cells separated by thin dark divider lines, but
|
|
139
|
+
* the model draws the lines where it likes: on a correctly drawn 4x4 sheet
|
|
140
|
+
* the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448
|
|
141
|
+
* (2026-09-02), so fixed-interval slicing cut lower rows through the previous
|
|
142
|
+
* character's chest. The lines themselves are crisp — near-black across the
|
|
143
|
+
* full width — so they are easy to find, and the cells between them are the
|
|
144
|
+
* real ones. Columns have always measured exact; rows drift.
|
|
145
|
+
*
|
|
146
|
+
* Pure over a greyscale byte plane so it is testable without sharp.
|
|
147
|
+
*/
|
|
148
|
+
interface GreyPlane {
|
|
149
|
+
width: number;
|
|
150
|
+
height: number;
|
|
151
|
+
data: Uint8Array;
|
|
152
|
+
}
|
|
153
|
+
interface SheetGrid {
|
|
154
|
+
cells: ImageRect[];
|
|
155
|
+
cols: number;
|
|
156
|
+
rows: number;
|
|
157
|
+
detected: boolean;
|
|
158
|
+
}
|
|
159
|
+
interface Run {
|
|
160
|
+
start: number;
|
|
161
|
+
end: number;
|
|
162
|
+
}
|
|
163
|
+
/** Divider runs across the whole plane: row lines over the full width,
|
|
164
|
+
* column lines over the full height. */
|
|
165
|
+
declare function findDividers(plane: GreyPlane): {
|
|
166
|
+
rows: Run[];
|
|
167
|
+
cols: Run[];
|
|
168
|
+
};
|
|
169
|
+
declare function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid;
|
|
170
|
+
/**
|
|
171
|
+
* The sheet's cells. Read off the divider lines the model drew whenever they
|
|
172
|
+
* form a plausible grid, even when that grid differs from the cols x rows
|
|
173
|
+
* that were asked for — the model keeps row-major order, so the drawn cells
|
|
174
|
+
* still map to the characters in sequence. Row lines are found over the
|
|
175
|
+
* full width; column lines are found PER ROW BAND, because the model has
|
|
176
|
+
* drawn rows with different column layouts (and a column line that stops
|
|
177
|
+
* short of the last row). A band without lines of its own borrows the
|
|
178
|
+
* layout of the band above it. The equal split of the requested grid is the
|
|
179
|
+
* last resort when no lines are found at all (the caller logs it; the kept
|
|
180
|
+
* sheet lets the owner reframe by hand either way).
|
|
181
|
+
*/
|
|
182
|
+
declare function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid;
|
|
183
|
+
/** What findDividers saw, for the mismatch log line. */
|
|
184
|
+
declare function describeDividers(plane: GreyPlane): string;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Image-generation models: platform-side, never user-selected. Prices per 1M tokens
|
|
188
|
+
* from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens
|
|
189
|
+
* per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower
|
|
190
|
+
* resolution, is what minimizes cost.
|
|
191
|
+
*/
|
|
192
|
+
declare const IMAGE_MODEL_CONSTANTS: {
|
|
193
|
+
readonly GEMINI_FLASH_IMAGE: "gemini-3.1-flash-image";
|
|
194
|
+
};
|
|
195
|
+
type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];
|
|
196
|
+
interface ImageModelPricing {
|
|
197
|
+
imageOutputPricePerM: number;
|
|
198
|
+
textInputPricePerM: number;
|
|
199
|
+
}
|
|
200
|
+
declare const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing>;
|
|
201
|
+
declare const DEFAULT_IMAGE_MODEL: ImageModelId;
|
|
202
|
+
/** Cost of one image call from the usage Gemini reports. */
|
|
203
|
+
declare function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number;
|
|
204
|
+
|
|
205
|
+
type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';
|
|
206
|
+
interface GenerateImageOptions {
|
|
207
|
+
/** Labeled reference JPEGs (an established scene, character portraits) that
|
|
208
|
+
* anchor the drawing to a known style, place or faces. */
|
|
209
|
+
references?: {
|
|
210
|
+
label: string;
|
|
211
|
+
jpeg: Buffer;
|
|
212
|
+
}[];
|
|
213
|
+
imageSize?: '1K' | '2K';
|
|
214
|
+
model?: ImageModelId;
|
|
215
|
+
/** Test seam / custom transport; defaults to the global fetch. */
|
|
216
|
+
fetchImpl?: typeof fetch;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* One image-model call on Gemini's Interactions API. Pure — no auth, no billing —
|
|
220
|
+
* and the result reports its cost so the host decides whom to charge. Cost is per
|
|
221
|
+
* IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).
|
|
222
|
+
*/
|
|
223
|
+
declare function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts?: GenerateImageOptions): Promise<GeneratedImage>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Portrait sheets: one image-model call draws a whole cast as a grid of bust
|
|
227
|
+
* portraits, and the grid is cut into one card per character. One call costs the
|
|
228
|
+
* same whether it draws one face or sixteen, so this is the cheap way to give
|
|
229
|
+
* every character in a game a portrait in a single consistent style.
|
|
230
|
+
*
|
|
231
|
+
* Nothing here touches storage or billing: the host stores the cards and the kept
|
|
232
|
+
* sheet (so a card can be re-cut later at a new framing) and charges whoever it
|
|
233
|
+
* likes for `costUSD`. sharp is passed in, never imported.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* Grid dimensions by cell count on the 4:3 canvas. Cells must come out
|
|
237
|
+
* square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the
|
|
238
|
+
* sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards
|
|
239
|
+
* taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,
|
|
240
|
+
* one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell
|
|
241
|
+
* cast to 16.
|
|
242
|
+
*/
|
|
243
|
+
declare function gridFor(cells: number): {
|
|
244
|
+
cols: number;
|
|
245
|
+
rows: number;
|
|
246
|
+
};
|
|
247
|
+
declare const DEFAULT_FILLER_PROMPT = "\"Stranger\" \u2014 an anonymous hooded figure fitting the setting, face hidden in shadow";
|
|
248
|
+
/**
|
|
249
|
+
* Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the
|
|
250
|
+
* spare ones get an anonymous filler that is drawn and discarded.
|
|
251
|
+
*/
|
|
252
|
+
declare function padCells(cells: PortraitCell[], fillerPrompt?: string): {
|
|
253
|
+
cells: PortraitCell[];
|
|
254
|
+
cols: number;
|
|
255
|
+
rows: number;
|
|
256
|
+
realCount: number;
|
|
257
|
+
};
|
|
258
|
+
interface PortraitSheetPrompt {
|
|
259
|
+
cells: PortraitCell[];
|
|
260
|
+
cols: number;
|
|
261
|
+
rows: number;
|
|
262
|
+
/** What the sheet is for, e.g. "a social deduction game" — one noun phrase. */
|
|
263
|
+
purpose: string;
|
|
264
|
+
/** The world the characters live in: a title and a short description. */
|
|
265
|
+
setting: {
|
|
266
|
+
title: string;
|
|
267
|
+
description: string;
|
|
268
|
+
};
|
|
269
|
+
/** Player-chosen art direction, already sanitized by the host. Replaces the
|
|
270
|
+
* model's free choice of style. */
|
|
271
|
+
artStyle?: string;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The sheet prompt. The wording is load-bearing: equal cells with thin dark
|
|
275
|
+
* divider lines are what the slicer finds, one flat muted background per cell keeps
|
|
276
|
+
* the lines detectable in any art style, and the no-text rule is repeated because
|
|
277
|
+
* the model otherwise typesets names and bios into the image.
|
|
278
|
+
*/
|
|
279
|
+
declare function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string;
|
|
280
|
+
declare const SHEET_MAX_WIDTH = 2400;
|
|
281
|
+
declare const SHEET_JPEG_QUALITY = 85;
|
|
282
|
+
declare const SHEET_MAX_BASE64_BYTES = 900000;
|
|
283
|
+
/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */
|
|
284
|
+
interface SheetMismatch {
|
|
285
|
+
kind: 'no-dividers' | 'different-grid';
|
|
286
|
+
message: string;
|
|
287
|
+
detail: Record<string, unknown>;
|
|
288
|
+
}
|
|
289
|
+
interface SliceSheetOptions {
|
|
290
|
+
onMismatch?: (mismatch: SheetMismatch) => void;
|
|
291
|
+
/** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */
|
|
292
|
+
maxWidth?: number;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the
|
|
296
|
+
* divider lines the model drew (equal split as a last resort); each card is the
|
|
297
|
+
* largest 3:4 rectangle in its cell, top-anchored, with the default circle — the
|
|
298
|
+
* framing the host can let a user move later.
|
|
299
|
+
*/
|
|
300
|
+
declare function sliceSheet(sharp: SharpLike, raw: Buffer, cells: PortraitCell[], count: number, cols: number, rows: number, opts?: SliceSheetOptions): Promise<{
|
|
301
|
+
slices: PortraitSlice[];
|
|
302
|
+
sheet: DrawnSheet;
|
|
303
|
+
}>;
|
|
304
|
+
/** Cuts one card out of a stored sheet at the given framing. */
|
|
305
|
+
declare function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer>;
|
|
306
|
+
interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {
|
|
307
|
+
/** Prompt for the cells that pad the cast out to a full grid. */
|
|
308
|
+
fillerPrompt?: string;
|
|
309
|
+
}
|
|
310
|
+
interface DrawPortraitSheetOptions extends SliceSheetOptions {
|
|
311
|
+
image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;
|
|
312
|
+
}
|
|
313
|
+
interface DrawnPortraitSheet {
|
|
314
|
+
portraits: PortraitSlice[];
|
|
315
|
+
sheet: DrawnSheet;
|
|
316
|
+
/** What the image call cost, reported for the host's billing. */
|
|
317
|
+
costUSD: number;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The whole pipeline: lay the cast out on a grid, draw it in one call, cut the
|
|
321
|
+
* cards. Filler cells are drawn and discarded; `portraits` has exactly one entry
|
|
322
|
+
* per input cell, in order.
|
|
323
|
+
*/
|
|
324
|
+
declare function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts?: DrawPortraitSheetOptions): Promise<DrawnPortraitSheet>;
|
|
325
|
+
|
|
326
|
+
export { type AvatarCircle, type AvatarFraming, CARD_ASPECT, CARD_HEIGHT_PX, CARD_WIDTH_PX, DEFAULT_AVATAR_CIRCLE, DEFAULT_FILLER_PROMPT, DEFAULT_IMAGE_MODEL, type DrawPortraitSheetOptions, type DrawPortraitSheetSpec, type DrawnPortraitSheet, type DrawnSheet, type GenerateImageOptions, type GeneratedImage, type GreyPlane, IMAGE_MODEL_CONSTANTS, IMAGE_MODEL_PRICING, type ImageAspectRatio, type ImageFocus, type ImageModelId, type ImageModelPricing, type ImageRect, type ImageSize, MIN_CARD_HEIGHT_FRACTION, type PortraitCell, type PortraitSheetPrompt, type PortraitSlice, SHEET_JPEG_QUALITY, SHEET_MAX_BASE64_BYTES, SHEET_MAX_WIDTH, type SharpLike, type SharpPipeline, type SheetGrid, type SheetMismatch, type SliceSheetOptions, buildPortraitSheetPrompt, calculateImageCost, cardFocus, cardInCell, circleFocus, circleFocusOnSheet, cutCard, defaultFraming, describeDividers, detectSheetGrid, drawPortraitSheet, equalSplitGrid, findDividers, fitCard, fitCircle, fitFraming, focusToBackground, generateImage, gridFor, isFramingShape, padCells, sliceSheet };
|