@hiper2d/ai-agents 0.17.0 → 0.19.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/dist/images.js CHANGED
@@ -61,7 +61,7 @@ module.exports = __toCommonJS(images_exports);
61
61
  var CARD_ASPECT = 3 / 4;
62
62
  var CARD_WIDTH_PX = 600;
63
63
  var CARD_HEIGHT_PX = 800;
64
- var DEFAULT_AVATAR_CIRCLE = { x: 0.14, y: 0.03, d: 0.72 };
64
+ var DEFAULT_AVATAR_CIRCLE = { x: 0.07, y: 0.02, d: 0.86 };
65
65
  var MIN_CARD_HEIGHT_FRACTION = 0.12;
66
66
 
67
67
  // src/images/framing.ts
@@ -312,8 +312,14 @@ function buildPortraitSheetPrompt(spec) {
312
312
  const cellLines = cells.map(
313
313
  (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`
314
314
  ).join("\n");
315
- const styleLine = artStyle ? `Render every portrait in this art style, chosen by the player: "${artStyle}". Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.` : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;
316
- return `A character portrait sheet for ${purpose}, drawn 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.
315
+ const styleLead = artStyle ? `Art style, chosen by the player \u2014 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.
316
+
317
+ ` : "";
318
+ const styleLine = artStyle ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.` : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;
319
+ const styleTail = artStyle ? `
320
+
321
+ Every cell must be fully in this art style: "${artStyle}" \u2014 and still carry no names or text of any kind.` : "";
322
+ 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.
317
323
 
318
324
  Setting \u2014 "${setting.title}": ${setting.description}
319
325
 
@@ -321,7 +327,7 @@ ${styleLine} Every face must be distinct and memorable, and match its character
321
327
 
322
328
  ${cellLines}
323
329
 
324
- The character descriptions above are guidance for the drawing only \u2014 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 \u2014 and no lettering on clothing, equipment, insignia or logos.`;
330
+ The character descriptions above are guidance for the drawing only \u2014 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 \u2014 and no lettering on clothing, equipment, insignia or logos.${styleTail}`;
325
331
  }
326
332
  var SHEET_MAX_WIDTH = 2400;
327
333
  var SHEET_JPEG_QUALITY = 85;
@@ -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: 72% of its width, near the top.\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.14, y: 0.03, d: 0.72 };\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 const styleLine = artStyle\n ? `Render every portrait in this art style, chosen by the player: \"${artStyle}\". 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\n return `A character portrait sheet for ${purpose}, drawn 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.`;\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 } 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":[]}
package/dist/images.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  var CARD_ASPECT = 3 / 4;
3
3
  var CARD_WIDTH_PX = 600;
4
4
  var CARD_HEIGHT_PX = 800;
5
- var DEFAULT_AVATAR_CIRCLE = { x: 0.14, y: 0.03, d: 0.72 };
5
+ var DEFAULT_AVATAR_CIRCLE = { x: 0.07, y: 0.02, d: 0.86 };
6
6
  var MIN_CARD_HEIGHT_FRACTION = 0.12;
7
7
 
8
8
  // src/images/framing.ts
@@ -253,8 +253,14 @@ function buildPortraitSheetPrompt(spec) {
253
253
  const cellLines = cells.map(
254
254
  (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`
255
255
  ).join("\n");
256
- const styleLine = artStyle ? `Render every portrait in this art style, chosen by the player: "${artStyle}". Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.` : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;
257
- return `A character portrait sheet for ${purpose}, drawn 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.
256
+ const styleLead = artStyle ? `Art style, chosen by the player \u2014 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.
257
+
258
+ ` : "";
259
+ const styleLine = artStyle ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.` : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;
260
+ const styleTail = artStyle ? `
261
+
262
+ Every cell must be fully in this art style: "${artStyle}" \u2014 and still carry no names or text of any kind.` : "";
263
+ 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.
258
264
 
259
265
  Setting \u2014 "${setting.title}": ${setting.description}
260
266
 
@@ -262,7 +268,7 @@ ${styleLine} Every face must be distinct and memorable, and match its character
262
268
 
263
269
  ${cellLines}
264
270
 
265
- The character descriptions above are guidance for the drawing only \u2014 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 \u2014 and no lettering on clothing, equipment, insignia or logos.`;
271
+ The character descriptions above are guidance for the drawing only \u2014 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 \u2014 and no lettering on clothing, equipment, insignia or logos.${styleTail}`;
266
272
  }
267
273
  var SHEET_MAX_WIDTH = 2400;
268
274
  var SHEET_JPEG_QUALITY = 85;
@@ -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: 72% of its width, near the top.\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.14, y: 0.03, d: 0.72 };\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 const styleLine = artStyle\n ? `Render every portrait in this art style, chosen by the player: \"${artStyle}\". 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\n return `A character portrait sheet for ${purpose}, drawn 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.`;\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 * 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":[]}
package/dist/index.d.mts CHANGED
@@ -293,6 +293,17 @@ declare class ModelInvalidResponseError extends ModelError {
293
293
  * Google's non-configurable filter — no safetySettings change lifts it.
294
294
  * `reason` = the blockReason or finishReason string.
295
295
  */
296
+ /**
297
+ * The provider did not answer within the agent's `requestTimeoutMs`. The ask gives up
298
+ * instead of hanging until the host's serverless function is killed, which leaves no log
299
+ * line and no error for the player to retry from. Observed 2026-10-01: `deepseek-flash`
300
+ * accepted requests and never answered (no tokens, even streaming) while `deepseek-v4-pro`
301
+ * on the same key answered in 0.5s. Retryable — usually on another model.
302
+ */
303
+ declare class ModelTimeoutError extends ModelError {
304
+ timeoutMs: number;
305
+ constructor(modelType: string, timeoutMs: number);
306
+ }
296
307
  declare class ModelRefusalError extends ModelError {
297
308
  reason?: string;
298
309
  constructor(modelType: string, message?: string, reason?: string);
@@ -364,6 +375,7 @@ declare const LLM_CONSTANTS: {
364
375
  GEMINI_PRO: string;
365
376
  GEMINI_FLASH: string;
366
377
  GEMINI_LITE: string;
378
+ MISTRAL_LARGE: string;
367
379
  MISTRAL_MEDIUM: string;
368
380
  MISTRAL_SMALL: string;
369
381
  GROK: string;
@@ -388,6 +400,13 @@ declare const LLM_CONSTANTS: {
388
400
  * runaway one. Reasoning effort and thinking budgets are the knobs that change spend.
389
401
  */
390
402
  declare const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
403
+ /**
404
+ * Wall-clock limit on one ask, retries inside the provider SDK included. Past it the ask
405
+ * throws ModelTimeoutError. Measured 2026-10-01 over 7k production calls (14 days): 0.5%
406
+ * ran past 60s — mostly Grok turns and story generation. Callers that legitimately need
407
+ * longer raise `requestTimeoutMs` on the instance, like maxOutputTokens.
408
+ */
409
+ declare const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
391
410
  type ModelTag = 'very-fast' | 'fast' | 'slow' | 'very-slow' | 'extremely-slow' | 'cheap' | 'expensive';
392
411
  type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
393
412
  /**
@@ -1286,6 +1305,12 @@ declare abstract class AbstractAgent {
1286
1305
  */
1287
1306
  reasoningEffort?: ReasoningEffort;
1288
1307
  thinkingBudgetTokens?: number;
1308
+ /**
1309
+ * Wall-clock limit on one ask (DEFAULT_REQUEST_TIMEOUT_MS). Past it the ask throws
1310
+ * ModelTimeoutError; the provider request is abandoned, not cancelled. Raise it per
1311
+ * instance for calls that legitimately run long, like maxOutputTokens.
1312
+ */
1313
+ requestTimeoutMs: number;
1289
1314
  protected readonly instruction: string;
1290
1315
  /**
1291
1316
  * The instruction split on CACHE_TIER_MARKER: [shared static tier, per-bot tier].
@@ -1306,6 +1331,11 @@ declare abstract class AbstractAgent {
1306
1331
  */
1307
1332
  askWithZodSchema<T>(zodSchema: z.ZodSchema<T>, messages: AIMessage[]): Promise<[T, string, TokenUsage$1?, string?]>;
1308
1333
  askText(messages: AIMessage[]): Promise<[string, string, TokenUsage$1?, string?]>;
1334
+ /**
1335
+ * Races the provider call against requestTimeoutMs. A hung provider (accepts the request,
1336
+ * never answers) otherwise runs until the host kills the function with no error to show.
1337
+ */
1338
+ private withTimeout;
1309
1339
  private stampDuration;
1310
1340
  /** Failed calls carry their duration too — a 35s provider stall that errors is still signal. */
1311
1341
  private stampErrorDuration;
@@ -1804,4 +1834,4 @@ declare class MiniMaxAgent extends AbstractAgent {
1804
1834
  doAskText(messages: AIMessage[]): Promise<[string, string, TokenUsage$1?, string?]>;
1805
1835
  }
1806
1836
 
1807
- export { type AIMessage, ANTHROPIC_REASONING_EFFORTS, API_KEY_CONSTANTS, AbstractAgent, type AgentActivityData, AgentFactory, type AgentLoggingConfig, type AnthropicReasoningEffort, type AnthropicTokenUsage, type ApiKeyMap, type BeforeAskHook, BotResponseError, BudgetController, type BudgetControllerOptions, BudgetExceededError, type BudgetLimit, type BudgetVerdict, CACHE_TIER_MARKER, ClaudeAgent, type CostCalculationOptions, DEEPSEEK_PEAK_SCHEDULE, DEEPSEEK_REASONING_EFFORTS, DEFAULT_LOGGING_CONFIG, DEFAULT_MAX_OUTPUT_TOKENS, type DeepSeekReasoningEffort, type DeepSeekTokenUsage, DeepSeekV2Agent, FUGU_REASONING_EFFORTS, FuguAgent, type FuguReasoningEffort, GEMINI_AUDIO_TOKENS_PER_SECOND, GEMINI_REASONING_EFFORTS, GLM_REASONING_EFFORTS, type GeminiReasoningEffort, GlmAgent, type GlmReasoningEffort, GoogleAgent, type GoogleSttOptions, type GoogleSttResult, type GoogleTokenUsage, type GoogleTtsAudioOptions, type GoogleTtsResult, GoogleVoiceAgent, Gpt5Agent, GrokAgent, type GrokTokenUsage, InMemorySpendStore, type JsonSchemaOptions, KIMI_REASONING_EFFORTS, KimiAgent, type KimiReasoningEffort, type KimiTokenUsage, type LLMModel, LLM_CONSTANTS, type LlmLogger, type LoggingConfig, MESSAGE_ROLE, META_REASONING_EFFORTS, MISTRAL_REASONING_EFFORTS, MODEL_PRICING, MetaAgent, type MetaReasoningEffort, type MetaTokenUsage, MiniMaxAgent, MistralAgent, type MistralReasoningEffort, type MistralTokenUsage, type Modality, ModelAuthenticationError, type ModelConfig, ModelError, ModelInvalidResponseError, ModelOverloadError, type ModelPricing, ModelQuotaExceededError, ModelRateLimitError, ModelRefusalError, type ModelTag, ModelUnavailableError, OPENAI_REASONING_EFFORTS, type OpenAIReasoningEffort, type OpenAITokenUsage, type OpenAiSttOptions, type OpenAiSttResult, type OpenAiTtsAudioOptions, type OpenAiTtsVoice, OpenAiVoiceAgent, type PeakPricing, type PricingUnit, type ProviderSchema, type TokenUsage as ProviderTokenUsage, type ProviderType, QwenAgent, REASONING_EFFORT_SCALE, type ReasoningEffort, SUPPORTED_VOICE_PROVIDERS, type SpeechRequest, type SpeechResult, type SpeechUsage, type SpendInput, type SpendKind, type SpendLedger, type SpendLedgers, type SpendStore, type SpendWindow, SupportedAiKeyNames, SupportedAiModels, type TokenPairUsage, type TokenUsage$1 as TokenUsage, type TranscriptionRequest, type TranscriptionResult, type TranscriptionUsage, VOICE_MODEL_CONSTANTS, VOICE_MODEL_PRICING, VOICE_PROVIDER_API_KEY, type VoiceAgent, VoiceAgentFactory, type VoiceModelPricing, type VoiceProvider, XAI_REASONING_EFFORTS, type XaiReasoningEffort, ZodSchemaConverter, applySpend, calculateAnthropicCost, calculateCost, calculateDeepSeekCost, calculateGeminiSttCost, calculateGeminiTtsCost, calculateGoogleCost, calculateGrokCost, calculateKimiCost, calculateMetaCost, calculateMistralCost, calculateModelCost, calculateOpenAICost, calculateOpenAiSttCost, calculateOpenAiTtsCost, clampReasoningEffort, cleanResponse, createCatalog, createVoiceAgent, describeEmptyTtsResponse, evaluateBudget, extractAnthropicTokenUsage, extractTokenUsageFromResponse$2 as extractAnthropicTokenUsageFromResponse, extractDeepSeekTokenUsage, extractTokenUsageFromResponse$6 as extractDeepSeekTokenUsageFromResponse, extractFirstJsonObject, extractGoogleTokenUsage, extractTokenUsageFromResponse$1 as extractGoogleTokenUsageFromResponse, extractGrokTokenUsage, extractTokenUsageFromResponse$4 as extractGrokTokenUsageFromResponse, extractKimiTokenUsage, extractTokenUsageFromResponse$5 as extractKimiTokenUsageFromResponse, extractMetaTokenUsage, extractTokenUsageFromResponse$3 as extractMetaTokenUsageFromResponse, extractMistralTokenUsage, extractTokenUsageFromResponse as extractMistralTokenUsageFromResponse, extractOpenAITokenUsage, extractTokenUsageFromResponse$7 as extractOpenAITokenUsageFromResponse, extractTokenUsage, extractUsageAndCalculateCost, firstRefusal, generateGoogleTtsAudio, generateOpenAiTtsAudio, generateSchemaInstructions, getModelConfigByApiName, getModelDisplayName, getModelProviderName, getModelTags, getProviderSignatureFields, isBudgetExceededError, isHybridThinkingModel, isInPeakWindow, isPeakBilling, isSafetyBlocked, isWeekendAt, ledgerSpend, logger, mergeThinking, modelHasTag, modelIsFast, needsPromptBasedSchema, normalizeTtsStyle, parseAndValidateLlmJson, pcmToWav, periodEnd, periodKey, safeValidateResponse, setBeforeAskHook, setLlmLogger, stableHashHex, stripInlineThinking, supportsNativeJsonSchema, toAnthropicEffort, toDeepSeekEffort, toFuguEffort, toGeminiEffort, toGlmEffort, toKimiEffort, toMetaEffort, toMistralEffort, toOpenAIEffort, toXaiEffort, transcribeWithGemini, transcribeWithOpenAi, validateResponse };
1837
+ export { type AIMessage, ANTHROPIC_REASONING_EFFORTS, API_KEY_CONSTANTS, AbstractAgent, type AgentActivityData, AgentFactory, type AgentLoggingConfig, type AnthropicReasoningEffort, type AnthropicTokenUsage, type ApiKeyMap, type BeforeAskHook, BotResponseError, BudgetController, type BudgetControllerOptions, BudgetExceededError, type BudgetLimit, type BudgetVerdict, CACHE_TIER_MARKER, ClaudeAgent, type CostCalculationOptions, DEEPSEEK_PEAK_SCHEDULE, DEEPSEEK_REASONING_EFFORTS, DEFAULT_LOGGING_CONFIG, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_MS, type DeepSeekReasoningEffort, type DeepSeekTokenUsage, DeepSeekV2Agent, FUGU_REASONING_EFFORTS, FuguAgent, type FuguReasoningEffort, GEMINI_AUDIO_TOKENS_PER_SECOND, GEMINI_REASONING_EFFORTS, GLM_REASONING_EFFORTS, type GeminiReasoningEffort, GlmAgent, type GlmReasoningEffort, GoogleAgent, type GoogleSttOptions, type GoogleSttResult, type GoogleTokenUsage, type GoogleTtsAudioOptions, type GoogleTtsResult, GoogleVoiceAgent, Gpt5Agent, GrokAgent, type GrokTokenUsage, InMemorySpendStore, type JsonSchemaOptions, KIMI_REASONING_EFFORTS, KimiAgent, type KimiReasoningEffort, type KimiTokenUsage, type LLMModel, LLM_CONSTANTS, type LlmLogger, type LoggingConfig, MESSAGE_ROLE, META_REASONING_EFFORTS, MISTRAL_REASONING_EFFORTS, MODEL_PRICING, MetaAgent, type MetaReasoningEffort, type MetaTokenUsage, MiniMaxAgent, MistralAgent, type MistralReasoningEffort, type MistralTokenUsage, type Modality, ModelAuthenticationError, type ModelConfig, ModelError, ModelInvalidResponseError, ModelOverloadError, type ModelPricing, ModelQuotaExceededError, ModelRateLimitError, ModelRefusalError, type ModelTag, ModelTimeoutError, ModelUnavailableError, OPENAI_REASONING_EFFORTS, type OpenAIReasoningEffort, type OpenAITokenUsage, type OpenAiSttOptions, type OpenAiSttResult, type OpenAiTtsAudioOptions, type OpenAiTtsVoice, OpenAiVoiceAgent, type PeakPricing, type PricingUnit, type ProviderSchema, type TokenUsage as ProviderTokenUsage, type ProviderType, QwenAgent, REASONING_EFFORT_SCALE, type ReasoningEffort, SUPPORTED_VOICE_PROVIDERS, type SpeechRequest, type SpeechResult, type SpeechUsage, type SpendInput, type SpendKind, type SpendLedger, type SpendLedgers, type SpendStore, type SpendWindow, SupportedAiKeyNames, SupportedAiModels, type TokenPairUsage, type TokenUsage$1 as TokenUsage, type TranscriptionRequest, type TranscriptionResult, type TranscriptionUsage, VOICE_MODEL_CONSTANTS, VOICE_MODEL_PRICING, VOICE_PROVIDER_API_KEY, type VoiceAgent, VoiceAgentFactory, type VoiceModelPricing, type VoiceProvider, XAI_REASONING_EFFORTS, type XaiReasoningEffort, ZodSchemaConverter, applySpend, calculateAnthropicCost, calculateCost, calculateDeepSeekCost, calculateGeminiSttCost, calculateGeminiTtsCost, calculateGoogleCost, calculateGrokCost, calculateKimiCost, calculateMetaCost, calculateMistralCost, calculateModelCost, calculateOpenAICost, calculateOpenAiSttCost, calculateOpenAiTtsCost, clampReasoningEffort, cleanResponse, createCatalog, createVoiceAgent, describeEmptyTtsResponse, evaluateBudget, extractAnthropicTokenUsage, extractTokenUsageFromResponse$2 as extractAnthropicTokenUsageFromResponse, extractDeepSeekTokenUsage, extractTokenUsageFromResponse$6 as extractDeepSeekTokenUsageFromResponse, extractFirstJsonObject, extractGoogleTokenUsage, extractTokenUsageFromResponse$1 as extractGoogleTokenUsageFromResponse, extractGrokTokenUsage, extractTokenUsageFromResponse$4 as extractGrokTokenUsageFromResponse, extractKimiTokenUsage, extractTokenUsageFromResponse$5 as extractKimiTokenUsageFromResponse, extractMetaTokenUsage, extractTokenUsageFromResponse$3 as extractMetaTokenUsageFromResponse, extractMistralTokenUsage, extractTokenUsageFromResponse as extractMistralTokenUsageFromResponse, extractOpenAITokenUsage, extractTokenUsageFromResponse$7 as extractOpenAITokenUsageFromResponse, extractTokenUsage, extractUsageAndCalculateCost, firstRefusal, generateGoogleTtsAudio, generateOpenAiTtsAudio, generateSchemaInstructions, getModelConfigByApiName, getModelDisplayName, getModelProviderName, getModelTags, getProviderSignatureFields, isBudgetExceededError, isHybridThinkingModel, isInPeakWindow, isPeakBilling, isSafetyBlocked, isWeekendAt, ledgerSpend, logger, mergeThinking, modelHasTag, modelIsFast, needsPromptBasedSchema, normalizeTtsStyle, parseAndValidateLlmJson, pcmToWav, periodEnd, periodKey, safeValidateResponse, setBeforeAskHook, setLlmLogger, stableHashHex, stripInlineThinking, supportsNativeJsonSchema, toAnthropicEffort, toDeepSeekEffort, toFuguEffort, toGeminiEffort, toGlmEffort, toKimiEffort, toMetaEffort, toMistralEffort, toOpenAIEffort, toXaiEffort, transcribeWithGemini, transcribeWithOpenAi, validateResponse };