@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 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 };
@@ -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 };