@ai-game-assets/dev 0.5.6 → 0.7.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.
Files changed (38) hide show
  1. package/dist/asset-store.d.ts +27 -1
  2. package/dist/asset-store.d.ts.map +1 -1
  3. package/dist/asset-store.js +314 -8
  4. package/dist/asset-store.js.map +1 -1
  5. package/dist/audio-provider.d.ts +1 -0
  6. package/dist/audio-provider.d.ts.map +1 -1
  7. package/dist/audio-provider.js +23 -1
  8. package/dist/audio-provider.js.map +1 -1
  9. package/dist/build-manifest.d.ts.map +1 -1
  10. package/dist/build-manifest.js +14 -0
  11. package/dist/build-manifest.js.map +1 -1
  12. package/dist/image-generation-sizes.d.ts +7 -0
  13. package/dist/image-generation-sizes.d.ts.map +1 -0
  14. package/dist/image-generation-sizes.js +15 -0
  15. package/dist/image-generation-sizes.js.map +1 -0
  16. package/dist/index.d.ts +6 -6
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -3
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal.d.ts +1 -1
  21. package/dist/internal.d.ts.map +1 -1
  22. package/dist/provider-image-processing.d.ts +5 -1
  23. package/dist/provider-image-processing.d.ts.map +1 -1
  24. package/dist/provider-image-processing.js +278 -9
  25. package/dist/provider-image-processing.js.map +1 -1
  26. package/dist/provider.d.ts +45 -1
  27. package/dist/provider.d.ts.map +1 -1
  28. package/dist/provider.js +477 -57
  29. package/dist/provider.js.map +1 -1
  30. package/dist/server.d.ts +78 -2
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +339 -28
  33. package/dist/server.js.map +1 -1
  34. package/dist/tileset-sheet-processing.d.ts +49 -0
  35. package/dist/tileset-sheet-processing.d.ts.map +1 -0
  36. package/dist/tileset-sheet-processing.js +357 -0
  37. package/dist/tileset-sheet-processing.js.map +1 -0
  38. package/package.json +5 -3
package/dist/provider.js CHANGED
@@ -1,5 +1,100 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { hexColor, referenceLockPromptLines, removeChromaBackground, resizePngToDimensions, resolveRequestedBackground, selectChromaKey, shouldPostprocessTransparency, shouldRequestRgbaPng, variationDirectionPromptLine } from "./provider-image-processing.js";
2
+ import { alignSpriteSheetFrames, hexColor, referenceLockPromptLines, removeChromaBackground, removeTilesetChromaBackground, resizePngToDimensions, resizeRasterToDimensions, rasterizeSvgToPng, resolveRequestedBackground, selectChromaKey, shouldPostprocessTransparency, shouldRequestRgbaPng, variationDirectionPromptLine } from "./provider-image-processing.js";
3
+ import { closestImageGenerationSize } from "./image-generation-sizes.js";
4
+ import { cropTilesetSheetFromGeneration, planTilesetSheetGeneration, stageTilesetSheetReference, tilesetSheetRectLabel } from "./tileset-sheet-processing.js";
5
+ export { closestImageGenerationSize } from "./image-generation-sizes.js";
6
+ const OPAQUE_TILESET_PADDING = { red: 0, green: 0, blue: 0 };
7
+ export async function generateTilesetAnimationBranches(provider, request, onOption) {
8
+ if (request.asset.kind !== "tileset" || !request.asset.tileset) {
9
+ throw new Error(`AI asset "${request.asset.id}" is not a tileset.`);
10
+ }
11
+ const animation = request.asset.tileset.animations?.find((candidate) => candidate.key === request.animationKey);
12
+ if (!animation) {
13
+ throw new Error(`Unknown tileset animation "${request.animationKey}" for AI asset "${request.asset.id}".`);
14
+ }
15
+ const requestedBranchCount = request.count ?? 3;
16
+ if (!Number.isInteger(requestedBranchCount) || requestedBranchCount <= 0) {
17
+ throw new Error("Tileset animation candidate count must be a positive integer.");
18
+ }
19
+ const branchCount = Math.min(requestedBranchCount, 3);
20
+ request.signal?.throwIfAborted();
21
+ return Promise.all(Array.from({ length: branchCount }, async (_, index) => {
22
+ const frames = [];
23
+ const previousFrameReferences = [];
24
+ const branchSeed = createVariationSeed(index);
25
+ for (let frameIndex = 0; frameIndex < animation.frameCount; frameIndex += 1) {
26
+ request.signal?.throwIfAborted();
27
+ const generated = await provider.generate({
28
+ asset: request.asset,
29
+ purpose: "tileset-animation",
30
+ prompt: tilesetAnimationFramePrompt(request.asset, animation, {
31
+ prompt: request.prompt,
32
+ frameIndex,
33
+ branchIndex: index,
34
+ branchCount,
35
+ branchSeed,
36
+ priorFrameCount: previousFrameReferences.length
37
+ }),
38
+ count: 1,
39
+ settings: {
40
+ ...request.settings,
41
+ format: "png",
42
+ frameAlignment: "none"
43
+ },
44
+ references: [request.baseReference, ...previousFrameReferences],
45
+ stylePrompt: request.stylePrompt,
46
+ styleReferences: request.styleReferences,
47
+ signal: request.signal
48
+ });
49
+ const frame = generated[0];
50
+ if (!frame) {
51
+ throw new Error(`Tileset animation "${animation.key}" branch ${index + 1} frame ${frameIndex + 1} did not produce an image.`);
52
+ }
53
+ frames.push(frame);
54
+ previousFrameReferences.push({
55
+ image: frame.image,
56
+ mimeType: frame.mimeType,
57
+ fileName: `prior-${sanitizeReferenceName(animation.key)}-frame-${frameIndex + 1}.${extensionFromMimeType(frame.mimeType)}`
58
+ });
59
+ }
60
+ const option = {
61
+ index,
62
+ animationKey: animation.key,
63
+ frames
64
+ };
65
+ request.signal?.throwIfAborted();
66
+ await onOption?.(option);
67
+ return option;
68
+ }));
69
+ }
70
+ export function tilesetAnimationFramePrompt(asset, animation, context) {
71
+ const brief = context.prompt?.trim() || animation.prompt?.trim() ||
72
+ `Animate the tiles described by "${animation.key}".`;
73
+ const frameNumber = context.frameIndex + 1;
74
+ const priorReferenceDescription = context.priorFrameCount
75
+ ? `References 2 through ${context.priorFrameCount + 1} are earlier frames for motion continuity only, in chronological order. They never override Reference 1's sheet geometry, tile coordinates, or unchanged pixels.`
76
+ : "There are no prior animation frames yet; derive this first phase directly from the base sheet.";
77
+ const tileInstructions = animation.tiles?.map((tile, index) => (`Tile ${index + 1}: ${tile.prompt.trim()}`)) ?? [];
78
+ const finalFrameIndex = Math.max(0, animation.frameCount - 1);
79
+ return [
80
+ ...(animation.tiles?.length ? [] : [brief, ""]),
81
+ `Generate animation frame ${frameNumber} of ${animation.frameCount} for tileset animation "${animation.key}".`,
82
+ `This is candidate branch ${context.branchIndex + 1} of ${context.branchCount}; branch identity seed: ${context.branchSeed}.`,
83
+ "Edit Reference 1 in place and return one complete full-size tileset sheet, never an individual tile or a contact sheet of animation phases.",
84
+ "Reference 1 is the immutable spatial source of truth. Its canvas bounds, top-left origin, tile coordinates, palette, scale, cell boundaries, edge continuity, and pixel alignment have absolute precedence over every other reference and instruction.",
85
+ priorReferenceDescription,
86
+ "Do not redraw or re-lay out the sheet. Preserve every tile at exactly the same index and pixel coordinates; do not shift the canvas or add, remove, reorder, resize, crop, relight, restyle, or redesign tiles.",
87
+ "Only change pixels explicitly required by a tile's animation instruction. Copy every other pixel from Reference 1 unchanged.",
88
+ ...(tileInstructions.length
89
+ ? [
90
+ "Follow these tile instructions in exact row-major sheet order. Match each tile number to the authoritative generation-canvas rectangle supplied later in the complete model prompt:",
91
+ ...tileInstructions
92
+ ]
93
+ : []),
94
+ `This frame samples loop phase t=${context.frameIndex}/${animation.frameCount}. The sequence samples t=0/${animation.frameCount} through t=${finalFrameIndex}/${animation.frameCount}; do not duplicate the first phase as an extra final frame.`,
95
+ "Keep motion coherent with prior frames and make the final sampled phase transition cleanly back to the first."
96
+ ].join("\n");
97
+ }
3
98
  export function createOpenAiImageProvider(options = {}) {
4
99
  return {
5
100
  async generate(request, onOption) {
@@ -14,20 +109,53 @@ export function createOpenAiImageProvider(options = {}) {
14
109
  "gpt-image-2";
15
110
  const prompt = request.prompt ?? request.asset.prompt;
16
111
  const requestedFormat = request.settings?.format ?? request.asset.settings?.format ?? "png";
112
+ const requestedBackground = resolveRequestedBackground(request, options);
17
113
  if (requestedFormat === "svg") {
18
114
  return generateSvgAssets(request, {
19
115
  apiKey,
20
116
  model: request.settings?.model ?? options.svgModel ?? process.env.OPENAI_SVG_MODEL ?? "gpt-5",
21
117
  prompt,
22
- count: request.count ?? 1
118
+ count: request.count ?? 1,
119
+ requestedBackground,
120
+ signal: request.signal
23
121
  }, onOption);
24
122
  }
25
123
  const outputFormat = normalizeOutputFormat(requestedFormat);
26
- const requestedBackground = resolveRequestedBackground(request, options);
27
- const background = normalizeBackgroundForModel(model, requestedBackground);
28
124
  const chromaKey = selectChromaKey(request);
125
+ const postprocessTransparency = shouldPostprocessTransparency(request, {
126
+ prompt,
127
+ model,
128
+ outputFormat,
129
+ requestedBackground
130
+ });
131
+ // Tileset transparency is encoded as a visible chroma matte and removed
132
+ // locally. Requesting an opaque raster keeps the API-level background
133
+ // setting consistent with that contract instead of suggesting alpha or a
134
+ // visual checkerboard preview to the model.
135
+ const background = request.asset.kind === "tileset" && postprocessTransparency
136
+ ? "opaque"
137
+ : normalizeBackgroundForModel(model, requestedBackground);
138
+ const persistedBackground = request.settings?.background ??
139
+ request.asset.settings?.background ??
140
+ requestedBackground;
141
+ const frameAlignment = request.settings?.frameAlignment ??
142
+ request.asset.settings?.frameAlignment ??
143
+ "center";
144
+ const configuredSize = request.settings?.size ??
145
+ request.asset.settings?.size;
146
+ const tilesetGeometry = request.asset.kind === "tileset" && request.asset.tileset
147
+ ? planTilesetSheetGeneration(request.asset, configuredSize)
148
+ : undefined;
149
+ const generationSize = tilesetGeometry?.size ??
150
+ configuredSize ?? closestImageGenerationSize(dimensions);
151
+ const tilesetPadding = tilesetGeometry
152
+ ? tilesetOutputPadding(postprocessTransparency, chromaKey)
153
+ : undefined;
154
+ const assetReferences = tilesetGeometry
155
+ ? await Promise.all((request.references ?? []).map((reference) => (stageTilesetSheetReference(reference, tilesetGeometry, chromaKey))))
156
+ : request.references ?? [];
29
157
  const allReferences = [
30
- ...(request.references ?? []),
158
+ ...assetReferences,
31
159
  ...(request.styleReferences ?? []).map((reference, index) => ({
32
160
  ...reference,
33
161
  fileName: `style-reference-${index + 1}-${reference.fileName}`
@@ -44,10 +172,11 @@ export function createOpenAiImageProvider(options = {}) {
44
172
  chromaKey,
45
173
  variation: count > 1 ? createVariationSeed(index) : undefined,
46
174
  variationIndex: index,
47
- variationCount: count
175
+ variationCount: count,
176
+ tilesetGeometry
48
177
  }),
49
178
  n: 1,
50
- size: request.settings?.size ?? request.asset.settings?.size ?? "1024x1024",
179
+ size: generationSize,
51
180
  quality: request.settings?.quality ??
52
181
  request.asset.settings?.quality ??
53
182
  options.quality ??
@@ -57,9 +186,10 @@ export function createOpenAiImageProvider(options = {}) {
57
186
  moderation: request.settings?.moderation ?? request.asset.settings?.moderation
58
187
  }));
59
188
  const generatedByIndex = await Promise.all(requestBodies.map(async (requestBody, index) => {
189
+ request.signal?.throwIfAborted();
60
190
  const response = allReferences.length
61
- ? await createImageEdit(apiKey, requestBody, allReferences)
62
- : await createImageGeneration(apiKey, requestBody);
191
+ ? await createImageEdit(apiKey, requestBody, allReferences, request.signal)
192
+ : await createImageGeneration(apiKey, requestBody, request.signal);
63
193
  if (!response.ok) {
64
194
  const body = await response.text();
65
195
  throw new Error(`OpenAI image generation failed (${response.status}): ${openAiErrorMessage(body)}`);
@@ -70,15 +200,22 @@ export function createOpenAiImageProvider(options = {}) {
70
200
  if (!item.b64_json) {
71
201
  throw new Error("OpenAI image generation response did not include b64_json.");
72
202
  }
203
+ request.signal?.throwIfAborted();
73
204
  const image = Buffer.from(item.b64_json, "base64");
74
- const processedImage = resizePngToDimensions(shouldPostprocessTransparency(request, {
75
- prompt,
76
- model,
77
- outputFormat,
78
- requestedBackground
79
- })
80
- ? removeChromaBackground(image, chromaKey)
81
- : image, dimensions);
205
+ const resizedImage = tilesetGeometry
206
+ ? await cropTilesetSheetFromGeneration(image, tilesetGeometry, outputFormat, tilesetPadding)
207
+ : outputFormat === "png"
208
+ ? resizePngToDimensions(postprocessTransparency
209
+ ? removeChromaBackground(image, chromaKey)
210
+ : image, dimensions)
211
+ : await resizeRasterToDimensions(image, dimensions, outputFormat);
212
+ const transparencyProcessedImage = tilesetGeometry && postprocessTransparency && request.asset.tileset
213
+ ? removeTilesetChromaBackground(resizedImage, request.asset.tileset, chromaKey)
214
+ : resizedImage;
215
+ const processedImage = request.asset.kind !== "tileset" &&
216
+ postprocessTransparency && request.asset.frameGrid && frameAlignment === "center"
217
+ ? alignSpriteSheetFrames(transparencyProcessedImage, request.asset.frameGrid)
218
+ : transparencyProcessedImage;
82
219
  const option = {
83
220
  image: processedImage,
84
221
  mimeType: mimeTypeFromOutputFormat(outputFormat),
@@ -87,15 +224,18 @@ export function createOpenAiImageProvider(options = {}) {
87
224
  revisedPrompt: item.revised_prompt,
88
225
  dimensions,
89
226
  frameGrid: request.asset.frameGrid,
227
+ tileset: request.asset.tileset,
90
228
  settings: {
91
229
  ...request.asset.settings,
92
230
  ...request.settings,
93
231
  model,
94
- background,
95
- format: outputFormat === "jpeg" ? "jpg" : outputFormat
232
+ background: persistedBackground,
233
+ format: outputFormat === "jpeg" ? "jpg" : outputFormat,
234
+ ...(postprocessTransparency && request.asset.frameGrid ? { frameAlignment } : {})
96
235
  }
97
236
  };
98
237
  generatedForRequest.push(option);
238
+ request.signal?.throwIfAborted();
99
239
  await onOption?.(option, index);
100
240
  }
101
241
  return generatedForRequest;
@@ -114,8 +254,10 @@ async function generateSvgAssets(request, context, onOption) {
114
254
  }))
115
255
  ];
116
256
  return Promise.all(Array.from({ length: context.count }, async (_, index) => {
257
+ context.signal?.throwIfAborted();
117
258
  const prompt = svgAssetPrompt(request, {
118
259
  prompt: context.prompt,
260
+ requestedBackground: context.requestedBackground,
119
261
  variation: context.count > 1 ? createVariationSeed(index) : undefined,
120
262
  variationIndex: index,
121
263
  variationCount: context.count
@@ -124,12 +266,13 @@ async function generateSvgAssets(request, context, onOption) {
124
266
  model: context.model,
125
267
  prompt,
126
268
  references
127
- });
269
+ }, context.signal);
128
270
  if (!response.ok) {
129
271
  const body = await response.text();
130
272
  throw new Error(`OpenAI SVG generation failed (${response.status}): ${openAiErrorMessage(body)}`);
131
273
  }
132
274
  const payload = await response.json();
275
+ context.signal?.throwIfAborted();
133
276
  const svg = normalizeSvgOutput(extractResponseText(payload), dimensions);
134
277
  const option = {
135
278
  image: Buffer.from(svg, "utf8"),
@@ -138,42 +281,57 @@ async function generateSvgAssets(request, context, onOption) {
138
281
  model: context.model,
139
282
  dimensions,
140
283
  frameGrid: request.asset.frameGrid,
284
+ tileset: request.asset.tileset,
141
285
  settings: {
142
286
  ...request.asset.settings,
143
287
  ...request.settings,
144
288
  model: context.model,
289
+ background: context.requestedBackground,
145
290
  format: "svg"
146
291
  }
147
292
  };
293
+ context.signal?.throwIfAborted();
148
294
  await onOption?.(option, index);
149
295
  return option;
150
296
  }));
151
297
  }
152
298
  function svgAssetPrompt(request, context) {
153
299
  const dimensions = requireAssetDimensions(request.asset);
154
- const lines = [
155
- context.prompt,
156
- "",
157
- "Generate a single valid SVG file as XML markup for a 2D game asset.",
158
- "Return only the <svg>...</svg> document. Do not wrap it in Markdown, do not add commentary, and do not output raster images or base64 data.",
159
- `The root <svg> must use xmlns="http://www.w3.org/2000/svg", width="${dimensions.width}", height="${dimensions.height}", and viewBox="0 0 ${dimensions.width} ${dimensions.height}".`,
160
- `Asset kind: ${request.asset.kind}.`,
161
- `Target canvas: ${dimensions.width}x${dimensions.height}.`,
162
- "Use vector primitives such as paths, polygons, circles, ellipses, rects, gradients, masks, and groups. Keep IDs unique and descriptive.",
163
- "Do not include scripts, external URLs, foreignObject, CSS imports, font imports, animation tags, or event handlers."
164
- ];
300
+ const lines = [];
301
+ const brief = assetBriefForModel(request, context.prompt);
302
+ if (brief) {
303
+ lines.push(brief, "");
304
+ }
305
+ const structuredTilesetPrompt = structuredTilesetPromptLines(request.asset);
306
+ if (structuredTilesetPrompt.length &&
307
+ !brief?.includes(structuredTilesetPrompt.join("\n"))) {
308
+ lines.push(...structuredTilesetPrompt, "");
309
+ }
310
+ lines.push("Generate a single valid SVG file as XML markup for a 2D game asset.", "Return only the <svg>...</svg> document. Do not wrap it in Markdown, do not add commentary, and do not output raster images or base64 data.", `The root <svg> must use xmlns="http://www.w3.org/2000/svg", width="${dimensions.width}", height="${dimensions.height}", and viewBox="0 0 ${dimensions.width} ${dimensions.height}".`, `Asset kind: ${request.asset.kind}.`, `Target canvas: ${dimensions.width}x${dimensions.height}.`, "Use vector primitives such as paths, polygons, circles, ellipses, rects, gradients, masks, and groups. Keep IDs unique and descriptive.", "Do not include scripts, external URLs, foreignObject, CSS imports, font imports, animation tags, or event handlers.");
165
311
  if (request.stylePrompt?.trim()) {
166
312
  lines.push(`Style guide: ${request.stylePrompt.trim()}`);
167
313
  }
168
- if (referencesNeedIdentity(request)) {
314
+ if (referencesNeedIdentity(request) && request.asset.kind !== "tileset") {
169
315
  lines.push("Use the provided non-style reference image as the character identity reference. Preserve its silhouette, palette distribution, proportions, markings, and distinctive details while drawing it as clean SVG.");
170
316
  }
171
- if (request.asset.frameGrid) {
317
+ if (request.asset.kind === "tileset") {
318
+ lines.push(...tilesetContractPromptLines(request.asset, false));
319
+ if (request.references?.length) {
320
+ lines.push("Treat the first non-style reference as the immutable base tileset and any later non-style references as earlier animation phases. Preserve exact tile identity, indices, cell boundaries, palette, and alignment.");
321
+ }
322
+ }
323
+ else if (request.asset.frameGrid) {
172
324
  const frameCount = request.asset.frameGrid.frameCount ??
173
325
  request.asset.frameGrid.columns * request.asset.frameGrid.rows;
174
- lines.push(`Spritesheet contract: create exactly ${frameCount} animation frames arranged in the first ${frameCount} cells of a fixed grid with ${request.asset.frameGrid.columns} columns and ${request.asset.frameGrid.rows} rows.`, `Each frame cell is exactly ${request.asset.frameGrid.frameWidth}x${request.asset.frameGrid.frameHeight}. The full SVG canvas is ${dimensions.width}x${dimensions.height}.`, `Use one complete frame per grid cell, ordered left-to-right then top-to-bottom. Cell rectangles are: ${gridCellRectangles(request)}.`, "Keep the background transparent by leaving empty areas unpainted. Do not draw visible grid lines, labels, frame numbers, or cell borders.");
326
+ lines.push(`Spritesheet contract: create exactly ${frameCount} animation frames arranged in the first ${frameCount} cells of a fixed grid with ${request.asset.frameGrid.columns} columns and ${request.asset.frameGrid.rows} rows.`, `Each frame cell is exactly ${request.asset.frameGrid.frameWidth}x${request.asset.frameGrid.frameHeight}. The full SVG canvas is ${dimensions.width}x${dimensions.height}.`, `Use one complete frame per grid cell, ordered left-to-right then top-to-bottom. Cell rectangles are: ${gridCellRectangles(request)}.`);
327
+ if (context.requestedBackground === "opaque") {
328
+ lines.push("Fill every frame cell edge-to-edge with opaque artwork. Preserve stationary background scenery across frames. Do not draw visible grid lines, labels, frame numbers, or cell borders.");
329
+ }
330
+ else {
331
+ lines.push("Keep the background transparent by leaving empty areas unpainted. Do not draw visible grid lines, labels, frame numbers, or cell borders.");
332
+ }
175
333
  }
176
- else if (request.asset.settings?.background === "opaque") {
334
+ else if (context.requestedBackground === "opaque") {
177
335
  lines.push("Create one continuous opaque scene covering the full SVG canvas. Do not create a spritesheet, contact sheet, labels, or panels.");
178
336
  }
179
337
  else {
@@ -187,9 +345,10 @@ function svgAssetPrompt(request, context) {
187
345
  function referencesNeedIdentity(request) {
188
346
  return Boolean(request.references?.length);
189
347
  }
190
- async function createSvgResponse(apiKey, body) {
348
+ async function createSvgResponse(apiKey, body, signal) {
191
349
  const content = [{ type: "input_text", text: body.prompt }];
192
- for (const reference of body.references) {
350
+ for (const sourceReference of body.references) {
351
+ const reference = await normalizeOpenAiImageReference(sourceReference, signal);
193
352
  if (!isSupportedResponseImageMimeType(reference.mimeType))
194
353
  continue;
195
354
  content.push({
@@ -211,7 +370,8 @@ async function createSvgResponse(apiKey, body) {
211
370
  content
212
371
  }
213
372
  ]
214
- })
373
+ }),
374
+ signal
215
375
  });
216
376
  }
217
377
  function isSupportedResponseImageMimeType(mimeType) {
@@ -220,27 +380,69 @@ function isSupportedResponseImageMimeType(mimeType) {
220
380
  mimeType === "image/webp" ||
221
381
  mimeType === "image/gif";
222
382
  }
223
- function gameAssetPrompt(request, context) {
383
+ export function gameAssetPrompt(request, context) {
224
384
  const dimensions = requireAssetDimensions(request.asset);
225
- const lines = [
226
- context.prompt,
227
- "",
228
- "Create this as a clean 2D game asset sprite.",
229
- `Asset kind: ${request.asset.kind}.`,
230
- `Target canvas: ${dimensions.width}x${dimensions.height}.`
231
- ];
232
- if (shouldRequestRgbaPng(request, context)) {
385
+ const isTilesetAnimation = request.purpose === "tileset-animation";
386
+ const lines = [];
387
+ const brief = assetBriefForModel(request, context.prompt);
388
+ if (brief) {
389
+ lines.push(brief, "");
390
+ }
391
+ const structuredTilesetPrompt = isTilesetAnimation
392
+ ? []
393
+ : context.tilesetGeometry
394
+ ? modelTilesetArtworkPromptLines(request.asset, shouldRequestRgbaPng(request, context) ? context.chromaKey : undefined)
395
+ : structuredTilesetPromptLines(request.asset);
396
+ if (structuredTilesetPrompt.length &&
397
+ !brief?.includes(structuredTilesetPrompt.join("\n"))) {
398
+ lines.push(...structuredTilesetPrompt, "");
399
+ }
400
+ lines.push(...(isTilesetAnimation
401
+ ? [
402
+ "Perform a minimal in-place edit of the immutable base tileset reference; do not redraw the sheet.",
403
+ `Asset kind: ${request.asset.kind}.`,
404
+ ...(context.tilesetGeometry
405
+ ? tilesetGenerationGeometryPromptLines(request.asset, context.tilesetGeometry, context.chromaKey, shouldRequestRgbaPng(request, context), !isTilesetAnimation && !request.references?.length)
406
+ : [
407
+ `Target canvas: ${dimensions.width}x${dimensions.height}. The output origin and every cell boundary must exactly match Reference 1.`
408
+ ])
409
+ ]
410
+ : [
411
+ "Create this as a clean 2D game asset sprite.",
412
+ `Asset kind: ${request.asset.kind}.`,
413
+ ...(context.tilesetGeometry
414
+ ? tilesetGenerationGeometryPromptLines(request.asset, context.tilesetGeometry, context.chromaKey, shouldRequestRgbaPng(request, context), !isTilesetAnimation && !request.references?.length)
415
+ : [`Target canvas: ${dimensions.width}x${dimensions.height}.`])
416
+ ]));
417
+ if (request.asset.kind === "tileset" && shouldRequestRgbaPng(request, context)) {
418
+ lines.push("Decide independently for each tile whether its artwork should be opaque edge-to-edge or should contain transparent pixels, based on what that tile depicts.", `For any tile that needs transparency, encode every transparent or empty pixel with the exact flat chroma-key color ${hexColor(context.chromaKey)}. This color is the transparency marker and will be removed after generation.`, `Never use any other matte, background, checkerboard, or substitute transparency color. Do not use ${hexColor(context.chromaKey)} in visible tile artwork.`, "For a tile that does not need transparency, fill the cell edge-to-edge and do not use the chroma-key color. Do not add labels, borders, or shadows outside tiles, and do not treat the entire multi-tile canvas as one centered presentation card.");
419
+ }
420
+ else if (shouldRequestRgbaPng(request, context)) {
233
421
  lines.push("Use a transparent background, centered subject, no text, no watermark, no cast shadow, no floor shadow, no ground plane, no reflection. Keep the sprite readable through its shape and pose; do not darken or recolor the character to create contrast.", "Clean it into a real RGBA PNG: the final game asset needs actual alpha transparency, not white, black, gray, checkerboard, or any matte color.", `For local transparency processing, render every background and empty padding pixel as the flat chroma-key color ${hexColor(context.chromaKey)}. Do not use that exact chroma-key color inside the game asset itself.`, "Keep the asset edges crisp against the chroma-key background so it can be removed cleanly.");
234
422
  }
423
+ else if (context.tilesetGeometry) {
424
+ lines.push("Make every usable tile cell opaque edge-to-edge as required by its tile instruction. Never extend tile artwork into another cell or into the temporary outer padding.");
425
+ }
235
426
  else {
236
427
  lines.push("Fill the entire canvas edge-to-edge with an opaque image. Do not use transparency, empty padding, borders, text, or watermarks.");
237
428
  }
238
- if (request.asset.frameGrid) {
429
+ if (request.asset.kind === "tileset") {
430
+ if (!isTilesetAnimation && !context.tilesetGeometry) {
431
+ lines.push(...tilesetContractPromptLines(request.asset, false));
432
+ }
433
+ }
434
+ else if (request.asset.frameGrid) {
239
435
  const frameCount = request.asset.frameGrid.frameCount ??
240
436
  request.asset.frameGrid.columns * request.asset.frameGrid.rows;
241
437
  const rowLabel = request.asset.frameGrid.rows === 1 ? "row" : "rows";
242
438
  const columnLabel = request.asset.frameGrid.columns === 1 ? "column" : "columns";
243
- lines.push(`Spritesheet contract: exactly ${frameCount} animation frames arranged in the first ${frameCount} cells of a fixed grid with ${request.asset.frameGrid.columns} ${columnLabel} and ${request.asset.frameGrid.rows} ${rowLabel}.`, `The final image must be one ${dimensions.width}x${dimensions.height} spritesheet, not separate images and not a different grid.`, `Use one frame per grid cell, ordered left-to-right then top-to-bottom. If the grid has more cells than ${frameCount}, leave the extra trailing cells fully transparent and empty.`, `Each cell is exactly ${request.asset.frameGrid.frameWidth}x${request.asset.frameGrid.frameHeight}; do not merge cells, crop cells, add extra frames beyond ${frameCount}, or change the grid layout.`, `Cell rectangles are: ${gridCellRectangles(request)}.`, `Frame centers must be at these cell centers: ${gridCellCenters(request)}.`, "Each grid cell must contain exactly one complete frame of the subject. Do not place a nested spritesheet, turnaround sheet, contact sheet, labels, thumbnails, or multiple mini-poses inside any single cell.", "Keep the character centered at a consistent scale in every cell, leaving transparent padding inside the cell.", "The grid layout is mandatory even if the animation would look nicer in another arrangement.");
439
+ lines.push(`Spritesheet contract: exactly ${frameCount} animation frames arranged in the first ${frameCount} cells of a fixed grid with ${request.asset.frameGrid.columns} ${columnLabel} and ${request.asset.frameGrid.rows} ${rowLabel}.`, `The final image must be one ${dimensions.width}x${dimensions.height} spritesheet, not separate images and not a different grid.`, `Use one frame per grid cell, ordered left-to-right then top-to-bottom.`, `Each cell is exactly ${request.asset.frameGrid.frameWidth}x${request.asset.frameGrid.frameHeight}; do not merge cells, crop cells, add extra frames beyond ${frameCount}, or change the grid layout.`, `Cell rectangles are: ${gridCellRectangles(request)}.`, `Frame centers must be at these cell centers: ${gridCellCenters(request)}.`, "Each grid cell must contain exactly one complete frame of the subject. Do not place a nested spritesheet, turnaround sheet, contact sheet, labels, thumbnails, or multiple mini-poses inside any single cell.", "The grid layout is mandatory even if the animation would look nicer in another arrangement.");
440
+ if (shouldRequestRgbaPng(request, context)) {
441
+ lines.push(`If the grid has more cells than ${frameCount}, leave the extra trailing cells fully transparent and empty.`, "Keep the character centered at a consistent scale in every cell, leaving transparent padding inside the cell.");
442
+ }
443
+ else {
444
+ lines.push("Every frame cell must be fully opaque from edge to edge with no alpha padding and no checkerboard pattern.", "Preserve the referenced background, framing, and stationary scenery in every frame; animate only the motion requested by the asset prompt.", `If the grid has more cells than ${frameCount}, fill the extra trailing cells with the same opaque background and no animation subject.`);
445
+ }
244
446
  }
245
447
  else {
246
448
  if (shouldRequestRgbaPng(request, context)) {
@@ -250,17 +452,213 @@ function gameAssetPrompt(request, context) {
250
452
  lines.push("Single-image background contract: create exactly one continuous scene covering the complete canvas.", "Do not create a spritesheet, contact sheet, sequence, grid, panels, labels, frame divisions, or isolated cutout sprite.");
251
453
  }
252
454
  }
253
- if (request.references?.length) {
455
+ if (request.references?.length && request.asset.kind === "tileset" && isTilesetAnimation) {
456
+ lines.push("Reference 1 always controls sheet geometry and unchanged artwork. Use later non-style references only to understand chronological motion; never copy a shifted grid, changed cell boundary, or unintended redraw from them.", "Keep Reference 1's top-left origin and exact tile rectangles. Change only pixels explicitly requested by the animation tile instructions.");
457
+ }
458
+ else if (request.references?.length && request.asset.kind === "tileset") {
459
+ lines.push("The first non-style reference image is the immutable base tileset. Any additional non-style references are prior frames in this candidate's animation sequence, ordered chronologically by filename.", "Preserve the exact base sheet composition: every tile must keep its index, coordinates, dimensions, palette, shape identity, edge connections, and pixel alignment. Change only the pixels required by the requested animation phase; keep all other tiles identical.");
460
+ }
461
+ else if (request.references?.length) {
254
462
  lines.push("The generated asset must depict the same exact character as the provided character reference image. Character reference filenames do not begin with style-reference-. Preserve the silhouette, body proportions, face or head shape, colors, materials, markings, costume, and distinctive details. Do not redesign the character, change species, swap materials, alter the palette, or simplify it into a different character.", ...referenceLockPromptLines(request.references), "For animation spritesheets, every frame must show that same exact character performing only the requested motion or state change.");
255
463
  }
256
464
  if (request.stylePrompt || request.styleReferences?.length) {
257
465
  lines.push("Style guide: apply the following visual language consistently without copying the subject matter, characters, composition, or objects from the style reference images.", request.stylePrompt?.trim() || "Match the visual style shown by the style reference images.", "Style reference image filenames begin with style-reference-.", "Use the style references only for rendering style: line quality, shape language, palette character, shading, material treatment, texture, and level of detail. The asset prompt and character references still determine what the asset depicts.");
258
466
  }
259
- if (context.variation) {
260
- lines.push(`Variation seed: ${context.variation}. Use this seed to make this option visually distinct from sibling options, not a near-duplicate. Vary the animation timing, pose rhythm, secondary motion, and effect shape while preserving the asset brief, frame grid, transparency instructions, and same exact character identity.`, variationDirectionPromptLine(context.variationIndex ?? 0));
467
+ if (context.variation &&
468
+ request.asset.kind === "tileset" &&
469
+ isTilesetAnimation) {
470
+ lines.push(`Variation seed: ${context.variation}. Use it only to choose a coherent motion treatment for this candidate; never vary tile identity, sheet layout, cell alignment, palette, or indices.`, variationDirectionPromptLine(context.variationIndex ?? 0));
471
+ }
472
+ else if (context.variation && request.asset.kind === "tileset") {
473
+ lines.push(`Variation seed: ${context.variation}. Use it only to choose a coherent visual treatment for this complete base tileset candidate; never vary tile identity, sheet layout, cell alignment, scale, or indices.`, tilesetBaseVariationDirectionPromptLine(context.variationIndex ?? 0));
474
+ }
475
+ else if (context.variation) {
476
+ lines.push(`Variation seed: ${context.variation}. Use this seed to make this option visually distinct from sibling options, not a near-duplicate. Vary the animation timing, pose rhythm, secondary motion, and effect shape while preserving the asset brief, frame grid, background instructions, and same exact character identity.`, variationDirectionPromptLine(context.variationIndex ?? 0));
477
+ }
478
+ return lines.join("\n");
479
+ }
480
+ function tilesetBaseVariationDirectionPromptLine(index) {
481
+ const variants = [
482
+ "Base tileset variation direction: explore a distinct cohesive palette nuance and material treatment across all tiles. Never animate a tile, create alternate phases, or place multiple depictions inside one cell.",
483
+ "Base tileset variation direction: explore a distinct cohesive shape language and detail distribution across all tiles. Never animate a tile, create alternate phases, or place multiple depictions inside one cell.",
484
+ "Base tileset variation direction: explore a distinct cohesive lighting, shading, and texture treatment across all tiles. Never animate a tile, create alternate phases, or place multiple depictions inside one cell.",
485
+ "Base tileset variation direction: explore a distinct cohesive pixel treatment and silhouette character across all tiles. Never animate a tile, create alternate phases, or place multiple depictions inside one cell."
486
+ ];
487
+ return variants[index % variants.length];
488
+ }
489
+ export function tilesetBasePrompt(asset) {
490
+ const lines = structuredTilesetPromptLines(asset);
491
+ if (!lines.length) {
492
+ throw new Error(`AI asset "${asset.id}" must be a tileset with per-tile prompts to build a structured tileset prompt.`);
261
493
  }
262
494
  return lines.join("\n");
263
495
  }
496
+ function assetBriefForModel(request, prompt) {
497
+ if (request.asset.kind === "tileset" &&
498
+ request.asset.tileset?.tiles !== undefined &&
499
+ request.prompt === undefined) {
500
+ return undefined;
501
+ }
502
+ return prompt.trim() || undefined;
503
+ }
504
+ function structuredTilesetPromptLines(asset) {
505
+ const tileset = asset.tileset;
506
+ const dimensions = asset.dimensions;
507
+ if (!tileset?.tiles || !dimensions)
508
+ return [];
509
+ const tileCount = tileset.tileCount ?? tileset.columns * tileset.rows;
510
+ const margin = tileset.margin ?? 0;
511
+ const spacing = tileset.spacing ?? 0;
512
+ const gridSpacing = margin === 0 && spacing === 0
513
+ ? "with no margin or spacing"
514
+ : `with ${margin === 0 ? "no outer margin" : `${margin}px outer margin`} and ${spacing === 0 ? "no spacing" : `${spacing}px spacing between tiles`}`;
515
+ return [
516
+ `Create a deterministic hand-authored tileset whose final tile resolution is ${tileset.tileWidth}×${tileset.tileHeight} pixels.`,
517
+ `The final post-processed asset is one ${dimensions.width}×${dimensions.height} image arranged as a ${tileset.columns}-column × ${tileset.rows}-row grid ${gridSpacing}.`,
518
+ "Read tiles left-to-right, then top-to-bottom.",
519
+ "Use one cohesive visual style, palette, scale, lighting, perspective, and pixel treatment across every tile.",
520
+ `Draw exactly these ${tileCount} tiles in this exact order:`,
521
+ ...tileset.tiles.map((tile, index) => `Tile ${index + 1} — ${tile.prompt.trim()}`)
522
+ ];
523
+ }
524
+ function modelTilesetArtworkPromptLines(asset, chromaKey) {
525
+ const tileset = asset.tileset;
526
+ if (!tileset?.tiles)
527
+ return [];
528
+ const tileCount = tileset.tileCount ?? tileset.columns * tileset.rows;
529
+ const chroma = chromaKey ? hexColor(chromaKey) : undefined;
530
+ return [
531
+ "Create one deterministic hand-authored tileset in the isolated generation-canvas tile slots declared below.",
532
+ "Read tile slots left-to-right, then top-to-bottom.",
533
+ "Use one cohesive visual style, palette, scale, lighting, perspective, and pixel treatment across every tile.",
534
+ ...(chroma
535
+ ? [
536
+ `Raster transparency encoding for every numbered tile: "transparent" or "empty" describes game alpha after post-processing. In the raster you return, paint every such pixel the exact flat chroma color ${chroma}. Never draw a checkerboard transparency preview, white/gray squares, actual-alpha preview pattern, or any other matte.`
537
+ ]
538
+ : []),
539
+ `Draw exactly these ${tileCount} tiles in this exact order:`,
540
+ ...tileset.tiles.map((tile, index) => (`Tile ${index + 1} — ${tile.prompt.trim()}` +
541
+ (chroma
542
+ ? ` Encoding rule for Tile ${index + 1}: if any pixel should be transparent or empty, paint that pixel only ${chroma}; never represent transparency with a checkerboard or another background.`
543
+ : "")))
544
+ ];
545
+ }
546
+ function tilesetContractPromptLines(asset, includeStructuredPrompt = true) {
547
+ const tileset = asset.tileset;
548
+ const dimensions = asset.dimensions;
549
+ if (!tileset || !dimensions)
550
+ return [];
551
+ const tileCount = tileset.tileCount ?? tileset.columns * tileset.rows;
552
+ const margin = tileset.margin ?? 0;
553
+ const spacing = tileset.spacing ?? 0;
554
+ const structuredPrompt = structuredTilesetPromptLines(asset);
555
+ const geometryPrompt = includeStructuredPrompt && structuredPrompt.length
556
+ ? structuredPrompt
557
+ : [
558
+ `Logical final-sheet geometry after server crop and downsampling: one ${dimensions.width}x${dimensions.height} sheet with ${tileset.columns} columns and ${tileset.rows} rows.`,
559
+ `At final game resolution every tile cell is exactly ${tileset.tileWidth}x${tileset.tileHeight}, with ${margin}px outer margin and ${spacing}px spacing. The sheet contains ${tileCount} usable tiles in row-major order.`,
560
+ `Logical final-resolution usable tile rectangles: ${tilesetTileRectangles(asset)}.`
561
+ ];
562
+ return [
563
+ ...geometryPrompt,
564
+ "Each usable cell contains exactly one tile filling its declared cell at the scale appropriate to that coordinate space. Do not create animation panels, nested sheets, thumbnails, labels, tile numbers, visible grid lines, gutters beyond the declared spacing, or presentation padding.",
565
+ "Tile indices and cell coordinates are immutable. Keep artwork pixel-aligned to cell boundaries and preserve seamless edge connections between compatible terrain tiles.",
566
+ `If the grid has more than ${tileCount} cells, leave only the trailing unused cells empty while preserving the full declared sheet geometry.`
567
+ ];
568
+ }
569
+ function tilesetGenerationGeometryPromptLines(asset, geometry, chromaKey, transparentPadding, requireSafeContentInset) {
570
+ const tileset = asset.tileset;
571
+ if (!tileset)
572
+ return [];
573
+ const usableCells = geometry.cells.filter((cell) => cell.usable);
574
+ const unusedCells = geometry.unusedSlots;
575
+ const usableRectangles = usableCells.map((cell) => (`Tile ${cell.index + 1} [${tilesetSheetRectLabel(cell)}]`)).join("; ");
576
+ const safeRectangles = usableCells.map((cell) => {
577
+ const insetX = safeTilesetContentInset(cell.width);
578
+ const insetY = safeTilesetContentInset(cell.height);
579
+ return `Tile ${cell.index + 1} [${tilesetSheetRectLabel({
580
+ x: cell.x + insetX,
581
+ y: cell.y + insetY,
582
+ width: cell.width - insetX * 2,
583
+ height: cell.height - insetY * 2
584
+ })}]`;
585
+ }).join("; ");
586
+ const unusedRectangles = unusedCells.map((cell) => (`Packing slot ${cell.index + 1} [${tilesetSheetRectLabel(cell)}]`)).join("; ");
587
+ const rowLabel = geometry.generationRows === 1 ? "row" : "rows";
588
+ const columnLabel = geometry.generationColumns === 1 ? "column" : "columns";
589
+ const placementRegions = geometry.placementRegions.map((region) => (`Placement region ${region.index + 1} [${tilesetSheetRectLabel(region)}]`)).join("; ");
590
+ return [
591
+ `Actual returned raster canvas: ${geometry.canvas.width}x${geometry.canvas.height} pixels. These are the coordinates you must draw in.`,
592
+ `Temporary full-canvas placement grid: divide the entire raster into ${geometry.generationColumns} equal ${columnLabel} by ${geometry.generationRows} equal ${rowLabel}, covering the canvas edge-to-edge with no area outside the grid.`,
593
+ `Exact equal placement regions in immutable row-major order: ${placementRegions}.`,
594
+ "Placement regions are spatial guides only. They are not tile bounds, are not extracted by the server, and are not the final game-sheet layout. Do not scale artwork to fill a placement region.",
595
+ "Assign each numbered tile to the placement region with the same number. Center that tile's actual extracted rectangle inside its assigned equal placement region.",
596
+ `Exact actual extracted tile rectangles in immutable row-major order: ${usableRectangles}.`,
597
+ "The actual extracted tile rectangles remain separated from one another by hard temporary gutters inside the placement grid.",
598
+ "Only the actual extracted tile rectangles are drawable tile bounds. These rectangles are the only tile coordinates for this raster request; do not infer, draw, or reproduce a second smaller logical sheet or any alternate coordinate system.",
599
+ requireSafeContentInset
600
+ ? "Put exactly one requested tile in each usable rectangle. Keep every visible pixel wholly inside its own rectangle. Edge-to-edge opaque terrain must fill only its own rectangle. Every isolated object that uses transparency must be complete, centered, and surrounded by empty padding. Never bridge two slots or continue artwork through a gutter."
601
+ : "Put exactly one requested tile in each usable rectangle and preserve referenced artwork at its existing scale and position. Keep every visible pixel wholly inside its own rectangle. Never bridge two slots or continue artwork through a gutter.",
602
+ `Fill every pixel that is not inside an actual usable extracted tile rectangle with the exact flat hard-gutter color ${hexColor(chromaKey)}. This explicitly includes the remainder of every equal placement region, every inter-tile gap, and all outer padding around extracted rectangles. Put no artwork, shadow, outline, texture, or antialiasing there.`,
603
+ "The server extracts each tile rectangle independently and composes the final game sheet in row-major order. Any pixel drawn outside its rectangle is irretrievably discarded with the temporary gutter, so keep every tile complete and entirely within its own rectangle.",
604
+ "Compatible terrain tiles must match edge colors and connectors conceptually while remaining physically isolated by the temporary gutters. The gutters are discarded during composition and are not part of the game tiles.",
605
+ ...(transparentPadding && requireSafeContentInset
606
+ ? [
607
+ `For any tile that both needs transparency and depicts an isolated, non-connecting object or cutout, keep every visible pixel strictly inside its centered safe-content rectangle: ${safeRectangles}. The complete silhouette must not touch or cross the safe-content rectangle edge.`,
608
+ "A transparent terrain, connector, wall, corner, or overlay that is explicitly meant to meet a tile edge is exempt from the safe-content inset on that required edge, but it must never cross its outer tile rectangle.",
609
+ `Within an isolated transparent object or cutout tile, fill every pixel outside the visible silhouette—including the entire band between its safe-content rectangle and its outer tile rectangle—with only the exact chroma-key color ${hexColor(chromaKey)}; the server converts it to transparency.`
610
+ ]
611
+ : []),
612
+ ...(unusedRectangles
613
+ ? [
614
+ `Leave these unused generation-canvas slots empty and filled only with the hard-gutter color ${hexColor(chromaKey)}: ${unusedRectangles}.`
615
+ ]
616
+ : [])
617
+ ];
618
+ }
619
+ function safeTilesetContentInset(size) {
620
+ return Math.min(Math.max(1, Math.floor(size / 8)), Math.max(0, Math.floor((size - 1) / 2)));
621
+ }
622
+ function tilesetOutputPadding(transparent, chromaKey) {
623
+ return {
624
+ color: transparent ? chromaKey : OPAQUE_TILESET_PADDING,
625
+ transparent
626
+ };
627
+ }
628
+ function tilesetTileRectangle(asset, index) {
629
+ const tileset = asset.tileset;
630
+ if (!tileset)
631
+ return `Tile ${index + 1}`;
632
+ const margin = tileset.margin ?? 0;
633
+ const spacing = tileset.spacing ?? 0;
634
+ const column = index % tileset.columns;
635
+ const row = Math.floor(index / tileset.columns);
636
+ const x1 = margin + column * (tileset.tileWidth + spacing);
637
+ const y1 = margin + row * (tileset.tileHeight + spacing);
638
+ const x2 = x1 + tileset.tileWidth - 1;
639
+ const y2 = y1 + tileset.tileHeight - 1;
640
+ return `Tile ${index + 1} [x=${x1}-${x2}, y=${y1}-${y2}]`;
641
+ }
642
+ function tilesetTileRectangles(asset) {
643
+ const tileset = asset.tileset;
644
+ if (!tileset)
645
+ return "";
646
+ const tileCount = tileset.tileCount ?? tileset.columns * tileset.rows;
647
+ return Array.from({ length: tileCount }, (_, index) => tilesetTileRectangle(asset, index))
648
+ .join("; ");
649
+ }
650
+ function sanitizeReferenceName(value) {
651
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-");
652
+ }
653
+ function extensionFromMimeType(mimeType) {
654
+ if (mimeType === "image/webp")
655
+ return "webp";
656
+ if (mimeType === "image/jpeg")
657
+ return "jpg";
658
+ if (mimeType === "image/svg+xml")
659
+ return "svg";
660
+ return "png";
661
+ }
264
662
  function createVariationSeed(index) {
265
663
  return `option-${index + 1}-${randomUUID()}`;
266
664
  }
@@ -296,24 +694,26 @@ function gridCellRectangles(request) {
296
694
  }
297
695
  return rectangles.join("; ");
298
696
  }
299
- async function createImageGeneration(apiKey, body) {
697
+ async function createImageGeneration(apiKey, body, signal) {
300
698
  return fetch("https://api.openai.com/v1/images/generations", {
301
699
  method: "POST",
302
700
  headers: {
303
701
  Authorization: `Bearer ${apiKey}`,
304
702
  "Content-Type": "application/json"
305
703
  },
306
- body: JSON.stringify(body)
704
+ body: JSON.stringify(body),
705
+ signal
307
706
  });
308
707
  }
309
- async function createImageEdit(apiKey, body, references) {
708
+ async function createImageEdit(apiKey, body, references, signal) {
310
709
  const form = new FormData();
311
710
  for (const [key, value] of Object.entries(body)) {
312
711
  if (value !== undefined) {
313
712
  form.append(key, String(value));
314
713
  }
315
714
  }
316
- for (const reference of references) {
715
+ for (const sourceReference of references) {
716
+ const reference = await normalizeOpenAiImageReference(sourceReference, signal);
317
717
  form.append("image[]", new Blob([arrayBufferFromBytes(reference.image)], { type: reference.mimeType }), reference.fileName);
318
718
  }
319
719
  return fetch("https://api.openai.com/v1/images/edits", {
@@ -321,9 +721,29 @@ async function createImageEdit(apiKey, body, references) {
321
721
  headers: {
322
722
  Authorization: `Bearer ${apiKey}`
323
723
  },
324
- body: form
724
+ body: form,
725
+ signal
325
726
  });
326
727
  }
728
+ const rasterizedSvgReferenceCache = new WeakMap();
729
+ async function normalizeOpenAiImageReference(reference, signal) {
730
+ if (reference.mimeType.split(";", 1)[0]?.trim().toLowerCase() !== "image/svg+xml") {
731
+ return reference;
732
+ }
733
+ signal?.throwIfAborted();
734
+ let rasterized = rasterizedSvgReferenceCache.get(reference.image);
735
+ if (!rasterized) {
736
+ rasterized = rasterizeSvgToPng(reference.image);
737
+ rasterizedSvgReferenceCache.set(reference.image, rasterized);
738
+ }
739
+ const image = await rasterized;
740
+ signal?.throwIfAborted();
741
+ return {
742
+ image,
743
+ mimeType: "image/png",
744
+ fileName: `${reference.fileName.replace(/\.[^.]+$/, "") || "reference"}.png`
745
+ };
746
+ }
327
747
  async function readImagePayload(response) {
328
748
  return await response.json();
329
749
  }