@ai-game-assets/dev 0.6.0 → 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.
- package/dist/asset-store.d.ts +27 -1
- package/dist/asset-store.d.ts.map +1 -1
- package/dist/asset-store.js +314 -8
- package/dist/asset-store.js.map +1 -1
- package/dist/build-manifest.d.ts.map +1 -1
- package/dist/build-manifest.js +14 -0
- package/dist/build-manifest.js.map +1 -1
- package/dist/image-generation-sizes.d.ts +7 -0
- package/dist/image-generation-sizes.d.ts.map +1 -0
- package/dist/image-generation-sizes.js +15 -0
- package/dist/image-generation-sizes.js.map +1 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/provider-image-processing.d.ts +4 -1
- package/dist/provider-image-processing.d.ts.map +1 -1
- package/dist/provider-image-processing.js +129 -7
- package/dist/provider-image-processing.js.map +1 -1
- package/dist/provider.d.ts +34 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +444 -45
- package/dist/provider.js.map +1 -1
- package/dist/server.d.ts +78 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +283 -23
- package/dist/server.js.map +1 -1
- package/dist/tileset-sheet-processing.d.ts +49 -0
- package/dist/tileset-sheet-processing.d.ts.map +1 -0
- package/dist/tileset-sheet-processing.js +357 -0
- package/dist/tileset-sheet-processing.js.map +1 -0
- package/package.json +4 -3
package/dist/provider.js
CHANGED
|
@@ -1,5 +1,100 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { alignSpriteSheetFrames, 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) {
|
|
@@ -21,11 +116,11 @@ export function createOpenAiImageProvider(options = {}) {
|
|
|
21
116
|
model: request.settings?.model ?? options.svgModel ?? process.env.OPENAI_SVG_MODEL ?? "gpt-5",
|
|
22
117
|
prompt,
|
|
23
118
|
count: request.count ?? 1,
|
|
24
|
-
requestedBackground
|
|
119
|
+
requestedBackground,
|
|
120
|
+
signal: request.signal
|
|
25
121
|
}, onOption);
|
|
26
122
|
}
|
|
27
123
|
const outputFormat = normalizeOutputFormat(requestedFormat);
|
|
28
|
-
const background = normalizeBackgroundForModel(model, requestedBackground);
|
|
29
124
|
const chromaKey = selectChromaKey(request);
|
|
30
125
|
const postprocessTransparency = shouldPostprocessTransparency(request, {
|
|
31
126
|
prompt,
|
|
@@ -33,11 +128,34 @@ export function createOpenAiImageProvider(options = {}) {
|
|
|
33
128
|
outputFormat,
|
|
34
129
|
requestedBackground
|
|
35
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;
|
|
36
141
|
const frameAlignment = request.settings?.frameAlignment ??
|
|
37
142
|
request.asset.settings?.frameAlignment ??
|
|
38
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 ?? [];
|
|
39
157
|
const allReferences = [
|
|
40
|
-
...
|
|
158
|
+
...assetReferences,
|
|
41
159
|
...(request.styleReferences ?? []).map((reference, index) => ({
|
|
42
160
|
...reference,
|
|
43
161
|
fileName: `style-reference-${index + 1}-${reference.fileName}`
|
|
@@ -54,10 +172,11 @@ export function createOpenAiImageProvider(options = {}) {
|
|
|
54
172
|
chromaKey,
|
|
55
173
|
variation: count > 1 ? createVariationSeed(index) : undefined,
|
|
56
174
|
variationIndex: index,
|
|
57
|
-
variationCount: count
|
|
175
|
+
variationCount: count,
|
|
176
|
+
tilesetGeometry
|
|
58
177
|
}),
|
|
59
178
|
n: 1,
|
|
60
|
-
size:
|
|
179
|
+
size: generationSize,
|
|
61
180
|
quality: request.settings?.quality ??
|
|
62
181
|
request.asset.settings?.quality ??
|
|
63
182
|
options.quality ??
|
|
@@ -67,9 +186,10 @@ export function createOpenAiImageProvider(options = {}) {
|
|
|
67
186
|
moderation: request.settings?.moderation ?? request.asset.settings?.moderation
|
|
68
187
|
}));
|
|
69
188
|
const generatedByIndex = await Promise.all(requestBodies.map(async (requestBody, index) => {
|
|
189
|
+
request.signal?.throwIfAborted();
|
|
70
190
|
const response = allReferences.length
|
|
71
|
-
? await createImageEdit(apiKey, requestBody, allReferences)
|
|
72
|
-
: await createImageGeneration(apiKey, requestBody);
|
|
191
|
+
? await createImageEdit(apiKey, requestBody, allReferences, request.signal)
|
|
192
|
+
: await createImageGeneration(apiKey, requestBody, request.signal);
|
|
73
193
|
if (!response.ok) {
|
|
74
194
|
const body = await response.text();
|
|
75
195
|
throw new Error(`OpenAI image generation failed (${response.status}): ${openAiErrorMessage(body)}`);
|
|
@@ -80,11 +200,22 @@ export function createOpenAiImageProvider(options = {}) {
|
|
|
80
200
|
if (!item.b64_json) {
|
|
81
201
|
throw new Error("OpenAI image generation response did not include b64_json.");
|
|
82
202
|
}
|
|
203
|
+
request.signal?.throwIfAborted();
|
|
83
204
|
const image = Buffer.from(item.b64_json, "base64");
|
|
84
|
-
const resizedImage =
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
|
87
214
|
: resizedImage;
|
|
215
|
+
const processedImage = request.asset.kind !== "tileset" &&
|
|
216
|
+
postprocessTransparency && request.asset.frameGrid && frameAlignment === "center"
|
|
217
|
+
? alignSpriteSheetFrames(transparencyProcessedImage, request.asset.frameGrid)
|
|
218
|
+
: transparencyProcessedImage;
|
|
88
219
|
const option = {
|
|
89
220
|
image: processedImage,
|
|
90
221
|
mimeType: mimeTypeFromOutputFormat(outputFormat),
|
|
@@ -93,16 +224,18 @@ export function createOpenAiImageProvider(options = {}) {
|
|
|
93
224
|
revisedPrompt: item.revised_prompt,
|
|
94
225
|
dimensions,
|
|
95
226
|
frameGrid: request.asset.frameGrid,
|
|
227
|
+
tileset: request.asset.tileset,
|
|
96
228
|
settings: {
|
|
97
229
|
...request.asset.settings,
|
|
98
230
|
...request.settings,
|
|
99
231
|
model,
|
|
100
|
-
background,
|
|
232
|
+
background: persistedBackground,
|
|
101
233
|
format: outputFormat === "jpeg" ? "jpg" : outputFormat,
|
|
102
234
|
...(postprocessTransparency && request.asset.frameGrid ? { frameAlignment } : {})
|
|
103
235
|
}
|
|
104
236
|
};
|
|
105
237
|
generatedForRequest.push(option);
|
|
238
|
+
request.signal?.throwIfAborted();
|
|
106
239
|
await onOption?.(option, index);
|
|
107
240
|
}
|
|
108
241
|
return generatedForRequest;
|
|
@@ -121,6 +254,7 @@ async function generateSvgAssets(request, context, onOption) {
|
|
|
121
254
|
}))
|
|
122
255
|
];
|
|
123
256
|
return Promise.all(Array.from({ length: context.count }, async (_, index) => {
|
|
257
|
+
context.signal?.throwIfAborted();
|
|
124
258
|
const prompt = svgAssetPrompt(request, {
|
|
125
259
|
prompt: context.prompt,
|
|
126
260
|
requestedBackground: context.requestedBackground,
|
|
@@ -132,12 +266,13 @@ async function generateSvgAssets(request, context, onOption) {
|
|
|
132
266
|
model: context.model,
|
|
133
267
|
prompt,
|
|
134
268
|
references
|
|
135
|
-
});
|
|
269
|
+
}, context.signal);
|
|
136
270
|
if (!response.ok) {
|
|
137
271
|
const body = await response.text();
|
|
138
272
|
throw new Error(`OpenAI SVG generation failed (${response.status}): ${openAiErrorMessage(body)}`);
|
|
139
273
|
}
|
|
140
274
|
const payload = await response.json();
|
|
275
|
+
context.signal?.throwIfAborted();
|
|
141
276
|
const svg = normalizeSvgOutput(extractResponseText(payload), dimensions);
|
|
142
277
|
const option = {
|
|
143
278
|
image: Buffer.from(svg, "utf8"),
|
|
@@ -146,6 +281,7 @@ async function generateSvgAssets(request, context, onOption) {
|
|
|
146
281
|
model: context.model,
|
|
147
282
|
dimensions,
|
|
148
283
|
frameGrid: request.asset.frameGrid,
|
|
284
|
+
tileset: request.asset.tileset,
|
|
149
285
|
settings: {
|
|
150
286
|
...request.asset.settings,
|
|
151
287
|
...request.settings,
|
|
@@ -154,30 +290,37 @@ async function generateSvgAssets(request, context, onOption) {
|
|
|
154
290
|
format: "svg"
|
|
155
291
|
}
|
|
156
292
|
};
|
|
293
|
+
context.signal?.throwIfAborted();
|
|
157
294
|
await onOption?.(option, index);
|
|
158
295
|
return option;
|
|
159
296
|
}));
|
|
160
297
|
}
|
|
161
298
|
function svgAssetPrompt(request, context) {
|
|
162
299
|
const dimensions = requireAssetDimensions(request.asset);
|
|
163
|
-
const lines = [
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.");
|
|
174
311
|
if (request.stylePrompt?.trim()) {
|
|
175
312
|
lines.push(`Style guide: ${request.stylePrompt.trim()}`);
|
|
176
313
|
}
|
|
177
|
-
if (referencesNeedIdentity(request)) {
|
|
314
|
+
if (referencesNeedIdentity(request) && request.asset.kind !== "tileset") {
|
|
178
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.");
|
|
179
316
|
}
|
|
180
|
-
if (request.asset.
|
|
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) {
|
|
181
324
|
const frameCount = request.asset.frameGrid.frameCount ??
|
|
182
325
|
request.asset.frameGrid.columns * request.asset.frameGrid.rows;
|
|
183
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)}.`);
|
|
@@ -202,9 +345,10 @@ function svgAssetPrompt(request, context) {
|
|
|
202
345
|
function referencesNeedIdentity(request) {
|
|
203
346
|
return Boolean(request.references?.length);
|
|
204
347
|
}
|
|
205
|
-
async function createSvgResponse(apiKey, body) {
|
|
348
|
+
async function createSvgResponse(apiKey, body, signal) {
|
|
206
349
|
const content = [{ type: "input_text", text: body.prompt }];
|
|
207
|
-
for (const
|
|
350
|
+
for (const sourceReference of body.references) {
|
|
351
|
+
const reference = await normalizeOpenAiImageReference(sourceReference, signal);
|
|
208
352
|
if (!isSupportedResponseImageMimeType(reference.mimeType))
|
|
209
353
|
continue;
|
|
210
354
|
content.push({
|
|
@@ -226,7 +370,8 @@ async function createSvgResponse(apiKey, body) {
|
|
|
226
370
|
content
|
|
227
371
|
}
|
|
228
372
|
]
|
|
229
|
-
})
|
|
373
|
+
}),
|
|
374
|
+
signal
|
|
230
375
|
});
|
|
231
376
|
}
|
|
232
377
|
function isSupportedResponseImageMimeType(mimeType) {
|
|
@@ -237,20 +382,56 @@ function isSupportedResponseImageMimeType(mimeType) {
|
|
|
237
382
|
}
|
|
238
383
|
export function gameAssetPrompt(request, context) {
|
|
239
384
|
const dimensions = requireAssetDimensions(request.asset);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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)) {
|
|
248
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.");
|
|
249
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
|
+
}
|
|
250
426
|
else {
|
|
251
427
|
lines.push("Fill the entire canvas edge-to-edge with an opaque image. Do not use transparency, empty padding, borders, text, or watermarks.");
|
|
252
428
|
}
|
|
253
|
-
if (request.asset.
|
|
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) {
|
|
254
435
|
const frameCount = request.asset.frameGrid.frameCount ??
|
|
255
436
|
request.asset.frameGrid.columns * request.asset.frameGrid.rows;
|
|
256
437
|
const rowLabel = request.asset.frameGrid.rows === 1 ? "row" : "rows";
|
|
@@ -271,17 +452,213 @@ export function gameAssetPrompt(request, context) {
|
|
|
271
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.");
|
|
272
453
|
}
|
|
273
454
|
}
|
|
274
|
-
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) {
|
|
275
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.");
|
|
276
463
|
}
|
|
277
464
|
if (request.stylePrompt || request.styleReferences?.length) {
|
|
278
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.");
|
|
279
466
|
}
|
|
280
|
-
if (context.variation
|
|
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) {
|
|
281
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));
|
|
282
477
|
}
|
|
283
478
|
return lines.join("\n");
|
|
284
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.`);
|
|
493
|
+
}
|
|
494
|
+
return lines.join("\n");
|
|
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
|
+
}
|
|
285
662
|
function createVariationSeed(index) {
|
|
286
663
|
return `option-${index + 1}-${randomUUID()}`;
|
|
287
664
|
}
|
|
@@ -317,24 +694,26 @@ function gridCellRectangles(request) {
|
|
|
317
694
|
}
|
|
318
695
|
return rectangles.join("; ");
|
|
319
696
|
}
|
|
320
|
-
async function createImageGeneration(apiKey, body) {
|
|
697
|
+
async function createImageGeneration(apiKey, body, signal) {
|
|
321
698
|
return fetch("https://api.openai.com/v1/images/generations", {
|
|
322
699
|
method: "POST",
|
|
323
700
|
headers: {
|
|
324
701
|
Authorization: `Bearer ${apiKey}`,
|
|
325
702
|
"Content-Type": "application/json"
|
|
326
703
|
},
|
|
327
|
-
body: JSON.stringify(body)
|
|
704
|
+
body: JSON.stringify(body),
|
|
705
|
+
signal
|
|
328
706
|
});
|
|
329
707
|
}
|
|
330
|
-
async function createImageEdit(apiKey, body, references) {
|
|
708
|
+
async function createImageEdit(apiKey, body, references, signal) {
|
|
331
709
|
const form = new FormData();
|
|
332
710
|
for (const [key, value] of Object.entries(body)) {
|
|
333
711
|
if (value !== undefined) {
|
|
334
712
|
form.append(key, String(value));
|
|
335
713
|
}
|
|
336
714
|
}
|
|
337
|
-
for (const
|
|
715
|
+
for (const sourceReference of references) {
|
|
716
|
+
const reference = await normalizeOpenAiImageReference(sourceReference, signal);
|
|
338
717
|
form.append("image[]", new Blob([arrayBufferFromBytes(reference.image)], { type: reference.mimeType }), reference.fileName);
|
|
339
718
|
}
|
|
340
719
|
return fetch("https://api.openai.com/v1/images/edits", {
|
|
@@ -342,9 +721,29 @@ async function createImageEdit(apiKey, body, references) {
|
|
|
342
721
|
headers: {
|
|
343
722
|
Authorization: `Bearer ${apiKey}`
|
|
344
723
|
},
|
|
345
|
-
body: form
|
|
724
|
+
body: form,
|
|
725
|
+
signal
|
|
346
726
|
});
|
|
347
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
|
+
}
|
|
348
747
|
async function readImagePayload(response) {
|
|
349
748
|
return await response.json();
|
|
350
749
|
}
|