@hiper2d/ai-agents 0.21.0 → 0.22.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
@@ -100,6 +100,13 @@ const { portraits, sheet, costUSD } = await drawPortraitSheet(googleKey, sharp,
100
100
  The pure geometry (`fitFraming`, `circleFocus`, `focusToBackground`, …) is safe in a
101
101
  browser bundle for reframe editors and avatar renderers.
102
102
 
103
+ A blocked drawing (Gemini answers HTTP 200 with no image and an `errors[]` code such as
104
+ `image_safety` or `image_prohibited_content`) throws `ImageRefusalError` with the `code`
105
+ and Gemini's `reason`; it is not retryable with the same prompt. Check it with
106
+ `isImageRefusalError(error)` — by name, since this subpath is bundled apart from the main
107
+ entry's `ModelRefusalError`. Any other imageless reply is a plain `Error` naming the
108
+ interaction `status` and error code.
109
+
103
110
  ### Budget control
104
111
 
105
112
  Per-subject spend caps (a user, a tenant, a job) over UTC day and month windows. Pure and
package/dist/images.d.mts CHANGED
@@ -215,10 +215,35 @@ interface GenerateImageOptions {
215
215
  /** Test seam / custom transport; defaults to the global fetch. */
216
216
  fetchImpl?: typeof fetch;
217
217
  }
218
+ /**
219
+ * Gemini's Interactions API answers a blocked image with HTTP 200, no image block,
220
+ * `status: "failed"` (or `incomplete`) and an `errors[]` entry whose `code` names the
221
+ * block (https://ai.google.dev/gemini-api/docs/api-errors, "Generation blocked"):
222
+ * `image_safety`, `image_prohibited_content`, `image_recitation`, `image_other`, plus
223
+ * the text-side codes `safety`, `prohibited_content`, `recitation`, `blocklist`, `spii`,
224
+ * `content_blocked`. `no_image` is a plain generation failure, not a block.
225
+ *
226
+ * Deliberately NOT a `ModelRefusalError` subclass: this subpath is bundled apart from the
227
+ * main entry, so an `instanceof` against the main entry's class would never match. Hosts
228
+ * check `isImageRefusalError(error)` (by name) instead. Not retryable on the same prompt.
229
+ */
230
+ declare class ImageRefusalError extends Error {
231
+ /** The block code as Gemini sent it (a URI or bare `snake_case` name). */
232
+ code: string;
233
+ /** Gemini's human-readable message, when it sent one. */
234
+ reason?: string;
235
+ model: string;
236
+ constructor(model: string, code: string, reason?: string);
237
+ }
238
+ /** Works across bundle copies and serialization boundaries (checks the name, not the class). */
239
+ declare function isImageRefusalError(error: unknown): error is ImageRefusalError;
218
240
  /**
219
241
  * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —
220
242
  * and the result reports its cost so the host decides whom to charge. Cost is per
221
243
  * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).
244
+ *
245
+ * Throws `ImageRefusalError` when Gemini blocked the drawing (see the class), and a
246
+ * plain `Error` naming Gemini's status and error code for any other imageless reply.
222
247
  */
223
248
  declare function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts?: GenerateImageOptions): Promise<GeneratedImage>;
224
249
 
@@ -323,4 +348,4 @@ interface DrawnPortraitSheet {
323
348
  */
324
349
  declare function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts?: DrawPortraitSheetOptions): Promise<DrawnPortraitSheet>;
325
350
 
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 };
351
+ 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, ImageRefusalError, 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, isImageRefusalError, padCells, sliceSheet };
package/dist/images.d.ts CHANGED
@@ -215,10 +215,35 @@ interface GenerateImageOptions {
215
215
  /** Test seam / custom transport; defaults to the global fetch. */
216
216
  fetchImpl?: typeof fetch;
217
217
  }
218
+ /**
219
+ * Gemini's Interactions API answers a blocked image with HTTP 200, no image block,
220
+ * `status: "failed"` (or `incomplete`) and an `errors[]` entry whose `code` names the
221
+ * block (https://ai.google.dev/gemini-api/docs/api-errors, "Generation blocked"):
222
+ * `image_safety`, `image_prohibited_content`, `image_recitation`, `image_other`, plus
223
+ * the text-side codes `safety`, `prohibited_content`, `recitation`, `blocklist`, `spii`,
224
+ * `content_blocked`. `no_image` is a plain generation failure, not a block.
225
+ *
226
+ * Deliberately NOT a `ModelRefusalError` subclass: this subpath is bundled apart from the
227
+ * main entry, so an `instanceof` against the main entry's class would never match. Hosts
228
+ * check `isImageRefusalError(error)` (by name) instead. Not retryable on the same prompt.
229
+ */
230
+ declare class ImageRefusalError extends Error {
231
+ /** The block code as Gemini sent it (a URI or bare `snake_case` name). */
232
+ code: string;
233
+ /** Gemini's human-readable message, when it sent one. */
234
+ reason?: string;
235
+ model: string;
236
+ constructor(model: string, code: string, reason?: string);
237
+ }
238
+ /** Works across bundle copies and serialization boundaries (checks the name, not the class). */
239
+ declare function isImageRefusalError(error: unknown): error is ImageRefusalError;
218
240
  /**
219
241
  * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —
220
242
  * and the result reports its cost so the host decides whom to charge. Cost is per
221
243
  * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).
244
+ *
245
+ * Throws `ImageRefusalError` when Gemini blocked the drawing (see the class), and a
246
+ * plain `Error` naming Gemini's status and error code for any other imageless reply.
222
247
  */
223
248
  declare function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts?: GenerateImageOptions): Promise<GeneratedImage>;
224
249
 
@@ -323,4 +348,4 @@ interface DrawnPortraitSheet {
323
348
  */
324
349
  declare function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts?: DrawPortraitSheetOptions): Promise<DrawnPortraitSheet>;
325
350
 
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 };
351
+ 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, ImageRefusalError, 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, isImageRefusalError, padCells, sliceSheet };
package/dist/images.js CHANGED
@@ -28,6 +28,7 @@ __export(images_exports, {
28
28
  DEFAULT_IMAGE_MODEL: () => DEFAULT_IMAGE_MODEL,
29
29
  IMAGE_MODEL_CONSTANTS: () => IMAGE_MODEL_CONSTANTS,
30
30
  IMAGE_MODEL_PRICING: () => IMAGE_MODEL_PRICING,
31
+ ImageRefusalError: () => ImageRefusalError,
31
32
  MIN_CARD_HEIGHT_FRACTION: () => MIN_CARD_HEIGHT_FRACTION,
32
33
  SHEET_JPEG_QUALITY: () => SHEET_JPEG_QUALITY,
33
34
  SHEET_MAX_BASE64_BYTES: () => SHEET_MAX_BASE64_BYTES,
@@ -52,6 +53,7 @@ __export(images_exports, {
52
53
  generateImage: () => generateImage,
53
54
  gridFor: () => gridFor,
54
55
  isFramingShape: () => isFramingShape,
56
+ isImageRefusalError: () => isImageRefusalError,
55
57
  padCells: () => padCells,
56
58
  sliceSheet: () => sliceSheet
57
59
  });
@@ -259,6 +261,32 @@ function calculateImageCost(model, imageOutputTokens, textInputTokens) {
259
261
  }
260
262
 
261
263
  // src/images/google-image.ts
264
+ var ImageRefusalError = class extends Error {
265
+ /** The block code as Gemini sent it (a URI or bare `snake_case` name). */
266
+ code;
267
+ /** Gemini's human-readable message, when it sent one. */
268
+ reason;
269
+ model;
270
+ constructor(model, code, reason) {
271
+ super(`${model} declined to draw the image (${describeBlockCode(code)})${reason ? `: ${reason}` : ""}`);
272
+ this.name = "ImageRefusalError";
273
+ this.model = model;
274
+ this.code = code;
275
+ this.reason = reason;
276
+ }
277
+ };
278
+ function isImageRefusalError(error) {
279
+ return !!error && typeof error === "object" && error.name === "ImageRefusalError";
280
+ }
281
+ var BLOCK_CODE_MARKERS = ["safety", "prohibited", "recitation", "blocklist", "spii", "content_blocked", "image_other"];
282
+ function describeBlockCode(code) {
283
+ const tail = code.split(/[#/]/).filter(Boolean).pop() ?? code;
284
+ return tail.toLowerCase();
285
+ }
286
+ function isBlockCode(code) {
287
+ const tail = describeBlockCode(code);
288
+ return BLOCK_CODE_MARKERS.some((marker) => tail.includes(marker));
289
+ }
262
290
  async function generateImage(apiKey, prompt, aspectRatio, opts = {}) {
263
291
  const model = opts.model ?? DEFAULT_IMAGE_MODEL;
264
292
  const input = [{ type: "text", text: prompt }];
@@ -281,12 +309,24 @@ async function generateImage(apiKey, prompt, aspectRatio, opts = {}) {
281
309
  }
282
310
  const json = await res.json();
283
311
  const b64 = (json.steps || []).flatMap((s) => s.content || []).find((c) => c.type === "image" && c.data)?.data;
284
- if (!b64) throw new Error("Image response contained no image data");
312
+ if (!b64) throw imagelessReplyError(model, json);
285
313
  const usage = json.usage || {};
286
314
  const imageTokens = (usage.output_tokens_by_modality || []).filter((m) => m.modality === "image").reduce((sum, m) => sum + (m.tokens || 0), 0);
287
315
  const inputTokens = usage.total_input_tokens || 0;
288
316
  return { buffer: Buffer.from(b64, "base64"), costUSD: calculateImageCost(model, imageTokens, inputTokens) };
289
317
  }
318
+ function imagelessReplyError(model, json) {
319
+ const errors = Array.isArray(json?.errors) ? json.errors : [];
320
+ const codes = errors.map((e) => String(e?.code ?? "")).filter(Boolean);
321
+ const messages = errors.map((e) => typeof e?.message === "string" ? e.message.trim() : "").filter(Boolean);
322
+ const block = codes.find(isBlockCode);
323
+ if (block) {
324
+ return new ImageRefusalError(model, block, messages[0]?.slice(0, 300));
325
+ }
326
+ const status = typeof json?.status === "string" ? json.status : "unknown";
327
+ const detail = [codes.length ? `code ${codes.map(describeBlockCode).join(", ")}` : "", messages[0]?.slice(0, 300) ?? ""].filter(Boolean).join(": ");
328
+ return new Error(`Image response contained no image data (status ${status}${detail ? `; ${detail}` : ""})`);
329
+ }
290
330
 
291
331
  // src/images/portrait-sheet.ts
292
332
  function gridFor(cells) {
@@ -388,6 +428,7 @@ async function drawPortraitSheet(apiKey, sharp, spec, opts = {}) {
388
428
  DEFAULT_IMAGE_MODEL,
389
429
  IMAGE_MODEL_CONSTANTS,
390
430
  IMAGE_MODEL_PRICING,
431
+ ImageRefusalError,
391
432
  MIN_CARD_HEIGHT_FRACTION,
392
433
  SHEET_JPEG_QUALITY,
393
434
  SHEET_MAX_BASE64_BYTES,
@@ -412,6 +453,7 @@ async function drawPortraitSheet(apiKey, sharp, spec, opts = {}) {
412
453
  generateImage,
413
454
  gridFor,
414
455
  isFramingShape,
456
+ isImageRefusalError,
415
457
  padCells,
416
458
  sliceSheet
417
459
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/images/index.ts","../src/images/types.ts","../src/images/framing.ts","../src/images/sheet-detection.ts","../src/images/image-catalog.ts","../src/images/google-image.ts","../src/images/portrait-sheet.ts"],"sourcesContent":["/**\n * `@hiper2d/ai-agents/images` — image generation and portrait sheets.\n *\n * Kept off the main entry on purpose: this subpath is for hosts that draw pictures.\n * It never imports sharp (the host passes its own instance in), so nothing native\n * rides along, and the geometry helpers are pure and safe in a browser bundle.\n */\nexport type {\n ImageRect, ImageSize, AvatarCircle, AvatarFraming, ImageFocus,\n PortraitCell, PortraitSlice, DrawnSheet, GeneratedImage, SharpLike, SharpPipeline,\n} from './types';\nexport {\n CARD_ASPECT, CARD_WIDTH_PX, CARD_HEIGHT_PX, DEFAULT_AVATAR_CIRCLE, MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\nexport {\n cardInCell, defaultFraming, fitCircle, fitCard, fitFraming, isFramingShape,\n circleFocus, cardFocus, circleFocusOnSheet, focusToBackground,\n} from './framing';\n\nexport { findDividers, equalSplitGrid, detectSheetGrid, describeDividers } from './sheet-detection';\nexport type { GreyPlane, SheetGrid } from './sheet-detection';\n\nexport {\n IMAGE_MODEL_CONSTANTS, IMAGE_MODEL_PRICING, DEFAULT_IMAGE_MODEL, calculateImageCost,\n} from './image-catalog';\nexport type { ImageModelId, ImageModelPricing } from './image-catalog';\n\nexport { generateImage } from './google-image';\nexport type { GenerateImageOptions, ImageAspectRatio } from './google-image';\n\nexport {\n gridFor, padCells, buildPortraitSheetPrompt, sliceSheet, cutCard, drawPortraitSheet,\n DEFAULT_FILLER_PROMPT, SHEET_MAX_WIDTH, SHEET_JPEG_QUALITY, SHEET_MAX_BASE64_BYTES,\n} from './portrait-sheet';\nexport type {\n PortraitSheetPrompt, SheetMismatch, SliceSheetOptions,\n DrawPortraitSheetSpec, DrawPortraitSheetOptions, DrawnPortraitSheet,\n} from './portrait-sheet';\n","/**\n * Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless\n * stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.\n */\n\n/** A rectangle in image pixels (sharp's `extract` shape). */\nexport interface ImageRect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface ImageSize {\n width: number;\n height: number;\n}\n\n/** The round avatar inside a card. `x` and `d` are fractions of the card's width,\n * `y` a fraction of its height. */\nexport interface AvatarCircle {\n x: number;\n y: number;\n d: number;\n}\n\n/** Where a portrait comes from on its sheet: the card cut and the circle in it. */\nexport interface AvatarFraming {\n card: ImageRect;\n circle: AvatarCircle;\n}\n\n/** The portion of an image to display, as fractions of its width/height. */\nexport interface ImageFocus {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n// Cards are portrait 3:4 (a poster's shape), stored at this size.\nexport const CARD_ASPECT = 3 / 4; // width / height\nexport const CARD_WIDTH_PX = 600;\nexport const CARD_HEIGHT_PX = 800;\n// Where the circle starts on a freshly cut card: 86% of its width, centred,\n// near the top — wide enough to take in a chin or beard (0.72 cut them off).\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.07, y: 0.02, d: 0.86 };\n// A card narrower than this fraction of the sheet's height upscales too much.\nexport const MIN_CARD_HEIGHT_FRACTION = 0.12;\n\n/** One character on a portrait sheet. */\nexport interface PortraitCell {\n /** Stable id the caller stores the crop under; never drawn into the image. */\n key: string;\n /** Character name, for logs and prompt guidance; never drawn into the image. */\n label: string;\n /** One-line visual description for this cell (gender, name, appearance). */\n prompt: string;\n}\n\n/** A card cut from a drawn sheet. */\nexport interface PortraitSlice {\n key: string;\n label: string;\n jpeg: Buffer;\n /** The framing the slicer chose: the card on the sheet and the default circle. */\n framing: AvatarFraming;\n}\n\n/** The kept sheet: the grid image itself (re-encoded), its size and the cells\n * the cards were cut from — enough to re-cut any card later at a new framing. */\nexport interface DrawnSheet {\n jpeg: Buffer;\n width: number;\n height: number;\n cells: ImageRect[];\n /** false = no divider lines were found and the cells are the equal split of the\n * requested grid; true = the cells between the lines the model actually drew. */\n detected: boolean;\n}\n\nexport interface GeneratedImage {\n buffer: Buffer;\n costUSD: number;\n}\n\n/**\n * The slice of sharp's API the pipeline uses. The library never imports sharp — a\n * native module the host installs and passes in (`(await import('sharp')).default`) —\n * so text-only consumers pull in nothing new.\n */\nexport type SharpLike = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement?: boolean }): SharpPipeline;\n resize(width: number, height: number): SharpPipeline;\n greyscale(): SharpPipeline;\n extract(rect: ImageRect): SharpPipeline;\n jpeg(options: { quality: number; mozjpeg?: boolean }): SharpPipeline;\n raw(): SharpPipeline;\n toBuffer(): Promise<Buffer>;\n toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: { width: number; height: number } }>;\n metadata(): Promise<{ width?: number; height?: number }>;\n};\n\nexport type SharpPipeline = ReturnType<SharpLike>;\n","import {\n AvatarCircle,\n AvatarFraming,\n CARD_ASPECT,\n DEFAULT_AVATAR_CIRCLE,\n ImageFocus,\n ImageRect,\n ImageSize,\n MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\n/**\n * Pure framing geometry, shared by the slicer (server), reframe validation (server)\n * and renderers/editors (browser). No I/O here.\n *\n * Coordinates: a card is an ImageRect in SHEET pixels (portrait 3:4); the circle is\n * card-relative (x, d = fractions of the card's width, y = fraction of its height).\n * An ImageFocus is what a renderer needs: the fraction of an image to show, so the\n * same avatar component can show a circle cut out of a stored card OR out of a\n * static preset sheet.\n */\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\n\n/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:\n * portraits are busts with the head near the top, so the top edge is the\n * anchor that keeps the face in. */\nexport function cardInCell(cell: ImageRect): ImageRect {\n const width = Math.floor(Math.min(cell.width, cell.height * CARD_ASPECT));\n const height = Math.min(cell.height, Math.round(width / CARD_ASPECT));\n return {\n left: Math.round(cell.left + (cell.width - width) / 2),\n top: cell.top,\n width,\n height,\n };\n}\n\nexport function defaultFraming(cell: ImageRect): AvatarFraming {\n return { card: cardInCell(cell), circle: { ...DEFAULT_AVATAR_CIRCLE } };\n}\n\n/** Keeps the circle inside its card. */\nexport function fitCircle(circle: AvatarCircle): AvatarCircle {\n const d = clamp(circle.d, 0.15, 1);\n return {\n d,\n x: clamp(circle.x, 0, 1 - d),\n y: clamp(circle.y, 0, 1 - d * CARD_ASPECT),\n };\n}\n\n/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the\n * minimum size. Width is the free variable; height follows the aspect. */\nexport function fitCard(card: ImageRect, sheet: ImageSize): ImageRect {\n const minHeight = Math.max(16, Math.round(sheet.height * MIN_CARD_HEIGHT_FRACTION));\n const maxHeight = Math.min(sheet.height, sheet.width / CARD_ASPECT);\n const height = Math.round(clamp(card.height, minHeight, maxHeight));\n const width = Math.round(height * CARD_ASPECT);\n return {\n width,\n height,\n left: Math.round(clamp(card.left, 0, sheet.width - width)),\n top: Math.round(clamp(card.top, 0, sheet.height - height)),\n };\n}\n\nexport function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming {\n return { card: fitCard(framing.card, sheet), circle: fitCircle(framing.circle) };\n}\n\n/** True when the framing is a well-formed object with finite numbers. Shape\n * only — call fitFraming for the geometric bounds. */\nexport function isFramingShape(value: unknown): value is AvatarFraming {\n if (!value || typeof value !== 'object') return false;\n const f = value as any;\n const rect = f.card, circle = f.circle;\n const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\n return !!rect && !!circle\n && num(rect.left) && num(rect.top) && num(rect.width) && num(rect.height)\n && rect.width > 0 && rect.height > 0\n && num(circle.x) && num(circle.y) && num(circle.d) && circle.d > 0;\n}\n\n/** The circle as a focus on the CARD image itself (a stored card cut). */\nexport function circleFocus(circle: AvatarCircle): ImageFocus {\n const c = fitCircle(circle);\n return { x: c.x, y: c.y, w: c.d, h: c.d * CARD_ASPECT };\n}\n\n/** The card as a focus on its sheet. */\nexport function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus {\n return {\n x: card.left / sheet.width,\n y: card.top / sheet.height,\n w: card.width / sheet.width,\n h: card.height / sheet.height,\n };\n}\n\n/** The circle as a focus on the SHEET (card and circle composed) — how a preset\n * mannequin renders straight off a static sheet. */\nexport function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus {\n const c = fitCircle(framing.circle);\n const card = framing.card;\n const side = c.d * card.width; // the circle is square, in sheet px\n return {\n x: (card.left + c.x * card.width) / sheet.width,\n y: (card.top + c.y * card.height) / sheet.height,\n w: side / sheet.width,\n h: side / sheet.height,\n };\n}\n\n/** CSS `background-size` / `background-position` that shows exactly `focus`\n * of an image inside a box. */\nexport function focusToBackground(focus: ImageFocus): { backgroundSize: string; backgroundPosition: string } {\n const axis = (pos: number, ext: number) => ext >= 1 ? 50 : (pos / (1 - ext)) * 100;\n return {\n backgroundSize: `${(100 / focus.w).toFixed(3)}% ${(100 / focus.h).toFixed(3)}%`,\n backgroundPosition: `${axis(focus.x, focus.w).toFixed(3)}% ${axis(focus.y, focus.h).toFixed(3)}%`,\n };\n}\n","import { ImageRect } from \"./types\";\n\n/**\n * Finds the cells of a drawn portrait sheet from its pixels.\n *\n * The prompt asks for equal cells separated by thin dark divider lines, but\n * the model draws the lines where it likes: on a correctly drawn 4x4 sheet\n * the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448\n * (2026-09-02), so fixed-interval slicing cut lower rows through the previous\n * character's chest. The lines themselves are crisp — near-black across the\n * full width — so they are easy to find, and the cells between them are the\n * real ones. Columns have always measured exact; rows drift.\n *\n * Pure over a greyscale byte plane so it is testable without sharp.\n */\n\nexport interface GreyPlane {\n width: number;\n height: number;\n // width*height bytes, row-major, 0 = black.\n data: Uint8Array;\n}\n\nexport interface SheetGrid {\n cells: ImageRect[]; // row-major\n cols: number;\n rows: number;\n // false = no usable divider lines were found and this is the equal split\n // of the REQUESTED grid. true = the cells between the lines the model\n // actually drew, which may be a different grid than requested (a 4x4\n // request has come back as 4x3 and as 6x3) — the sheet is the truth.\n detected: boolean;\n}\n\n// A pixel darker than this is \"line\"; a scanline with more than this share\n// of dark pixels is a divider. Cell backgrounds are flat muted colours, well\n// above the threshold, and the dark portrait clothing never spans a full line.\nconst DARK_MAX = 60;\nconst DIVIDER_SHARE = 0.85;\n// Pixels kept clear of a divider when cutting (anti-aliased edges).\nconst DIVIDER_INSET = 4;\n// A real divider is a thin line (~7 px on a 2400 px sheet). Thinner runs are\n// specks (a dark hairline inside a hooded figure once passed as a row line).\n// Thicker runs up to a small share of the sheet are still boundaries: the\n// model sometimes adds a dark name plate under each portrait despite the\n// no-text rule, and the plate belongs OUTSIDE the cell. Beyond that share a\n// dark run is a dark art style's background, not a boundary.\nconst DIVIDER_MIN_THICKNESS = 3;\nconst DIVIDER_MAX_THICKNESS_SHARE = 0.08;\n// A span between lines narrower than this share of the sheet can't hold a\n// face; the line that would create it is not a cell boundary.\nconst MIN_SPAN_SHARE = 0.12;\n\ninterface Run { start: number; end: number }\n\nfunction runs(indices: number[]): Run[] {\n const out: Run[] = [];\n for (const i of indices) {\n const last = out[out.length - 1];\n if (last && i === last.end + 1) last.end = i;\n else out.push({start: i, end: i});\n }\n return out;\n}\n\n/** Dark runs along one axis, measured over a window of the other axis (a\n * band of rows for columns, the full height by default). Border runs and\n * runs that aren't line-shaped are dropped. */\nfunction lineRuns(plane: GreyPlane, axis: 'rows' | 'cols', from: number, to: number): Run[] {\n const {width, height, data} = plane;\n const extent = axis === 'rows' ? height : width;\n const dark: number[] = [];\n for (let i = 0; i < extent; i++) {\n let count = 0;\n if (axis === 'rows') {\n const base = i * width;\n for (let x = from; x < to; x++) if (data[base + x] < DARK_MAX) count++;\n } else {\n for (let y = from; y < to; y++) if (data[y * width + i] < DARK_MAX) count++;\n }\n if (count / (to - from) > DIVIDER_SHARE) dark.push(i);\n }\n const maxThickness = extent * DIVIDER_MAX_THICKNESS_SHARE;\n return runs(dark)\n .filter(r => r.start > 2 && r.end < extent - 3)\n .filter(r => { const t = r.end - r.start + 1; return t >= DIVIDER_MIN_THICKNESS && t <= maxThickness; });\n}\n\n/** Lines that would leave a sliver on either side are not boundaries. */\nfunction dropSliverMakers(lines: Run[], extent: number): Run[] {\n const minSpan = extent * MIN_SPAN_SHARE;\n const kept: Run[] = [];\n let cursor = 0;\n for (const line of lines) {\n if (line.start - cursor < minSpan || extent - (line.end + 1) < minSpan) continue;\n kept.push(line);\n cursor = line.end + 1;\n }\n return kept;\n}\n\n/** Divider runs across the whole plane: row lines over the full width,\n * column lines over the full height. */\nexport function findDividers(plane: GreyPlane): {rows: Run[]; cols: Run[]} {\n return {\n rows: dropSliverMakers(lineRuns(plane, 'rows', 0, plane.width), plane.height),\n cols: dropSliverMakers(lineRuns(plane, 'cols', 0, plane.height), plane.width),\n };\n}\n\n/** Cells between dividers along one axis, from the border to the first line,\n * line to line, last line to the border. */\nfunction spans(dividers: Run[], extent: number): {start: number; end: number}[] {\n const out: {start: number; end: number}[] = [];\n let cursor = 0;\n for (const d of dividers) {\n out.push({start: cursor, end: d.start});\n cursor = d.end + 1;\n }\n out.push({start: cursor, end: extent});\n return out;\n}\n\nexport function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid {\n const cellW = Math.floor(width / cols), cellH = Math.floor(height / rows);\n const cells: ImageRect[] = [];\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n cells.push(inset({left: c * cellW, top: r * cellH, width: cellW, height: cellH}, 8));\n }\n }\n return {cells, cols, rows, detected: false};\n}\n\nfunction inset(rect: ImageRect, by: number): ImageRect {\n return {left: rect.left + by, top: rect.top + by, width: Math.max(1, rect.width - 2 * by), height: Math.max(1, rect.height - 2 * by)};\n}\n\n/**\n * The sheet's cells. Read off the divider lines the model drew whenever they\n * form a plausible grid, even when that grid differs from the cols x rows\n * that were asked for — the model keeps row-major order, so the drawn cells\n * still map to the characters in sequence. Row lines are found over the\n * full width; column lines are found PER ROW BAND, because the model has\n * drawn rows with different column layouts (and a column line that stops\n * short of the last row). A band without lines of its own borrows the\n * layout of the band above it. The equal split of the requested grid is the\n * last resort when no lines are found at all (the caller logs it; the kept\n * sheet lets the owner reframe by hand either way).\n */\nexport function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid {\n const {width, height} = plane;\n const rowLines = dropSliverMakers(lineRuns(plane, 'rows', 0, width), height);\n const bands = spans(rowLines, height);\n const cells: ImageRect[] = [];\n let previous: Run[] | null = null;\n let colCount = 0;\n for (const band of bands) {\n let colLines = dropSliverMakers(lineRuns(plane, 'cols', band.start, band.end), width);\n if (colLines.length === 0 && previous) colLines = previous;\n previous = colLines;\n const xs = spans(colLines, width);\n colCount = Math.max(colCount, xs.length);\n for (const x of xs) {\n cells.push(inset({left: x.start, top: band.start, width: x.end - x.start, height: band.end - band.start}, DIVIDER_INSET));\n }\n }\n if (colCount < 2) return equalSplitGrid(width, height, cols, rows);\n return {cells, cols: colCount, rows: bands.length, detected: true};\n}\n\n/** What findDividers saw, for the mismatch log line. */\nexport function describeDividers(plane: GreyPlane): string {\n const d = findDividers(plane);\n const fmt = (r: Run[]) => r.map(x => `${x.start}-${x.end}`).join(',') || 'none';\n return `rows@${fmt(d.rows)} cols@${fmt(d.cols)}`;\n}\n","/**\n * Image-generation models: platform-side, never user-selected. Prices per 1M tokens\n * from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens\n * per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower\n * resolution, is what minimizes cost.\n */\nexport const IMAGE_MODEL_CONSTANTS = {\n GEMINI_FLASH_IMAGE: 'gemini-3.1-flash-image',\n} as const;\n\nexport type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];\n\nexport interface ImageModelPricing {\n imageOutputPricePerM: number;\n textInputPricePerM: number;\n}\n\nexport const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing> = {\n [IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE]: {\n imageOutputPricePerM: 60,\n textInputPricePerM: 0.5,\n },\n};\n\nexport const DEFAULT_IMAGE_MODEL: ImageModelId = IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE;\n\n/** Cost of one image call from the usage Gemini reports. */\nexport function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number {\n const pricing = IMAGE_MODEL_PRICING[model];\n return parseFloat((\n imageOutputTokens / 1_000_000 * pricing.imageOutputPricePerM +\n textInputTokens / 1_000_000 * pricing.textInputPricePerM\n ).toFixed(6));\n}\n","import { calculateImageCost, DEFAULT_IMAGE_MODEL, ImageModelId } from './image-catalog';\nimport { GeneratedImage } from './types';\n\nexport type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';\n\nexport interface GenerateImageOptions {\n /** Labeled reference JPEGs (an established scene, character portraits) that\n * anchor the drawing to a known style, place or faces. */\n references?: { label: string; jpeg: Buffer }[];\n imageSize?: '1K' | '2K';\n model?: ImageModelId;\n /** Test seam / custom transport; defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —\n * and the result reports its cost so the host decides whom to charge. Cost is per\n * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).\n */\nexport async function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts: GenerateImageOptions = {}): Promise<GeneratedImage> {\n const model = opts.model ?? DEFAULT_IMAGE_MODEL;\n const input: any[] = [{ type: 'text', text: prompt }];\n for (const ref of opts.references ?? []) {\n input.push({ type: 'text', text: ref.label });\n input.push({ type: 'image', mime_type: 'image/jpeg', data: ref.jpeg.toString('base64') });\n }\n const doFetch = opts.fetchImpl ?? fetch;\n const res = await doFetch('https://generativelanguage.googleapis.com/v1beta/interactions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },\n body: JSON.stringify({\n model,\n input,\n response_format: { type: 'image', mime_type: 'image/jpeg', aspect_ratio: aspectRatio, image_size: opts.imageSize ?? '2K' },\n }),\n });\n if (!res.ok) {\n throw new Error(`Image request failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);\n }\n const json: any = await res.json();\n const b64 = (json.steps || [])\n .flatMap((s: any) => s.content || [])\n .find((c: any) => c.type === 'image' && c.data)?.data;\n if (!b64) throw new Error('Image response contained no image data');\n\n const usage = json.usage || {};\n const imageTokens = (usage.output_tokens_by_modality || [])\n .filter((m: any) => m.modality === 'image')\n .reduce((sum: number, m: any) => sum + (m.tokens || 0), 0);\n const inputTokens = usage.total_input_tokens || 0;\n\n return { buffer: Buffer.from(b64, 'base64'), costUSD: calculateImageCost(model, imageTokens, inputTokens) };\n}\n","import { generateImage, GenerateImageOptions } from './google-image';\nimport { defaultFraming } from './framing';\nimport { describeDividers, detectSheetGrid, equalSplitGrid } from './sheet-detection';\nimport { CARD_HEIGHT_PX, CARD_WIDTH_PX, DrawnSheet, ImageRect, PortraitCell, PortraitSlice, SharpLike } from './types';\n\n/**\n * Portrait sheets: one image-model call draws a whole cast as a grid of bust\n * portraits, and the grid is cut into one card per character. One call costs the\n * same whether it draws one face or sixteen, so this is the cheap way to give\n * every character in a game a portrait in a single consistent style.\n *\n * Nothing here touches storage or billing: the host stores the cards and the kept\n * sheet (so a card can be re-cut later at a new framing) and charges whoever it\n * likes for `costUSD`. sharp is passed in, never imported.\n */\n\n/**\n * Grid dimensions by cell count on the 4:3 canvas. Cells must come out\n * square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the\n * sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards\n * taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,\n * one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell\n * cast to 16.\n */\nexport function gridFor(cells: number): { cols: number; rows: number } {\n if (cells <= 6) return { cols: 3, rows: 2 };\n if (cells <= 8) return { cols: 4, rows: 2 };\n if (cells <= 9) return { cols: 3, rows: 3 };\n if (cells <= 12) return { cols: 4, rows: 3 };\n if (cells <= 15) return { cols: 5, rows: 3 };\n if (cells <= 18) return { cols: 6, rows: 3 };\n throw new Error(`Portrait sheet cannot hold ${cells} cells (max 18)`);\n}\n\nexport const DEFAULT_FILLER_PROMPT = `\"Stranger\" — an anonymous hooded figure fitting the setting, face hidden in shadow`;\n\n/**\n * Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the\n * spare ones get an anonymous filler that is drawn and discarded.\n */\nexport function padCells(cells: PortraitCell[], fillerPrompt: string = DEFAULT_FILLER_PROMPT): { cells: PortraitCell[]; cols: number; rows: number; realCount: number } {\n const { cols, rows } = gridFor(cells.length);\n const padded = [...cells];\n for (let i = padded.length; i < cols * rows; i++) {\n padded.push({ key: `__filler${i}`, label: 'Stranger', prompt: fillerPrompt });\n }\n return { cells: padded, cols, rows, realCount: cells.length };\n}\n\nexport interface PortraitSheetPrompt {\n cells: PortraitCell[];\n cols: number;\n rows: number;\n /** What the sheet is for, e.g. \"a social deduction game\" — one noun phrase. */\n purpose: string;\n /** The world the characters live in: a title and a short description. */\n setting: { title: string; description: string };\n /** Player-chosen art direction, already sanitized by the host. Replaces the\n * model's free choice of style. */\n artStyle?: string;\n}\n\n/**\n * The sheet prompt. The wording is load-bearing: equal cells with thin dark\n * divider lines are what the slicer finds, one flat muted background per cell keeps\n * the lines detectable in any art style, and the no-text rule is repeated because\n * the model otherwise typesets names and bios into the image.\n */\nexport function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string {\n const { cells, cols, rows, purpose, setting, artStyle } = spec;\n const cellLines = cells.map(\n (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`\n ).join('\\n');\n\n // A player style leads the prompt and is restated at the end: the sheet\n // framing (grid, bust portraits, flat backgrounds) otherwise pulls the model\n // toward drawn character-sheet art whatever style was asked for (an\n // \"ultra-realism\" game came out as comic portraits). The grid wording stays\n // intact — it is what the slicer relies on — but is declared layout only.\n const styleLead = artStyle\n ? `Art style, chosen by the player — it governs every portrait: \"${artStyle}\". Each cell must look made in that style: a photographic or cinematic style means real photographs, a painted style means paintings.\\n\\n`\n : '';\n const styleLine = artStyle\n ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`\n : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;\n const styleTail = artStyle ? `\\n\\nEvery cell must be fully in this art style: \"${artStyle}\" — and still carry no names or text of any kind.` : '';\n\n return `${styleLead}A character portrait sheet for ${purpose}, rendered as a single image: a precise grid of exactly ${cells.length} rectangular cells, ${cols} columns and ${rows} rows, all cells exactly equal size, separated by thin dark divider lines. Each cell contains one bust portrait (head and shoulders) of a different character, centered in its cell.\n\nSetting — \"${setting.title}\": ${setting.description}\n\n${styleLine} Every face must be distinct and memorable, and match its character description. No character may span more than one cell. Give each cell its own flat solid muted desaturated background color, different from its neighbors. Row-major order, left to right, top to bottom:\n\n${cellLines}\n\nThe character descriptions above are guidance for the drawing only — NEVER render them as text. Absolutely no text anywhere in the image: no names, no labels, no captions, no letters, no writing of any kind — and no lettering on clothing, equipment, insignia or logos.${styleTail}`;\n}\n\n// The model returns ~2.4 MB loosely-compressed JPEGs; at this width and quality a\n// sheet is ~400 KB (~530 KB as base64), under a 1 MiB document limit with cells\n// still ~500 px tall.\nexport const SHEET_MAX_WIDTH = 2400;\nexport const SHEET_JPEG_QUALITY = 85;\nexport const SHEET_MAX_BASE64_BYTES = 900_000;\n\n/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */\nexport interface SheetMismatch {\n kind: 'no-dividers' | 'different-grid';\n message: string;\n detail: Record<string, unknown>;\n}\n\nexport interface SliceSheetOptions {\n onMismatch?: (mismatch: SheetMismatch) => void;\n /** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */\n maxWidth?: number;\n}\n\n/**\n * Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the\n * divider lines the model drew (equal split as a last resort); each card is the\n * largest 3:4 rectangle in its cell, top-anchored, with the default circle — the\n * framing the host can let a user move later.\n */\nexport async function sliceSheet(\n sharp: SharpLike,\n raw: Buffer,\n cells: PortraitCell[],\n count: number,\n cols: number,\n rows: number,\n opts: SliceSheetOptions = {},\n): Promise<{ slices: PortraitSlice[]; sheet: DrawnSheet }> {\n // Normalise the working resolution first so cells, cards and the stored sheet\n // all share one pixel space.\n const grid: Buffer = await sharp(raw).resize({ width: opts.maxWidth ?? SHEET_MAX_WIDTH, withoutEnlargement: true }).toBuffer();\n const { data, info } = await sharp(grid).greyscale().raw().toBuffer({ resolveWithObject: true });\n const width = info.width, height = info.height;\n if (width < 100 * cols || height < 100 * rows) throw new Error(`Portrait sheet has unusable dimensions ${width}x${height}`);\n\n const plane = { width, height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };\n const gridCells = detectSheetGrid(plane, cols, rows);\n if (!gridCells.detected) {\n opts.onMismatch?.({\n kind: 'no-dividers',\n message: `no divider lines found on the ${cols}x${rows} sheet; using equal split`,\n detail: { width, height, dividers: describeDividers(plane) },\n });\n } else if (gridCells.cols !== cols || gridCells.rows !== rows) {\n // The model drew a different grid than asked. Its cells are still in row-major\n // order, so they are used as drawn; only characters past the last drawn cell\n // fall back to the equal split.\n opts.onMismatch?.({\n kind: 'different-grid',\n message: `sheet drawn as ${gridCells.cols}x${gridCells.rows}, requested ${cols}x${rows}; using the drawn cells`,\n detail: { width, height, drawnCells: gridCells.cells.length, needed: count },\n });\n }\n const fallback = equalSplitGrid(width, height, cols, rows).cells;\n\n const slices: PortraitSlice[] = [];\n for (let i = 0; i < count; i++) {\n const framing = defaultFraming(gridCells.cells[i] ?? fallback[i]);\n const jpeg = await cutCard(sharp, grid, framing.card);\n slices.push({ key: cells[i].key, label: cells[i].label, jpeg, framing });\n }\n\n let sheetJpeg: Buffer = await sharp(grid).jpeg({ quality: SHEET_JPEG_QUALITY, mozjpeg: true }).toBuffer();\n if (sheetJpeg.length * 4 / 3 > SHEET_MAX_BASE64_BYTES) {\n sheetJpeg = await sharp(grid).jpeg({ quality: 72, mozjpeg: true }).toBuffer();\n }\n return {\n slices,\n sheet: { jpeg: sheetJpeg, width, height, cells: gridCells.cells.slice(0, count), detected: gridCells.detected },\n };\n}\n\n/** Cuts one card out of a stored sheet at the given framing. */\nexport async function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer> {\n return sharp(sheet)\n .extract(card)\n .resize(CARD_WIDTH_PX, CARD_HEIGHT_PX)\n .jpeg({ quality: 85 })\n .toBuffer();\n}\n\nexport interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {\n /** Prompt for the cells that pad the cast out to a full grid. */\n fillerPrompt?: string;\n}\n\nexport interface DrawPortraitSheetOptions extends SliceSheetOptions {\n image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;\n}\n\nexport interface DrawnPortraitSheet {\n portraits: PortraitSlice[];\n sheet: DrawnSheet;\n /** What the image call cost, reported for the host's billing. */\n costUSD: number;\n}\n\n/**\n * The whole pipeline: lay the cast out on a grid, draw it in one call, cut the\n * cards. Filler cells are drawn and discarded; `portraits` has exactly one entry\n * per input cell, in order.\n */\nexport async function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts: DrawPortraitSheetOptions = {}): Promise<DrawnPortraitSheet> {\n const { cells, cols, rows, realCount } = padCells(spec.cells, spec.fillerPrompt);\n const prompt = buildPortraitSheetPrompt({ cells, cols, rows, purpose: spec.purpose, setting: spec.setting, artStyle: spec.artStyle });\n const image = await generateImage(apiKey, prompt, '4:3', opts.image);\n const { slices, sheet } = await sliceSheet(sharp, image.buffer, cells, realCount, cols, rows, opts);\n return { portraits: slices, sheet, costUSD: image.costUSD };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/images/index.ts","../src/images/types.ts","../src/images/framing.ts","../src/images/sheet-detection.ts","../src/images/image-catalog.ts","../src/images/google-image.ts","../src/images/portrait-sheet.ts"],"sourcesContent":["/**\n * `@hiper2d/ai-agents/images` — image generation and portrait sheets.\n *\n * Kept off the main entry on purpose: this subpath is for hosts that draw pictures.\n * It never imports sharp (the host passes its own instance in), so nothing native\n * rides along, and the geometry helpers are pure and safe in a browser bundle.\n */\nexport type {\n ImageRect, ImageSize, AvatarCircle, AvatarFraming, ImageFocus,\n PortraitCell, PortraitSlice, DrawnSheet, GeneratedImage, SharpLike, SharpPipeline,\n} from './types';\nexport {\n CARD_ASPECT, CARD_WIDTH_PX, CARD_HEIGHT_PX, DEFAULT_AVATAR_CIRCLE, MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\nexport {\n cardInCell, defaultFraming, fitCircle, fitCard, fitFraming, isFramingShape,\n circleFocus, cardFocus, circleFocusOnSheet, focusToBackground,\n} from './framing';\n\nexport { findDividers, equalSplitGrid, detectSheetGrid, describeDividers } from './sheet-detection';\nexport type { GreyPlane, SheetGrid } from './sheet-detection';\n\nexport {\n IMAGE_MODEL_CONSTANTS, IMAGE_MODEL_PRICING, DEFAULT_IMAGE_MODEL, calculateImageCost,\n} from './image-catalog';\nexport type { ImageModelId, ImageModelPricing } from './image-catalog';\n\nexport { generateImage, ImageRefusalError, isImageRefusalError } from './google-image';\nexport type { GenerateImageOptions, ImageAspectRatio } from './google-image';\n\nexport {\n gridFor, padCells, buildPortraitSheetPrompt, sliceSheet, cutCard, drawPortraitSheet,\n DEFAULT_FILLER_PROMPT, SHEET_MAX_WIDTH, SHEET_JPEG_QUALITY, SHEET_MAX_BASE64_BYTES,\n} from './portrait-sheet';\nexport type {\n PortraitSheetPrompt, SheetMismatch, SliceSheetOptions,\n DrawPortraitSheetSpec, DrawPortraitSheetOptions, DrawnPortraitSheet,\n} from './portrait-sheet';\n","/**\n * Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless\n * stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.\n */\n\n/** A rectangle in image pixels (sharp's `extract` shape). */\nexport interface ImageRect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface ImageSize {\n width: number;\n height: number;\n}\n\n/** The round avatar inside a card. `x` and `d` are fractions of the card's width,\n * `y` a fraction of its height. */\nexport interface AvatarCircle {\n x: number;\n y: number;\n d: number;\n}\n\n/** Where a portrait comes from on its sheet: the card cut and the circle in it. */\nexport interface AvatarFraming {\n card: ImageRect;\n circle: AvatarCircle;\n}\n\n/** The portion of an image to display, as fractions of its width/height. */\nexport interface ImageFocus {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n// Cards are portrait 3:4 (a poster's shape), stored at this size.\nexport const CARD_ASPECT = 3 / 4; // width / height\nexport const CARD_WIDTH_PX = 600;\nexport const CARD_HEIGHT_PX = 800;\n// Where the circle starts on a freshly cut card: 86% of its width, centred,\n// near the top — wide enough to take in a chin or beard (0.72 cut them off).\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.07, y: 0.02, d: 0.86 };\n// A card narrower than this fraction of the sheet's height upscales too much.\nexport const MIN_CARD_HEIGHT_FRACTION = 0.12;\n\n/** One character on a portrait sheet. */\nexport interface PortraitCell {\n /** Stable id the caller stores the crop under; never drawn into the image. */\n key: string;\n /** Character name, for logs and prompt guidance; never drawn into the image. */\n label: string;\n /** One-line visual description for this cell (gender, name, appearance). */\n prompt: string;\n}\n\n/** A card cut from a drawn sheet. */\nexport interface PortraitSlice {\n key: string;\n label: string;\n jpeg: Buffer;\n /** The framing the slicer chose: the card on the sheet and the default circle. */\n framing: AvatarFraming;\n}\n\n/** The kept sheet: the grid image itself (re-encoded), its size and the cells\n * the cards were cut from — enough to re-cut any card later at a new framing. */\nexport interface DrawnSheet {\n jpeg: Buffer;\n width: number;\n height: number;\n cells: ImageRect[];\n /** false = no divider lines were found and the cells are the equal split of the\n * requested grid; true = the cells between the lines the model actually drew. */\n detected: boolean;\n}\n\nexport interface GeneratedImage {\n buffer: Buffer;\n costUSD: number;\n}\n\n/**\n * The slice of sharp's API the pipeline uses. The library never imports sharp — a\n * native module the host installs and passes in (`(await import('sharp')).default`) —\n * so text-only consumers pull in nothing new.\n */\nexport type SharpLike = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement?: boolean }): SharpPipeline;\n resize(width: number, height: number): SharpPipeline;\n greyscale(): SharpPipeline;\n extract(rect: ImageRect): SharpPipeline;\n jpeg(options: { quality: number; mozjpeg?: boolean }): SharpPipeline;\n raw(): SharpPipeline;\n toBuffer(): Promise<Buffer>;\n toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: { width: number; height: number } }>;\n metadata(): Promise<{ width?: number; height?: number }>;\n};\n\nexport type SharpPipeline = ReturnType<SharpLike>;\n","import {\n AvatarCircle,\n AvatarFraming,\n CARD_ASPECT,\n DEFAULT_AVATAR_CIRCLE,\n ImageFocus,\n ImageRect,\n ImageSize,\n MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\n/**\n * Pure framing geometry, shared by the slicer (server), reframe validation (server)\n * and renderers/editors (browser). No I/O here.\n *\n * Coordinates: a card is an ImageRect in SHEET pixels (portrait 3:4); the circle is\n * card-relative (x, d = fractions of the card's width, y = fraction of its height).\n * An ImageFocus is what a renderer needs: the fraction of an image to show, so the\n * same avatar component can show a circle cut out of a stored card OR out of a\n * static preset sheet.\n */\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\n\n/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:\n * portraits are busts with the head near the top, so the top edge is the\n * anchor that keeps the face in. */\nexport function cardInCell(cell: ImageRect): ImageRect {\n const width = Math.floor(Math.min(cell.width, cell.height * CARD_ASPECT));\n const height = Math.min(cell.height, Math.round(width / CARD_ASPECT));\n return {\n left: Math.round(cell.left + (cell.width - width) / 2),\n top: cell.top,\n width,\n height,\n };\n}\n\nexport function defaultFraming(cell: ImageRect): AvatarFraming {\n return { card: cardInCell(cell), circle: { ...DEFAULT_AVATAR_CIRCLE } };\n}\n\n/** Keeps the circle inside its card. */\nexport function fitCircle(circle: AvatarCircle): AvatarCircle {\n const d = clamp(circle.d, 0.15, 1);\n return {\n d,\n x: clamp(circle.x, 0, 1 - d),\n y: clamp(circle.y, 0, 1 - d * CARD_ASPECT),\n };\n}\n\n/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the\n * minimum size. Width is the free variable; height follows the aspect. */\nexport function fitCard(card: ImageRect, sheet: ImageSize): ImageRect {\n const minHeight = Math.max(16, Math.round(sheet.height * MIN_CARD_HEIGHT_FRACTION));\n const maxHeight = Math.min(sheet.height, sheet.width / CARD_ASPECT);\n const height = Math.round(clamp(card.height, minHeight, maxHeight));\n const width = Math.round(height * CARD_ASPECT);\n return {\n width,\n height,\n left: Math.round(clamp(card.left, 0, sheet.width - width)),\n top: Math.round(clamp(card.top, 0, sheet.height - height)),\n };\n}\n\nexport function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming {\n return { card: fitCard(framing.card, sheet), circle: fitCircle(framing.circle) };\n}\n\n/** True when the framing is a well-formed object with finite numbers. Shape\n * only — call fitFraming for the geometric bounds. */\nexport function isFramingShape(value: unknown): value is AvatarFraming {\n if (!value || typeof value !== 'object') return false;\n const f = value as any;\n const rect = f.card, circle = f.circle;\n const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\n return !!rect && !!circle\n && num(rect.left) && num(rect.top) && num(rect.width) && num(rect.height)\n && rect.width > 0 && rect.height > 0\n && num(circle.x) && num(circle.y) && num(circle.d) && circle.d > 0;\n}\n\n/** The circle as a focus on the CARD image itself (a stored card cut). */\nexport function circleFocus(circle: AvatarCircle): ImageFocus {\n const c = fitCircle(circle);\n return { x: c.x, y: c.y, w: c.d, h: c.d * CARD_ASPECT };\n}\n\n/** The card as a focus on its sheet. */\nexport function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus {\n return {\n x: card.left / sheet.width,\n y: card.top / sheet.height,\n w: card.width / sheet.width,\n h: card.height / sheet.height,\n };\n}\n\n/** The circle as a focus on the SHEET (card and circle composed) — how a preset\n * mannequin renders straight off a static sheet. */\nexport function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus {\n const c = fitCircle(framing.circle);\n const card = framing.card;\n const side = c.d * card.width; // the circle is square, in sheet px\n return {\n x: (card.left + c.x * card.width) / sheet.width,\n y: (card.top + c.y * card.height) / sheet.height,\n w: side / sheet.width,\n h: side / sheet.height,\n };\n}\n\n/** CSS `background-size` / `background-position` that shows exactly `focus`\n * of an image inside a box. */\nexport function focusToBackground(focus: ImageFocus): { backgroundSize: string; backgroundPosition: string } {\n const axis = (pos: number, ext: number) => ext >= 1 ? 50 : (pos / (1 - ext)) * 100;\n return {\n backgroundSize: `${(100 / focus.w).toFixed(3)}% ${(100 / focus.h).toFixed(3)}%`,\n backgroundPosition: `${axis(focus.x, focus.w).toFixed(3)}% ${axis(focus.y, focus.h).toFixed(3)}%`,\n };\n}\n","import { ImageRect } from \"./types\";\n\n/**\n * Finds the cells of a drawn portrait sheet from its pixels.\n *\n * The prompt asks for equal cells separated by thin dark divider lines, but\n * the model draws the lines where it likes: on a correctly drawn 4x4 sheet\n * the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448\n * (2026-09-02), so fixed-interval slicing cut lower rows through the previous\n * character's chest. The lines themselves are crisp — near-black across the\n * full width — so they are easy to find, and the cells between them are the\n * real ones. Columns have always measured exact; rows drift.\n *\n * Pure over a greyscale byte plane so it is testable without sharp.\n */\n\nexport interface GreyPlane {\n width: number;\n height: number;\n // width*height bytes, row-major, 0 = black.\n data: Uint8Array;\n}\n\nexport interface SheetGrid {\n cells: ImageRect[]; // row-major\n cols: number;\n rows: number;\n // false = no usable divider lines were found and this is the equal split\n // of the REQUESTED grid. true = the cells between the lines the model\n // actually drew, which may be a different grid than requested (a 4x4\n // request has come back as 4x3 and as 6x3) — the sheet is the truth.\n detected: boolean;\n}\n\n// A pixel darker than this is \"line\"; a scanline with more than this share\n// of dark pixels is a divider. Cell backgrounds are flat muted colours, well\n// above the threshold, and the dark portrait clothing never spans a full line.\nconst DARK_MAX = 60;\nconst DIVIDER_SHARE = 0.85;\n// Pixels kept clear of a divider when cutting (anti-aliased edges).\nconst DIVIDER_INSET = 4;\n// A real divider is a thin line (~7 px on a 2400 px sheet). Thinner runs are\n// specks (a dark hairline inside a hooded figure once passed as a row line).\n// Thicker runs up to a small share of the sheet are still boundaries: the\n// model sometimes adds a dark name plate under each portrait despite the\n// no-text rule, and the plate belongs OUTSIDE the cell. Beyond that share a\n// dark run is a dark art style's background, not a boundary.\nconst DIVIDER_MIN_THICKNESS = 3;\nconst DIVIDER_MAX_THICKNESS_SHARE = 0.08;\n// A span between lines narrower than this share of the sheet can't hold a\n// face; the line that would create it is not a cell boundary.\nconst MIN_SPAN_SHARE = 0.12;\n\ninterface Run { start: number; end: number }\n\nfunction runs(indices: number[]): Run[] {\n const out: Run[] = [];\n for (const i of indices) {\n const last = out[out.length - 1];\n if (last && i === last.end + 1) last.end = i;\n else out.push({start: i, end: i});\n }\n return out;\n}\n\n/** Dark runs along one axis, measured over a window of the other axis (a\n * band of rows for columns, the full height by default). Border runs and\n * runs that aren't line-shaped are dropped. */\nfunction lineRuns(plane: GreyPlane, axis: 'rows' | 'cols', from: number, to: number): Run[] {\n const {width, height, data} = plane;\n const extent = axis === 'rows' ? height : width;\n const dark: number[] = [];\n for (let i = 0; i < extent; i++) {\n let count = 0;\n if (axis === 'rows') {\n const base = i * width;\n for (let x = from; x < to; x++) if (data[base + x] < DARK_MAX) count++;\n } else {\n for (let y = from; y < to; y++) if (data[y * width + i] < DARK_MAX) count++;\n }\n if (count / (to - from) > DIVIDER_SHARE) dark.push(i);\n }\n const maxThickness = extent * DIVIDER_MAX_THICKNESS_SHARE;\n return runs(dark)\n .filter(r => r.start > 2 && r.end < extent - 3)\n .filter(r => { const t = r.end - r.start + 1; return t >= DIVIDER_MIN_THICKNESS && t <= maxThickness; });\n}\n\n/** Lines that would leave a sliver on either side are not boundaries. */\nfunction dropSliverMakers(lines: Run[], extent: number): Run[] {\n const minSpan = extent * MIN_SPAN_SHARE;\n const kept: Run[] = [];\n let cursor = 0;\n for (const line of lines) {\n if (line.start - cursor < minSpan || extent - (line.end + 1) < minSpan) continue;\n kept.push(line);\n cursor = line.end + 1;\n }\n return kept;\n}\n\n/** Divider runs across the whole plane: row lines over the full width,\n * column lines over the full height. */\nexport function findDividers(plane: GreyPlane): {rows: Run[]; cols: Run[]} {\n return {\n rows: dropSliverMakers(lineRuns(plane, 'rows', 0, plane.width), plane.height),\n cols: dropSliverMakers(lineRuns(plane, 'cols', 0, plane.height), plane.width),\n };\n}\n\n/** Cells between dividers along one axis, from the border to the first line,\n * line to line, last line to the border. */\nfunction spans(dividers: Run[], extent: number): {start: number; end: number}[] {\n const out: {start: number; end: number}[] = [];\n let cursor = 0;\n for (const d of dividers) {\n out.push({start: cursor, end: d.start});\n cursor = d.end + 1;\n }\n out.push({start: cursor, end: extent});\n return out;\n}\n\nexport function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid {\n const cellW = Math.floor(width / cols), cellH = Math.floor(height / rows);\n const cells: ImageRect[] = [];\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n cells.push(inset({left: c * cellW, top: r * cellH, width: cellW, height: cellH}, 8));\n }\n }\n return {cells, cols, rows, detected: false};\n}\n\nfunction inset(rect: ImageRect, by: number): ImageRect {\n return {left: rect.left + by, top: rect.top + by, width: Math.max(1, rect.width - 2 * by), height: Math.max(1, rect.height - 2 * by)};\n}\n\n/**\n * The sheet's cells. Read off the divider lines the model drew whenever they\n * form a plausible grid, even when that grid differs from the cols x rows\n * that were asked for — the model keeps row-major order, so the drawn cells\n * still map to the characters in sequence. Row lines are found over the\n * full width; column lines are found PER ROW BAND, because the model has\n * drawn rows with different column layouts (and a column line that stops\n * short of the last row). A band without lines of its own borrows the\n * layout of the band above it. The equal split of the requested grid is the\n * last resort when no lines are found at all (the caller logs it; the kept\n * sheet lets the owner reframe by hand either way).\n */\nexport function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid {\n const {width, height} = plane;\n const rowLines = dropSliverMakers(lineRuns(plane, 'rows', 0, width), height);\n const bands = spans(rowLines, height);\n const cells: ImageRect[] = [];\n let previous: Run[] | null = null;\n let colCount = 0;\n for (const band of bands) {\n let colLines = dropSliverMakers(lineRuns(plane, 'cols', band.start, band.end), width);\n if (colLines.length === 0 && previous) colLines = previous;\n previous = colLines;\n const xs = spans(colLines, width);\n colCount = Math.max(colCount, xs.length);\n for (const x of xs) {\n cells.push(inset({left: x.start, top: band.start, width: x.end - x.start, height: band.end - band.start}, DIVIDER_INSET));\n }\n }\n if (colCount < 2) return equalSplitGrid(width, height, cols, rows);\n return {cells, cols: colCount, rows: bands.length, detected: true};\n}\n\n/** What findDividers saw, for the mismatch log line. */\nexport function describeDividers(plane: GreyPlane): string {\n const d = findDividers(plane);\n const fmt = (r: Run[]) => r.map(x => `${x.start}-${x.end}`).join(',') || 'none';\n return `rows@${fmt(d.rows)} cols@${fmt(d.cols)}`;\n}\n","/**\n * Image-generation models: platform-side, never user-selected. Prices per 1M tokens\n * from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens\n * per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower\n * resolution, is what minimizes cost.\n */\nexport const IMAGE_MODEL_CONSTANTS = {\n GEMINI_FLASH_IMAGE: 'gemini-3.1-flash-image',\n} as const;\n\nexport type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];\n\nexport interface ImageModelPricing {\n imageOutputPricePerM: number;\n textInputPricePerM: number;\n}\n\nexport const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing> = {\n [IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE]: {\n imageOutputPricePerM: 60,\n textInputPricePerM: 0.5,\n },\n};\n\nexport const DEFAULT_IMAGE_MODEL: ImageModelId = IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE;\n\n/** Cost of one image call from the usage Gemini reports. */\nexport function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number {\n const pricing = IMAGE_MODEL_PRICING[model];\n return parseFloat((\n imageOutputTokens / 1_000_000 * pricing.imageOutputPricePerM +\n textInputTokens / 1_000_000 * pricing.textInputPricePerM\n ).toFixed(6));\n}\n","import { calculateImageCost, DEFAULT_IMAGE_MODEL, ImageModelId } from './image-catalog';\nimport { GeneratedImage } from './types';\n\nexport type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';\n\nexport interface GenerateImageOptions {\n /** Labeled reference JPEGs (an established scene, character portraits) that\n * anchor the drawing to a known style, place or faces. */\n references?: { label: string; jpeg: Buffer }[];\n imageSize?: '1K' | '2K';\n model?: ImageModelId;\n /** Test seam / custom transport; defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Gemini's Interactions API answers a blocked image with HTTP 200, no image block,\n * `status: \"failed\"` (or `incomplete`) and an `errors[]` entry whose `code` names the\n * block (https://ai.google.dev/gemini-api/docs/api-errors, \"Generation blocked\"):\n * `image_safety`, `image_prohibited_content`, `image_recitation`, `image_other`, plus\n * the text-side codes `safety`, `prohibited_content`, `recitation`, `blocklist`, `spii`,\n * `content_blocked`. `no_image` is a plain generation failure, not a block.\n *\n * Deliberately NOT a `ModelRefusalError` subclass: this subpath is bundled apart from the\n * main entry, so an `instanceof` against the main entry's class would never match. Hosts\n * check `isImageRefusalError(error)` (by name) instead. Not retryable on the same prompt.\n */\nexport class ImageRefusalError extends Error {\n /** The block code as Gemini sent it (a URI or bare `snake_case` name). */\n public code: string;\n /** Gemini's human-readable message, when it sent one. */\n public reason?: string;\n public model: string;\n\n constructor(model: string, code: string, reason?: string) {\n super(`${model} declined to draw the image (${describeBlockCode(code)})${reason ? `: ${reason}` : ''}`);\n this.name = 'ImageRefusalError';\n this.model = model;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/** Works across bundle copies and serialization boundaries (checks the name, not the class). */\nexport function isImageRefusalError(error: unknown): error is ImageRefusalError {\n return !!error && typeof error === 'object' && (error as { name?: unknown }).name === 'ImageRefusalError';\n}\n\nconst BLOCK_CODE_MARKERS = ['safety', 'prohibited', 'recitation', 'blocklist', 'spii', 'content_blocked', 'image_other'];\n\n/** The trailing identifier of a code URI such as `https://…/api-errors#image_safety`. */\nfunction describeBlockCode(code: string): string {\n const tail = code.split(/[#/]/).filter(Boolean).pop() ?? code;\n return tail.toLowerCase();\n}\n\nfunction isBlockCode(code: string): boolean {\n const tail = describeBlockCode(code);\n return BLOCK_CODE_MARKERS.some(marker => tail.includes(marker));\n}\n\n/**\n * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —\n * and the result reports its cost so the host decides whom to charge. Cost is per\n * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).\n *\n * Throws `ImageRefusalError` when Gemini blocked the drawing (see the class), and a\n * plain `Error` naming Gemini's status and error code for any other imageless reply.\n */\nexport async function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts: GenerateImageOptions = {}): Promise<GeneratedImage> {\n const model = opts.model ?? DEFAULT_IMAGE_MODEL;\n const input: any[] = [{ type: 'text', text: prompt }];\n for (const ref of opts.references ?? []) {\n input.push({ type: 'text', text: ref.label });\n input.push({ type: 'image', mime_type: 'image/jpeg', data: ref.jpeg.toString('base64') });\n }\n const doFetch = opts.fetchImpl ?? fetch;\n const res = await doFetch('https://generativelanguage.googleapis.com/v1beta/interactions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },\n body: JSON.stringify({\n model,\n input,\n response_format: { type: 'image', mime_type: 'image/jpeg', aspect_ratio: aspectRatio, image_size: opts.imageSize ?? '2K' },\n }),\n });\n if (!res.ok) {\n throw new Error(`Image request failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);\n }\n const json: any = await res.json();\n const b64 = (json.steps || [])\n .flatMap((s: any) => s.content || [])\n .find((c: any) => c.type === 'image' && c.data)?.data;\n if (!b64) throw imagelessReplyError(model, json);\n\n const usage = json.usage || {};\n const imageTokens = (usage.output_tokens_by_modality || [])\n .filter((m: any) => m.modality === 'image')\n .reduce((sum: number, m: any) => sum + (m.tokens || 0), 0);\n const inputTokens = usage.total_input_tokens || 0;\n\n return { buffer: Buffer.from(b64, 'base64'), costUSD: calculateImageCost(model, imageTokens, inputTokens) };\n}\n\n/** Classifies an HTTP 200 reply that carried no image block. */\nfunction imagelessReplyError(model: string, json: any): Error {\n const errors: { code?: unknown; message?: unknown }[] = Array.isArray(json?.errors) ? json.errors : [];\n const codes = errors.map(e => String(e?.code ?? '')).filter(Boolean);\n const messages = errors.map(e => (typeof e?.message === 'string' ? e.message.trim() : '')).filter(Boolean);\n const block = codes.find(isBlockCode);\n if (block) {\n return new ImageRefusalError(model, block, messages[0]?.slice(0, 300));\n }\n const status = typeof json?.status === 'string' ? json.status : 'unknown';\n const detail = [codes.length ? `code ${codes.map(describeBlockCode).join(', ')}` : '', messages[0]?.slice(0, 300) ?? '']\n .filter(Boolean)\n .join(': ');\n return new Error(`Image response contained no image data (status ${status}${detail ? `; ${detail}` : ''})`);\n}\n","import { generateImage, GenerateImageOptions } from './google-image';\nimport { defaultFraming } from './framing';\nimport { describeDividers, detectSheetGrid, equalSplitGrid } from './sheet-detection';\nimport { CARD_HEIGHT_PX, CARD_WIDTH_PX, DrawnSheet, ImageRect, PortraitCell, PortraitSlice, SharpLike } from './types';\n\n/**\n * Portrait sheets: one image-model call draws a whole cast as a grid of bust\n * portraits, and the grid is cut into one card per character. One call costs the\n * same whether it draws one face or sixteen, so this is the cheap way to give\n * every character in a game a portrait in a single consistent style.\n *\n * Nothing here touches storage or billing: the host stores the cards and the kept\n * sheet (so a card can be re-cut later at a new framing) and charges whoever it\n * likes for `costUSD`. sharp is passed in, never imported.\n */\n\n/**\n * Grid dimensions by cell count on the 4:3 canvas. Cells must come out\n * square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the\n * sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards\n * taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,\n * one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell\n * cast to 16.\n */\nexport function gridFor(cells: number): { cols: number; rows: number } {\n if (cells <= 6) return { cols: 3, rows: 2 };\n if (cells <= 8) return { cols: 4, rows: 2 };\n if (cells <= 9) return { cols: 3, rows: 3 };\n if (cells <= 12) return { cols: 4, rows: 3 };\n if (cells <= 15) return { cols: 5, rows: 3 };\n if (cells <= 18) return { cols: 6, rows: 3 };\n throw new Error(`Portrait sheet cannot hold ${cells} cells (max 18)`);\n}\n\nexport const DEFAULT_FILLER_PROMPT = `\"Stranger\" — an anonymous hooded figure fitting the setting, face hidden in shadow`;\n\n/**\n * Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the\n * spare ones get an anonymous filler that is drawn and discarded.\n */\nexport function padCells(cells: PortraitCell[], fillerPrompt: string = DEFAULT_FILLER_PROMPT): { cells: PortraitCell[]; cols: number; rows: number; realCount: number } {\n const { cols, rows } = gridFor(cells.length);\n const padded = [...cells];\n for (let i = padded.length; i < cols * rows; i++) {\n padded.push({ key: `__filler${i}`, label: 'Stranger', prompt: fillerPrompt });\n }\n return { cells: padded, cols, rows, realCount: cells.length };\n}\n\nexport interface PortraitSheetPrompt {\n cells: PortraitCell[];\n cols: number;\n rows: number;\n /** What the sheet is for, e.g. \"a social deduction game\" — one noun phrase. */\n purpose: string;\n /** The world the characters live in: a title and a short description. */\n setting: { title: string; description: string };\n /** Player-chosen art direction, already sanitized by the host. Replaces the\n * model's free choice of style. */\n artStyle?: string;\n}\n\n/**\n * The sheet prompt. The wording is load-bearing: equal cells with thin dark\n * divider lines are what the slicer finds, one flat muted background per cell keeps\n * the lines detectable in any art style, and the no-text rule is repeated because\n * the model otherwise typesets names and bios into the image.\n */\nexport function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string {\n const { cells, cols, rows, purpose, setting, artStyle } = spec;\n const cellLines = cells.map(\n (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`\n ).join('\\n');\n\n // A player style leads the prompt and is restated at the end: the sheet\n // framing (grid, bust portraits, flat backgrounds) otherwise pulls the model\n // toward drawn character-sheet art whatever style was asked for (an\n // \"ultra-realism\" game came out as comic portraits). The grid wording stays\n // intact — it is what the slicer relies on — but is declared layout only.\n const styleLead = artStyle\n ? `Art style, chosen by the player — it governs every portrait: \"${artStyle}\". Each cell must look made in that style: a photographic or cinematic style means real photographs, a painted style means paintings.\\n\\n`\n : '';\n const styleLine = artStyle\n ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`\n : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;\n const styleTail = artStyle ? `\\n\\nEvery cell must be fully in this art style: \"${artStyle}\" — and still carry no names or text of any kind.` : '';\n\n return `${styleLead}A character portrait sheet for ${purpose}, rendered as a single image: a precise grid of exactly ${cells.length} rectangular cells, ${cols} columns and ${rows} rows, all cells exactly equal size, separated by thin dark divider lines. Each cell contains one bust portrait (head and shoulders) of a different character, centered in its cell.\n\nSetting — \"${setting.title}\": ${setting.description}\n\n${styleLine} Every face must be distinct and memorable, and match its character description. No character may span more than one cell. Give each cell its own flat solid muted desaturated background color, different from its neighbors. Row-major order, left to right, top to bottom:\n\n${cellLines}\n\nThe character descriptions above are guidance for the drawing only — NEVER render them as text. Absolutely no text anywhere in the image: no names, no labels, no captions, no letters, no writing of any kind — and no lettering on clothing, equipment, insignia or logos.${styleTail}`;\n}\n\n// The model returns ~2.4 MB loosely-compressed JPEGs; at this width and quality a\n// sheet is ~400 KB (~530 KB as base64), under a 1 MiB document limit with cells\n// still ~500 px tall.\nexport const SHEET_MAX_WIDTH = 2400;\nexport const SHEET_JPEG_QUALITY = 85;\nexport const SHEET_MAX_BASE64_BYTES = 900_000;\n\n/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */\nexport interface SheetMismatch {\n kind: 'no-dividers' | 'different-grid';\n message: string;\n detail: Record<string, unknown>;\n}\n\nexport interface SliceSheetOptions {\n onMismatch?: (mismatch: SheetMismatch) => void;\n /** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */\n maxWidth?: number;\n}\n\n/**\n * Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the\n * divider lines the model drew (equal split as a last resort); each card is the\n * largest 3:4 rectangle in its cell, top-anchored, with the default circle — the\n * framing the host can let a user move later.\n */\nexport async function sliceSheet(\n sharp: SharpLike,\n raw: Buffer,\n cells: PortraitCell[],\n count: number,\n cols: number,\n rows: number,\n opts: SliceSheetOptions = {},\n): Promise<{ slices: PortraitSlice[]; sheet: DrawnSheet }> {\n // Normalise the working resolution first so cells, cards and the stored sheet\n // all share one pixel space.\n const grid: Buffer = await sharp(raw).resize({ width: opts.maxWidth ?? SHEET_MAX_WIDTH, withoutEnlargement: true }).toBuffer();\n const { data, info } = await sharp(grid).greyscale().raw().toBuffer({ resolveWithObject: true });\n const width = info.width, height = info.height;\n if (width < 100 * cols || height < 100 * rows) throw new Error(`Portrait sheet has unusable dimensions ${width}x${height}`);\n\n const plane = { width, height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };\n const gridCells = detectSheetGrid(plane, cols, rows);\n if (!gridCells.detected) {\n opts.onMismatch?.({\n kind: 'no-dividers',\n message: `no divider lines found on the ${cols}x${rows} sheet; using equal split`,\n detail: { width, height, dividers: describeDividers(plane) },\n });\n } else if (gridCells.cols !== cols || gridCells.rows !== rows) {\n // The model drew a different grid than asked. Its cells are still in row-major\n // order, so they are used as drawn; only characters past the last drawn cell\n // fall back to the equal split.\n opts.onMismatch?.({\n kind: 'different-grid',\n message: `sheet drawn as ${gridCells.cols}x${gridCells.rows}, requested ${cols}x${rows}; using the drawn cells`,\n detail: { width, height, drawnCells: gridCells.cells.length, needed: count },\n });\n }\n const fallback = equalSplitGrid(width, height, cols, rows).cells;\n\n const slices: PortraitSlice[] = [];\n for (let i = 0; i < count; i++) {\n const framing = defaultFraming(gridCells.cells[i] ?? fallback[i]);\n const jpeg = await cutCard(sharp, grid, framing.card);\n slices.push({ key: cells[i].key, label: cells[i].label, jpeg, framing });\n }\n\n let sheetJpeg: Buffer = await sharp(grid).jpeg({ quality: SHEET_JPEG_QUALITY, mozjpeg: true }).toBuffer();\n if (sheetJpeg.length * 4 / 3 > SHEET_MAX_BASE64_BYTES) {\n sheetJpeg = await sharp(grid).jpeg({ quality: 72, mozjpeg: true }).toBuffer();\n }\n return {\n slices,\n sheet: { jpeg: sheetJpeg, width, height, cells: gridCells.cells.slice(0, count), detected: gridCells.detected },\n };\n}\n\n/** Cuts one card out of a stored sheet at the given framing. */\nexport async function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer> {\n return sharp(sheet)\n .extract(card)\n .resize(CARD_WIDTH_PX, CARD_HEIGHT_PX)\n .jpeg({ quality: 85 })\n .toBuffer();\n}\n\nexport interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {\n /** Prompt for the cells that pad the cast out to a full grid. */\n fillerPrompt?: string;\n}\n\nexport interface DrawPortraitSheetOptions extends SliceSheetOptions {\n image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;\n}\n\nexport interface DrawnPortraitSheet {\n portraits: PortraitSlice[];\n sheet: DrawnSheet;\n /** What the image call cost, reported for the host's billing. */\n costUSD: number;\n}\n\n/**\n * The whole pipeline: lay the cast out on a grid, draw it in one call, cut the\n * cards. Filler cells are drawn and discarded; `portraits` has exactly one entry\n * per input cell, in order.\n */\nexport async function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts: DrawPortraitSheetOptions = {}): Promise<DrawnPortraitSheet> {\n const { cells, cols, rows, realCount } = padCells(spec.cells, spec.fillerPrompt);\n const prompt = buildPortraitSheetPrompt({ cells, cols, rows, purpose: spec.purpose, setting: spec.setting, artStyle: spec.artStyle });\n const image = await generateImage(apiKey, prompt, '4:3', opts.image);\n const { slices, sheet } = await sliceSheet(sharp, image.buffer, cells, realCount, cols, rows, opts);\n return { portraits: slices, sheet, costUSD: image.costUSD };\n}\n"],"mappings":"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","names":[]}
package/dist/images.mjs CHANGED
@@ -200,6 +200,32 @@ function calculateImageCost(model, imageOutputTokens, textInputTokens) {
200
200
  }
201
201
 
202
202
  // src/images/google-image.ts
203
+ var ImageRefusalError = class extends Error {
204
+ /** The block code as Gemini sent it (a URI or bare `snake_case` name). */
205
+ code;
206
+ /** Gemini's human-readable message, when it sent one. */
207
+ reason;
208
+ model;
209
+ constructor(model, code, reason) {
210
+ super(`${model} declined to draw the image (${describeBlockCode(code)})${reason ? `: ${reason}` : ""}`);
211
+ this.name = "ImageRefusalError";
212
+ this.model = model;
213
+ this.code = code;
214
+ this.reason = reason;
215
+ }
216
+ };
217
+ function isImageRefusalError(error) {
218
+ return !!error && typeof error === "object" && error.name === "ImageRefusalError";
219
+ }
220
+ var BLOCK_CODE_MARKERS = ["safety", "prohibited", "recitation", "blocklist", "spii", "content_blocked", "image_other"];
221
+ function describeBlockCode(code) {
222
+ const tail = code.split(/[#/]/).filter(Boolean).pop() ?? code;
223
+ return tail.toLowerCase();
224
+ }
225
+ function isBlockCode(code) {
226
+ const tail = describeBlockCode(code);
227
+ return BLOCK_CODE_MARKERS.some((marker) => tail.includes(marker));
228
+ }
203
229
  async function generateImage(apiKey, prompt, aspectRatio, opts = {}) {
204
230
  const model = opts.model ?? DEFAULT_IMAGE_MODEL;
205
231
  const input = [{ type: "text", text: prompt }];
@@ -222,12 +248,24 @@ async function generateImage(apiKey, prompt, aspectRatio, opts = {}) {
222
248
  }
223
249
  const json = await res.json();
224
250
  const b64 = (json.steps || []).flatMap((s) => s.content || []).find((c) => c.type === "image" && c.data)?.data;
225
- if (!b64) throw new Error("Image response contained no image data");
251
+ if (!b64) throw imagelessReplyError(model, json);
226
252
  const usage = json.usage || {};
227
253
  const imageTokens = (usage.output_tokens_by_modality || []).filter((m) => m.modality === "image").reduce((sum, m) => sum + (m.tokens || 0), 0);
228
254
  const inputTokens = usage.total_input_tokens || 0;
229
255
  return { buffer: Buffer.from(b64, "base64"), costUSD: calculateImageCost(model, imageTokens, inputTokens) };
230
256
  }
257
+ function imagelessReplyError(model, json) {
258
+ const errors = Array.isArray(json?.errors) ? json.errors : [];
259
+ const codes = errors.map((e) => String(e?.code ?? "")).filter(Boolean);
260
+ const messages = errors.map((e) => typeof e?.message === "string" ? e.message.trim() : "").filter(Boolean);
261
+ const block = codes.find(isBlockCode);
262
+ if (block) {
263
+ return new ImageRefusalError(model, block, messages[0]?.slice(0, 300));
264
+ }
265
+ const status = typeof json?.status === "string" ? json.status : "unknown";
266
+ const detail = [codes.length ? `code ${codes.map(describeBlockCode).join(", ")}` : "", messages[0]?.slice(0, 300) ?? ""].filter(Boolean).join(": ");
267
+ return new Error(`Image response contained no image data (status ${status}${detail ? `; ${detail}` : ""})`);
268
+ }
231
269
 
232
270
  // src/images/portrait-sheet.ts
233
271
  function gridFor(cells) {
@@ -328,6 +366,7 @@ export {
328
366
  DEFAULT_IMAGE_MODEL,
329
367
  IMAGE_MODEL_CONSTANTS,
330
368
  IMAGE_MODEL_PRICING,
369
+ ImageRefusalError,
331
370
  MIN_CARD_HEIGHT_FRACTION,
332
371
  SHEET_JPEG_QUALITY,
333
372
  SHEET_MAX_BASE64_BYTES,
@@ -352,6 +391,7 @@ export {
352
391
  generateImage,
353
392
  gridFor,
354
393
  isFramingShape,
394
+ isImageRefusalError,
355
395
  padCells,
356
396
  sliceSheet
357
397
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/images/types.ts","../src/images/framing.ts","../src/images/sheet-detection.ts","../src/images/image-catalog.ts","../src/images/google-image.ts","../src/images/portrait-sheet.ts"],"sourcesContent":["/**\n * Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless\n * stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.\n */\n\n/** A rectangle in image pixels (sharp's `extract` shape). */\nexport interface ImageRect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface ImageSize {\n width: number;\n height: number;\n}\n\n/** The round avatar inside a card. `x` and `d` are fractions of the card's width,\n * `y` a fraction of its height. */\nexport interface AvatarCircle {\n x: number;\n y: number;\n d: number;\n}\n\n/** Where a portrait comes from on its sheet: the card cut and the circle in it. */\nexport interface AvatarFraming {\n card: ImageRect;\n circle: AvatarCircle;\n}\n\n/** The portion of an image to display, as fractions of its width/height. */\nexport interface ImageFocus {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n// Cards are portrait 3:4 (a poster's shape), stored at this size.\nexport const CARD_ASPECT = 3 / 4; // width / height\nexport const CARD_WIDTH_PX = 600;\nexport const CARD_HEIGHT_PX = 800;\n// Where the circle starts on a freshly cut card: 86% of its width, centred,\n// near the top — wide enough to take in a chin or beard (0.72 cut them off).\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.07, y: 0.02, d: 0.86 };\n// A card narrower than this fraction of the sheet's height upscales too much.\nexport const MIN_CARD_HEIGHT_FRACTION = 0.12;\n\n/** One character on a portrait sheet. */\nexport interface PortraitCell {\n /** Stable id the caller stores the crop under; never drawn into the image. */\n key: string;\n /** Character name, for logs and prompt guidance; never drawn into the image. */\n label: string;\n /** One-line visual description for this cell (gender, name, appearance). */\n prompt: string;\n}\n\n/** A card cut from a drawn sheet. */\nexport interface PortraitSlice {\n key: string;\n label: string;\n jpeg: Buffer;\n /** The framing the slicer chose: the card on the sheet and the default circle. */\n framing: AvatarFraming;\n}\n\n/** The kept sheet: the grid image itself (re-encoded), its size and the cells\n * the cards were cut from — enough to re-cut any card later at a new framing. */\nexport interface DrawnSheet {\n jpeg: Buffer;\n width: number;\n height: number;\n cells: ImageRect[];\n /** false = no divider lines were found and the cells are the equal split of the\n * requested grid; true = the cells between the lines the model actually drew. */\n detected: boolean;\n}\n\nexport interface GeneratedImage {\n buffer: Buffer;\n costUSD: number;\n}\n\n/**\n * The slice of sharp's API the pipeline uses. The library never imports sharp — a\n * native module the host installs and passes in (`(await import('sharp')).default`) —\n * so text-only consumers pull in nothing new.\n */\nexport type SharpLike = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement?: boolean }): SharpPipeline;\n resize(width: number, height: number): SharpPipeline;\n greyscale(): SharpPipeline;\n extract(rect: ImageRect): SharpPipeline;\n jpeg(options: { quality: number; mozjpeg?: boolean }): SharpPipeline;\n raw(): SharpPipeline;\n toBuffer(): Promise<Buffer>;\n toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: { width: number; height: number } }>;\n metadata(): Promise<{ width?: number; height?: number }>;\n};\n\nexport type SharpPipeline = ReturnType<SharpLike>;\n","import {\n AvatarCircle,\n AvatarFraming,\n CARD_ASPECT,\n DEFAULT_AVATAR_CIRCLE,\n ImageFocus,\n ImageRect,\n ImageSize,\n MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\n/**\n * Pure framing geometry, shared by the slicer (server), reframe validation (server)\n * and renderers/editors (browser). No I/O here.\n *\n * Coordinates: a card is an ImageRect in SHEET pixels (portrait 3:4); the circle is\n * card-relative (x, d = fractions of the card's width, y = fraction of its height).\n * An ImageFocus is what a renderer needs: the fraction of an image to show, so the\n * same avatar component can show a circle cut out of a stored card OR out of a\n * static preset sheet.\n */\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\n\n/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:\n * portraits are busts with the head near the top, so the top edge is the\n * anchor that keeps the face in. */\nexport function cardInCell(cell: ImageRect): ImageRect {\n const width = Math.floor(Math.min(cell.width, cell.height * CARD_ASPECT));\n const height = Math.min(cell.height, Math.round(width / CARD_ASPECT));\n return {\n left: Math.round(cell.left + (cell.width - width) / 2),\n top: cell.top,\n width,\n height,\n };\n}\n\nexport function defaultFraming(cell: ImageRect): AvatarFraming {\n return { card: cardInCell(cell), circle: { ...DEFAULT_AVATAR_CIRCLE } };\n}\n\n/** Keeps the circle inside its card. */\nexport function fitCircle(circle: AvatarCircle): AvatarCircle {\n const d = clamp(circle.d, 0.15, 1);\n return {\n d,\n x: clamp(circle.x, 0, 1 - d),\n y: clamp(circle.y, 0, 1 - d * CARD_ASPECT),\n };\n}\n\n/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the\n * minimum size. Width is the free variable; height follows the aspect. */\nexport function fitCard(card: ImageRect, sheet: ImageSize): ImageRect {\n const minHeight = Math.max(16, Math.round(sheet.height * MIN_CARD_HEIGHT_FRACTION));\n const maxHeight = Math.min(sheet.height, sheet.width / CARD_ASPECT);\n const height = Math.round(clamp(card.height, minHeight, maxHeight));\n const width = Math.round(height * CARD_ASPECT);\n return {\n width,\n height,\n left: Math.round(clamp(card.left, 0, sheet.width - width)),\n top: Math.round(clamp(card.top, 0, sheet.height - height)),\n };\n}\n\nexport function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming {\n return { card: fitCard(framing.card, sheet), circle: fitCircle(framing.circle) };\n}\n\n/** True when the framing is a well-formed object with finite numbers. Shape\n * only — call fitFraming for the geometric bounds. */\nexport function isFramingShape(value: unknown): value is AvatarFraming {\n if (!value || typeof value !== 'object') return false;\n const f = value as any;\n const rect = f.card, circle = f.circle;\n const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\n return !!rect && !!circle\n && num(rect.left) && num(rect.top) && num(rect.width) && num(rect.height)\n && rect.width > 0 && rect.height > 0\n && num(circle.x) && num(circle.y) && num(circle.d) && circle.d > 0;\n}\n\n/** The circle as a focus on the CARD image itself (a stored card cut). */\nexport function circleFocus(circle: AvatarCircle): ImageFocus {\n const c = fitCircle(circle);\n return { x: c.x, y: c.y, w: c.d, h: c.d * CARD_ASPECT };\n}\n\n/** The card as a focus on its sheet. */\nexport function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus {\n return {\n x: card.left / sheet.width,\n y: card.top / sheet.height,\n w: card.width / sheet.width,\n h: card.height / sheet.height,\n };\n}\n\n/** The circle as a focus on the SHEET (card and circle composed) — how a preset\n * mannequin renders straight off a static sheet. */\nexport function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus {\n const c = fitCircle(framing.circle);\n const card = framing.card;\n const side = c.d * card.width; // the circle is square, in sheet px\n return {\n x: (card.left + c.x * card.width) / sheet.width,\n y: (card.top + c.y * card.height) / sheet.height,\n w: side / sheet.width,\n h: side / sheet.height,\n };\n}\n\n/** CSS `background-size` / `background-position` that shows exactly `focus`\n * of an image inside a box. */\nexport function focusToBackground(focus: ImageFocus): { backgroundSize: string; backgroundPosition: string } {\n const axis = (pos: number, ext: number) => ext >= 1 ? 50 : (pos / (1 - ext)) * 100;\n return {\n backgroundSize: `${(100 / focus.w).toFixed(3)}% ${(100 / focus.h).toFixed(3)}%`,\n backgroundPosition: `${axis(focus.x, focus.w).toFixed(3)}% ${axis(focus.y, focus.h).toFixed(3)}%`,\n };\n}\n","import { ImageRect } from \"./types\";\n\n/**\n * Finds the cells of a drawn portrait sheet from its pixels.\n *\n * The prompt asks for equal cells separated by thin dark divider lines, but\n * the model draws the lines where it likes: on a correctly drawn 4x4 sheet\n * the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448\n * (2026-09-02), so fixed-interval slicing cut lower rows through the previous\n * character's chest. The lines themselves are crisp — near-black across the\n * full width — so they are easy to find, and the cells between them are the\n * real ones. Columns have always measured exact; rows drift.\n *\n * Pure over a greyscale byte plane so it is testable without sharp.\n */\n\nexport interface GreyPlane {\n width: number;\n height: number;\n // width*height bytes, row-major, 0 = black.\n data: Uint8Array;\n}\n\nexport interface SheetGrid {\n cells: ImageRect[]; // row-major\n cols: number;\n rows: number;\n // false = no usable divider lines were found and this is the equal split\n // of the REQUESTED grid. true = the cells between the lines the model\n // actually drew, which may be a different grid than requested (a 4x4\n // request has come back as 4x3 and as 6x3) — the sheet is the truth.\n detected: boolean;\n}\n\n// A pixel darker than this is \"line\"; a scanline with more than this share\n// of dark pixels is a divider. Cell backgrounds are flat muted colours, well\n// above the threshold, and the dark portrait clothing never spans a full line.\nconst DARK_MAX = 60;\nconst DIVIDER_SHARE = 0.85;\n// Pixels kept clear of a divider when cutting (anti-aliased edges).\nconst DIVIDER_INSET = 4;\n// A real divider is a thin line (~7 px on a 2400 px sheet). Thinner runs are\n// specks (a dark hairline inside a hooded figure once passed as a row line).\n// Thicker runs up to a small share of the sheet are still boundaries: the\n// model sometimes adds a dark name plate under each portrait despite the\n// no-text rule, and the plate belongs OUTSIDE the cell. Beyond that share a\n// dark run is a dark art style's background, not a boundary.\nconst DIVIDER_MIN_THICKNESS = 3;\nconst DIVIDER_MAX_THICKNESS_SHARE = 0.08;\n// A span between lines narrower than this share of the sheet can't hold a\n// face; the line that would create it is not a cell boundary.\nconst MIN_SPAN_SHARE = 0.12;\n\ninterface Run { start: number; end: number }\n\nfunction runs(indices: number[]): Run[] {\n const out: Run[] = [];\n for (const i of indices) {\n const last = out[out.length - 1];\n if (last && i === last.end + 1) last.end = i;\n else out.push({start: i, end: i});\n }\n return out;\n}\n\n/** Dark runs along one axis, measured over a window of the other axis (a\n * band of rows for columns, the full height by default). Border runs and\n * runs that aren't line-shaped are dropped. */\nfunction lineRuns(plane: GreyPlane, axis: 'rows' | 'cols', from: number, to: number): Run[] {\n const {width, height, data} = plane;\n const extent = axis === 'rows' ? height : width;\n const dark: number[] = [];\n for (let i = 0; i < extent; i++) {\n let count = 0;\n if (axis === 'rows') {\n const base = i * width;\n for (let x = from; x < to; x++) if (data[base + x] < DARK_MAX) count++;\n } else {\n for (let y = from; y < to; y++) if (data[y * width + i] < DARK_MAX) count++;\n }\n if (count / (to - from) > DIVIDER_SHARE) dark.push(i);\n }\n const maxThickness = extent * DIVIDER_MAX_THICKNESS_SHARE;\n return runs(dark)\n .filter(r => r.start > 2 && r.end < extent - 3)\n .filter(r => { const t = r.end - r.start + 1; return t >= DIVIDER_MIN_THICKNESS && t <= maxThickness; });\n}\n\n/** Lines that would leave a sliver on either side are not boundaries. */\nfunction dropSliverMakers(lines: Run[], extent: number): Run[] {\n const minSpan = extent * MIN_SPAN_SHARE;\n const kept: Run[] = [];\n let cursor = 0;\n for (const line of lines) {\n if (line.start - cursor < minSpan || extent - (line.end + 1) < minSpan) continue;\n kept.push(line);\n cursor = line.end + 1;\n }\n return kept;\n}\n\n/** Divider runs across the whole plane: row lines over the full width,\n * column lines over the full height. */\nexport function findDividers(plane: GreyPlane): {rows: Run[]; cols: Run[]} {\n return {\n rows: dropSliverMakers(lineRuns(plane, 'rows', 0, plane.width), plane.height),\n cols: dropSliverMakers(lineRuns(plane, 'cols', 0, plane.height), plane.width),\n };\n}\n\n/** Cells between dividers along one axis, from the border to the first line,\n * line to line, last line to the border. */\nfunction spans(dividers: Run[], extent: number): {start: number; end: number}[] {\n const out: {start: number; end: number}[] = [];\n let cursor = 0;\n for (const d of dividers) {\n out.push({start: cursor, end: d.start});\n cursor = d.end + 1;\n }\n out.push({start: cursor, end: extent});\n return out;\n}\n\nexport function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid {\n const cellW = Math.floor(width / cols), cellH = Math.floor(height / rows);\n const cells: ImageRect[] = [];\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n cells.push(inset({left: c * cellW, top: r * cellH, width: cellW, height: cellH}, 8));\n }\n }\n return {cells, cols, rows, detected: false};\n}\n\nfunction inset(rect: ImageRect, by: number): ImageRect {\n return {left: rect.left + by, top: rect.top + by, width: Math.max(1, rect.width - 2 * by), height: Math.max(1, rect.height - 2 * by)};\n}\n\n/**\n * The sheet's cells. Read off the divider lines the model drew whenever they\n * form a plausible grid, even when that grid differs from the cols x rows\n * that were asked for — the model keeps row-major order, so the drawn cells\n * still map to the characters in sequence. Row lines are found over the\n * full width; column lines are found PER ROW BAND, because the model has\n * drawn rows with different column layouts (and a column line that stops\n * short of the last row). A band without lines of its own borrows the\n * layout of the band above it. The equal split of the requested grid is the\n * last resort when no lines are found at all (the caller logs it; the kept\n * sheet lets the owner reframe by hand either way).\n */\nexport function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid {\n const {width, height} = plane;\n const rowLines = dropSliverMakers(lineRuns(plane, 'rows', 0, width), height);\n const bands = spans(rowLines, height);\n const cells: ImageRect[] = [];\n let previous: Run[] | null = null;\n let colCount = 0;\n for (const band of bands) {\n let colLines = dropSliverMakers(lineRuns(plane, 'cols', band.start, band.end), width);\n if (colLines.length === 0 && previous) colLines = previous;\n previous = colLines;\n const xs = spans(colLines, width);\n colCount = Math.max(colCount, xs.length);\n for (const x of xs) {\n cells.push(inset({left: x.start, top: band.start, width: x.end - x.start, height: band.end - band.start}, DIVIDER_INSET));\n }\n }\n if (colCount < 2) return equalSplitGrid(width, height, cols, rows);\n return {cells, cols: colCount, rows: bands.length, detected: true};\n}\n\n/** What findDividers saw, for the mismatch log line. */\nexport function describeDividers(plane: GreyPlane): string {\n const d = findDividers(plane);\n const fmt = (r: Run[]) => r.map(x => `${x.start}-${x.end}`).join(',') || 'none';\n return `rows@${fmt(d.rows)} cols@${fmt(d.cols)}`;\n}\n","/**\n * Image-generation models: platform-side, never user-selected. Prices per 1M tokens\n * from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens\n * per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower\n * resolution, is what minimizes cost.\n */\nexport const IMAGE_MODEL_CONSTANTS = {\n GEMINI_FLASH_IMAGE: 'gemini-3.1-flash-image',\n} as const;\n\nexport type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];\n\nexport interface ImageModelPricing {\n imageOutputPricePerM: number;\n textInputPricePerM: number;\n}\n\nexport const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing> = {\n [IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE]: {\n imageOutputPricePerM: 60,\n textInputPricePerM: 0.5,\n },\n};\n\nexport const DEFAULT_IMAGE_MODEL: ImageModelId = IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE;\n\n/** Cost of one image call from the usage Gemini reports. */\nexport function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number {\n const pricing = IMAGE_MODEL_PRICING[model];\n return parseFloat((\n imageOutputTokens / 1_000_000 * pricing.imageOutputPricePerM +\n textInputTokens / 1_000_000 * pricing.textInputPricePerM\n ).toFixed(6));\n}\n","import { calculateImageCost, DEFAULT_IMAGE_MODEL, ImageModelId } from './image-catalog';\nimport { GeneratedImage } from './types';\n\nexport type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';\n\nexport interface GenerateImageOptions {\n /** Labeled reference JPEGs (an established scene, character portraits) that\n * anchor the drawing to a known style, place or faces. */\n references?: { label: string; jpeg: Buffer }[];\n imageSize?: '1K' | '2K';\n model?: ImageModelId;\n /** Test seam / custom transport; defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —\n * and the result reports its cost so the host decides whom to charge. Cost is per\n * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).\n */\nexport async function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts: GenerateImageOptions = {}): Promise<GeneratedImage> {\n const model = opts.model ?? DEFAULT_IMAGE_MODEL;\n const input: any[] = [{ type: 'text', text: prompt }];\n for (const ref of opts.references ?? []) {\n input.push({ type: 'text', text: ref.label });\n input.push({ type: 'image', mime_type: 'image/jpeg', data: ref.jpeg.toString('base64') });\n }\n const doFetch = opts.fetchImpl ?? fetch;\n const res = await doFetch('https://generativelanguage.googleapis.com/v1beta/interactions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },\n body: JSON.stringify({\n model,\n input,\n response_format: { type: 'image', mime_type: 'image/jpeg', aspect_ratio: aspectRatio, image_size: opts.imageSize ?? '2K' },\n }),\n });\n if (!res.ok) {\n throw new Error(`Image request failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);\n }\n const json: any = await res.json();\n const b64 = (json.steps || [])\n .flatMap((s: any) => s.content || [])\n .find((c: any) => c.type === 'image' && c.data)?.data;\n if (!b64) throw new Error('Image response contained no image data');\n\n const usage = json.usage || {};\n const imageTokens = (usage.output_tokens_by_modality || [])\n .filter((m: any) => m.modality === 'image')\n .reduce((sum: number, m: any) => sum + (m.tokens || 0), 0);\n const inputTokens = usage.total_input_tokens || 0;\n\n return { buffer: Buffer.from(b64, 'base64'), costUSD: calculateImageCost(model, imageTokens, inputTokens) };\n}\n","import { generateImage, GenerateImageOptions } from './google-image';\nimport { defaultFraming } from './framing';\nimport { describeDividers, detectSheetGrid, equalSplitGrid } from './sheet-detection';\nimport { CARD_HEIGHT_PX, CARD_WIDTH_PX, DrawnSheet, ImageRect, PortraitCell, PortraitSlice, SharpLike } from './types';\n\n/**\n * Portrait sheets: one image-model call draws a whole cast as a grid of bust\n * portraits, and the grid is cut into one card per character. One call costs the\n * same whether it draws one face or sixteen, so this is the cheap way to give\n * every character in a game a portrait in a single consistent style.\n *\n * Nothing here touches storage or billing: the host stores the cards and the kept\n * sheet (so a card can be re-cut later at a new framing) and charges whoever it\n * likes for `costUSD`. sharp is passed in, never imported.\n */\n\n/**\n * Grid dimensions by cell count on the 4:3 canvas. Cells must come out\n * square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the\n * sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards\n * taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,\n * one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell\n * cast to 16.\n */\nexport function gridFor(cells: number): { cols: number; rows: number } {\n if (cells <= 6) return { cols: 3, rows: 2 };\n if (cells <= 8) return { cols: 4, rows: 2 };\n if (cells <= 9) return { cols: 3, rows: 3 };\n if (cells <= 12) return { cols: 4, rows: 3 };\n if (cells <= 15) return { cols: 5, rows: 3 };\n if (cells <= 18) return { cols: 6, rows: 3 };\n throw new Error(`Portrait sheet cannot hold ${cells} cells (max 18)`);\n}\n\nexport const DEFAULT_FILLER_PROMPT = `\"Stranger\" — an anonymous hooded figure fitting the setting, face hidden in shadow`;\n\n/**\n * Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the\n * spare ones get an anonymous filler that is drawn and discarded.\n */\nexport function padCells(cells: PortraitCell[], fillerPrompt: string = DEFAULT_FILLER_PROMPT): { cells: PortraitCell[]; cols: number; rows: number; realCount: number } {\n const { cols, rows } = gridFor(cells.length);\n const padded = [...cells];\n for (let i = padded.length; i < cols * rows; i++) {\n padded.push({ key: `__filler${i}`, label: 'Stranger', prompt: fillerPrompt });\n }\n return { cells: padded, cols, rows, realCount: cells.length };\n}\n\nexport interface PortraitSheetPrompt {\n cells: PortraitCell[];\n cols: number;\n rows: number;\n /** What the sheet is for, e.g. \"a social deduction game\" — one noun phrase. */\n purpose: string;\n /** The world the characters live in: a title and a short description. */\n setting: { title: string; description: string };\n /** Player-chosen art direction, already sanitized by the host. Replaces the\n * model's free choice of style. */\n artStyle?: string;\n}\n\n/**\n * The sheet prompt. The wording is load-bearing: equal cells with thin dark\n * divider lines are what the slicer finds, one flat muted background per cell keeps\n * the lines detectable in any art style, and the no-text rule is repeated because\n * the model otherwise typesets names and bios into the image.\n */\nexport function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string {\n const { cells, cols, rows, purpose, setting, artStyle } = spec;\n const cellLines = cells.map(\n (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`\n ).join('\\n');\n\n // A player style leads the prompt and is restated at the end: the sheet\n // framing (grid, bust portraits, flat backgrounds) otherwise pulls the model\n // toward drawn character-sheet art whatever style was asked for (an\n // \"ultra-realism\" game came out as comic portraits). The grid wording stays\n // intact — it is what the slicer relies on — but is declared layout only.\n const styleLead = artStyle\n ? `Art style, chosen by the player — it governs every portrait: \"${artStyle}\". Each cell must look made in that style: a photographic or cinematic style means real photographs, a painted style means paintings.\\n\\n`\n : '';\n const styleLine = artStyle\n ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`\n : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;\n const styleTail = artStyle ? `\\n\\nEvery cell must be fully in this art style: \"${artStyle}\" — and still carry no names or text of any kind.` : '';\n\n return `${styleLead}A character portrait sheet for ${purpose}, rendered as a single image: a precise grid of exactly ${cells.length} rectangular cells, ${cols} columns and ${rows} rows, all cells exactly equal size, separated by thin dark divider lines. Each cell contains one bust portrait (head and shoulders) of a different character, centered in its cell.\n\nSetting — \"${setting.title}\": ${setting.description}\n\n${styleLine} Every face must be distinct and memorable, and match its character description. No character may span more than one cell. Give each cell its own flat solid muted desaturated background color, different from its neighbors. Row-major order, left to right, top to bottom:\n\n${cellLines}\n\nThe character descriptions above are guidance for the drawing only — NEVER render them as text. Absolutely no text anywhere in the image: no names, no labels, no captions, no letters, no writing of any kind — and no lettering on clothing, equipment, insignia or logos.${styleTail}`;\n}\n\n// The model returns ~2.4 MB loosely-compressed JPEGs; at this width and quality a\n// sheet is ~400 KB (~530 KB as base64), under a 1 MiB document limit with cells\n// still ~500 px tall.\nexport const SHEET_MAX_WIDTH = 2400;\nexport const SHEET_JPEG_QUALITY = 85;\nexport const SHEET_MAX_BASE64_BYTES = 900_000;\n\n/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */\nexport interface SheetMismatch {\n kind: 'no-dividers' | 'different-grid';\n message: string;\n detail: Record<string, unknown>;\n}\n\nexport interface SliceSheetOptions {\n onMismatch?: (mismatch: SheetMismatch) => void;\n /** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */\n maxWidth?: number;\n}\n\n/**\n * Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the\n * divider lines the model drew (equal split as a last resort); each card is the\n * largest 3:4 rectangle in its cell, top-anchored, with the default circle — the\n * framing the host can let a user move later.\n */\nexport async function sliceSheet(\n sharp: SharpLike,\n raw: Buffer,\n cells: PortraitCell[],\n count: number,\n cols: number,\n rows: number,\n opts: SliceSheetOptions = {},\n): Promise<{ slices: PortraitSlice[]; sheet: DrawnSheet }> {\n // Normalise the working resolution first so cells, cards and the stored sheet\n // all share one pixel space.\n const grid: Buffer = await sharp(raw).resize({ width: opts.maxWidth ?? SHEET_MAX_WIDTH, withoutEnlargement: true }).toBuffer();\n const { data, info } = await sharp(grid).greyscale().raw().toBuffer({ resolveWithObject: true });\n const width = info.width, height = info.height;\n if (width < 100 * cols || height < 100 * rows) throw new Error(`Portrait sheet has unusable dimensions ${width}x${height}`);\n\n const plane = { width, height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };\n const gridCells = detectSheetGrid(plane, cols, rows);\n if (!gridCells.detected) {\n opts.onMismatch?.({\n kind: 'no-dividers',\n message: `no divider lines found on the ${cols}x${rows} sheet; using equal split`,\n detail: { width, height, dividers: describeDividers(plane) },\n });\n } else if (gridCells.cols !== cols || gridCells.rows !== rows) {\n // The model drew a different grid than asked. Its cells are still in row-major\n // order, so they are used as drawn; only characters past the last drawn cell\n // fall back to the equal split.\n opts.onMismatch?.({\n kind: 'different-grid',\n message: `sheet drawn as ${gridCells.cols}x${gridCells.rows}, requested ${cols}x${rows}; using the drawn cells`,\n detail: { width, height, drawnCells: gridCells.cells.length, needed: count },\n });\n }\n const fallback = equalSplitGrid(width, height, cols, rows).cells;\n\n const slices: PortraitSlice[] = [];\n for (let i = 0; i < count; i++) {\n const framing = defaultFraming(gridCells.cells[i] ?? fallback[i]);\n const jpeg = await cutCard(sharp, grid, framing.card);\n slices.push({ key: cells[i].key, label: cells[i].label, jpeg, framing });\n }\n\n let sheetJpeg: Buffer = await sharp(grid).jpeg({ quality: SHEET_JPEG_QUALITY, mozjpeg: true }).toBuffer();\n if (sheetJpeg.length * 4 / 3 > SHEET_MAX_BASE64_BYTES) {\n sheetJpeg = await sharp(grid).jpeg({ quality: 72, mozjpeg: true }).toBuffer();\n }\n return {\n slices,\n sheet: { jpeg: sheetJpeg, width, height, cells: gridCells.cells.slice(0, count), detected: gridCells.detected },\n };\n}\n\n/** Cuts one card out of a stored sheet at the given framing. */\nexport async function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer> {\n return sharp(sheet)\n .extract(card)\n .resize(CARD_WIDTH_PX, CARD_HEIGHT_PX)\n .jpeg({ quality: 85 })\n .toBuffer();\n}\n\nexport interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {\n /** Prompt for the cells that pad the cast out to a full grid. */\n fillerPrompt?: string;\n}\n\nexport interface DrawPortraitSheetOptions extends SliceSheetOptions {\n image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;\n}\n\nexport interface DrawnPortraitSheet {\n portraits: PortraitSlice[];\n sheet: DrawnSheet;\n /** What the image call cost, reported for the host's billing. */\n costUSD: number;\n}\n\n/**\n * The whole pipeline: lay the cast out on a grid, draw it in one call, cut the\n * cards. Filler cells are drawn and discarded; `portraits` has exactly one entry\n * per input cell, in order.\n */\nexport async function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts: DrawPortraitSheetOptions = {}): Promise<DrawnPortraitSheet> {\n const { cells, cols, rows, realCount } = padCells(spec.cells, spec.fillerPrompt);\n const prompt = buildPortraitSheetPrompt({ cells, cols, rows, purpose: spec.purpose, setting: spec.setting, artStyle: spec.artStyle });\n const image = await generateImage(apiKey, prompt, '4:3', opts.image);\n const { slices, sheet } = await sliceSheet(sharp, image.buffer, cells, realCount, cols, rows, opts);\n return { portraits: slices, sheet, costUSD: image.costUSD };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/images/types.ts","../src/images/framing.ts","../src/images/sheet-detection.ts","../src/images/image-catalog.ts","../src/images/google-image.ts","../src/images/portrait-sheet.ts"],"sourcesContent":["/**\n * Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless\n * stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.\n */\n\n/** A rectangle in image pixels (sharp's `extract` shape). */\nexport interface ImageRect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface ImageSize {\n width: number;\n height: number;\n}\n\n/** The round avatar inside a card. `x` and `d` are fractions of the card's width,\n * `y` a fraction of its height. */\nexport interface AvatarCircle {\n x: number;\n y: number;\n d: number;\n}\n\n/** Where a portrait comes from on its sheet: the card cut and the circle in it. */\nexport interface AvatarFraming {\n card: ImageRect;\n circle: AvatarCircle;\n}\n\n/** The portion of an image to display, as fractions of its width/height. */\nexport interface ImageFocus {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n// Cards are portrait 3:4 (a poster's shape), stored at this size.\nexport const CARD_ASPECT = 3 / 4; // width / height\nexport const CARD_WIDTH_PX = 600;\nexport const CARD_HEIGHT_PX = 800;\n// Where the circle starts on a freshly cut card: 86% of its width, centred,\n// near the top — wide enough to take in a chin or beard (0.72 cut them off).\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.07, y: 0.02, d: 0.86 };\n// A card narrower than this fraction of the sheet's height upscales too much.\nexport const MIN_CARD_HEIGHT_FRACTION = 0.12;\n\n/** One character on a portrait sheet. */\nexport interface PortraitCell {\n /** Stable id the caller stores the crop under; never drawn into the image. */\n key: string;\n /** Character name, for logs and prompt guidance; never drawn into the image. */\n label: string;\n /** One-line visual description for this cell (gender, name, appearance). */\n prompt: string;\n}\n\n/** A card cut from a drawn sheet. */\nexport interface PortraitSlice {\n key: string;\n label: string;\n jpeg: Buffer;\n /** The framing the slicer chose: the card on the sheet and the default circle. */\n framing: AvatarFraming;\n}\n\n/** The kept sheet: the grid image itself (re-encoded), its size and the cells\n * the cards were cut from — enough to re-cut any card later at a new framing. */\nexport interface DrawnSheet {\n jpeg: Buffer;\n width: number;\n height: number;\n cells: ImageRect[];\n /** false = no divider lines were found and the cells are the equal split of the\n * requested grid; true = the cells between the lines the model actually drew. */\n detected: boolean;\n}\n\nexport interface GeneratedImage {\n buffer: Buffer;\n costUSD: number;\n}\n\n/**\n * The slice of sharp's API the pipeline uses. The library never imports sharp — a\n * native module the host installs and passes in (`(await import('sharp')).default`) —\n * so text-only consumers pull in nothing new.\n */\nexport type SharpLike = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement?: boolean }): SharpPipeline;\n resize(width: number, height: number): SharpPipeline;\n greyscale(): SharpPipeline;\n extract(rect: ImageRect): SharpPipeline;\n jpeg(options: { quality: number; mozjpeg?: boolean }): SharpPipeline;\n raw(): SharpPipeline;\n toBuffer(): Promise<Buffer>;\n toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: { width: number; height: number } }>;\n metadata(): Promise<{ width?: number; height?: number }>;\n};\n\nexport type SharpPipeline = ReturnType<SharpLike>;\n","import {\n AvatarCircle,\n AvatarFraming,\n CARD_ASPECT,\n DEFAULT_AVATAR_CIRCLE,\n ImageFocus,\n ImageRect,\n ImageSize,\n MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\n/**\n * Pure framing geometry, shared by the slicer (server), reframe validation (server)\n * and renderers/editors (browser). No I/O here.\n *\n * Coordinates: a card is an ImageRect in SHEET pixels (portrait 3:4); the circle is\n * card-relative (x, d = fractions of the card's width, y = fraction of its height).\n * An ImageFocus is what a renderer needs: the fraction of an image to show, so the\n * same avatar component can show a circle cut out of a stored card OR out of a\n * static preset sheet.\n */\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\n\n/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:\n * portraits are busts with the head near the top, so the top edge is the\n * anchor that keeps the face in. */\nexport function cardInCell(cell: ImageRect): ImageRect {\n const width = Math.floor(Math.min(cell.width, cell.height * CARD_ASPECT));\n const height = Math.min(cell.height, Math.round(width / CARD_ASPECT));\n return {\n left: Math.round(cell.left + (cell.width - width) / 2),\n top: cell.top,\n width,\n height,\n };\n}\n\nexport function defaultFraming(cell: ImageRect): AvatarFraming {\n return { card: cardInCell(cell), circle: { ...DEFAULT_AVATAR_CIRCLE } };\n}\n\n/** Keeps the circle inside its card. */\nexport function fitCircle(circle: AvatarCircle): AvatarCircle {\n const d = clamp(circle.d, 0.15, 1);\n return {\n d,\n x: clamp(circle.x, 0, 1 - d),\n y: clamp(circle.y, 0, 1 - d * CARD_ASPECT),\n };\n}\n\n/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the\n * minimum size. Width is the free variable; height follows the aspect. */\nexport function fitCard(card: ImageRect, sheet: ImageSize): ImageRect {\n const minHeight = Math.max(16, Math.round(sheet.height * MIN_CARD_HEIGHT_FRACTION));\n const maxHeight = Math.min(sheet.height, sheet.width / CARD_ASPECT);\n const height = Math.round(clamp(card.height, minHeight, maxHeight));\n const width = Math.round(height * CARD_ASPECT);\n return {\n width,\n height,\n left: Math.round(clamp(card.left, 0, sheet.width - width)),\n top: Math.round(clamp(card.top, 0, sheet.height - height)),\n };\n}\n\nexport function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming {\n return { card: fitCard(framing.card, sheet), circle: fitCircle(framing.circle) };\n}\n\n/** True when the framing is a well-formed object with finite numbers. Shape\n * only — call fitFraming for the geometric bounds. */\nexport function isFramingShape(value: unknown): value is AvatarFraming {\n if (!value || typeof value !== 'object') return false;\n const f = value as any;\n const rect = f.card, circle = f.circle;\n const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\n return !!rect && !!circle\n && num(rect.left) && num(rect.top) && num(rect.width) && num(rect.height)\n && rect.width > 0 && rect.height > 0\n && num(circle.x) && num(circle.y) && num(circle.d) && circle.d > 0;\n}\n\n/** The circle as a focus on the CARD image itself (a stored card cut). */\nexport function circleFocus(circle: AvatarCircle): ImageFocus {\n const c = fitCircle(circle);\n return { x: c.x, y: c.y, w: c.d, h: c.d * CARD_ASPECT };\n}\n\n/** The card as a focus on its sheet. */\nexport function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus {\n return {\n x: card.left / sheet.width,\n y: card.top / sheet.height,\n w: card.width / sheet.width,\n h: card.height / sheet.height,\n };\n}\n\n/** The circle as a focus on the SHEET (card and circle composed) — how a preset\n * mannequin renders straight off a static sheet. */\nexport function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus {\n const c = fitCircle(framing.circle);\n const card = framing.card;\n const side = c.d * card.width; // the circle is square, in sheet px\n return {\n x: (card.left + c.x * card.width) / sheet.width,\n y: (card.top + c.y * card.height) / sheet.height,\n w: side / sheet.width,\n h: side / sheet.height,\n };\n}\n\n/** CSS `background-size` / `background-position` that shows exactly `focus`\n * of an image inside a box. */\nexport function focusToBackground(focus: ImageFocus): { backgroundSize: string; backgroundPosition: string } {\n const axis = (pos: number, ext: number) => ext >= 1 ? 50 : (pos / (1 - ext)) * 100;\n return {\n backgroundSize: `${(100 / focus.w).toFixed(3)}% ${(100 / focus.h).toFixed(3)}%`,\n backgroundPosition: `${axis(focus.x, focus.w).toFixed(3)}% ${axis(focus.y, focus.h).toFixed(3)}%`,\n };\n}\n","import { ImageRect } from \"./types\";\n\n/**\n * Finds the cells of a drawn portrait sheet from its pixels.\n *\n * The prompt asks for equal cells separated by thin dark divider lines, but\n * the model draws the lines where it likes: on a correctly drawn 4x4 sheet\n * the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448\n * (2026-09-02), so fixed-interval slicing cut lower rows through the previous\n * character's chest. The lines themselves are crisp — near-black across the\n * full width — so they are easy to find, and the cells between them are the\n * real ones. Columns have always measured exact; rows drift.\n *\n * Pure over a greyscale byte plane so it is testable without sharp.\n */\n\nexport interface GreyPlane {\n width: number;\n height: number;\n // width*height bytes, row-major, 0 = black.\n data: Uint8Array;\n}\n\nexport interface SheetGrid {\n cells: ImageRect[]; // row-major\n cols: number;\n rows: number;\n // false = no usable divider lines were found and this is the equal split\n // of the REQUESTED grid. true = the cells between the lines the model\n // actually drew, which may be a different grid than requested (a 4x4\n // request has come back as 4x3 and as 6x3) — the sheet is the truth.\n detected: boolean;\n}\n\n// A pixel darker than this is \"line\"; a scanline with more than this share\n// of dark pixels is a divider. Cell backgrounds are flat muted colours, well\n// above the threshold, and the dark portrait clothing never spans a full line.\nconst DARK_MAX = 60;\nconst DIVIDER_SHARE = 0.85;\n// Pixels kept clear of a divider when cutting (anti-aliased edges).\nconst DIVIDER_INSET = 4;\n// A real divider is a thin line (~7 px on a 2400 px sheet). Thinner runs are\n// specks (a dark hairline inside a hooded figure once passed as a row line).\n// Thicker runs up to a small share of the sheet are still boundaries: the\n// model sometimes adds a dark name plate under each portrait despite the\n// no-text rule, and the plate belongs OUTSIDE the cell. Beyond that share a\n// dark run is a dark art style's background, not a boundary.\nconst DIVIDER_MIN_THICKNESS = 3;\nconst DIVIDER_MAX_THICKNESS_SHARE = 0.08;\n// A span between lines narrower than this share of the sheet can't hold a\n// face; the line that would create it is not a cell boundary.\nconst MIN_SPAN_SHARE = 0.12;\n\ninterface Run { start: number; end: number }\n\nfunction runs(indices: number[]): Run[] {\n const out: Run[] = [];\n for (const i of indices) {\n const last = out[out.length - 1];\n if (last && i === last.end + 1) last.end = i;\n else out.push({start: i, end: i});\n }\n return out;\n}\n\n/** Dark runs along one axis, measured over a window of the other axis (a\n * band of rows for columns, the full height by default). Border runs and\n * runs that aren't line-shaped are dropped. */\nfunction lineRuns(plane: GreyPlane, axis: 'rows' | 'cols', from: number, to: number): Run[] {\n const {width, height, data} = plane;\n const extent = axis === 'rows' ? height : width;\n const dark: number[] = [];\n for (let i = 0; i < extent; i++) {\n let count = 0;\n if (axis === 'rows') {\n const base = i * width;\n for (let x = from; x < to; x++) if (data[base + x] < DARK_MAX) count++;\n } else {\n for (let y = from; y < to; y++) if (data[y * width + i] < DARK_MAX) count++;\n }\n if (count / (to - from) > DIVIDER_SHARE) dark.push(i);\n }\n const maxThickness = extent * DIVIDER_MAX_THICKNESS_SHARE;\n return runs(dark)\n .filter(r => r.start > 2 && r.end < extent - 3)\n .filter(r => { const t = r.end - r.start + 1; return t >= DIVIDER_MIN_THICKNESS && t <= maxThickness; });\n}\n\n/** Lines that would leave a sliver on either side are not boundaries. */\nfunction dropSliverMakers(lines: Run[], extent: number): Run[] {\n const minSpan = extent * MIN_SPAN_SHARE;\n const kept: Run[] = [];\n let cursor = 0;\n for (const line of lines) {\n if (line.start - cursor < minSpan || extent - (line.end + 1) < minSpan) continue;\n kept.push(line);\n cursor = line.end + 1;\n }\n return kept;\n}\n\n/** Divider runs across the whole plane: row lines over the full width,\n * column lines over the full height. */\nexport function findDividers(plane: GreyPlane): {rows: Run[]; cols: Run[]} {\n return {\n rows: dropSliverMakers(lineRuns(plane, 'rows', 0, plane.width), plane.height),\n cols: dropSliverMakers(lineRuns(plane, 'cols', 0, plane.height), plane.width),\n };\n}\n\n/** Cells between dividers along one axis, from the border to the first line,\n * line to line, last line to the border. */\nfunction spans(dividers: Run[], extent: number): {start: number; end: number}[] {\n const out: {start: number; end: number}[] = [];\n let cursor = 0;\n for (const d of dividers) {\n out.push({start: cursor, end: d.start});\n cursor = d.end + 1;\n }\n out.push({start: cursor, end: extent});\n return out;\n}\n\nexport function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid {\n const cellW = Math.floor(width / cols), cellH = Math.floor(height / rows);\n const cells: ImageRect[] = [];\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n cells.push(inset({left: c * cellW, top: r * cellH, width: cellW, height: cellH}, 8));\n }\n }\n return {cells, cols, rows, detected: false};\n}\n\nfunction inset(rect: ImageRect, by: number): ImageRect {\n return {left: rect.left + by, top: rect.top + by, width: Math.max(1, rect.width - 2 * by), height: Math.max(1, rect.height - 2 * by)};\n}\n\n/**\n * The sheet's cells. Read off the divider lines the model drew whenever they\n * form a plausible grid, even when that grid differs from the cols x rows\n * that were asked for — the model keeps row-major order, so the drawn cells\n * still map to the characters in sequence. Row lines are found over the\n * full width; column lines are found PER ROW BAND, because the model has\n * drawn rows with different column layouts (and a column line that stops\n * short of the last row). A band without lines of its own borrows the\n * layout of the band above it. The equal split of the requested grid is the\n * last resort when no lines are found at all (the caller logs it; the kept\n * sheet lets the owner reframe by hand either way).\n */\nexport function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid {\n const {width, height} = plane;\n const rowLines = dropSliverMakers(lineRuns(plane, 'rows', 0, width), height);\n const bands = spans(rowLines, height);\n const cells: ImageRect[] = [];\n let previous: Run[] | null = null;\n let colCount = 0;\n for (const band of bands) {\n let colLines = dropSliverMakers(lineRuns(plane, 'cols', band.start, band.end), width);\n if (colLines.length === 0 && previous) colLines = previous;\n previous = colLines;\n const xs = spans(colLines, width);\n colCount = Math.max(colCount, xs.length);\n for (const x of xs) {\n cells.push(inset({left: x.start, top: band.start, width: x.end - x.start, height: band.end - band.start}, DIVIDER_INSET));\n }\n }\n if (colCount < 2) return equalSplitGrid(width, height, cols, rows);\n return {cells, cols: colCount, rows: bands.length, detected: true};\n}\n\n/** What findDividers saw, for the mismatch log line. */\nexport function describeDividers(plane: GreyPlane): string {\n const d = findDividers(plane);\n const fmt = (r: Run[]) => r.map(x => `${x.start}-${x.end}`).join(',') || 'none';\n return `rows@${fmt(d.rows)} cols@${fmt(d.cols)}`;\n}\n","/**\n * Image-generation models: platform-side, never user-selected. Prices per 1M tokens\n * from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens\n * per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower\n * resolution, is what minimizes cost.\n */\nexport const IMAGE_MODEL_CONSTANTS = {\n GEMINI_FLASH_IMAGE: 'gemini-3.1-flash-image',\n} as const;\n\nexport type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];\n\nexport interface ImageModelPricing {\n imageOutputPricePerM: number;\n textInputPricePerM: number;\n}\n\nexport const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing> = {\n [IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE]: {\n imageOutputPricePerM: 60,\n textInputPricePerM: 0.5,\n },\n};\n\nexport const DEFAULT_IMAGE_MODEL: ImageModelId = IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE;\n\n/** Cost of one image call from the usage Gemini reports. */\nexport function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number {\n const pricing = IMAGE_MODEL_PRICING[model];\n return parseFloat((\n imageOutputTokens / 1_000_000 * pricing.imageOutputPricePerM +\n textInputTokens / 1_000_000 * pricing.textInputPricePerM\n ).toFixed(6));\n}\n","import { calculateImageCost, DEFAULT_IMAGE_MODEL, ImageModelId } from './image-catalog';\nimport { GeneratedImage } from './types';\n\nexport type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';\n\nexport interface GenerateImageOptions {\n /** Labeled reference JPEGs (an established scene, character portraits) that\n * anchor the drawing to a known style, place or faces. */\n references?: { label: string; jpeg: Buffer }[];\n imageSize?: '1K' | '2K';\n model?: ImageModelId;\n /** Test seam / custom transport; defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Gemini's Interactions API answers a blocked image with HTTP 200, no image block,\n * `status: \"failed\"` (or `incomplete`) and an `errors[]` entry whose `code` names the\n * block (https://ai.google.dev/gemini-api/docs/api-errors, \"Generation blocked\"):\n * `image_safety`, `image_prohibited_content`, `image_recitation`, `image_other`, plus\n * the text-side codes `safety`, `prohibited_content`, `recitation`, `blocklist`, `spii`,\n * `content_blocked`. `no_image` is a plain generation failure, not a block.\n *\n * Deliberately NOT a `ModelRefusalError` subclass: this subpath is bundled apart from the\n * main entry, so an `instanceof` against the main entry's class would never match. Hosts\n * check `isImageRefusalError(error)` (by name) instead. Not retryable on the same prompt.\n */\nexport class ImageRefusalError extends Error {\n /** The block code as Gemini sent it (a URI or bare `snake_case` name). */\n public code: string;\n /** Gemini's human-readable message, when it sent one. */\n public reason?: string;\n public model: string;\n\n constructor(model: string, code: string, reason?: string) {\n super(`${model} declined to draw the image (${describeBlockCode(code)})${reason ? `: ${reason}` : ''}`);\n this.name = 'ImageRefusalError';\n this.model = model;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/** Works across bundle copies and serialization boundaries (checks the name, not the class). */\nexport function isImageRefusalError(error: unknown): error is ImageRefusalError {\n return !!error && typeof error === 'object' && (error as { name?: unknown }).name === 'ImageRefusalError';\n}\n\nconst BLOCK_CODE_MARKERS = ['safety', 'prohibited', 'recitation', 'blocklist', 'spii', 'content_blocked', 'image_other'];\n\n/** The trailing identifier of a code URI such as `https://…/api-errors#image_safety`. */\nfunction describeBlockCode(code: string): string {\n const tail = code.split(/[#/]/).filter(Boolean).pop() ?? code;\n return tail.toLowerCase();\n}\n\nfunction isBlockCode(code: string): boolean {\n const tail = describeBlockCode(code);\n return BLOCK_CODE_MARKERS.some(marker => tail.includes(marker));\n}\n\n/**\n * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —\n * and the result reports its cost so the host decides whom to charge. Cost is per\n * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).\n *\n * Throws `ImageRefusalError` when Gemini blocked the drawing (see the class), and a\n * plain `Error` naming Gemini's status and error code for any other imageless reply.\n */\nexport async function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts: GenerateImageOptions = {}): Promise<GeneratedImage> {\n const model = opts.model ?? DEFAULT_IMAGE_MODEL;\n const input: any[] = [{ type: 'text', text: prompt }];\n for (const ref of opts.references ?? []) {\n input.push({ type: 'text', text: ref.label });\n input.push({ type: 'image', mime_type: 'image/jpeg', data: ref.jpeg.toString('base64') });\n }\n const doFetch = opts.fetchImpl ?? fetch;\n const res = await doFetch('https://generativelanguage.googleapis.com/v1beta/interactions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },\n body: JSON.stringify({\n model,\n input,\n response_format: { type: 'image', mime_type: 'image/jpeg', aspect_ratio: aspectRatio, image_size: opts.imageSize ?? '2K' },\n }),\n });\n if (!res.ok) {\n throw new Error(`Image request failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);\n }\n const json: any = await res.json();\n const b64 = (json.steps || [])\n .flatMap((s: any) => s.content || [])\n .find((c: any) => c.type === 'image' && c.data)?.data;\n if (!b64) throw imagelessReplyError(model, json);\n\n const usage = json.usage || {};\n const imageTokens = (usage.output_tokens_by_modality || [])\n .filter((m: any) => m.modality === 'image')\n .reduce((sum: number, m: any) => sum + (m.tokens || 0), 0);\n const inputTokens = usage.total_input_tokens || 0;\n\n return { buffer: Buffer.from(b64, 'base64'), costUSD: calculateImageCost(model, imageTokens, inputTokens) };\n}\n\n/** Classifies an HTTP 200 reply that carried no image block. */\nfunction imagelessReplyError(model: string, json: any): Error {\n const errors: { code?: unknown; message?: unknown }[] = Array.isArray(json?.errors) ? json.errors : [];\n const codes = errors.map(e => String(e?.code ?? '')).filter(Boolean);\n const messages = errors.map(e => (typeof e?.message === 'string' ? e.message.trim() : '')).filter(Boolean);\n const block = codes.find(isBlockCode);\n if (block) {\n return new ImageRefusalError(model, block, messages[0]?.slice(0, 300));\n }\n const status = typeof json?.status === 'string' ? json.status : 'unknown';\n const detail = [codes.length ? `code ${codes.map(describeBlockCode).join(', ')}` : '', messages[0]?.slice(0, 300) ?? '']\n .filter(Boolean)\n .join(': ');\n return new Error(`Image response contained no image data (status ${status}${detail ? `; ${detail}` : ''})`);\n}\n","import { generateImage, GenerateImageOptions } from './google-image';\nimport { defaultFraming } from './framing';\nimport { describeDividers, detectSheetGrid, equalSplitGrid } from './sheet-detection';\nimport { CARD_HEIGHT_PX, CARD_WIDTH_PX, DrawnSheet, ImageRect, PortraitCell, PortraitSlice, SharpLike } from './types';\n\n/**\n * Portrait sheets: one image-model call draws a whole cast as a grid of bust\n * portraits, and the grid is cut into one card per character. One call costs the\n * same whether it draws one face or sixteen, so this is the cheap way to give\n * every character in a game a portrait in a single consistent style.\n *\n * Nothing here touches storage or billing: the host stores the cards and the kept\n * sheet (so a card can be re-cut later at a new framing) and charges whoever it\n * likes for `costUSD`. sharp is passed in, never imported.\n */\n\n/**\n * Grid dimensions by cell count on the 4:3 canvas. Cells must come out\n * square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the\n * sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards\n * taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,\n * one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell\n * cast to 16.\n */\nexport function gridFor(cells: number): { cols: number; rows: number } {\n if (cells <= 6) return { cols: 3, rows: 2 };\n if (cells <= 8) return { cols: 4, rows: 2 };\n if (cells <= 9) return { cols: 3, rows: 3 };\n if (cells <= 12) return { cols: 4, rows: 3 };\n if (cells <= 15) return { cols: 5, rows: 3 };\n if (cells <= 18) return { cols: 6, rows: 3 };\n throw new Error(`Portrait sheet cannot hold ${cells} cells (max 18)`);\n}\n\nexport const DEFAULT_FILLER_PROMPT = `\"Stranger\" — an anonymous hooded figure fitting the setting, face hidden in shadow`;\n\n/**\n * Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the\n * spare ones get an anonymous filler that is drawn and discarded.\n */\nexport function padCells(cells: PortraitCell[], fillerPrompt: string = DEFAULT_FILLER_PROMPT): { cells: PortraitCell[]; cols: number; rows: number; realCount: number } {\n const { cols, rows } = gridFor(cells.length);\n const padded = [...cells];\n for (let i = padded.length; i < cols * rows; i++) {\n padded.push({ key: `__filler${i}`, label: 'Stranger', prompt: fillerPrompt });\n }\n return { cells: padded, cols, rows, realCount: cells.length };\n}\n\nexport interface PortraitSheetPrompt {\n cells: PortraitCell[];\n cols: number;\n rows: number;\n /** What the sheet is for, e.g. \"a social deduction game\" — one noun phrase. */\n purpose: string;\n /** The world the characters live in: a title and a short description. */\n setting: { title: string; description: string };\n /** Player-chosen art direction, already sanitized by the host. Replaces the\n * model's free choice of style. */\n artStyle?: string;\n}\n\n/**\n * The sheet prompt. The wording is load-bearing: equal cells with thin dark\n * divider lines are what the slicer finds, one flat muted background per cell keeps\n * the lines detectable in any art style, and the no-text rule is repeated because\n * the model otherwise typesets names and bios into the image.\n */\nexport function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string {\n const { cells, cols, rows, purpose, setting, artStyle } = spec;\n const cellLines = cells.map(\n (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`\n ).join('\\n');\n\n // A player style leads the prompt and is restated at the end: the sheet\n // framing (grid, bust portraits, flat backgrounds) otherwise pulls the model\n // toward drawn character-sheet art whatever style was asked for (an\n // \"ultra-realism\" game came out as comic portraits). The grid wording stays\n // intact — it is what the slicer relies on — but is declared layout only.\n const styleLead = artStyle\n ? `Art style, chosen by the player — it governs every portrait: \"${artStyle}\". Each cell must look made in that style: a photographic or cinematic style means real photographs, a painted style means paintings.\\n\\n`\n : '';\n const styleLine = artStyle\n ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`\n : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;\n const styleTail = artStyle ? `\\n\\nEvery cell must be fully in this art style: \"${artStyle}\" — and still carry no names or text of any kind.` : '';\n\n return `${styleLead}A character portrait sheet for ${purpose}, rendered as a single image: a precise grid of exactly ${cells.length} rectangular cells, ${cols} columns and ${rows} rows, all cells exactly equal size, separated by thin dark divider lines. Each cell contains one bust portrait (head and shoulders) of a different character, centered in its cell.\n\nSetting — \"${setting.title}\": ${setting.description}\n\n${styleLine} Every face must be distinct and memorable, and match its character description. No character may span more than one cell. Give each cell its own flat solid muted desaturated background color, different from its neighbors. Row-major order, left to right, top to bottom:\n\n${cellLines}\n\nThe character descriptions above are guidance for the drawing only — NEVER render them as text. Absolutely no text anywhere in the image: no names, no labels, no captions, no letters, no writing of any kind — and no lettering on clothing, equipment, insignia or logos.${styleTail}`;\n}\n\n// The model returns ~2.4 MB loosely-compressed JPEGs; at this width and quality a\n// sheet is ~400 KB (~530 KB as base64), under a 1 MiB document limit with cells\n// still ~500 px tall.\nexport const SHEET_MAX_WIDTH = 2400;\nexport const SHEET_JPEG_QUALITY = 85;\nexport const SHEET_MAX_BASE64_BYTES = 900_000;\n\n/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */\nexport interface SheetMismatch {\n kind: 'no-dividers' | 'different-grid';\n message: string;\n detail: Record<string, unknown>;\n}\n\nexport interface SliceSheetOptions {\n onMismatch?: (mismatch: SheetMismatch) => void;\n /** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */\n maxWidth?: number;\n}\n\n/**\n * Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the\n * divider lines the model drew (equal split as a last resort); each card is the\n * largest 3:4 rectangle in its cell, top-anchored, with the default circle — the\n * framing the host can let a user move later.\n */\nexport async function sliceSheet(\n sharp: SharpLike,\n raw: Buffer,\n cells: PortraitCell[],\n count: number,\n cols: number,\n rows: number,\n opts: SliceSheetOptions = {},\n): Promise<{ slices: PortraitSlice[]; sheet: DrawnSheet }> {\n // Normalise the working resolution first so cells, cards and the stored sheet\n // all share one pixel space.\n const grid: Buffer = await sharp(raw).resize({ width: opts.maxWidth ?? SHEET_MAX_WIDTH, withoutEnlargement: true }).toBuffer();\n const { data, info } = await sharp(grid).greyscale().raw().toBuffer({ resolveWithObject: true });\n const width = info.width, height = info.height;\n if (width < 100 * cols || height < 100 * rows) throw new Error(`Portrait sheet has unusable dimensions ${width}x${height}`);\n\n const plane = { width, height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };\n const gridCells = detectSheetGrid(plane, cols, rows);\n if (!gridCells.detected) {\n opts.onMismatch?.({\n kind: 'no-dividers',\n message: `no divider lines found on the ${cols}x${rows} sheet; using equal split`,\n detail: { width, height, dividers: describeDividers(plane) },\n });\n } else if (gridCells.cols !== cols || gridCells.rows !== rows) {\n // The model drew a different grid than asked. Its cells are still in row-major\n // order, so they are used as drawn; only characters past the last drawn cell\n // fall back to the equal split.\n opts.onMismatch?.({\n kind: 'different-grid',\n message: `sheet drawn as ${gridCells.cols}x${gridCells.rows}, requested ${cols}x${rows}; using the drawn cells`,\n detail: { width, height, drawnCells: gridCells.cells.length, needed: count },\n });\n }\n const fallback = equalSplitGrid(width, height, cols, rows).cells;\n\n const slices: PortraitSlice[] = [];\n for (let i = 0; i < count; i++) {\n const framing = defaultFraming(gridCells.cells[i] ?? fallback[i]);\n const jpeg = await cutCard(sharp, grid, framing.card);\n slices.push({ key: cells[i].key, label: cells[i].label, jpeg, framing });\n }\n\n let sheetJpeg: Buffer = await sharp(grid).jpeg({ quality: SHEET_JPEG_QUALITY, mozjpeg: true }).toBuffer();\n if (sheetJpeg.length * 4 / 3 > SHEET_MAX_BASE64_BYTES) {\n sheetJpeg = await sharp(grid).jpeg({ quality: 72, mozjpeg: true }).toBuffer();\n }\n return {\n slices,\n sheet: { jpeg: sheetJpeg, width, height, cells: gridCells.cells.slice(0, count), detected: gridCells.detected },\n };\n}\n\n/** Cuts one card out of a stored sheet at the given framing. */\nexport async function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer> {\n return sharp(sheet)\n .extract(card)\n .resize(CARD_WIDTH_PX, CARD_HEIGHT_PX)\n .jpeg({ quality: 85 })\n .toBuffer();\n}\n\nexport interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {\n /** Prompt for the cells that pad the cast out to a full grid. */\n fillerPrompt?: string;\n}\n\nexport interface DrawPortraitSheetOptions extends SliceSheetOptions {\n image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;\n}\n\nexport interface DrawnPortraitSheet {\n portraits: PortraitSlice[];\n sheet: DrawnSheet;\n /** What the image call cost, reported for the host's billing. */\n costUSD: number;\n}\n\n/**\n * The whole pipeline: lay the cast out on a grid, draw it in one call, cut the\n * cards. Filler cells are drawn and discarded; `portraits` has exactly one entry\n * per input cell, in order.\n */\nexport async function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts: DrawPortraitSheetOptions = {}): Promise<DrawnPortraitSheet> {\n const { cells, cols, rows, realCount } = padCells(spec.cells, spec.fillerPrompt);\n const prompt = buildPortraitSheetPrompt({ cells, cols, rows, purpose: spec.purpose, setting: spec.setting, artStyle: spec.artStyle });\n const image = await generateImage(apiKey, prompt, '4:3', opts.image);\n const { slices, sheet } = await sliceSheet(sharp, image.buffer, cells, realCount, cols, rows, opts);\n return { portraits: slices, sheet, costUSD: image.costUSD };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiper2d/ai-agents",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Multi-provider AI agent layer: schema-validated asks, thinking extraction, a model catalog with per-model tuning defaults, token cost accounting for 12 LLM providers, voice agents (TTS/STT) for OpenAI and Gemini, per-subject spend budgets, and (via ./images) Gemini image generation with cast portrait sheets.",
5
5
  "license": "MIT",
6
6
  "author": "hiper2d",