@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.
- 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/audio-provider.d.ts +1 -0
- package/dist/audio-provider.d.ts.map +1 -1
- package/dist/audio-provider.js +23 -1
- package/dist/audio-provider.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 +5 -1
- package/dist/provider-image-processing.d.ts.map +1 -1
- package/dist/provider-image-processing.js +278 -9
- package/dist/provider-image-processing.js.map +1 -1
- package/dist/provider.d.ts +45 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +477 -57
- 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 +339 -28
- 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 +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
|
-
...
|
|
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:
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
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)}
|
|
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 (
|
|
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
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|