@hiper2d/ai-agents 0.22.0 → 0.23.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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/images/types.ts","../src/images/framing.ts","../src/images/sheet-detection.ts","../src/images/image-catalog.ts","../src/images/google-image.ts","../src/images/portrait-sheet.ts"],"sourcesContent":["/**\n * Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless\n * stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.\n */\n\n/** A rectangle in image pixels (sharp's `extract` shape). */\nexport interface ImageRect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface ImageSize {\n width: number;\n height: number;\n}\n\n/** The round avatar inside a card. `x` and `d` are fractions of the card's width,\n * `y` a fraction of its height. */\nexport interface AvatarCircle {\n x: number;\n y: number;\n d: number;\n}\n\n/** Where a portrait comes from on its sheet: the card cut and the circle in it. */\nexport interface AvatarFraming {\n card: ImageRect;\n circle: AvatarCircle;\n}\n\n/** The portion of an image to display, as fractions of its width/height. */\nexport interface ImageFocus {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n// Cards are portrait 3:4 (a poster's shape), stored at this size.\nexport const CARD_ASPECT = 3 / 4; // width / height\nexport const CARD_WIDTH_PX = 600;\nexport const CARD_HEIGHT_PX = 800;\n// Where the circle starts on a freshly cut card: 86% of its width, centred,\n// near the top — wide enough to take in a chin or beard (0.72 cut them off).\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.07, y: 0.02, d: 0.86 };\n// A card narrower than this fraction of the sheet's height upscales too much.\nexport const MIN_CARD_HEIGHT_FRACTION = 0.12;\n\n/** One character on a portrait sheet. */\nexport interface PortraitCell {\n /** Stable id the caller stores the crop under; never drawn into the image. */\n key: string;\n /** Character name, for logs and prompt guidance; never drawn into the image. */\n label: string;\n /** One-line visual description for this cell (gender, name, appearance). */\n prompt: string;\n}\n\n/** A card cut from a drawn sheet. */\nexport interface PortraitSlice {\n key: string;\n label: string;\n jpeg: Buffer;\n /** The framing the slicer chose: the card on the sheet and the default circle. */\n framing: AvatarFraming;\n}\n\n/** The kept sheet: the grid image itself (re-encoded), its size and the cells\n * the cards were cut from — enough to re-cut any card later at a new framing. */\nexport interface DrawnSheet {\n jpeg: Buffer;\n width: number;\n height: number;\n cells: ImageRect[];\n /** false = no divider lines were found and the cells are the equal split of the\n * requested grid; true = the cells between the lines the model actually drew. */\n detected: boolean;\n}\n\nexport interface GeneratedImage {\n buffer: Buffer;\n costUSD: number;\n}\n\n/**\n * The slice of sharp's API the pipeline uses. The library never imports sharp — a\n * native module the host installs and passes in (`(await import('sharp')).default`) —\n * so text-only consumers pull in nothing new.\n */\nexport type SharpLike = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement?: boolean }): SharpPipeline;\n resize(width: number, height: number): SharpPipeline;\n greyscale(): SharpPipeline;\n extract(rect: ImageRect): SharpPipeline;\n jpeg(options: { quality: number; mozjpeg?: boolean }): SharpPipeline;\n raw(): SharpPipeline;\n toBuffer(): Promise<Buffer>;\n toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: { width: number; height: number } }>;\n metadata(): Promise<{ width?: number; height?: number }>;\n};\n\nexport type SharpPipeline = ReturnType<SharpLike>;\n","import {\n AvatarCircle,\n AvatarFraming,\n CARD_ASPECT,\n DEFAULT_AVATAR_CIRCLE,\n ImageFocus,\n ImageRect,\n ImageSize,\n MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\n/**\n * Pure framing geometry, shared by the slicer (server), reframe validation (server)\n * and renderers/editors (browser). No I/O here.\n *\n * Coordinates: a card is an ImageRect in SHEET pixels (portrait 3:4); the circle is\n * card-relative (x, d = fractions of the card's width, y = fraction of its height).\n * An ImageFocus is what a renderer needs: the fraction of an image to show, so the\n * same avatar component can show a circle cut out of a stored card OR out of a\n * static preset sheet.\n */\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\n\n/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:\n * portraits are busts with the head near the top, so the top edge is the\n * anchor that keeps the face in. */\nexport function cardInCell(cell: ImageRect): ImageRect {\n const width = Math.floor(Math.min(cell.width, cell.height * CARD_ASPECT));\n const height = Math.min(cell.height, Math.round(width / CARD_ASPECT));\n return {\n left: Math.round(cell.left + (cell.width - width) / 2),\n top: cell.top,\n width,\n height,\n };\n}\n\nexport function defaultFraming(cell: ImageRect): AvatarFraming {\n return { card: cardInCell(cell), circle: { ...DEFAULT_AVATAR_CIRCLE } };\n}\n\n/** Keeps the circle inside its card. */\nexport function fitCircle(circle: AvatarCircle): AvatarCircle {\n const d = clamp(circle.d, 0.15, 1);\n return {\n d,\n x: clamp(circle.x, 0, 1 - d),\n y: clamp(circle.y, 0, 1 - d * CARD_ASPECT),\n };\n}\n\n/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the\n * minimum size. Width is the free variable; height follows the aspect. */\nexport function fitCard(card: ImageRect, sheet: ImageSize): ImageRect {\n const minHeight = Math.max(16, Math.round(sheet.height * MIN_CARD_HEIGHT_FRACTION));\n const maxHeight = Math.min(sheet.height, sheet.width / CARD_ASPECT);\n const height = Math.round(clamp(card.height, minHeight, maxHeight));\n const width = Math.round(height * CARD_ASPECT);\n return {\n width,\n height,\n left: Math.round(clamp(card.left, 0, sheet.width - width)),\n top: Math.round(clamp(card.top, 0, sheet.height - height)),\n };\n}\n\nexport function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming {\n return { card: fitCard(framing.card, sheet), circle: fitCircle(framing.circle) };\n}\n\n/** True when the framing is a well-formed object with finite numbers. Shape\n * only — call fitFraming for the geometric bounds. */\nexport function isFramingShape(value: unknown): value is AvatarFraming {\n if (!value || typeof value !== 'object') return false;\n const f = value as any;\n const rect = f.card, circle = f.circle;\n const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\n return !!rect && !!circle\n && num(rect.left) && num(rect.top) && num(rect.width) && num(rect.height)\n && rect.width > 0 && rect.height > 0\n && num(circle.x) && num(circle.y) && num(circle.d) && circle.d > 0;\n}\n\n/** The circle as a focus on the CARD image itself (a stored card cut). */\nexport function circleFocus(circle: AvatarCircle): ImageFocus {\n const c = fitCircle(circle);\n return { x: c.x, y: c.y, w: c.d, h: c.d * CARD_ASPECT };\n}\n\n/** The card as a focus on its sheet. */\nexport function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus {\n return {\n x: card.left / sheet.width,\n y: card.top / sheet.height,\n w: card.width / sheet.width,\n h: card.height / sheet.height,\n };\n}\n\n/** The circle as a focus on the SHEET (card and circle composed) — how a preset\n * mannequin renders straight off a static sheet. */\nexport function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus {\n const c = fitCircle(framing.circle);\n const card = framing.card;\n const side = c.d * card.width; // the circle is square, in sheet px\n return {\n x: (card.left + c.x * card.width) / sheet.width,\n y: (card.top + c.y * card.height) / sheet.height,\n w: side / sheet.width,\n h: side / sheet.height,\n };\n}\n\n/** CSS `background-size` / `background-position` that shows exactly `focus`\n * of an image inside a box. */\nexport function focusToBackground(focus: ImageFocus): { backgroundSize: string; backgroundPosition: string } {\n const axis = (pos: number, ext: number) => ext >= 1 ? 50 : (pos / (1 - ext)) * 100;\n return {\n backgroundSize: `${(100 / focus.w).toFixed(3)}% ${(100 / focus.h).toFixed(3)}%`,\n backgroundPosition: `${axis(focus.x, focus.w).toFixed(3)}% ${axis(focus.y, focus.h).toFixed(3)}%`,\n };\n}\n","import { ImageRect } from \"./types\";\n\n/**\n * Finds the cells of a drawn portrait sheet from its pixels.\n *\n * The prompt asks for equal cells separated by thin dark divider lines, but\n * the model draws the lines where it likes: on a correctly drawn 4x4 sheet\n * the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448\n * (2026-09-02), so fixed-interval slicing cut lower rows through the previous\n * character's chest. The lines themselves are crisp — near-black across the\n * full width — so they are easy to find, and the cells between them are the\n * real ones. Columns have always measured exact; rows drift.\n *\n * Pure over a greyscale byte plane so it is testable without sharp.\n */\n\nexport interface GreyPlane {\n width: number;\n height: number;\n // width*height bytes, row-major, 0 = black.\n data: Uint8Array;\n}\n\nexport interface SheetGrid {\n cells: ImageRect[]; // row-major\n cols: number;\n rows: number;\n // false = no usable divider lines were found and this is the equal split\n // of the REQUESTED grid. true = the cells between the lines the model\n // actually drew, which may be a different grid than requested (a 4x4\n // request has come back as 4x3 and as 6x3) — the sheet is the truth.\n detected: boolean;\n}\n\n// A pixel darker than this is \"line\"; a scanline with more than this share\n// of dark pixels is a divider. Cell backgrounds are flat muted colours, well\n// above the threshold, and the dark portrait clothing never spans a full line.\nconst DARK_MAX = 60;\nconst DIVIDER_SHARE = 0.85;\n// Pixels kept clear of a divider when cutting (anti-aliased edges).\nconst DIVIDER_INSET = 4;\n// A real divider is a thin line (~7 px on a 2400 px sheet). Thinner runs are\n// specks (a dark hairline inside a hooded figure once passed as a row line).\n// Thicker runs up to a small share of the sheet are still boundaries: the\n// model sometimes adds a dark name plate under each portrait despite the\n// no-text rule, and the plate belongs OUTSIDE the cell. Beyond that share a\n// dark run is a dark art style's background, not a boundary.\nconst DIVIDER_MIN_THICKNESS = 3;\nconst DIVIDER_MAX_THICKNESS_SHARE = 0.08;\n// A span between lines narrower than this share of the sheet can't hold a\n// face; the line that would create it is not a cell boundary.\nconst MIN_SPAN_SHARE = 0.12;\n\ninterface Run { start: number; end: number }\n\nfunction runs(indices: number[]): Run[] {\n const out: Run[] = [];\n for (const i of indices) {\n const last = out[out.length - 1];\n if (last && i === last.end + 1) last.end = i;\n else out.push({start: i, end: i});\n }\n return out;\n}\n\n/** Dark runs along one axis, measured over a window of the other axis (a\n * band of rows for columns, the full height by default). Border runs and\n * runs that aren't line-shaped are dropped. */\nfunction lineRuns(plane: GreyPlane, axis: 'rows' | 'cols', from: number, to: number): Run[] {\n const {width, height, data} = plane;\n const extent = axis === 'rows' ? height : width;\n const dark: number[] = [];\n for (let i = 0; i < extent; i++) {\n let count = 0;\n if (axis === 'rows') {\n const base = i * width;\n for (let x = from; x < to; x++) if (data[base + x] < DARK_MAX) count++;\n } else {\n for (let y = from; y < to; y++) if (data[y * width + i] < DARK_MAX) count++;\n }\n if (count / (to - from) > DIVIDER_SHARE) dark.push(i);\n }\n const maxThickness = extent * DIVIDER_MAX_THICKNESS_SHARE;\n return runs(dark)\n .filter(r => r.start > 2 && r.end < extent - 3)\n .filter(r => { const t = r.end - r.start + 1; return t >= DIVIDER_MIN_THICKNESS && t <= maxThickness; });\n}\n\n/** Lines that would leave a sliver on either side are not boundaries. */\nfunction dropSliverMakers(lines: Run[], extent: number): Run[] {\n const minSpan = extent * MIN_SPAN_SHARE;\n const kept: Run[] = [];\n let cursor = 0;\n for (const line of lines) {\n if (line.start - cursor < minSpan || extent - (line.end + 1) < minSpan) continue;\n kept.push(line);\n cursor = line.end + 1;\n }\n return kept;\n}\n\n/** Divider runs across the whole plane: row lines over the full width,\n * column lines over the full height. */\nexport function findDividers(plane: GreyPlane): {rows: Run[]; cols: Run[]} {\n return {\n rows: dropSliverMakers(lineRuns(plane, 'rows', 0, plane.width), plane.height),\n cols: dropSliverMakers(lineRuns(plane, 'cols', 0, plane.height), plane.width),\n };\n}\n\n/** Cells between dividers along one axis, from the border to the first line,\n * line to line, last line to the border. */\nfunction spans(dividers: Run[], extent: number): {start: number; end: number}[] {\n const out: {start: number; end: number}[] = [];\n let cursor = 0;\n for (const d of dividers) {\n out.push({start: cursor, end: d.start});\n cursor = d.end + 1;\n }\n out.push({start: cursor, end: extent});\n return out;\n}\n\nexport function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid {\n const cellW = Math.floor(width / cols), cellH = Math.floor(height / rows);\n const cells: ImageRect[] = [];\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n cells.push(inset({left: c * cellW, top: r * cellH, width: cellW, height: cellH}, 8));\n }\n }\n return {cells, cols, rows, detected: false};\n}\n\nfunction inset(rect: ImageRect, by: number): ImageRect {\n return {left: rect.left + by, top: rect.top + by, width: Math.max(1, rect.width - 2 * by), height: Math.max(1, rect.height - 2 * by)};\n}\n\n/**\n * The sheet's cells. Read off the divider lines the model drew whenever they\n * form a plausible grid, even when that grid differs from the cols x rows\n * that were asked for — the model keeps row-major order, so the drawn cells\n * still map to the characters in sequence. Row lines are found over the\n * full width; column lines are found PER ROW BAND, because the model has\n * drawn rows with different column layouts (and a column line that stops\n * short of the last row). A band without lines of its own borrows the\n * layout of the band above it. The equal split of the requested grid is the\n * last resort when no lines are found at all (the caller logs it; the kept\n * sheet lets the owner reframe by hand either way).\n */\nexport function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid {\n const {width, height} = plane;\n const rowLines = dropSliverMakers(lineRuns(plane, 'rows', 0, width), height);\n const bands = spans(rowLines, height);\n const cells: ImageRect[] = [];\n let previous: Run[] | null = null;\n let colCount = 0;\n for (const band of bands) {\n let colLines = dropSliverMakers(lineRuns(plane, 'cols', band.start, band.end), width);\n if (colLines.length === 0 && previous) colLines = previous;\n previous = colLines;\n const xs = spans(colLines, width);\n colCount = Math.max(colCount, xs.length);\n for (const x of xs) {\n cells.push(inset({left: x.start, top: band.start, width: x.end - x.start, height: band.end - band.start}, DIVIDER_INSET));\n }\n }\n if (colCount < 2) return equalSplitGrid(width, height, cols, rows);\n return {cells, cols: colCount, rows: bands.length, detected: true};\n}\n\n/** What findDividers saw, for the mismatch log line. */\nexport function describeDividers(plane: GreyPlane): string {\n const d = findDividers(plane);\n const fmt = (r: Run[]) => r.map(x => `${x.start}-${x.end}`).join(',') || 'none';\n return `rows@${fmt(d.rows)} cols@${fmt(d.cols)}`;\n}\n","/**\n * Image-generation models: platform-side, never user-selected. Prices per 1M tokens\n * from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens\n * per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower\n * resolution, is what minimizes cost.\n */\nexport const IMAGE_MODEL_CONSTANTS = {\n GEMINI_FLASH_IMAGE: 'gemini-3.1-flash-image',\n} as const;\n\nexport type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];\n\nexport interface ImageModelPricing {\n imageOutputPricePerM: number;\n textInputPricePerM: number;\n}\n\nexport const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing> = {\n [IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE]: {\n imageOutputPricePerM: 60,\n textInputPricePerM: 0.5,\n },\n};\n\nexport const DEFAULT_IMAGE_MODEL: ImageModelId = IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE;\n\n/** Cost of one image call from the usage Gemini reports. */\nexport function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number {\n const pricing = IMAGE_MODEL_PRICING[model];\n return parseFloat((\n imageOutputTokens / 1_000_000 * pricing.imageOutputPricePerM +\n textInputTokens / 1_000_000 * pricing.textInputPricePerM\n ).toFixed(6));\n}\n","import { calculateImageCost, DEFAULT_IMAGE_MODEL, ImageModelId } from './image-catalog';\nimport { GeneratedImage } from './types';\n\nexport type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';\n\nexport interface GenerateImageOptions {\n /** Labeled reference JPEGs (an established scene, character portraits) that\n * anchor the drawing to a known style, place or faces. */\n references?: { label: string; jpeg: Buffer }[];\n imageSize?: '1K' | '2K';\n model?: ImageModelId;\n /** Test seam / custom transport; defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Gemini's Interactions API answers a blocked image with HTTP 200, no image block,\n * `status: \"failed\"` (or `incomplete`) and an `errors[]` entry whose `code` names the\n * block (https://ai.google.dev/gemini-api/docs/api-errors, \"Generation blocked\"):\n * `image_safety`, `image_prohibited_content`, `image_recitation`, `image_other`, plus\n * the text-side codes `safety`, `prohibited_content`, `recitation`, `blocklist`, `spii`,\n * `content_blocked`. `no_image` is a plain generation failure, not a block.\n *\n * Deliberately NOT a `ModelRefusalError` subclass: this subpath is bundled apart from the\n * main entry, so an `instanceof` against the main entry's class would never match. Hosts\n * check `isImageRefusalError(error)` (by name) instead. Not retryable on the same prompt.\n */\nexport class ImageRefusalError extends Error {\n /** The block code as Gemini sent it (a URI or bare `snake_case` name). */\n public code: string;\n /** Gemini's human-readable message, when it sent one. */\n public reason?: string;\n public model: string;\n\n constructor(model: string, code: string, reason?: string) {\n super(`${model} declined to draw the image (${describeBlockCode(code)})${reason ? `: ${reason}` : ''}`);\n this.name = 'ImageRefusalError';\n this.model = model;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/** Works across bundle copies and serialization boundaries (checks the name, not the class). */\nexport function isImageRefusalError(error: unknown): error is ImageRefusalError {\n return !!error && typeof error === 'object' && (error as { name?: unknown }).name === 'ImageRefusalError';\n}\n\nconst BLOCK_CODE_MARKERS = ['safety', 'prohibited', 'recitation', 'blocklist', 'spii', 'content_blocked', 'image_other'];\n\n/** The trailing identifier of a code URI such as `https://…/api-errors#image_safety`. */\nfunction describeBlockCode(code: string): string {\n const tail = code.split(/[#/]/).filter(Boolean).pop() ?? code;\n return tail.toLowerCase();\n}\n\nfunction isBlockCode(code: string): boolean {\n const tail = describeBlockCode(code);\n return BLOCK_CODE_MARKERS.some(marker => tail.includes(marker));\n}\n\n/**\n * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —\n * and the result reports its cost so the host decides whom to charge. Cost is per\n * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).\n *\n * Throws `ImageRefusalError` when Gemini blocked the drawing (see the class), and a\n * plain `Error` naming Gemini's status and error code for any other imageless reply.\n */\nexport async function generateImage(apiKey: string, prompt: string, aspectRatio: ImageAspectRatio, opts: GenerateImageOptions = {}): Promise<GeneratedImage> {\n const model = opts.model ?? DEFAULT_IMAGE_MODEL;\n const input: any[] = [{ type: 'text', text: prompt }];\n for (const ref of opts.references ?? []) {\n input.push({ type: 'text', text: ref.label });\n input.push({ type: 'image', mime_type: 'image/jpeg', data: ref.jpeg.toString('base64') });\n }\n const doFetch = opts.fetchImpl ?? fetch;\n const res = await doFetch('https://generativelanguage.googleapis.com/v1beta/interactions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },\n body: JSON.stringify({\n model,\n input,\n response_format: { type: 'image', mime_type: 'image/jpeg', aspect_ratio: aspectRatio, image_size: opts.imageSize ?? '2K' },\n }),\n });\n if (!res.ok) {\n throw new Error(`Image request failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);\n }\n const json: any = await res.json();\n const b64 = (json.steps || [])\n .flatMap((s: any) => s.content || [])\n .find((c: any) => c.type === 'image' && c.data)?.data;\n if (!b64) throw imagelessReplyError(model, json);\n\n const usage = json.usage || {};\n const imageTokens = (usage.output_tokens_by_modality || [])\n .filter((m: any) => m.modality === 'image')\n .reduce((sum: number, m: any) => sum + (m.tokens || 0), 0);\n const inputTokens = usage.total_input_tokens || 0;\n\n return { buffer: Buffer.from(b64, 'base64'), costUSD: calculateImageCost(model, imageTokens, inputTokens) };\n}\n\n/** Classifies an HTTP 200 reply that carried no image block. */\nfunction imagelessReplyError(model: string, json: any): Error {\n const errors: { code?: unknown; message?: unknown }[] = Array.isArray(json?.errors) ? json.errors : [];\n const codes = errors.map(e => String(e?.code ?? '')).filter(Boolean);\n const messages = errors.map(e => (typeof e?.message === 'string' ? e.message.trim() : '')).filter(Boolean);\n const block = codes.find(isBlockCode);\n if (block) {\n return new ImageRefusalError(model, block, messages[0]?.slice(0, 300));\n }\n const status = typeof json?.status === 'string' ? json.status : 'unknown';\n const detail = [codes.length ? `code ${codes.map(describeBlockCode).join(', ')}` : '', messages[0]?.slice(0, 300) ?? '']\n .filter(Boolean)\n .join(': ');\n return new Error(`Image response contained no image data (status ${status}${detail ? `; ${detail}` : ''})`);\n}\n","import { generateImage, GenerateImageOptions } from './google-image';\nimport { defaultFraming } from './framing';\nimport { describeDividers, detectSheetGrid, equalSplitGrid } from './sheet-detection';\nimport { CARD_HEIGHT_PX, CARD_WIDTH_PX, DrawnSheet, ImageRect, PortraitCell, PortraitSlice, SharpLike } from './types';\n\n/**\n * Portrait sheets: one image-model call draws a whole cast as a grid of bust\n * portraits, and the grid is cut into one card per character. One call costs the\n * same whether it draws one face or sixteen, so this is the cheap way to give\n * every character in a game a portrait in a single consistent style.\n *\n * Nothing here touches storage or billing: the host stores the cards and the kept\n * sheet (so a card can be re-cut later at a new framing) and charges whoever it\n * likes for `costUSD`. sharp is passed in, never imported.\n */\n\n/**\n * Grid dimensions by cell count on the 4:3 canvas. Cells must come out\n * square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the\n * sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards\n * taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,\n * one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell\n * cast to 16.\n */\nexport function gridFor(cells: number): { cols: number; rows: number } {\n if (cells <= 6) return { cols: 3, rows: 2 };\n if (cells <= 8) return { cols: 4, rows: 2 };\n if (cells <= 9) return { cols: 3, rows: 3 };\n if (cells <= 12) return { cols: 4, rows: 3 };\n if (cells <= 15) return { cols: 5, rows: 3 };\n if (cells <= 18) return { cols: 6, rows: 3 };\n throw new Error(`Portrait sheet cannot hold ${cells} cells (max 18)`);\n}\n\nexport const DEFAULT_FILLER_PROMPT = `\"Stranger\" — an anonymous hooded figure fitting the setting, face hidden in shadow`;\n\n/**\n * Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the\n * spare ones get an anonymous filler that is drawn and discarded.\n */\nexport function padCells(cells: PortraitCell[], fillerPrompt: string = DEFAULT_FILLER_PROMPT): { cells: PortraitCell[]; cols: number; rows: number; realCount: number } {\n const { cols, rows } = gridFor(cells.length);\n const padded = [...cells];\n for (let i = padded.length; i < cols * rows; i++) {\n padded.push({ key: `__filler${i}`, label: 'Stranger', prompt: fillerPrompt });\n }\n return { cells: padded, cols, rows, realCount: cells.length };\n}\n\nexport interface PortraitSheetPrompt {\n cells: PortraitCell[];\n cols: number;\n rows: number;\n /** What the sheet is for, e.g. \"a social deduction game\" — one noun phrase. */\n purpose: string;\n /** The world the characters live in: a title and a short description. */\n setting: { title: string; description: string };\n /** Player-chosen art direction, already sanitized by the host. Replaces the\n * model's free choice of style. */\n artStyle?: string;\n}\n\n/**\n * The sheet prompt. The wording is load-bearing: equal cells with thin dark\n * divider lines are what the slicer finds, one flat muted background per cell keeps\n * the lines detectable in any art style, and the no-text rule is repeated because\n * the model otherwise typesets names and bios into the image.\n */\nexport function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string {\n const { cells, cols, rows, purpose, setting, artStyle } = spec;\n const cellLines = cells.map(\n (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`\n ).join('\\n');\n\n // A player style leads the prompt and is restated at the end: the sheet\n // framing (grid, bust portraits, flat backgrounds) otherwise pulls the model\n // toward drawn character-sheet art whatever style was asked for (an\n // \"ultra-realism\" game came out as comic portraits). The grid wording stays\n // intact — it is what the slicer relies on — but is declared layout only.\n const styleLead = artStyle\n ? `Art style, chosen by the player — it governs every portrait: \"${artStyle}\". Each cell must look made in that style: a photographic or cinematic style means real photographs, a painted style means paintings.\\n\\n`\n : '';\n const styleLine = artStyle\n ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`\n : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;\n const styleTail = artStyle ? `\\n\\nEvery cell must be fully in this art style: \"${artStyle}\" — and still carry no names or text of any kind.` : '';\n\n return `${styleLead}A character portrait sheet for ${purpose}, rendered as a single image: a precise grid of exactly ${cells.length} rectangular cells, ${cols} columns and ${rows} rows, all cells exactly equal size, separated by thin dark divider lines. Each cell contains one bust portrait (head and shoulders) of a different character, centered in its cell.\n\nSetting — \"${setting.title}\": ${setting.description}\n\n${styleLine} Every face must be distinct and memorable, and match its character description. No character may span more than one cell. Give each cell its own flat solid muted desaturated background color, different from its neighbors. Row-major order, left to right, top to bottom:\n\n${cellLines}\n\nThe character descriptions above are guidance for the drawing only — NEVER render them as text. Absolutely no text anywhere in the image: no names, no labels, no captions, no letters, no writing of any kind — and no lettering on clothing, equipment, insignia or logos.${styleTail}`;\n}\n\n// The model returns ~2.4 MB loosely-compressed JPEGs; at this width and quality a\n// sheet is ~400 KB (~530 KB as base64), under a 1 MiB document limit with cells\n// still ~500 px tall.\nexport const SHEET_MAX_WIDTH = 2400;\nexport const SHEET_JPEG_QUALITY = 85;\nexport const SHEET_MAX_BASE64_BYTES = 900_000;\n\n/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */\nexport interface SheetMismatch {\n kind: 'no-dividers' | 'different-grid';\n message: string;\n detail: Record<string, unknown>;\n}\n\nexport interface SliceSheetOptions {\n onMismatch?: (mismatch: SheetMismatch) => void;\n /** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */\n maxWidth?: number;\n}\n\n/**\n * Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the\n * divider lines the model drew (equal split as a last resort); each card is the\n * largest 3:4 rectangle in its cell, top-anchored, with the default circle — the\n * framing the host can let a user move later.\n */\nexport async function sliceSheet(\n sharp: SharpLike,\n raw: Buffer,\n cells: PortraitCell[],\n count: number,\n cols: number,\n rows: number,\n opts: SliceSheetOptions = {},\n): Promise<{ slices: PortraitSlice[]; sheet: DrawnSheet }> {\n // Normalise the working resolution first so cells, cards and the stored sheet\n // all share one pixel space.\n const grid: Buffer = await sharp(raw).resize({ width: opts.maxWidth ?? SHEET_MAX_WIDTH, withoutEnlargement: true }).toBuffer();\n const { data, info } = await sharp(grid).greyscale().raw().toBuffer({ resolveWithObject: true });\n const width = info.width, height = info.height;\n if (width < 100 * cols || height < 100 * rows) throw new Error(`Portrait sheet has unusable dimensions ${width}x${height}`);\n\n const plane = { width, height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };\n const gridCells = detectSheetGrid(plane, cols, rows);\n if (!gridCells.detected) {\n opts.onMismatch?.({\n kind: 'no-dividers',\n message: `no divider lines found on the ${cols}x${rows} sheet; using equal split`,\n detail: { width, height, dividers: describeDividers(plane) },\n });\n } else if (gridCells.cols !== cols || gridCells.rows !== rows) {\n // The model drew a different grid than asked. Its cells are still in row-major\n // order, so they are used as drawn; only characters past the last drawn cell\n // fall back to the equal split.\n opts.onMismatch?.({\n kind: 'different-grid',\n message: `sheet drawn as ${gridCells.cols}x${gridCells.rows}, requested ${cols}x${rows}; using the drawn cells`,\n detail: { width, height, drawnCells: gridCells.cells.length, needed: count },\n });\n }\n const fallback = equalSplitGrid(width, height, cols, rows).cells;\n\n const slices: PortraitSlice[] = [];\n for (let i = 0; i < count; i++) {\n const framing = defaultFraming(gridCells.cells[i] ?? fallback[i]);\n const jpeg = await cutCard(sharp, grid, framing.card);\n slices.push({ key: cells[i].key, label: cells[i].label, jpeg, framing });\n }\n\n let sheetJpeg: Buffer = await sharp(grid).jpeg({ quality: SHEET_JPEG_QUALITY, mozjpeg: true }).toBuffer();\n if (sheetJpeg.length * 4 / 3 > SHEET_MAX_BASE64_BYTES) {\n sheetJpeg = await sharp(grid).jpeg({ quality: 72, mozjpeg: true }).toBuffer();\n }\n return {\n slices,\n sheet: { jpeg: sheetJpeg, width, height, cells: gridCells.cells.slice(0, count), detected: gridCells.detected },\n };\n}\n\n/** Cuts one card out of a stored sheet at the given framing. */\nexport async function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer> {\n return sharp(sheet)\n .extract(card)\n .resize(CARD_WIDTH_PX, CARD_HEIGHT_PX)\n .jpeg({ quality: 85 })\n .toBuffer();\n}\n\nexport interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {\n /** Prompt for the cells that pad the cast out to a full grid. */\n fillerPrompt?: string;\n}\n\nexport interface DrawPortraitSheetOptions extends SliceSheetOptions {\n image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;\n}\n\nexport interface DrawnPortraitSheet {\n portraits: PortraitSlice[];\n sheet: DrawnSheet;\n /** What the image call cost, reported for the host's billing. */\n costUSD: number;\n}\n\n/**\n * The whole pipeline: lay the cast out on a grid, draw it in one call, cut the\n * cards. Filler cells are drawn and discarded; `portraits` has exactly one entry\n * per input cell, in order.\n */\nexport async function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts: DrawPortraitSheetOptions = {}): Promise<DrawnPortraitSheet> {\n const { cells, cols, rows, realCount } = padCells(spec.cells, spec.fillerPrompt);\n const prompt = buildPortraitSheetPrompt({ cells, cols, rows, purpose: spec.purpose, setting: spec.setting, artStyle: spec.artStyle });\n const image = await generateImage(apiKey, prompt, '4:3', opts.image);\n const { slices, sheet } = await sliceSheet(sharp, image.buffer, cells, realCount, cols, rows, opts);\n return { portraits: slices, sheet, costUSD: image.costUSD };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/images/types.ts","../src/images/framing.ts","../src/images/sheet-detection.ts","../src/images/image-catalog.ts","../src/images/google-image.ts","../src/images/portrait-sheet.ts"],"sourcesContent":["/**\n * Shared shapes for the portrait-sheet pipeline. Coordinates are SHEET pixels unless\n * stated otherwise; a card is portrait 3:4 and the circle inside it is card-relative.\n */\n\n/** A rectangle in image pixels (sharp's `extract` shape). */\nexport interface ImageRect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface ImageSize {\n width: number;\n height: number;\n}\n\n/** The round avatar inside a card. `x` and `d` are fractions of the card's width,\n * `y` a fraction of its height. */\nexport interface AvatarCircle {\n x: number;\n y: number;\n d: number;\n}\n\n/** Where a portrait comes from on its sheet: the card cut and the circle in it. */\nexport interface AvatarFraming {\n card: ImageRect;\n circle: AvatarCircle;\n}\n\n/** The portion of an image to display, as fractions of its width/height. */\nexport interface ImageFocus {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n// Cards are portrait 3:4 (a poster's shape), stored at this size.\nexport const CARD_ASPECT = 3 / 4; // width / height\nexport const CARD_WIDTH_PX = 600;\nexport const CARD_HEIGHT_PX = 800;\n// Where the circle starts on a freshly cut card: 86% of its width, centred,\n// near the top — wide enough to take in a chin or beard (0.72 cut them off).\nexport const DEFAULT_AVATAR_CIRCLE: AvatarCircle = { x: 0.07, y: 0.02, d: 0.86 };\n// A card narrower than this fraction of the sheet's height upscales too much.\nexport const MIN_CARD_HEIGHT_FRACTION = 0.12;\n\n/** One character on a portrait sheet. */\nexport interface PortraitCell {\n /** Stable id the caller stores the crop under; never drawn into the image. */\n key: string;\n /** Character name, for logs and prompt guidance; never drawn into the image. */\n label: string;\n /** One-line visual description for this cell (gender, name, appearance). */\n prompt: string;\n}\n\n/** A card cut from a drawn sheet. */\nexport interface PortraitSlice {\n key: string;\n label: string;\n jpeg: Buffer;\n /** The framing the slicer chose: the card on the sheet and the default circle. */\n framing: AvatarFraming;\n}\n\n/** The kept sheet: the grid image itself (re-encoded), its size and the cells\n * the cards were cut from — enough to re-cut any card later at a new framing. */\nexport interface DrawnSheet {\n jpeg: Buffer;\n width: number;\n height: number;\n cells: ImageRect[];\n /** false = no divider lines were found and the cells are the equal split of the\n * requested grid; true = the cells between the lines the model actually drew. */\n detected: boolean;\n}\n\nexport interface GeneratedImage {\n buffer: Buffer;\n costUSD: number;\n}\n\n/**\n * The slice of sharp's API the pipeline uses. The library never imports sharp — a\n * native module the host installs and passes in (`(await import('sharp')).default`) —\n * so text-only consumers pull in nothing new.\n */\nexport type SharpLike = (input: Buffer) => {\n resize(options: { width: number; withoutEnlargement?: boolean }): SharpPipeline;\n resize(width: number, height: number): SharpPipeline;\n greyscale(): SharpPipeline;\n extract(rect: ImageRect): SharpPipeline;\n jpeg(options: { quality: number; mozjpeg?: boolean }): SharpPipeline;\n raw(): SharpPipeline;\n toBuffer(): Promise<Buffer>;\n toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: { width: number; height: number } }>;\n metadata(): Promise<{ width?: number; height?: number }>;\n};\n\nexport type SharpPipeline = ReturnType<SharpLike>;\n","import {\n AvatarCircle,\n AvatarFraming,\n CARD_ASPECT,\n DEFAULT_AVATAR_CIRCLE,\n ImageFocus,\n ImageRect,\n ImageSize,\n MIN_CARD_HEIGHT_FRACTION,\n} from './types';\n\n/**\n * Pure framing geometry, shared by the slicer (server), reframe validation (server)\n * and renderers/editors (browser). No I/O here.\n *\n * Coordinates: a card is an ImageRect in SHEET pixels (portrait 3:4); the circle is\n * card-relative (x, d = fractions of the card's width, y = fraction of its height).\n * An ImageFocus is what a renderer needs: the fraction of an image to show, so the\n * same avatar component can show a circle cut out of a stored card OR out of a\n * static preset sheet.\n */\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\n\n/** Largest 3:4 card that fits a cell, centered horizontally and top-aligned:\n * portraits are busts with the head near the top, so the top edge is the\n * anchor that keeps the face in. */\nexport function cardInCell(cell: ImageRect): ImageRect {\n const width = Math.floor(Math.min(cell.width, cell.height * CARD_ASPECT));\n const height = Math.min(cell.height, Math.round(width / CARD_ASPECT));\n return {\n left: Math.round(cell.left + (cell.width - width) / 2),\n top: cell.top,\n width,\n height,\n };\n}\n\nexport function defaultFraming(cell: ImageRect): AvatarFraming {\n return { card: cardInCell(cell), circle: { ...DEFAULT_AVATAR_CIRCLE } };\n}\n\n/** Keeps the circle inside its card. */\nexport function fitCircle(circle: AvatarCircle): AvatarCircle {\n const d = clamp(circle.d, 0.15, 1);\n return {\n d,\n x: clamp(circle.x, 0, 1 - d),\n y: clamp(circle.y, 0, 1 - d * CARD_ASPECT),\n };\n}\n\n/** Snaps a card to a valid 3:4 rectangle inside the sheet, at least the\n * minimum size. Width is the free variable; height follows the aspect. */\nexport function fitCard(card: ImageRect, sheet: ImageSize): ImageRect {\n const minHeight = Math.max(16, Math.round(sheet.height * MIN_CARD_HEIGHT_FRACTION));\n const maxHeight = Math.min(sheet.height, sheet.width / CARD_ASPECT);\n const height = Math.round(clamp(card.height, minHeight, maxHeight));\n const width = Math.round(height * CARD_ASPECT);\n return {\n width,\n height,\n left: Math.round(clamp(card.left, 0, sheet.width - width)),\n top: Math.round(clamp(card.top, 0, sheet.height - height)),\n };\n}\n\nexport function fitFraming(framing: AvatarFraming, sheet: ImageSize): AvatarFraming {\n return { card: fitCard(framing.card, sheet), circle: fitCircle(framing.circle) };\n}\n\n/** True when the framing is a well-formed object with finite numbers. Shape\n * only — call fitFraming for the geometric bounds. */\nexport function isFramingShape(value: unknown): value is AvatarFraming {\n if (!value || typeof value !== 'object') return false;\n const f = value as any;\n const rect = f.card, circle = f.circle;\n const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v);\n return !!rect && !!circle\n && num(rect.left) && num(rect.top) && num(rect.width) && num(rect.height)\n && rect.width > 0 && rect.height > 0\n && num(circle.x) && num(circle.y) && num(circle.d) && circle.d > 0;\n}\n\n/** The circle as a focus on the CARD image itself (a stored card cut). */\nexport function circleFocus(circle: AvatarCircle): ImageFocus {\n const c = fitCircle(circle);\n return { x: c.x, y: c.y, w: c.d, h: c.d * CARD_ASPECT };\n}\n\n/** The card as a focus on its sheet. */\nexport function cardFocus(card: ImageRect, sheet: ImageSize): ImageFocus {\n return {\n x: card.left / sheet.width,\n y: card.top / sheet.height,\n w: card.width / sheet.width,\n h: card.height / sheet.height,\n };\n}\n\n/** The circle as a focus on the SHEET (card and circle composed) — how a preset\n * mannequin renders straight off a static sheet. */\nexport function circleFocusOnSheet(framing: AvatarFraming, sheet: ImageSize): ImageFocus {\n const c = fitCircle(framing.circle);\n const card = framing.card;\n const side = c.d * card.width; // the circle is square, in sheet px\n return {\n x: (card.left + c.x * card.width) / sheet.width,\n y: (card.top + c.y * card.height) / sheet.height,\n w: side / sheet.width,\n h: side / sheet.height,\n };\n}\n\n/** CSS `background-size` / `background-position` that shows exactly `focus`\n * of an image inside a box. */\nexport function focusToBackground(focus: ImageFocus): { backgroundSize: string; backgroundPosition: string } {\n const axis = (pos: number, ext: number) => ext >= 1 ? 50 : (pos / (1 - ext)) * 100;\n return {\n backgroundSize: `${(100 / focus.w).toFixed(3)}% ${(100 / focus.h).toFixed(3)}%`,\n backgroundPosition: `${axis(focus.x, focus.w).toFixed(3)}% ${axis(focus.y, focus.h).toFixed(3)}%`,\n };\n}\n","import { ImageRect } from \"./types\";\n\n/**\n * Finds the cells of a drawn portrait sheet from its pixels.\n *\n * The prompt asks for equal cells separated by thin dark divider lines, but\n * the model draws the lines where it likes: on a correctly drawn 4x4 sheet\n * the rows measured 497 / 485 / 428 / 382 px where equal splits assumed 448\n * (2026-09-02), so fixed-interval slicing cut lower rows through the previous\n * character's chest. The lines themselves are crisp — near-black across the\n * full width — so they are easy to find, and the cells between them are the\n * real ones. Columns have always measured exact; rows drift.\n *\n * Pure over a greyscale byte plane so it is testable without sharp.\n */\n\nexport interface GreyPlane {\n width: number;\n height: number;\n // width*height bytes, row-major, 0 = black.\n data: Uint8Array;\n}\n\nexport interface SheetGrid {\n cells: ImageRect[]; // row-major\n cols: number;\n rows: number;\n // false = no usable divider lines were found and this is the equal split\n // of the REQUESTED grid. true = the cells between the lines the model\n // actually drew, which may be a different grid than requested (a 4x4\n // request has come back as 4x3 and as 6x3) — the sheet is the truth.\n detected: boolean;\n}\n\n// A pixel darker than this is \"line\"; a scanline with more than this share\n// of dark pixels is a divider. Cell backgrounds are flat muted colours, well\n// above the threshold, and the dark portrait clothing never spans a full line.\nconst DARK_MAX = 60;\nconst DIVIDER_SHARE = 0.85;\n// Pixels kept clear of a divider when cutting (anti-aliased edges).\nconst DIVIDER_INSET = 4;\n// A real divider is a thin line (~7 px on a 2400 px sheet). Thinner runs are\n// specks (a dark hairline inside a hooded figure once passed as a row line).\n// Thicker runs up to a small share of the sheet are still boundaries: the\n// model sometimes adds a dark name plate under each portrait despite the\n// no-text rule, and the plate belongs OUTSIDE the cell. Beyond that share a\n// dark run is a dark art style's background, not a boundary.\nconst DIVIDER_MIN_THICKNESS = 3;\nconst DIVIDER_MAX_THICKNESS_SHARE = 0.08;\n// A span between lines narrower than this share of the sheet can't hold a\n// face; the line that would create it is not a cell boundary.\nconst MIN_SPAN_SHARE = 0.12;\n\ninterface Run { start: number; end: number }\n\nfunction runs(indices: number[]): Run[] {\n const out: Run[] = [];\n for (const i of indices) {\n const last = out[out.length - 1];\n if (last && i === last.end + 1) last.end = i;\n else out.push({start: i, end: i});\n }\n return out;\n}\n\n/** Dark runs along one axis, measured over a window of the other axis (a\n * band of rows for columns, the full height by default). Border runs and\n * runs that aren't line-shaped are dropped. */\nfunction lineRuns(plane: GreyPlane, axis: 'rows' | 'cols', from: number, to: number): Run[] {\n const {width, height, data} = plane;\n const extent = axis === 'rows' ? height : width;\n const dark: number[] = [];\n for (let i = 0; i < extent; i++) {\n let count = 0;\n if (axis === 'rows') {\n const base = i * width;\n for (let x = from; x < to; x++) if (data[base + x] < DARK_MAX) count++;\n } else {\n for (let y = from; y < to; y++) if (data[y * width + i] < DARK_MAX) count++;\n }\n if (count / (to - from) > DIVIDER_SHARE) dark.push(i);\n }\n const maxThickness = extent * DIVIDER_MAX_THICKNESS_SHARE;\n return runs(dark)\n .filter(r => r.start > 2 && r.end < extent - 3)\n .filter(r => { const t = r.end - r.start + 1; return t >= DIVIDER_MIN_THICKNESS && t <= maxThickness; });\n}\n\n/** Lines that would leave a sliver on either side are not boundaries. */\nfunction dropSliverMakers(lines: Run[], extent: number): Run[] {\n const minSpan = extent * MIN_SPAN_SHARE;\n const kept: Run[] = [];\n let cursor = 0;\n for (const line of lines) {\n if (line.start - cursor < minSpan || extent - (line.end + 1) < minSpan) continue;\n kept.push(line);\n cursor = line.end + 1;\n }\n return kept;\n}\n\n/** Divider runs across the whole plane: row lines over the full width,\n * column lines over the full height. */\nexport function findDividers(plane: GreyPlane): {rows: Run[]; cols: Run[]} {\n return {\n rows: dropSliverMakers(lineRuns(plane, 'rows', 0, plane.width), plane.height),\n cols: dropSliverMakers(lineRuns(plane, 'cols', 0, plane.height), plane.width),\n };\n}\n\n/** Cells between dividers along one axis, from the border to the first line,\n * line to line, last line to the border. */\nfunction spans(dividers: Run[], extent: number): {start: number; end: number}[] {\n const out: {start: number; end: number}[] = [];\n let cursor = 0;\n for (const d of dividers) {\n out.push({start: cursor, end: d.start});\n cursor = d.end + 1;\n }\n out.push({start: cursor, end: extent});\n return out;\n}\n\nexport function equalSplitGrid(width: number, height: number, cols: number, rows: number): SheetGrid {\n const cellW = Math.floor(width / cols), cellH = Math.floor(height / rows);\n const cells: ImageRect[] = [];\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n cells.push(inset({left: c * cellW, top: r * cellH, width: cellW, height: cellH}, 8));\n }\n }\n return {cells, cols, rows, detected: false};\n}\n\nfunction inset(rect: ImageRect, by: number): ImageRect {\n return {left: rect.left + by, top: rect.top + by, width: Math.max(1, rect.width - 2 * by), height: Math.max(1, rect.height - 2 * by)};\n}\n\n/**\n * The sheet's cells. Read off the divider lines the model drew whenever they\n * form a plausible grid, even when that grid differs from the cols x rows\n * that were asked for — the model keeps row-major order, so the drawn cells\n * still map to the characters in sequence. Row lines are found over the\n * full width; column lines are found PER ROW BAND, because the model has\n * drawn rows with different column layouts (and a column line that stops\n * short of the last row). A band without lines of its own borrows the\n * layout of the band above it. The equal split of the requested grid is the\n * last resort when no lines are found at all (the caller logs it; the kept\n * sheet lets the owner reframe by hand either way).\n */\nexport function detectSheetGrid(plane: GreyPlane, cols: number, rows: number): SheetGrid {\n const {width, height} = plane;\n const rowLines = dropSliverMakers(lineRuns(plane, 'rows', 0, width), height);\n const bands = spans(rowLines, height);\n const cells: ImageRect[] = [];\n let previous: Run[] | null = null;\n let colCount = 0;\n for (const band of bands) {\n let colLines = dropSliverMakers(lineRuns(plane, 'cols', band.start, band.end), width);\n if (colLines.length === 0 && previous) colLines = previous;\n previous = colLines;\n const xs = spans(colLines, width);\n colCount = Math.max(colCount, xs.length);\n for (const x of xs) {\n cells.push(inset({left: x.start, top: band.start, width: x.end - x.start, height: band.end - band.start}, DIVIDER_INSET));\n }\n }\n if (colCount < 2) return equalSplitGrid(width, height, cols, rows);\n return {cells, cols: colCount, rows: bands.length, detected: true};\n}\n\n/** What findDividers saw, for the mismatch log line. */\nexport function describeDividers(plane: GreyPlane): string {\n const d = findDividers(plane);\n const fmt = (r: Run[]) => r.map(x => `${x.start}-${x.end}`).join(',') || 'none';\n return `rows@${fmt(d.rows)} cols@${fmt(d.cols)}`;\n}\n","/**\n * Image-generation models: platform-side, never user-selected. Prices per 1M tokens\n * from ai.google.dev/gemini-api/docs/pricing (2026-08). Image output bills ~1120 tokens\n * per image regardless of resolution, so one image ≈ $0.067 — fewer calls, not lower\n * resolution, is what minimizes cost.\n */\nexport const IMAGE_MODEL_CONSTANTS = {\n GEMINI_FLASH_IMAGE: 'gemini-3.1-flash-image',\n} as const;\n\nexport type ImageModelId = typeof IMAGE_MODEL_CONSTANTS[keyof typeof IMAGE_MODEL_CONSTANTS];\n\nexport interface ImageModelPricing {\n imageOutputPricePerM: number;\n textInputPricePerM: number;\n}\n\nexport const IMAGE_MODEL_PRICING: Record<ImageModelId, ImageModelPricing> = {\n [IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE]: {\n imageOutputPricePerM: 60,\n textInputPricePerM: 0.5,\n },\n};\n\nexport const DEFAULT_IMAGE_MODEL: ImageModelId = IMAGE_MODEL_CONSTANTS.GEMINI_FLASH_IMAGE;\n\n/** Cost of one image call from the usage Gemini reports. */\nexport function calculateImageCost(model: ImageModelId, imageOutputTokens: number, textInputTokens: number): number {\n const pricing = IMAGE_MODEL_PRICING[model];\n return parseFloat((\n imageOutputTokens / 1_000_000 * pricing.imageOutputPricePerM +\n textInputTokens / 1_000_000 * pricing.textInputPricePerM\n ).toFixed(6));\n}\n","import { calculateImageCost, DEFAULT_IMAGE_MODEL, ImageModelId } from './image-catalog';\nimport { GeneratedImage } from './types';\n\nexport type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '3:2' | '2:3' | '16:9' | '9:16';\n\nexport interface GenerateImageOptions {\n /** Labeled reference JPEGs (an established scene, character portraits) that\n * anchor the drawing to a known style, place or faces. */\n references?: { label: string; jpeg: Buffer }[];\n imageSize?: '1K' | '2K';\n model?: ImageModelId;\n /** Test seam / custom transport; defaults to the global fetch. */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Gemini's Interactions API blocks a drawing in two shapes (both seen in production,\n * 2026-10-13):\n *\n * - **HTTP 400** with `{\"error\": {\"message\": \"Image generation blocked due to safety\n * violations. Please modify your input and retry.\", \"code\": \"Unable to show the generated\n * image. The image was filtered out because it violated Google's Generative AI Prohibited\n * Use policy …\"}}` — or \"Request blocked due to prohibited content guidelines …\". This is\n * what an explicit prompt gets.\n * - **HTTP 200** with no image block, `status: \"failed\" | \"incomplete\"` and an `errors[]`\n * entry whose `code` names the block (https://ai.google.dev/gemini-api/docs/api-errors,\n * \"Generation blocked\"): `image_safety`, `image_prohibited_content`, `image_recitation`,\n * `image_other`, plus the text-side `safety`, `prohibited_content`, `recitation`,\n * `blocklist`, `spii`, `content_blocked`. `no_image` is a plain generation failure.\n *\n * Deliberately NOT a `ModelRefusalError` subclass: this subpath is bundled apart from the\n * main entry, so an `instanceof` against the main entry's class would never match. Hosts\n * check `isImageRefusalError(error)` (by name) instead. Not retryable on the same prompt.\n */\nexport class ImageRefusalError extends Error {\n /** The block code as Gemini sent it: an `errors[].code` (a URI or bare `snake_case`\n * name) on the HTTP 200 shape, or `http_400` on the error-body shape. */\n public code: string;\n /** Gemini's human-readable message, when it sent one. */\n public reason?: string;\n public model: string;\n\n constructor(model: string, code: string, reason?: string) {\n super(`${model} declined to draw the image (${describeBlockCode(code)})${reason ? `: ${reason}` : ''}`);\n this.name = 'ImageRefusalError';\n this.model = model;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/** Works across bundle copies and serialization boundaries (checks the name, not the class). */\nexport function isImageRefusalError(error: unknown): error is ImageRefusalError {\n return !!error && typeof error === 'object' && (error as { name?: unknown }).name === 'ImageRefusalError';\n}\n\nconst BLOCK_CODE_MARKERS = ['safety', 'prohibited', 'recitation', 'blocklist', 'spii', 'content_blocked', 'image_other'];\n\n/** Wording Gemini uses in a 400 body or a text block when it filtered the drawing. */\nconst BLOCK_TEXT = /blocked|safety violation|prohibited|filtered out|prohibited use policy/i;\n\n/** The trailing identifier of a code URI such as `https://…/api-errors#image_safety`. */\nfunction describeBlockCode(code: string): string {\n const tail = code.split(/[#/]/).filter(Boolean).pop() ?? code;\n return tail.toLowerCase();\n}\n\nfunction isBlockCode(code: string): boolean {\n const tail = describeBlockCode(code);\n return BLOCK_CODE_MARKERS.some(marker => tail.includes(marker));\n}\n\n/** Gemini's messages end in a markdown link to the policy; keep the sentence, drop the link. */\nfunction trimReason(text: string): string {\n return text.replace(/\\s*\\[[^\\]]*\\]\\([^)]*\\)[^.]*\\.?/g, '').trim().slice(0, 300);\n}\n\n/**\n * One image-model call on Gemini's Interactions API. Pure — no auth, no billing —\n * and the result reports its cost so the host decides whom to charge. Cost is per\n * IMAGE, not per pixel (a 1K and a 2K image both bill ~1120 output tokens).\n *\n * Throws `ImageRefusalError` when Gemini blocked the drawing (see the class), and a\n * plain `Error` naming the HTTP status, or the interaction status and error code, for\n * anything else.\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 httpError(model, res.status, await res.text());\n }\n const json: any = await res.json();\n const b64 = (json.steps || [])\n .flatMap((s: any) => s.content || [])\n .find((c: any) => c.type === 'image' && c.data)?.data;\n if (!b64) throw imagelessReplyError(model, json);\n\n const usage = json.usage || {};\n const imageTokens = (usage.output_tokens_by_modality || [])\n .filter((m: any) => m.modality === 'image')\n .reduce((sum: number, m: any) => sum + (m.tokens || 0), 0);\n const inputTokens = usage.total_input_tokens || 0;\n\n return { buffer: Buffer.from(b64, 'base64'), costUSD: calculateImageCost(model, imageTokens, inputTokens) };\n}\n\n/** Classifies a non-2xx reply: a 400 whose body says the drawing was filtered is a refusal. */\nfunction httpError(model: string, status: number, body: string): Error {\n if (status === 400) {\n let error: { message?: unknown; code?: unknown } | undefined;\n try { error = JSON.parse(body)?.error; } catch { /* not JSON */ }\n const message = typeof error?.message === 'string' ? error.message : '';\n const code = typeof error?.code === 'string' ? error.code : '';\n if (BLOCK_TEXT.test(message) || BLOCK_TEXT.test(code)) {\n return new ImageRefusalError(model, 'http_400', trimReason(message || code));\n }\n }\n return new Error(`Image request failed: HTTP ${status} ${body.slice(0, 600)}`);\n}\n\n/** Classifies an HTTP 200 reply that carried no image block. */\nfunction imagelessReplyError(model: string, json: any): Error {\n const errors: { code?: unknown; message?: unknown }[] = Array.isArray(json?.errors) ? json.errors : [];\n const codes = errors.map(e => String(e?.code ?? '')).filter(Boolean);\n const messages = errors.map(e => (typeof e?.message === 'string' ? e.message.trim() : '')).filter(Boolean);\n const block = codes.find(isBlockCode);\n if (block) {\n return new ImageRefusalError(model, block, messages[0] ? trimReason(messages[0]) : undefined);\n }\n // A reply with no errors[] sometimes explains itself in a text block instead.\n const text: string = (json?.steps || [])\n .flatMap((s: any) => s.content || [])\n .filter((c: any) => c.type === 'text' && typeof c.text === 'string')\n .map((c: any) => c.text.trim())\n .join(' ')\n .slice(0, 300);\n if (!codes.length && BLOCK_TEXT.test(text)) {\n return new ImageRefusalError(model, 'text_block', trimReason(text));\n }\n const status = typeof json?.status === 'string' ? json.status : 'unknown';\n const detail = [\n codes.length ? `code ${codes.map(describeBlockCode).join(', ')}` : '',\n messages[0]?.slice(0, 300) ?? '',\n text ? `text \"${text}\"` : '',\n ].filter(Boolean).join('; ');\n return new Error(`Image response contained no image data (status ${status}${detail ? `; ${detail}` : ''})`);\n}\n","import { generateImage, GenerateImageOptions } from './google-image';\nimport { defaultFraming } from './framing';\nimport { describeDividers, detectSheetGrid, equalSplitGrid } from './sheet-detection';\nimport { CARD_HEIGHT_PX, CARD_WIDTH_PX, DrawnSheet, ImageRect, PortraitCell, PortraitSlice, SharpLike } from './types';\n\n/**\n * Portrait sheets: one image-model call draws a whole cast as a grid of bust\n * portraits, and the grid is cut into one card per character. One call costs the\n * same whether it draws one face or sixteen, so this is the cheap way to give\n * every character in a game a portrait in a single consistent style.\n *\n * Nothing here touches storage or billing: the host stores the cards and the kept\n * sheet (so a card can be re-cut later at a new framing) and charges whoever it\n * likes for `costUSD`. sharp is passed in, never imported.\n */\n\n/**\n * Grid dimensions by cell count on the 4:3 canvas. Cells must come out\n * square-to-portrait: asked for 4x4 (landscape 600x448 cells) the model redrew the\n * sheet as 4x3, 6x3 and once as an irregular two-layout sheet — every time towards\n * taller cells. 5x3 keeps 480x597 cells for 13-15; 17 cells get 6x3 (400x600, 2:3,\n * one of the shapes the model volunteered). Never 4x4: it silently cut a 17-cell\n * cast to 16.\n */\nexport function gridFor(cells: number): { cols: number; rows: number } {\n if (cells <= 6) return { cols: 3, rows: 2 };\n if (cells <= 8) return { cols: 4, rows: 2 };\n if (cells <= 9) return { cols: 3, rows: 3 };\n if (cells <= 12) return { cols: 4, rows: 3 };\n if (cells <= 15) return { cols: 5, rows: 3 };\n if (cells <= 18) return { cols: 6, rows: 3 };\n throw new Error(`Portrait sheet cannot hold ${cells} cells (max 18)`);\n}\n\nexport const DEFAULT_FILLER_PROMPT = `\"Stranger\" — an anonymous hooded figure fitting the setting, face hidden in shadow`;\n\n/**\n * Pads a cast out to a full grid: the model draws exactly cols x rows cells, so the\n * spare ones get an anonymous filler that is drawn and discarded.\n */\nexport function padCells(cells: PortraitCell[], fillerPrompt: string = DEFAULT_FILLER_PROMPT): { cells: PortraitCell[]; cols: number; rows: number; realCount: number } {\n const { cols, rows } = gridFor(cells.length);\n const padded = [...cells];\n for (let i = padded.length; i < cols * rows; i++) {\n padded.push({ key: `__filler${i}`, label: 'Stranger', prompt: fillerPrompt });\n }\n return { cells: padded, cols, rows, realCount: cells.length };\n}\n\nexport interface PortraitSheetPrompt {\n cells: PortraitCell[];\n cols: number;\n rows: number;\n /** What the sheet is for, e.g. \"a social deduction game\" — one noun phrase. */\n purpose: string;\n /** The world the characters live in: a title and a short description. */\n setting: { title: string; description: string };\n /** Player-chosen art direction, already sanitized by the host. Replaces the\n * model's free choice of style. */\n artStyle?: string;\n}\n\n/**\n * The sheet prompt. The wording is load-bearing: equal cells with thin dark\n * divider lines are what the slicer finds, one flat muted background per cell keeps\n * the lines detectable in any art style, and the no-text rule is repeated because\n * the model otherwise typesets names and bios into the image.\n */\nexport function buildPortraitSheetPrompt(spec: PortraitSheetPrompt): string {\n const { cells, cols, rows, purpose, setting, artStyle } = spec;\n const cellLines = cells.map(\n (c, i) => `Cell ${i + 1}: ${c.prompt}. Its own distinct flat solid muted background color.`\n ).join('\\n');\n\n // A player style leads the prompt and is restated at the end: the sheet\n // framing (grid, bust portraits, flat backgrounds) otherwise pulls the model\n // toward drawn character-sheet art whatever style was asked for (an\n // \"ultra-realism\" game came out as comic portraits). The grid wording stays\n // intact — it is what the slicer relies on — but is declared layout only.\n const styleLead = artStyle\n ? `Art style, chosen by the player — it governs every portrait: \"${artStyle}\". Each cell must look made in that style: a photographic or cinematic style means real photographs, a painted style means paintings.\\n\\n`\n : '';\n const styleLine = artStyle\n ? `Render every portrait in the player's art style above. Apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`\n : `Choose ONE cohesive illustration style that fits this setting and apply it consistently to every portrait: same rendering technique, same palette family, same lighting.`;\n const styleTail = artStyle ? `\\n\\nEvery cell must be fully in this art style: \"${artStyle}\" — and still carry no names or text of any kind.` : '';\n\n return `${styleLead}A character portrait sheet for ${purpose}, rendered as a single image: a precise grid of exactly ${cells.length} rectangular cells, ${cols} columns and ${rows} rows, all cells exactly equal size, separated by thin dark divider lines. Each cell contains one bust portrait (head and shoulders) of a different character, centered in its cell.\n\nSetting — \"${setting.title}\": ${setting.description}\n\n${styleLine} Every face must be distinct and memorable, and match its character description. No character may span more than one cell. Give each cell its own flat solid muted desaturated background color, different from its neighbors. Row-major order, left to right, top to bottom:\n\n${cellLines}\n\nThe character descriptions above are guidance for the drawing only — NEVER render them as text. Absolutely no text anywhere in the image: no names, no labels, no captions, no letters, no writing of any kind — and no lettering on clothing, equipment, insignia or logos.${styleTail}`;\n}\n\n// The model returns ~2.4 MB loosely-compressed JPEGs; at this width and quality a\n// sheet is ~400 KB (~530 KB as base64), under a 1 MiB document limit with cells\n// still ~500 px tall.\nexport const SHEET_MAX_WIDTH = 2400;\nexport const SHEET_JPEG_QUALITY = 85;\nexport const SHEET_MAX_BASE64_BYTES = 900_000;\n\n/** Why a sheet's cells did not come out as requested; the host decides how loudly to log. */\nexport interface SheetMismatch {\n kind: 'no-dividers' | 'different-grid';\n message: string;\n detail: Record<string, unknown>;\n}\n\nexport interface SliceSheetOptions {\n onMismatch?: (mismatch: SheetMismatch) => void;\n /** Long edge the sheet is normalized to before cutting; defaults to SHEET_MAX_WIDTH. */\n maxWidth?: number;\n}\n\n/**\n * Cuts the cards out of a drawn sheet and keeps the sheet. Cells come from the\n * divider lines the model drew (equal split as a last resort); each card is the\n * largest 3:4 rectangle in its cell, top-anchored, with the default circle — the\n * framing the host can let a user move later.\n */\nexport async function sliceSheet(\n sharp: SharpLike,\n raw: Buffer,\n cells: PortraitCell[],\n count: number,\n cols: number,\n rows: number,\n opts: SliceSheetOptions = {},\n): Promise<{ slices: PortraitSlice[]; sheet: DrawnSheet }> {\n // Normalise the working resolution first so cells, cards and the stored sheet\n // all share one pixel space.\n const grid: Buffer = await sharp(raw).resize({ width: opts.maxWidth ?? SHEET_MAX_WIDTH, withoutEnlargement: true }).toBuffer();\n const { data, info } = await sharp(grid).greyscale().raw().toBuffer({ resolveWithObject: true });\n const width = info.width, height = info.height;\n if (width < 100 * cols || height < 100 * rows) throw new Error(`Portrait sheet has unusable dimensions ${width}x${height}`);\n\n const plane = { width, height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };\n const gridCells = detectSheetGrid(plane, cols, rows);\n if (!gridCells.detected) {\n opts.onMismatch?.({\n kind: 'no-dividers',\n message: `no divider lines found on the ${cols}x${rows} sheet; using equal split`,\n detail: { width, height, dividers: describeDividers(plane) },\n });\n } else if (gridCells.cols !== cols || gridCells.rows !== rows) {\n // The model drew a different grid than asked. Its cells are still in row-major\n // order, so they are used as drawn; only characters past the last drawn cell\n // fall back to the equal split.\n opts.onMismatch?.({\n kind: 'different-grid',\n message: `sheet drawn as ${gridCells.cols}x${gridCells.rows}, requested ${cols}x${rows}; using the drawn cells`,\n detail: { width, height, drawnCells: gridCells.cells.length, needed: count },\n });\n }\n const fallback = equalSplitGrid(width, height, cols, rows).cells;\n\n const slices: PortraitSlice[] = [];\n for (let i = 0; i < count; i++) {\n const framing = defaultFraming(gridCells.cells[i] ?? fallback[i]);\n const jpeg = await cutCard(sharp, grid, framing.card);\n slices.push({ key: cells[i].key, label: cells[i].label, jpeg, framing });\n }\n\n let sheetJpeg: Buffer = await sharp(grid).jpeg({ quality: SHEET_JPEG_QUALITY, mozjpeg: true }).toBuffer();\n if (sheetJpeg.length * 4 / 3 > SHEET_MAX_BASE64_BYTES) {\n sheetJpeg = await sharp(grid).jpeg({ quality: 72, mozjpeg: true }).toBuffer();\n }\n return {\n slices,\n sheet: { jpeg: sheetJpeg, width, height, cells: gridCells.cells.slice(0, count), detected: gridCells.detected },\n };\n}\n\n/** Cuts one card out of a stored sheet at the given framing. */\nexport async function cutCard(sharp: SharpLike, sheet: Buffer, card: ImageRect): Promise<Buffer> {\n return sharp(sheet)\n .extract(card)\n .resize(CARD_WIDTH_PX, CARD_HEIGHT_PX)\n .jpeg({ quality: 85 })\n .toBuffer();\n}\n\nexport interface DrawPortraitSheetSpec extends Omit<PortraitSheetPrompt, 'cols' | 'rows'> {\n /** Prompt for the cells that pad the cast out to a full grid. */\n fillerPrompt?: string;\n}\n\nexport interface DrawPortraitSheetOptions extends SliceSheetOptions {\n image?: Pick<GenerateImageOptions, 'model' | 'imageSize' | 'fetchImpl'>;\n}\n\nexport interface DrawnPortraitSheet {\n portraits: PortraitSlice[];\n sheet: DrawnSheet;\n /** What the image call cost, reported for the host's billing. */\n costUSD: number;\n}\n\n/**\n * The whole pipeline: lay the cast out on a grid, draw it in one call, cut the\n * cards. Filler cells are drawn and discarded; `portraits` has exactly one entry\n * per input cell, in order.\n */\nexport async function drawPortraitSheet(apiKey: string, sharp: SharpLike, spec: DrawPortraitSheetSpec, opts: DrawPortraitSheetOptions = {}): Promise<DrawnPortraitSheet> {\n const { cells, cols, rows, realCount } = padCells(spec.cells, spec.fillerPrompt);\n const prompt = buildPortraitSheetPrompt({ cells, cols, rows, purpose: spec.purpose, setting: spec.setting, artStyle: spec.artStyle });\n const image = await generateImage(apiKey, prompt, '4:3', opts.image);\n const { slices, sheet } = await sliceSheet(sharp, image.buffer, cells, realCount, cols, rows, opts);\n return { portraits: slices, sheet, costUSD: image.costUSD };\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.mts CHANGED
@@ -372,6 +372,7 @@ declare const LLM_CONSTANTS: {
372
372
  GPT_SOL: string;
373
373
  GPT: string;
374
374
  GPT_MINI: string;
375
+ GEMINI_ARGON: string;
375
376
  GEMINI_PRO: string;
376
377
  GEMINI_FLASH: string;
377
378
  GEMINI_LITE: string;
package/dist/index.d.ts CHANGED
@@ -372,6 +372,7 @@ declare const LLM_CONSTANTS: {
372
372
  GPT_SOL: string;
373
373
  GPT: string;
374
374
  GPT_MINI: string;
375
+ GEMINI_ARGON: string;
375
376
  GEMINI_PRO: string;
376
377
  GEMINI_FLASH: string;
377
378
  GEMINI_LITE: string;
package/dist/index.js CHANGED
@@ -981,6 +981,8 @@ var LLM_CONSTANTS = {
981
981
  GPT_SOL: "gpt-sol",
982
982
  GPT: "gpt",
983
983
  GPT_MINI: "gpt-mini",
984
+ // Gemini 4 Argon sits above Pro; version-free id so a later Argon repoint keeps persisted ids.
985
+ GEMINI_ARGON: "gemini-argon",
984
986
  GEMINI_PRO: "gemini-pro",
985
987
  GEMINI_FLASH: "gemini-flash",
986
988
  GEMINI_LITE: "gemini-lite",
@@ -1138,6 +1140,21 @@ var SupportedAiModels = {
1138
1140
  // 2.5-era thinkingBudget: 1024 (2026-08-06), which HAD been binding — so Flash Lite now
1139
1141
  // thinks noticeably less under its "minimal" default (0.8s/49-token votes vs 4.5s/650
1140
1142
  // budgeted); bump it to 'low' if its output quality visibly drops.
1143
+ // Gemini 4 Argon (added 2026-10-13, id verified against GET /v1beta/models): Google's
1144
+ // flagship reasoning model, 1M in / 32K out. Same thinkingLevel dialect as 3.x — accepts
1145
+ // low|medium|high, rejects 'minimal' (probed). Pinned 'high' like Pro: on a 1.7K-token game
1146
+ // prompt 'medium' thought 0 tokens (3.2s) while 'high' thought 622 (8.1s); the level is a
1147
+ // ceiling, so 'high' leaves the dynamic range open. No `temperature`: Gemini 4 accepts it
1148
+ // but Google deprecated sampling params (see the Pro note). Story-pipeline speed as Game
1149
+ // Master not measured yet — run the host's live story suite before trusting it there.
1150
+ [LLM_CONSTANTS.GEMINI_ARGON]: {
1151
+ displayName: "Gemini 4 Argon",
1152
+ modelApiName: "gemini-4-argon",
1153
+ apiKeyName: API_KEY_CONSTANTS.GOOGLE,
1154
+ hasThinking: true,
1155
+ reasoningEffort: "high",
1156
+ tags: ["expensive"]
1157
+ },
1141
1158
  [LLM_CONSTANTS.GEMINI_PRO]: {
1142
1159
  displayName: "Gemini 3.1 Pro Preview",
1143
1160
  modelApiName: "gemini-3.1-pro-preview",
@@ -1573,6 +1590,15 @@ var MODEL_PRICING = {
1573
1590
  extendedContextThresholdTokens: 1e5
1574
1591
  },
1575
1592
  // Google models
1593
+ [SupportedAiModels[LLM_CONSTANTS.GEMINI_ARGON].modelApiName]: {
1594
+ // Launch pricing (ai.google.dev pricing page, fetched 2026-10-13), one flat tier — no
1595
+ // >200K long-context rows, unlike 3.1 Pro. DOUBLES on 2026-12-01 to $4.00 / $20.00 /
1596
+ // $0.20 — ACTION NEEDED then. Implicit caching only (explicit caching unsupported),
1597
+ // cached input bills at 5% of the input price. Not on Google's free tier or AI Pro plan.
1598
+ inputPrice: 2,
1599
+ outputPrice: 10,
1600
+ cacheHitPrice: 0.1
1601
+ },
1576
1602
  [SupportedAiModels[LLM_CONSTANTS.GEMINI_PRO].modelApiName]: {
1577
1603
  inputPrice: 2,
1578
1604
  outputPrice: 12,
@@ -5504,6 +5530,7 @@ var AgentFactory = class {
5504
5530
  case LLM_CONSTANTS.GPT:
5505
5531
  case LLM_CONSTANTS.GPT_MINI:
5506
5532
  return new Gpt5Agent(name, instruction, model.modelApiName, key, model.temperature, shouldEnableThinking);
5533
+ case LLM_CONSTANTS.GEMINI_ARGON:
5507
5534
  case LLM_CONSTANTS.GEMINI_PRO:
5508
5535
  case LLM_CONSTANTS.GEMINI_FLASH:
5509
5536
  case LLM_CONSTANTS.GEMINI_LITE: