@ai-game-assets/phaser 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.
Files changed (44) hide show
  1. package/dist/animations.d.ts +2 -0
  2. package/dist/animations.d.ts.map +1 -1
  3. package/dist/animations.js +4 -3
  4. package/dist/animations.js.map +1 -1
  5. package/dist/debug-client.d.ts +52 -2
  6. package/dist/debug-client.d.ts.map +1 -1
  7. package/dist/debug-client.js +66 -0
  8. package/dist/debug-client.js.map +1 -1
  9. package/dist/designer-support.d.ts +28 -3
  10. package/dist/designer-support.d.ts.map +1 -1
  11. package/dist/designer-support.js +552 -29
  12. package/dist/designer-support.js.map +1 -1
  13. package/dist/designer.d.ts +19 -2
  14. package/dist/designer.d.ts.map +1 -1
  15. package/dist/designer.js +1546 -134
  16. package/dist/designer.js.map +1 -1
  17. package/dist/first-drafts.js +21 -9
  18. package/dist/first-drafts.js.map +1 -1
  19. package/dist/index.d.ts +7 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/keys.d.ts +2 -0
  24. package/dist/keys.d.ts.map +1 -1
  25. package/dist/keys.js +6 -0
  26. package/dist/keys.js.map +1 -1
  27. package/dist/loader.d.ts.map +1 -1
  28. package/dist/loader.js +50 -3
  29. package/dist/loader.js.map +1 -1
  30. package/dist/phaser-types.d.ts +1 -0
  31. package/dist/phaser-types.d.ts.map +1 -1
  32. package/dist/runtime.d.ts +30 -2
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +235 -8
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/tileset-dialog.d.ts +121 -0
  37. package/dist/tileset-dialog.d.ts.map +1 -0
  38. package/dist/tileset-dialog.js +1594 -0
  39. package/dist/tileset-dialog.js.map +1 -0
  40. package/dist/tilesets.d.ts +13 -0
  41. package/dist/tilesets.d.ts.map +1 -0
  42. package/dist/tilesets.js +60 -0
  43. package/dist/tilesets.js.map +1 -0
  44. package/package.json +3 -2
package/dist/designer.js CHANGED
@@ -1,8 +1,39 @@
1
- import { linkedAnimationAssetIds, registerInGameDesignerPanel, resolveTargetAssetId, topLevelAiAssetIds } from "@ai-game-assets/core";
1
+ import { expandAiAssetIds, linkedAnimationAssetIds, registerInGameDesignerPanel, resolveTargetAssetId, topLevelAiAssetIds } from "@ai-game-assets/core";
2
2
  import { AiAssetDebugClient } from "./debug-client.js";
3
3
  import { ensureMissingAiAssetFirstDrafts } from "./first-drafts.js";
4
4
  import { createLocalDerivedOption, openDeriveDialog, referenceImageForCandidate } from "./derive-dialog.js";
5
- import { assetWithGeneratedGeometry, audioGenerationOverridesFromInputs, bindKeyboardCapture, canEditGenerationFormat, createDesignerElements, effectiveGenerationFormat, ensureDesignerStyles, errorMessage, generationOverridesFromInputs, hasStyleGuide, imageSourceToDataUrl, isAbortError, isAudioAsset, isSvgSource, isVoiceAsset, mimeTypeFromDataUrl, normalizeAssetFormat, normalizeAudioFormat, openAnimationEditor, openAssetVersionsDialog, openAudioEditor, openFrameTouchUpEditor, openStyleGuideEditor, pickUploadFile, previewCurrentAsset, previewImageSource, previewOption, readableAssetName, renderAssetFolderBrowser, renderAudioPlayer, renderOptions, resolvePreviewDisplaySize, setStatus, stopStatusAnimation, styleGuideDraftFromManifest, styleGuideRequest, uploadedOptionFromFile, voiceGenerationOverridesFromInputs } from "./designer-support.js";
5
+ import { createMixedTilesetOption, isDesignerTilesetAsset, openTilesetBaseMixerDialog, openTilesetAnimationEditor, openTilesetAnimationMixerDialog, openTilesetEditor, tilesetAnimationForKey, resolveTilesetBaseMixCurrent, tilesetMetadataForAsset, tilesetTileGenerationOverride } from "./tileset-dialog.js";
6
+ import { aiTilesetAnimationTextureKey } from "./keys.js";
7
+ /**
8
+ * Resolves the canonical, untransformed source used by Edit Tileset. A pending
9
+ * option is its own editing lineage: when it has no saved source metadata, its
10
+ * rendered image becomes the new source instead of borrowing the active
11
+ * version's unrelated source sheet.
12
+ */
13
+ export function resolveTilesetEditorInitialState(options) {
14
+ if (options.currentOption) {
15
+ const sourceSrc = options.currentOption.tilesetSourceDataUrl;
16
+ return {
17
+ sourceSrc: sourceSrc ?? options.renderedSrc,
18
+ initialTransforms: sourceSrc
19
+ ? cloneTilesetTransforms(options.currentOption.tilesetTransforms)
20
+ : undefined
21
+ };
22
+ }
23
+ const sourceFile = options.activeVersion?.tilesetSourceFile;
24
+ return {
25
+ sourceSrc: sourceFile
26
+ ? options.resolveAssetUrl(sourceFile)
27
+ : options.renderedSrc,
28
+ initialTransforms: sourceFile
29
+ ? cloneTilesetTransforms(options.activeVersion?.tilesetTransforms)
30
+ : undefined
31
+ };
32
+ }
33
+ function cloneTilesetTransforms(transforms) {
34
+ return transforms?.map((transform) => ({ ...transform }));
35
+ }
36
+ import { assetWithGeneratedGeometry, audioGenerationOverridesFromInputs, bindKeyboardCapture, canEditGenerationFormat, createDesignerElements, effectiveGenerationFormat, ensureDesignerStyles, errorMessage, generationOverridesFromInputs, hasStyleGuide, imageSourceToDataUrl, installPromotedImageTexture, isAbortError, isAudioAsset, isSvgSource, isVoiceAsset, loadImageElement, mimeTypeFromDataUrl, normalizeAssetFormat, normalizeAudioFormat, openAnimationEditor, openAssetVersionsDialog, openAudioEditor, openFrameTouchUpEditor, openStyleGuideEditor, pickUploadFile, positiveIntegerInput, previewCurrentAsset, previewImageSource, previewOption, readableAssetName, renderAssetFolderBrowser, renderAudioPlayer, renderOptions, resolvePreviewDisplaySize, setStatus, stopStatusAnimation, styleGuideDraftFromManifest, styleGuideRequest, uploadedOptionFromFile, voiceGenerationOverridesFromInputs } from "./designer-support.js";
6
37
  export function installAiAssetDesigner(options) {
7
38
  ensureDesignerStyles();
8
39
  const client = options.client ?? new AiAssetDebugClient();
@@ -12,14 +43,46 @@ export function installAiAssetDesigner(options) {
12
43
  let selectedAssetId = visibleAssetIds()[0] ?? Object.keys(manifest.assets)[0];
13
44
  let selectedTargetId = options.targetId;
14
45
  let selectedTargetAssetId = selectedAssetId;
46
+ let selectedTilesetAnimationKey;
15
47
  let selectedOption;
48
+ let displayedOptions;
16
49
  let stopCurrentAnimationPreview;
17
50
  let editedCurrentOption;
18
51
  let previewedVersionName;
52
+ const pendingOptions = new Map();
19
53
  let styleGuideDraft = styleGuideDraftFromManifest(manifest, resolveAssetUrl);
20
54
  const formatDrafts = new Map();
55
+ const tilesetPromptDrafts = new Map();
56
+ const tilesetAnimationPromptDrafts = new Map();
57
+ const tilesetAnimationFrameDrafts = new Map();
58
+ let tilesetAnimationPromptRevision = 0;
59
+ let displayedTilesetAnimationOptions;
60
+ let stopTilesetAnimationOptionPreviews;
21
61
  let activeGeneration;
22
62
  let generationId = 0;
63
+ let panelRevision = 0;
64
+ let promotionId = 0;
65
+ let activePromotionId;
66
+ let mixingTileset = false;
67
+ const regenerateTilesetTile = async (assetId, tile, tileset, currentTileSrc) => {
68
+ const guide = await styleGuideRequest(styleGuideDraft);
69
+ return client.generate({
70
+ assetId,
71
+ count: 3,
72
+ format: effectiveGenerationFormat(manifest, formatDrafts, selectedAssetId, assetId),
73
+ tileset,
74
+ styleGuide: {
75
+ ...guide,
76
+ images: [
77
+ ...guide.images,
78
+ {
79
+ name: `${assetId}-current-tile-${tile + 1}-style.png`,
80
+ dataUrl: currentTileSrc
81
+ }
82
+ ]
83
+ }
84
+ });
85
+ };
23
86
  if (!selectedAssetId) {
24
87
  throw new Error("AI asset designer requires at least one asset.");
25
88
  }
@@ -37,6 +100,59 @@ export function installAiAssetDesigner(options) {
37
100
  const mount = options.mount ?? document.body;
38
101
  mount.append(elements.root);
39
102
  bindKeyboardCapture(elements.root, options.scene);
103
+ const syncPromoteAllButton = () => {
104
+ elements.promoteAllButton.disabled =
105
+ pendingOptions.size === 0 ||
106
+ Boolean(activeGeneration) ||
107
+ activePromotionId !== undefined;
108
+ };
109
+ const rememberPendingOption = (assetId, option, pending = {}) => {
110
+ pendingOptions.set(assetId, {
111
+ option,
112
+ inheritAnimations: Boolean(pending.inheritAnimations),
113
+ previewedVersionName: pending.previewedVersionName,
114
+ tilesetAnimations: pending.tilesetAnimations,
115
+ animationOnlyKey: pending.animationOnlyKey
116
+ });
117
+ syncPromoteAllButton();
118
+ };
119
+ const forgetPendingOption = (assetId) => {
120
+ pendingOptions.delete(assetId);
121
+ syncPromoteAllButton();
122
+ };
123
+ const hasEditableTilesetAnimationFrames = (assetId, animationKey) => {
124
+ const pendingFrames = pendingOptions.get(assetId)?.tilesetAnimations?.[animationKey];
125
+ if (pendingFrames?.length)
126
+ return true;
127
+ const asset = manifest.assets[assetId];
128
+ const activeVersion = asset?.versions[asset.activeVersion];
129
+ return Boolean(activeVersion?.tilesetAnimations?.[animationKey]?.files.length);
130
+ };
131
+ const installTilesetAnimationFrameTextures = async (frames, asset, textureKeyForFrame) => {
132
+ const tileset = tilesetMetadataForAsset(asset);
133
+ if (!tileset || !options.scene.textures.addSpriteSheet)
134
+ return [];
135
+ const images = await Promise.all(frames.map((frame) => loadImageElement(frame)));
136
+ const textureKeys = images.map((image, index) => {
137
+ const textureKey = textureKeyForFrame(index);
138
+ if (options.scene.textures.exists(textureKey)) {
139
+ options.scene.textures.remove(textureKey);
140
+ }
141
+ options.scene.textures.addSpriteSheet?.(textureKey, image, {
142
+ frameWidth: tileset.tileWidth,
143
+ frameHeight: tileset.tileHeight,
144
+ margin: tileset.margin,
145
+ spacing: tileset.spacing
146
+ });
147
+ return textureKey;
148
+ });
149
+ return textureKeys;
150
+ };
151
+ const previewTilesetAnimationFrames = async (assetId, animationKey, frames, asset) => {
152
+ const previewId = Date.now();
153
+ const textureKeys = await installTilesetAnimationFrameTextures(frames, asset, (index) => `ai-preview:${assetId}:tileset:${encodeURIComponent(animationKey)}:${index}:${previewId}`);
154
+ options.onTilesetAnimationPreview?.(assetId, animationKey, textureKeys, asset);
155
+ };
40
156
  const applyOpenState = (isOpen) => {
41
157
  elements.root.dataset.open = String(isOpen);
42
158
  elements.toggle.setAttribute("aria-expanded", String(isOpen));
@@ -60,10 +176,31 @@ export function installAiAssetDesigner(options) {
60
176
  }
61
177
  };
62
178
  const syncAnimationChoices = (assetId) => {
179
+ const resolvedAssetId = resolveTargetAssetId(manifest, assetId, selectedTargetId);
180
+ const resolvedAsset = manifest.assets[resolvedAssetId] ?? manifest.assets[assetId];
63
181
  const asset = manifest.assets[assetId];
64
182
  const linkedAnimations = Object.entries(asset.linkedAnimationAssets ?? {});
65
183
  const isVoice = asset.kind === "voice";
66
184
  elements.animationSelect.innerHTML = "";
185
+ if (isDesignerTilesetAsset(resolvedAsset)) {
186
+ const baseOption = document.createElement("option");
187
+ baseOption.value = "tileset:base";
188
+ baseOption.textContent = "Base tileset";
189
+ elements.animationSelect.append(baseOption);
190
+ for (const animation of tilesetMetadataForAsset(resolvedAsset)?.animations ?? []) {
191
+ const option = document.createElement("option");
192
+ option.value = `tileset:animation:${encodeURIComponent(animation.key)}`;
193
+ option.textContent = readableAssetName(animation.key);
194
+ elements.animationSelect.append(option);
195
+ }
196
+ selectedTargetAssetId = resolvedAssetId;
197
+ selectedTilesetAnimationKey = undefined;
198
+ elements.animationField.hidden = false;
199
+ elements.animationField.firstElementChild.textContent = "Tileset view";
200
+ elements.animationSelect.value = "tileset:base";
201
+ return;
202
+ }
203
+ selectedTilesetAnimationKey = undefined;
67
204
  if (asset.kind !== "collection") {
68
205
  const baseOption = document.createElement("option");
69
206
  baseOption.value = resolveTargetAssetId(manifest, assetId, selectedTargetId);
@@ -83,17 +220,143 @@ export function installAiAssetDesigner(options) {
83
220
  : resolveTargetAssetId(manifest, assetId, selectedTargetId);
84
221
  elements.animationSelect.value = selectedTargetAssetId;
85
222
  };
86
- const syncTargetAsset = (assetId) => {
223
+ const effectiveTilesetTileCount = (asset) => {
224
+ const tileset = tilesetMetadataForAsset(asset);
225
+ if (!tileset)
226
+ return 0;
227
+ const capacity = tileset.columns * tileset.rows;
228
+ return positiveIntegerInput(elements.frameCountInput, Math.min(tileset.tileCount ?? capacity, capacity));
229
+ };
230
+ const tilePromptDraftsForAsset = (assetId) => {
231
+ const existing = tilesetPromptDrafts.get(assetId);
232
+ if (existing)
233
+ return existing;
234
+ const configured = tilesetMetadataForAsset(manifest.assets[assetId])?.tiles
235
+ ?.map((tile) => tile.prompt) ?? [];
236
+ tilesetPromptDrafts.set(assetId, configured);
237
+ return configured;
238
+ };
239
+ const tilesetAnimationPromptDraftKey = (assetId, animationKey) => (`${assetId}\u0000${animationKey}`);
240
+ const animationTilePromptDraftsForAsset = (assetId, animationKey) => {
241
+ const draftKey = tilesetAnimationPromptDraftKey(assetId, animationKey);
242
+ const existing = tilesetAnimationPromptDrafts.get(draftKey);
243
+ if (existing)
244
+ return existing;
245
+ const asset = manifest.assets[assetId];
246
+ const tileset = tilesetMetadataForAsset(asset);
247
+ const animation = tilesetAnimationForKey(asset, animationKey);
248
+ const tileCount = tileset
249
+ ? Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows)
250
+ : 0;
251
+ const configured = animation?.tiles?.map((tile) => tile.prompt) ??
252
+ Array.from({ length: tileCount }, (_, tile) => {
253
+ const basePrompt = tileset?.tiles?.[tile]?.prompt?.trim();
254
+ return basePrompt
255
+ ? `Keep this ${basePrompt} tile unchanged unless this animation needs it to move.`
256
+ : "Keep this tile unchanged unless this animation needs it to move.";
257
+ });
258
+ tilesetAnimationPromptDrafts.set(draftKey, configured);
259
+ return configured;
260
+ };
261
+ const renderTilesetPromptInputs = (assetId) => {
262
+ const asset = manifest.assets[assetId];
263
+ const tileset = tilesetMetadataForAsset(asset);
264
+ const isAnimation = Boolean(selectedTilesetAnimationKey);
265
+ const tileCount = isAnimation && tileset
266
+ ? Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows)
267
+ : effectiveTilesetTileCount(asset);
268
+ const drafts = selectedTilesetAnimationKey
269
+ ? animationTilePromptDraftsForAsset(assetId, selectedTilesetAnimationKey)
270
+ : tilePromptDraftsForAsset(assetId);
271
+ if (!isAnimation)
272
+ elements.frameCountInput.value = String(tileCount);
273
+ elements.tilesetPromptsList.replaceChildren();
274
+ const title = elements.tilesetPromptsField.querySelector(".ai-game-assets-designer__tile-prompts-title");
275
+ const hint = elements.tilesetPromptsField.querySelector(".ai-game-assets-designer__tile-prompts-hint");
276
+ if (title)
277
+ title.textContent = isAnimation ? "Tile animation prompts" : "Tile prompts";
278
+ if (hint) {
279
+ hint.textContent = isAnimation
280
+ ? "Describe how every tile behaves in this animation, in exact sheet order."
281
+ : "Describe each tile in exact sheet order.";
282
+ }
283
+ for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
284
+ const input = document.createElement("textarea");
285
+ input.rows = 3;
286
+ input.value = drafts[tileIndex] ?? "";
287
+ input.setAttribute("aria-label", `Tile ${tileIndex + 1} prompt`);
288
+ input.addEventListener("input", () => {
289
+ drafts[tileIndex] = input.value;
290
+ if (isAnimation && selectedTilesetAnimationKey) {
291
+ elements.promoteButton.disabled = true;
292
+ void stageTilesetAnimationPromptDraft(assetId, selectedTilesetAnimationKey);
293
+ }
294
+ });
295
+ const label = document.createElement("label");
296
+ label.className = "ai-game-assets-designer__field";
297
+ const labelText = document.createElement("span");
298
+ labelText.textContent = `Tile ${tileIndex + 1}`;
299
+ label.append(labelText, input);
300
+ elements.tilesetPromptsList.append(label);
301
+ }
302
+ };
303
+ const tilesetAnimationPromptDefinitions = (assetId, animationKey) => {
304
+ const tileset = tilesetMetadataForAsset(manifest.assets[assetId]);
305
+ if (!tileset)
306
+ return undefined;
307
+ const tileCount = Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows);
308
+ const prompts = animationTilePromptDraftsForAsset(assetId, animationKey)
309
+ .slice(0, tileCount)
310
+ .map((prompt) => prompt.trim());
311
+ if (prompts.length !== tileCount || prompts.some((prompt) => !prompt))
312
+ return undefined;
313
+ return prompts.map((prompt) => ({ prompt }));
314
+ };
315
+ const tilesetAnimationDefinitionFromInputs = (assetId, animationKey) => {
316
+ const animation = tilesetAnimationForKey(manifest.assets[assetId], animationKey);
317
+ const tiles = tilesetAnimationPromptDefinitions(assetId, animationKey);
318
+ if (!animation || !tiles)
319
+ return undefined;
320
+ const frameCount = positiveIntegerInput(elements.frameCountInput, animation.frameCount);
321
+ const defaultDelayMs = Math.round(1000 / Math.max(1, animation.frameRate));
322
+ return {
323
+ ...animation,
324
+ frameCount,
325
+ tiles,
326
+ frameTimings: Array.from({ length: frameCount }, (_, index) => (animation.frameTimings?.[index] ?? { delayMs: defaultDelayMs }))
327
+ };
328
+ };
329
+ const assetWithTilesetAnimationDefinition = (asset, definition) => ({
330
+ ...asset,
331
+ tileset: asset.tileset ? {
332
+ ...asset.tileset,
333
+ animations: (asset.tileset.animations ?? []).map((animation) => (animation.key === definition.key ? definition : animation))
334
+ } : asset.tileset
335
+ });
336
+ const tilesetPromptDefinitions = (assetId, tileCount) => {
337
+ const prompts = tilePromptDraftsForAsset(assetId)
338
+ .slice(0, tileCount)
339
+ .map((prompt) => prompt.trim());
340
+ if (prompts.length !== tileCount || prompts.some((prompt) => !prompt)) {
341
+ return undefined;
342
+ }
343
+ return prompts.map((prompt) => ({ prompt }));
344
+ };
345
+ const syncTargetAsset = (assetId, syncOptions = {}) => {
87
346
  const asset = manifest.assets[assetId];
88
347
  const activeVersion = asset.versions[asset.activeVersion];
89
348
  const isAudio = isAudioAsset(asset);
90
349
  const isVoice = isVoiceAsset(asset);
91
350
  const isVoiceLine = asset.kind === "voice-line";
351
+ const tileset = tilesetMetadataForAsset(asset);
352
+ const tilesetAnimation = selectedTilesetAnimationKey
353
+ ? tilesetAnimationForKey(asset, selectedTilesetAnimationKey)
354
+ : undefined;
92
355
  stopCurrentAnimationPreview?.();
93
356
  stopCurrentAnimationPreview = undefined;
94
- elements.promptInput.value = activeVersion?.prompt ?? asset.prompt;
95
- elements.widthInput.value = String(asset.frameGrid?.frameWidth ?? asset.dimensions?.width ?? 1);
96
- elements.heightInput.value = String(asset.frameGrid?.frameHeight ?? asset.dimensions?.height ?? 1);
357
+ elements.promptInput.value = tilesetAnimation?.prompt ?? activeVersion?.prompt ?? asset.prompt;
358
+ elements.widthInput.value = String(asset.frameGrid?.frameWidth ?? tileset?.tileWidth ?? asset.dimensions?.width ?? 1);
359
+ elements.heightInput.value = String(asset.frameGrid?.frameHeight ?? tileset?.tileHeight ?? asset.dimensions?.height ?? 1);
97
360
  elements.audioFormatSelect.value = asset.audioSettings?.format ?? "mp3";
98
361
  elements.audioDurationInput.value = String(asset.audioSettings?.durationSeconds ?? activeVersion?.durationSeconds ?? "");
99
362
  elements.audioLoopInput.checked = Boolean(asset.audioSettings?.loop);
@@ -110,14 +373,43 @@ export function installAiAssetDesigner(options) {
110
373
  elements.audioLoopField.hidden = !isAudio;
111
374
  elements.voiceTextField.hidden = !isVoice;
112
375
  elements.voiceTextField.firstChild.textContent = isVoiceLine ? "Text to say" : "Demo sentence";
113
- elements.formatField.hidden = isAudio || elements.formatField.hidden;
114
- elements.dimensionGrid.hidden = isAudio;
376
+ elements.formatField.hidden = isAudio || Boolean(selectedTilesetAnimationKey) ||
377
+ elements.formatField.hidden;
378
+ const isTilesetAnimation = Boolean(tileset && selectedTilesetAnimationKey);
379
+ const isBaseTileset = Boolean(tileset && !selectedTilesetAnimationKey);
380
+ elements.promptField.hidden = Boolean(tileset);
381
+ elements.tilesetPromptsField.hidden = !tileset;
382
+ elements.widthField.firstElementChild.textContent = tileset ? "Tile width" : "Width";
383
+ elements.heightField.firstElementChild.textContent = tileset ? "Tile height" : "Height";
384
+ elements.frameCountField.firstElementChild.textContent = isTilesetAnimation
385
+ ? "Animation frames"
386
+ : tileset ? "Tiles" : "Frames";
387
+ elements.dimensionGrid.hidden = isAudio || isTilesetAnimation;
115
388
  syncTargetVariantLabel(assetId);
116
- elements.frameCountInput.value = String(asset.frameGrid?.frameCount ??
117
- (asset.frameGrid ? asset.frameGrid.columns * asset.frameGrid.rows : 1));
389
+ elements.frameCountInput.value = String(isTilesetAnimation
390
+ ? tilesetAnimation?.frameCount ?? 1
391
+ : asset.frameGrid?.frameCount ??
392
+ (asset.frameGrid
393
+ ? asset.frameGrid.columns * asset.frameGrid.rows
394
+ : tileset?.tileCount ?? (tileset ? tileset.columns * tileset.rows : 1)));
395
+ if (tileset && !isTilesetAnimation) {
396
+ elements.frameCountInput.max = String(tileset.columns * tileset.rows);
397
+ }
398
+ else {
399
+ elements.frameCountInput.removeAttribute("max");
400
+ }
401
+ if (tileset) {
402
+ renderTilesetPromptInputs(assetId);
403
+ }
404
+ else {
405
+ elements.tilesetPromptsList.replaceChildren();
406
+ }
118
407
  const activeVersionSource = activeVersion?.file ? resolveAssetUrl(activeVersion.file) : "";
119
- elements.frameCountField.hidden = asset.kind === "image" || !asset.frameGrid;
120
- elements.versionLabel.textContent = `Active ${readableAssetName(assetId)}: ${asset.activeVersion}`;
408
+ elements.frameCountField.hidden = !isTilesetAnimation &&
409
+ (!tileset && (asset.kind === "image" || !asset.frameGrid));
410
+ elements.versionLabel.textContent = selectedTilesetAnimationKey
411
+ ? `Active ${readableAssetName(assetId)} animation: ${readableAssetName(selectedTilesetAnimationKey)}`
412
+ : `Active ${readableAssetName(assetId)}: ${asset.activeVersion}`;
121
413
  elements.currentImage.src = activeVersionSource;
122
414
  if (isAudio) {
123
415
  renderAudioPlayer({
@@ -136,29 +428,53 @@ export function installAiAssetDesigner(options) {
136
428
  elements.currentAnimation.hidden = true;
137
429
  elements.currentAudio.hidden = !isAudio;
138
430
  elements.currentImage.hidden = isAudio;
139
- elements.currentAnimationButton.textContent = "Edit...";
431
+ elements.currentAnimationButton.textContent = isTilesetAnimation
432
+ ? "Edit animation..."
433
+ : "Edit...";
434
+ elements.currentTouchUpButton.textContent = isBaseTileset ? "Edit tileset..." : "Touch up...";
140
435
  elements.currentRevertButton.hidden = true;
141
436
  elements.currentPreview.classList.add("is-selected");
142
- elements.currentAnimationButton.hidden = !activeVersion?.file || (!asset.frameGrid && !isAudio);
437
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
438
+ ? !hasEditableTilesetAnimationFrames(assetId, selectedTilesetAnimationKey)
439
+ : !activeVersion?.file || (!asset.frameGrid && !isAudio);
143
440
  elements.currentTouchUpButton.hidden = !activeVersion?.file ||
441
+ Boolean(selectedTilesetAnimationKey) ||
144
442
  isAudio ||
145
443
  Boolean(asset.frameGrid) ||
146
444
  normalizeAssetFormat(asset.settings?.format) === "svg" ||
147
445
  isSvgSource(activeVersion?.file ?? "");
148
- elements.options.innerHTML = "";
149
- elements.options.classList.remove("is-audio");
446
+ if (syncOptions.preserveOptions && displayedOptions?.assetId === assetId) {
447
+ renderGeneratedOptions(assetId, displayedOptions.generated);
448
+ }
449
+ else {
450
+ clearGeneratedOptions();
451
+ }
150
452
  setStatus(elements, "", "idle");
151
453
  elements.promoteButton.disabled = true;
454
+ elements.uploadButton.disabled = Boolean(selectedTilesetAnimationKey);
152
455
  elements.versionsButton.disabled = Object.keys(asset.versions).length <= 1;
153
- elements.deriveButton.hidden = isAudio || deriveCandidatesForAsset(assetId).length === 0;
456
+ elements.deriveButton.hidden = Boolean(selectedTilesetAnimationKey) ||
457
+ isAudio || deriveCandidatesForAsset(assetId).length === 0;
154
458
  selectedOption = undefined;
155
459
  editedCurrentOption = undefined;
156
460
  previewedVersionName = undefined;
461
+ const pending = pendingOptions.get(assetId);
462
+ if (pending) {
463
+ selectedOption = pending.option;
464
+ editedCurrentOption = pending.option;
465
+ previewedVersionName = pending.previewedVersionName;
466
+ showOptionInCurrentPreview(pending.option, "pending preview");
467
+ previewedVersionName = pending.previewedVersionName;
468
+ elements.currentPreview.classList.add("is-selected");
469
+ elements.promoteButton.disabled = activePromotionId !== undefined;
470
+ }
471
+ syncPromoteAllButton();
157
472
  };
158
473
  const showOptionInCurrentPreview = (option, label = "selected option") => {
159
474
  const asset = manifest.assets[selectedTargetAssetId];
160
475
  const optionAsset = assetWithGeneratedGeometry(asset, option);
161
476
  const isAudio = isAudioAsset(asset);
477
+ const isTilesetAnimation = Boolean(selectedTilesetAnimationKey && isDesignerTilesetAsset(optionAsset));
162
478
  stopCurrentAnimationPreview?.();
163
479
  stopCurrentAnimationPreview = undefined;
164
480
  editedCurrentOption = option;
@@ -166,10 +482,18 @@ export function installAiAssetDesigner(options) {
166
482
  elements.currentPreview.hidden = false;
167
483
  elements.currentPreview.setAttribute("aria-label", `Preview ${label} for ${readableAssetName(selectedTargetAssetId)}`);
168
484
  elements.currentAnimation.hidden = true;
169
- elements.currentAnimationButton.textContent = "Edit...";
170
- elements.currentAnimationButton.hidden = !optionAsset.frameGrid && !isAudio;
485
+ elements.currentAnimationButton.textContent = isTilesetAnimation
486
+ ? "Edit animation..."
487
+ : "Edit...";
488
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
489
+ ? !hasEditableTilesetAnimationFrames(selectedTargetAssetId, selectedTilesetAnimationKey)
490
+ : !optionAsset.frameGrid && !isAudio;
491
+ elements.currentTouchUpButton.textContent = isDesignerTilesetAsset(optionAsset)
492
+ ? "Edit tileset..."
493
+ : "Touch up...";
171
494
  elements.currentTouchUpButton.hidden =
172
495
  isAudio ||
496
+ isTilesetAnimation ||
173
497
  Boolean(optionAsset.frameGrid) ||
174
498
  option.mimeType === "image/svg+xml";
175
499
  elements.currentRevertButton.hidden = false;
@@ -189,6 +513,262 @@ export function installAiAssetDesigner(options) {
189
513
  elements.currentImage.alt = `${readableAssetName(selectedTargetAssetId)} ${label}`;
190
514
  }
191
515
  };
516
+ const syncMixTilesetButton = () => {
517
+ const session = displayedOptions;
518
+ const asset = session ? manifest.assets[session.assetId] : undefined;
519
+ const baseVisible = Boolean(session &&
520
+ session.assetId === selectedTargetAssetId &&
521
+ session.generated.length === 3 &&
522
+ isDesignerTilesetAsset(asset) &&
523
+ !selectedTilesetAnimationKey);
524
+ const animationSession = displayedTilesetAnimationOptions;
525
+ const animationVisible = Boolean(animationSession &&
526
+ animationSession.assetId === selectedTargetAssetId &&
527
+ animationSession.animationKey === selectedTilesetAnimationKey &&
528
+ animationSession.generated.length === 3);
529
+ const visible = baseVisible || animationVisible;
530
+ elements.mixTilesetButton.textContent = animationVisible ? "Mix tilesets" : "Mix tileset";
531
+ elements.mixTilesetButton.hidden = !visible;
532
+ elements.mixTilesetButton.disabled = !visible || Boolean(activeGeneration || activePromotionId !== undefined || mixingTileset);
533
+ };
534
+ const renderGeneratedOptions = (assetId, generated) => {
535
+ stopTilesetAnimationOptionPreviews?.();
536
+ stopTilesetAnimationOptionPreviews = undefined;
537
+ displayedTilesetAnimationOptions = undefined;
538
+ displayedOptions = {
539
+ assetId,
540
+ generated: [...generated]
541
+ };
542
+ renderOptions({
543
+ elements,
544
+ generated,
545
+ scene: options.scene,
546
+ manifest,
547
+ assetId,
548
+ designerOptions: options,
549
+ onPreview: options.onPreview,
550
+ onSelected(option) {
551
+ selectedOption = option;
552
+ rememberPendingOption(assetId, option);
553
+ showOptionInCurrentPreview(option);
554
+ elements.promoteButton.disabled = activePromotionId !== undefined;
555
+ }
556
+ });
557
+ syncMixTilesetButton();
558
+ };
559
+ const currentTilesetOption = async (assetId, definition) => {
560
+ const asset = manifest.assets[assetId];
561
+ const pending = pendingOptions.get(assetId)?.option;
562
+ const activeVersion = asset.versions[asset.activeVersion];
563
+ const dataUrl = pending?.dataUrl ??
564
+ await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
565
+ const tilesetSourceDataUrl = pending
566
+ ? pending.tilesetSourceDataUrl
567
+ : activeVersion.tilesetSourceFile
568
+ ? await imageSourceToDataUrl(resolveAssetUrl(activeVersion.tilesetSourceFile))
569
+ : undefined;
570
+ return {
571
+ ...(pending ?? {
572
+ index: -1,
573
+ dataUrl,
574
+ mimeType: mimeTypeFromDataUrl(dataUrl),
575
+ prompt: activeVersion.prompt ?? asset.prompt,
576
+ model: activeVersion.model,
577
+ revisedPrompt: activeVersion.revisedPrompt,
578
+ dimensions: asset.dimensions,
579
+ settings: activeVersion.settings ?? asset.settings
580
+ }),
581
+ dataUrl,
582
+ tilesetSourceDataUrl,
583
+ tilesetTransforms: tilesetSourceDataUrl
584
+ ? cloneTilesetTransforms(pending ? pending.tilesetTransforms : activeVersion.tilesetTransforms)
585
+ : undefined,
586
+ tileset: assetWithTilesetAnimationDefinition(asset, definition).tileset
587
+ };
588
+ };
589
+ const activeTilesetAnimationFrames = (assetId, animationKey) => {
590
+ const asset = manifest.assets[assetId];
591
+ const activeVersion = asset?.versions[asset.activeVersion];
592
+ const files = activeVersion?.tilesetAnimations?.[animationKey]?.files;
593
+ if (!asset || !activeVersion || !files?.length) {
594
+ return Promise.reject(new Error(`Generate ${readableAssetName(animationKey)} animation frames before promoting its prompts.`));
595
+ }
596
+ const cacheKey = `${assetId}\u0000${asset.activeVersion}\u0000${animationKey}`;
597
+ const existing = tilesetAnimationFrameDrafts.get(cacheKey);
598
+ if (existing)
599
+ return existing;
600
+ const loading = Promise.all(files.map((file) => imageSourceToDataUrl(resolveAssetUrl(file))));
601
+ tilesetAnimationFrameDrafts.set(cacheKey, loading);
602
+ void loading.catch(() => {
603
+ if (tilesetAnimationFrameDrafts.get(cacheKey) === loading) {
604
+ tilesetAnimationFrameDrafts.delete(cacheKey);
605
+ }
606
+ });
607
+ return loading;
608
+ };
609
+ const promptOnlyTilesetAnimationDefinition = (assetId, animationKey, pending = pendingOptions.get(assetId)) => {
610
+ const tiles = tilesetAnimationPromptDefinitions(assetId, animationKey);
611
+ const declared = tilesetAnimationForKey(manifest.assets[assetId], animationKey);
612
+ const pendingDefinition = pending?.option.tileset?.animations?.find((animation) => animation.key === animationKey);
613
+ const source = pendingDefinition ?? declared;
614
+ return source && tiles ? { ...source, tiles } : undefined;
615
+ };
616
+ const stageTilesetAnimationPromptDraft = async (assetId, animationKey) => {
617
+ const revision = tilesetAnimationPromptRevision + 1;
618
+ tilesetAnimationPromptRevision = revision;
619
+ if (!promptOnlyTilesetAnimationDefinition(assetId, animationKey)) {
620
+ elements.promoteButton.disabled = true;
621
+ return;
622
+ }
623
+ try {
624
+ const pendingBeforeLoad = pendingOptions.get(assetId);
625
+ const loadedFrames = pendingBeforeLoad?.tilesetAnimations?.[animationKey] ??
626
+ await activeTilesetAnimationFrames(assetId, animationKey);
627
+ if (revision !== tilesetAnimationPromptRevision ||
628
+ selectedTargetAssetId !== assetId ||
629
+ selectedTilesetAnimationKey !== animationKey)
630
+ return;
631
+ const pending = pendingOptions.get(assetId);
632
+ const frames = pending?.tilesetAnimations?.[animationKey] ?? loadedFrames;
633
+ const definition = promptOnlyTilesetAnimationDefinition(assetId, animationKey, pending);
634
+ if (!definition) {
635
+ elements.promoteButton.disabled = true;
636
+ return;
637
+ }
638
+ if (frames.length !== definition.frameCount) {
639
+ throw new Error(`${readableAssetName(animationKey)} has ${frames.length} current frames, but ` +
640
+ `${definition.frameCount} are configured. Generate new frames before promoting.`);
641
+ }
642
+ const option = await currentTilesetOption(assetId, definition);
643
+ if (revision !== tilesetAnimationPromptRevision ||
644
+ selectedTargetAssetId !== assetId ||
645
+ selectedTilesetAnimationKey !== animationKey)
646
+ return;
647
+ const latestPending = pendingOptions.get(assetId);
648
+ selectedOption = option;
649
+ editedCurrentOption = option;
650
+ rememberPendingOption(assetId, option, {
651
+ inheritAnimations: latestPending?.inheritAnimations,
652
+ previewedVersionName: latestPending?.previewedVersionName,
653
+ tilesetAnimations: {
654
+ ...(latestPending?.tilesetAnimations ?? {}),
655
+ [animationKey]: latestPending?.tilesetAnimations?.[animationKey] ?? frames
656
+ },
657
+ animationOnlyKey: latestPending?.animationOnlyKey ??
658
+ (latestPending ? undefined : animationKey)
659
+ });
660
+ if (displayedTilesetAnimationOptions?.assetId === assetId &&
661
+ displayedTilesetAnimationOptions.animationKey === animationKey) {
662
+ displayedTilesetAnimationOptions.animation = {
663
+ ...displayedTilesetAnimationOptions.animation,
664
+ tiles: definition.tiles
665
+ };
666
+ }
667
+ elements.promoteButton.disabled = activePromotionId !== undefined;
668
+ setStatus(elements, "Animation prompt changes ready. Promote to save them.", "info");
669
+ }
670
+ catch (error) {
671
+ if (revision !== tilesetAnimationPromptRevision ||
672
+ selectedTargetAssetId !== assetId ||
673
+ selectedTilesetAnimationKey !== animationKey)
674
+ return;
675
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
676
+ setStatus(elements, `Could not stage animation prompts. ${errorMessage(error)}`, "error");
677
+ }
678
+ };
679
+ const selectTilesetAnimationFrames = async (assetId, definition, frames, card) => {
680
+ const currentPrompts = tilesetAnimationPromptDefinitions(assetId, definition.key);
681
+ const selectedDefinition = currentPrompts
682
+ ? { ...definition, tiles: currentPrompts }
683
+ : definition;
684
+ const option = await currentTilesetOption(assetId, selectedDefinition);
685
+ const pending = pendingOptions.get(assetId);
686
+ selectedOption = option;
687
+ rememberPendingOption(assetId, option, {
688
+ inheritAnimations: pending?.inheritAnimations,
689
+ previewedVersionName: pending?.previewedVersionName,
690
+ tilesetAnimations: {
691
+ ...(pending?.tilesetAnimations ?? {}),
692
+ [selectedDefinition.key]: frames
693
+ },
694
+ animationOnlyKey: pending?.animationOnlyKey ??
695
+ (pending ? undefined : selectedDefinition.key)
696
+ });
697
+ for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
698
+ item.classList.toggle("is-selected", item === card);
699
+ }
700
+ elements.currentPreview.classList.remove("is-selected");
701
+ elements.promoteButton.disabled = activePromotionId !== undefined;
702
+ await previewTilesetAnimationFrames(assetId, selectedDefinition.key, frames, assetWithTilesetAnimationDefinition(manifest.assets[assetId], selectedDefinition));
703
+ };
704
+ const renderTilesetAnimationOptions = (assetId, definition, generated) => {
705
+ stopTilesetAnimationOptionPreviews?.();
706
+ displayedOptions = undefined;
707
+ displayedTilesetAnimationOptions = {
708
+ assetId,
709
+ animationKey: definition.key,
710
+ animation: definition,
711
+ generated: [...generated]
712
+ };
713
+ elements.options.replaceChildren();
714
+ elements.options.classList.remove("is-audio");
715
+ const stops = [];
716
+ const delayForFrame = (index) => (definition.frameTimings?.[index]?.delayMs ??
717
+ Math.round(1000 / Math.max(1, definition.frameRate)));
718
+ for (const candidate of generated) {
719
+ const card = document.createElement("div");
720
+ card.className = "ai-game-assets-designer__option";
721
+ const button = document.createElement("button");
722
+ button.type = "button";
723
+ button.className = "ai-game-assets-designer__option-select";
724
+ button.setAttribute("aria-label", `Select animation option ${candidate.index + 1}`);
725
+ const image = document.createElement("img");
726
+ image.alt = `${assetId} animation option ${candidate.index + 1}`;
727
+ const label = document.createElement("span");
728
+ label.textContent = `Option ${candidate.index + 1}`;
729
+ button.append(image, label);
730
+ card.append(button);
731
+ elements.options.append(card);
732
+ let frame = 0;
733
+ let timeout;
734
+ let stopped = false;
735
+ const animate = () => {
736
+ if (stopped || candidate.frames.length === 0)
737
+ return;
738
+ image.src = candidate.frames[frame % candidate.frames.length].dataUrl;
739
+ const currentFrame = frame;
740
+ frame += 1;
741
+ timeout = window.setTimeout(animate, delayForFrame(currentFrame));
742
+ };
743
+ animate();
744
+ stops.push(() => {
745
+ stopped = true;
746
+ if (timeout !== undefined)
747
+ window.clearTimeout(timeout);
748
+ });
749
+ button.addEventListener("click", async () => {
750
+ setStatus(elements, `Applying animation option ${candidate.index + 1}...`, "busy");
751
+ try {
752
+ await selectTilesetAnimationFrames(assetId, definition, candidate.frames.map((item) => item.dataUrl), card);
753
+ setStatus(elements, `Previewing animation option ${candidate.index + 1}. Promote to save it.`, "info");
754
+ }
755
+ catch (error) {
756
+ setStatus(elements, `Could not preview animation. ${errorMessage(error)}`, "error");
757
+ }
758
+ });
759
+ }
760
+ stopTilesetAnimationOptionPreviews = () => stops.forEach((stop) => stop());
761
+ syncMixTilesetButton();
762
+ };
763
+ const clearGeneratedOptions = () => {
764
+ stopTilesetAnimationOptionPreviews?.();
765
+ stopTilesetAnimationOptionPreviews = undefined;
766
+ displayedTilesetAnimationOptions = undefined;
767
+ displayedOptions = undefined;
768
+ elements.options.innerHTML = "";
769
+ elements.options.classList.remove("is-audio");
770
+ syncMixTilesetButton();
771
+ };
192
772
  const restoreActivePreview = () => {
193
773
  const asset = manifest.assets[selectedTargetAssetId];
194
774
  const activeVersion = asset.versions[asset.activeVersion];
@@ -199,18 +779,28 @@ export function installAiAssetDesigner(options) {
199
779
  editedCurrentOption = undefined;
200
780
  previewedVersionName = undefined;
201
781
  selectedOption = undefined;
782
+ forgetPendingOption(selectedTargetAssetId);
202
783
  for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
203
784
  item.classList.remove("is-selected");
204
785
  }
205
786
  const activeVersionSource = resolveAssetUrl(activeVersion.file);
206
787
  const isAudio = isAudioAsset(asset);
788
+ const isTilesetAnimation = Boolean(selectedTilesetAnimationKey && isDesignerTilesetAsset(asset));
207
789
  elements.currentPreview.classList.add("is-selected");
208
790
  elements.currentPreview.setAttribute("aria-label", `Preview active ${readableAssetName(selectedTargetAssetId)} version`);
209
791
  elements.currentAnimation.hidden = true;
210
- elements.currentAnimationButton.textContent = "Edit...";
211
- elements.currentAnimationButton.hidden = !asset.frameGrid && !isAudio;
792
+ elements.currentAnimationButton.textContent = isTilesetAnimation
793
+ ? "Edit animation..."
794
+ : "Edit...";
795
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
796
+ ? !hasEditableTilesetAnimationFrames(selectedTargetAssetId, selectedTilesetAnimationKey)
797
+ : !asset.frameGrid && !isAudio;
798
+ elements.currentTouchUpButton.textContent = isDesignerTilesetAsset(asset)
799
+ ? "Edit tileset..."
800
+ : "Touch up...";
212
801
  elements.currentTouchUpButton.hidden =
213
802
  isAudio ||
803
+ isTilesetAnimation ||
214
804
  Boolean(asset.frameGrid) ||
215
805
  normalizeAssetFormat(asset.settings?.format) === "svg" ||
216
806
  isSvgSource(activeVersion.file);
@@ -225,21 +815,28 @@ export function installAiAssetDesigner(options) {
225
815
  label: readableAssetName(selectedTargetAssetId),
226
816
  playback: activeVersion.audioPlayback
227
817
  });
228
- options.onPreview(selectedTargetAssetId, activeVersionSource, asset);
818
+ (options.onAssetReady ?? options.onPreview)(selectedTargetAssetId, activeVersionSource, asset);
819
+ setStatus(elements, "Reverted preview to the active version.", "info");
229
820
  }
230
821
  else {
231
822
  elements.currentAudio.innerHTML = "";
232
823
  elements.currentImage.src = activeVersionSource;
233
824
  elements.currentImage.alt = `${readableAssetName(selectedTargetAssetId)} active version`;
825
+ setStatus(elements, "Reverting preview in the game...", "busy");
234
826
  previewCurrentAsset({
235
827
  scene: options.scene,
236
828
  manifest,
237
829
  assetId: selectedTargetAssetId,
238
830
  src: activeVersionSource,
239
- onPreview: options.onPreview
831
+ onPreview(assetId, textureKey, previewAsset) {
832
+ (options.onAssetReady ?? options.onPreview)(assetId, textureKey, previewAsset);
833
+ setStatus(elements, "Reverted preview to the active version.", "info");
834
+ },
835
+ onError(error) {
836
+ setStatus(elements, `Could not revert the game preview. ${error.message}`, "error");
837
+ }
240
838
  });
241
839
  }
242
- setStatus(elements, "Reverted preview to the active version.", "info");
243
840
  };
244
841
  const syncAsset = (assetId) => {
245
842
  syncAnimationChoices(assetId);
@@ -335,6 +932,7 @@ export function installAiAssetDesigner(options) {
335
932
  selectedAssetId,
336
933
  assetIds: visibleAssetIds(),
337
934
  onSelect(assetId) {
935
+ invalidatePanelWork();
338
936
  selectedAssetId = assetId;
339
937
  syncAsset(selectedAssetId);
340
938
  }
@@ -362,13 +960,117 @@ export function installAiAssetDesigner(options) {
362
960
  activeGeneration = undefined;
363
961
  elements.regenerateButton.textContent = "Regenerate";
364
962
  unlockGenerationStatus();
963
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
964
+ syncPromoteAllButton();
965
+ syncMixTilesetButton();
365
966
  return true;
366
967
  };
968
+ const invalidatePanelWork = () => {
969
+ panelRevision += 1;
970
+ if (!activeGeneration)
971
+ return;
972
+ const { controller, id } = activeGeneration;
973
+ controller.abort();
974
+ finishGeneration(id);
975
+ };
976
+ const regenerateTilesetAnimation = async (animationKey) => {
977
+ const assetId = selectedTargetAssetId;
978
+ const workflowPanelRevision = panelRevision;
979
+ const isWorkflowPanelCurrent = () => panelRevision === workflowPanelRevision &&
980
+ selectedTargetAssetId === assetId &&
981
+ selectedTilesetAnimationKey === animationKey;
982
+ const asset = manifest.assets[assetId];
983
+ const animation = tilesetAnimationForKey(asset, animationKey);
984
+ const activeVersion = asset?.versions[asset.activeVersion];
985
+ const definition = tilesetAnimationDefinitionFromInputs(assetId, animationKey);
986
+ if (!asset || !animation || !activeVersion?.file) {
987
+ setStatus(elements, `Tileset animation "${animationKey}" is not ready to generate.`, "error");
988
+ return;
989
+ }
990
+ if (!definition) {
991
+ setStatus(elements, "Add an animation prompt for every tile before generating.", "error");
992
+ return;
993
+ }
994
+ const controller = new AbortController();
995
+ const currentGenerationId = generationId + 1;
996
+ generationId = currentGenerationId;
997
+ activeGeneration = { controller, id: currentGenerationId };
998
+ syncPromoteAllButton();
999
+ elements.regenerateButton.textContent = "Cancel generation";
1000
+ clearGeneratedOptions();
1001
+ elements.promoteButton.disabled = true;
1002
+ setStatus(elements, "Generating options...", "busy");
1003
+ lockGenerationStatus();
1004
+ try {
1005
+ const candidates = await client.generateTilesetAnimationStream({
1006
+ assetId,
1007
+ animationKey,
1008
+ frameCount: definition.frameCount,
1009
+ tiles: definition.tiles,
1010
+ count: 3
1011
+ }, (candidate) => {
1012
+ if (activeGeneration?.id !== currentGenerationId)
1013
+ return;
1014
+ if (candidate.frames.length !== definition.frameCount) {
1015
+ throw new Error(`Requested ${definition.frameCount} frames, but the generation server returned ` +
1016
+ `${candidate.frames.length} for option ${candidate.index + 1}. ` +
1017
+ "Restart the AI asset dev server and regenerate the options.");
1018
+ }
1019
+ const session = displayedTilesetAnimationOptions;
1020
+ const generated = [
1021
+ ...(session?.assetId === assetId && session.animationKey === animationKey
1022
+ ? session.generated
1023
+ : []),
1024
+ candidate
1025
+ ].sort((left, right) => left.index - right.index);
1026
+ renderTilesetAnimationOptions(assetId, definition, generated);
1027
+ setStatus(elements, `Generated option ${candidate.index + 1}. Waiting for all 3 animation sequences...`, "busy");
1028
+ }, {
1029
+ signal: controller.signal
1030
+ });
1031
+ if (!finishGeneration(currentGenerationId))
1032
+ return;
1033
+ if (candidates.length !== 3) {
1034
+ throw new Error(`Expected 3 tileset animation candidates, received ${candidates.length}.`);
1035
+ }
1036
+ const mismatchedCandidate = candidates.find((candidate) => (candidate.frames.length !== definition.frameCount));
1037
+ if (mismatchedCandidate) {
1038
+ throw new Error(`Requested ${definition.frameCount} frames, but option ` +
1039
+ `${mismatchedCandidate.index + 1} contains ${mismatchedCandidate.frames.length}. ` +
1040
+ "Regenerate the options for the current frame count.");
1041
+ }
1042
+ if (!isWorkflowPanelCurrent())
1043
+ return;
1044
+ renderTilesetAnimationOptions(assetId, definition, candidates);
1045
+ setStatus(elements, "Three animation options ready. Preview one or mix tilesets.", "info");
1046
+ }
1047
+ catch (error) {
1048
+ finishGeneration(currentGenerationId);
1049
+ if (isAbortError(error)) {
1050
+ if (isWorkflowPanelCurrent()) {
1051
+ setStatus(elements, "Generation cancelled.", "info");
1052
+ }
1053
+ return;
1054
+ }
1055
+ if (isWorkflowPanelCurrent()) {
1056
+ if (displayedTilesetAnimationOptions?.assetId === assetId &&
1057
+ displayedTilesetAnimationOptions.animationKey === animationKey) {
1058
+ clearGeneratedOptions();
1059
+ }
1060
+ setStatus(elements, `Tileset animation generation failed. ${errorMessage(error)}`, "error");
1061
+ }
1062
+ }
1063
+ finally {
1064
+ finishGeneration(currentGenerationId);
1065
+ }
1066
+ };
367
1067
  elements.assetSelect.addEventListener("change", () => {
1068
+ invalidatePanelWork();
368
1069
  selectedAssetId = elements.assetSelect.value;
369
1070
  syncAsset(selectedAssetId);
370
1071
  });
371
1072
  elements.targetSelect.addEventListener("change", () => {
1073
+ invalidatePanelWork();
372
1074
  selectedTargetId = elements.targetSelect.value || undefined;
373
1075
  syncAsset(selectedAssetId);
374
1076
  setStatus(elements, selectedTargetId
@@ -376,9 +1078,27 @@ export function installAiAssetDesigner(options) {
376
1078
  : "Editing default target.", "info");
377
1079
  });
378
1080
  elements.animationSelect.addEventListener("change", () => {
1081
+ invalidatePanelWork();
1082
+ if (isDesignerTilesetAsset(manifest.assets[selectedTargetAssetId])) {
1083
+ const prefix = "tileset:animation:";
1084
+ selectedTilesetAnimationKey = elements.animationSelect.value.startsWith(prefix)
1085
+ ? decodeURIComponent(elements.animationSelect.value.slice(prefix.length))
1086
+ : undefined;
1087
+ syncTargetAsset(selectedTargetAssetId);
1088
+ return;
1089
+ }
1090
+ selectedTilesetAnimationKey = undefined;
379
1091
  selectedTargetAssetId = elements.animationSelect.value;
380
1092
  syncTargetAsset(selectedTargetAssetId);
381
1093
  });
1094
+ const syncTilesetPromptCount = () => {
1095
+ const asset = manifest.assets[selectedTargetAssetId];
1096
+ if (isDesignerTilesetAsset(asset) && !selectedTilesetAnimationKey) {
1097
+ renderTilesetPromptInputs(selectedTargetAssetId);
1098
+ }
1099
+ };
1100
+ elements.frameCountInput.addEventListener("change", syncTilesetPromptCount);
1101
+ elements.frameCountInput.addEventListener("blur", syncTilesetPromptCount);
382
1102
  elements.formatSelect.addEventListener("change", () => {
383
1103
  if (canEditGenerationFormat(manifest, selectedAssetId, selectedTargetAssetId)) {
384
1104
  formatDrafts.set(selectedTargetAssetId, normalizeAssetFormat(elements.formatSelect.value));
@@ -464,57 +1184,212 @@ export function installAiAssetDesigner(options) {
464
1184
  event.preventDefault();
465
1185
  elements.currentPreview.click();
466
1186
  });
1187
+ elements.mixTilesetButton.addEventListener("click", async () => {
1188
+ const session = displayedOptions;
1189
+ const animationSession = displayedTilesetAnimationOptions;
1190
+ const assetId = selectedTargetAssetId;
1191
+ const asset = manifest.assets[assetId];
1192
+ const activeVersion = asset?.versions[asset.activeVersion];
1193
+ if (animationSession &&
1194
+ animationSession.assetId === assetId &&
1195
+ animationSession.animationKey === selectedTilesetAnimationKey &&
1196
+ animationSession.generated.length === 3 &&
1197
+ asset?.tileset &&
1198
+ activeVersion?.file &&
1199
+ !mixingTileset &&
1200
+ !activeGeneration &&
1201
+ activePromotionId === undefined) {
1202
+ const mixPanelRevision = panelRevision;
1203
+ const definition = animationSession.animation;
1204
+ const draftAsset = assetWithTilesetAnimationDefinition(asset, definition);
1205
+ const pending = pendingOptions.get(assetId);
1206
+ mixingTileset = true;
1207
+ syncMixTilesetButton();
1208
+ setStatus(elements, "Opening tileset animation mixer...", "busy");
1209
+ try {
1210
+ const baseSheetSrc = pending?.option.dataUrl ??
1211
+ await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
1212
+ const currentFrames = pending?.tilesetAnimations?.[definition.key] ??
1213
+ await Promise.all((activeVersion.tilesetAnimations?.[definition.key]?.files ?? [])
1214
+ .map((file) => imageSourceToDataUrl(resolveAssetUrl(file))));
1215
+ if (panelRevision !== mixPanelRevision ||
1216
+ selectedTargetAssetId !== assetId ||
1217
+ selectedTilesetAnimationKey !== definition.key)
1218
+ return;
1219
+ const mixed = await openTilesetAnimationMixerDialog({
1220
+ root: elements.root,
1221
+ asset: draftAsset,
1222
+ assetId,
1223
+ animationKey: definition.key,
1224
+ baseSheetSrc,
1225
+ baseAnimationFrameSrcs: currentFrames,
1226
+ candidates: animationSession.generated,
1227
+ tilePrompts: definition.tiles,
1228
+ regenerateTile: async (tile, currentTileSrc) => {
1229
+ const prompt = definition.tiles?.[tile];
1230
+ if (!prompt)
1231
+ throw new Error(`Tile ${tile + 1} requires an animation prompt.`);
1232
+ return client.generateTilesetAnimationStream({
1233
+ assetId,
1234
+ animationKey: definition.key,
1235
+ count: 3,
1236
+ frameCount: definition.frameCount,
1237
+ tiles: [prompt],
1238
+ tileset: tilesetTileGenerationOverride(asset.tileset, tile),
1239
+ baseDataUrl: currentTileSrc,
1240
+ styleGuide: await styleGuideRequest(styleGuideDraft)
1241
+ }, () => undefined);
1242
+ }
1243
+ });
1244
+ if (panelRevision !== mixPanelRevision ||
1245
+ selectedTargetAssetId !== assetId ||
1246
+ selectedTilesetAnimationKey !== definition.key)
1247
+ return;
1248
+ if (!mixed) {
1249
+ setStatus(elements, "Tileset animation mixing cancelled. The three options are still available.", "info");
1250
+ return;
1251
+ }
1252
+ await selectTilesetAnimationFrames(assetId, definition, mixed.frames);
1253
+ setStatus(elements, "Mixed tileset animation ready. Promote to save the selected tile combination.", "success");
1254
+ }
1255
+ catch (error) {
1256
+ setStatus(elements, `Could not mix tileset animation. ${errorMessage(error)}`, "error");
1257
+ }
1258
+ finally {
1259
+ mixingTileset = false;
1260
+ syncMixTilesetButton();
1261
+ }
1262
+ return;
1263
+ }
1264
+ if (mixingTileset ||
1265
+ activeGeneration ||
1266
+ activePromotionId !== undefined ||
1267
+ !session ||
1268
+ session.assetId !== assetId ||
1269
+ session.generated.length !== 3 ||
1270
+ !isDesignerTilesetAsset(asset) ||
1271
+ selectedTilesetAnimationKey ||
1272
+ !activeVersion?.file) {
1273
+ return;
1274
+ }
1275
+ const mixPanelRevision = panelRevision;
1276
+ const candidates = [...session.generated].sort((left, right) => left.index - right.index);
1277
+ mixingTileset = true;
1278
+ syncMixTilesetButton();
1279
+ setStatus(elements, "Opening tileset mixer...", "busy");
1280
+ try {
1281
+ const currentOption = editedCurrentOption;
1282
+ const activeSheetSrc = currentOption?.dataUrl ??
1283
+ await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
1284
+ const current = resolveTilesetBaseMixCurrent(asset, activeSheetSrc, currentOption);
1285
+ if (panelRevision !== mixPanelRevision ||
1286
+ selectedTargetAssetId !== assetId ||
1287
+ selectedTilesetAnimationKey) {
1288
+ return;
1289
+ }
1290
+ const mixed = await openTilesetBaseMixerDialog({
1291
+ root: elements.root,
1292
+ asset: current.asset,
1293
+ assetId,
1294
+ baseSheetSrc: current.sheetSrc,
1295
+ candidates,
1296
+ regenerateTile: (tile, tileset, currentTileSrc) => regenerateTilesetTile(assetId, tile, tileset, currentTileSrc)
1297
+ });
1298
+ if (panelRevision !== mixPanelRevision ||
1299
+ selectedTargetAssetId !== assetId ||
1300
+ selectedTilesetAnimationKey) {
1301
+ return;
1302
+ }
1303
+ if (!mixed) {
1304
+ setStatus(elements, "Tileset mixing cancelled. The three options are still available.", "info");
1305
+ return;
1306
+ }
1307
+ const template = candidates[0];
1308
+ const mixedOption = createMixedTilesetOption(template, mixed, template.prompt || asset.prompt, Math.max(...candidates.map((option) => option.index)) + 1);
1309
+ selectedOption = mixedOption;
1310
+ rememberPendingOption(assetId, mixedOption);
1311
+ showOptionInCurrentPreview(mixedOption, "mixed tileset");
1312
+ elements.currentPreview.classList.add("is-selected");
1313
+ elements.promoteButton.disabled = activePromotionId !== undefined;
1314
+ previewOption({
1315
+ scene: options.scene,
1316
+ manifest,
1317
+ assetId,
1318
+ option: mixedOption,
1319
+ onPreview: options.onPreview
1320
+ });
1321
+ setStatus(elements, "Mixed tileset ready. Promote to save the selected tile combination.", "success");
1322
+ }
1323
+ catch (error) {
1324
+ setStatus(elements, `Could not mix tileset. ${errorMessage(error)}`, "error");
1325
+ }
1326
+ finally {
1327
+ mixingTileset = false;
1328
+ syncMixTilesetButton();
1329
+ }
1330
+ });
467
1331
  elements.regenerateButton.addEventListener("click", async () => {
468
1332
  if (activeGeneration) {
469
1333
  activeGeneration.controller.abort();
470
1334
  activeGeneration = undefined;
471
1335
  elements.regenerateButton.textContent = "Regenerate";
472
1336
  unlockGenerationStatus();
1337
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
1338
+ syncPromoteAllButton();
473
1339
  setStatus(elements, "Generation cancelled.", "info");
474
1340
  return;
475
1341
  }
1342
+ if (selectedTilesetAnimationKey) {
1343
+ await regenerateTilesetAnimation(selectedTilesetAnimationKey);
1344
+ return;
1345
+ }
476
1346
  const controller = new AbortController();
477
1347
  const currentGenerationId = generationId + 1;
478
1348
  const generationAssetId = selectedTargetAssetId;
1349
+ const generationAsset = manifest.assets[generationAssetId];
479
1350
  const generationFormat = effectiveGenerationFormat(manifest, formatDrafts, selectedAssetId, generationAssetId);
1351
+ const generationOverrides = generationOverridesFromInputs(elements, generationAsset, generationFormat);
1352
+ const isBaseTilesetGeneration = isDesignerTilesetAsset(generationAsset);
1353
+ if (isBaseTilesetGeneration && generationOverrides.tileset) {
1354
+ renderTilesetPromptInputs(generationAssetId);
1355
+ const tileCount = generationOverrides.tileset.tileCount ??
1356
+ effectiveTilesetTileCount(generationAsset);
1357
+ const tiles = tilesetPromptDefinitions(generationAssetId, tileCount);
1358
+ if (!tiles) {
1359
+ setStatus(elements, "Add a prompt for every tile before generating.", "error");
1360
+ return;
1361
+ }
1362
+ generationOverrides.tileset = {
1363
+ ...generationOverrides.tileset,
1364
+ tiles
1365
+ };
1366
+ }
480
1367
  generationId = currentGenerationId;
481
1368
  activeGeneration = { controller, id: currentGenerationId };
1369
+ syncPromoteAllButton();
482
1370
  setStatus(elements, "Generating options...", "busy");
483
1371
  lockGenerationStatus();
484
1372
  elements.promoteButton.disabled = true;
485
1373
  elements.regenerateButton.textContent = "Cancel";
486
- elements.options.innerHTML = "";
487
- selectedOption = undefined;
1374
+ clearGeneratedOptions();
1375
+ selectedOption = editedCurrentOption;
488
1376
  const streamedOptions = [];
489
1377
  try {
490
1378
  const generationRequest = {
491
1379
  assetId: generationAssetId,
492
- prompt: elements.promptInput.value,
1380
+ prompt: isBaseTilesetGeneration ? undefined : elements.promptInput.value,
493
1381
  count: options.optionCount ?? 3,
494
1382
  format: generationFormat,
495
1383
  audioSettings: audioGenerationOverridesFromInputs(elements, manifest.assets[generationAssetId]),
496
1384
  voiceSettings: voiceGenerationOverridesFromInputs(elements, manifest.assets[generationAssetId]),
497
1385
  styleGuide: await styleGuideRequest(styleGuideDraft),
498
- ...generationOverridesFromInputs(elements, manifest.assets[generationAssetId], generationFormat)
1386
+ ...generationOverrides
499
1387
  };
500
1388
  await client.generateStream(generationRequest, (option) => {
501
1389
  if (activeGeneration?.id !== currentGenerationId)
502
1390
  return;
503
1391
  streamedOptions.push(option);
504
- renderOptions({
505
- elements,
506
- generated: [...streamedOptions].sort((left, right) => left.index - right.index),
507
- scene: options.scene,
508
- manifest,
509
- assetId: generationAssetId,
510
- designerOptions: options,
511
- onPreview: options.onPreview,
512
- onSelected(selected) {
513
- selectedOption = selected;
514
- showOptionInCurrentPreview(selected);
515
- elements.promoteButton.disabled = false;
516
- }
517
- });
1392
+ renderGeneratedOptions(generationAssetId, [...streamedOptions].sort((left, right) => left.index - right.index));
518
1393
  }, {
519
1394
  signal: controller.signal
520
1395
  });
@@ -522,7 +1397,11 @@ export function installAiAssetDesigner(options) {
522
1397
  return;
523
1398
  }
524
1399
  finishGeneration(currentGenerationId);
525
- setStatus(elements, streamedOptions.length > 0 ? "Pick an option to preview it." : "Generation finished with no options.", streamedOptions.length > 0 ? "info" : "error");
1400
+ setStatus(elements, streamedOptions.length === 3 && isBaseTilesetGeneration
1401
+ ? "Three tileset options ready. Preview one or mix tiles."
1402
+ : streamedOptions.length > 0
1403
+ ? "Pick an option to preview it."
1404
+ : "Generation finished with no options.", streamedOptions.length > 0 ? "info" : "error");
526
1405
  }
527
1406
  catch (error) {
528
1407
  if (isAbortError(error)) {
@@ -530,7 +1409,7 @@ export function installAiAssetDesigner(options) {
530
1409
  }
531
1410
  finishGeneration(currentGenerationId);
532
1411
  if (streamedOptions.length === 0) {
533
- elements.options.innerHTML = "";
1412
+ clearGeneratedOptions();
534
1413
  }
535
1414
  setStatus(elements, `Generation failed. ${errorMessage(error)}`, "error");
536
1415
  }
@@ -550,23 +1429,23 @@ export function installAiAssetDesigner(options) {
550
1429
  elements,
551
1430
  prompt: elements.promptInput.value
552
1431
  });
553
- renderOptions({
554
- elements,
555
- generated: [uploadedOption],
556
- scene: options.scene,
557
- manifest,
558
- assetId: selectedTargetAssetId,
559
- designerOptions: options,
560
- onPreview: options.onPreview,
561
- onSelected(option) {
562
- selectedOption = option;
563
- showOptionInCurrentPreview(option);
564
- elements.promoteButton.disabled = false;
1432
+ if (isDesignerTilesetAsset(asset) && uploadedOption.tileset) {
1433
+ const tileCount = uploadedOption.tileset.tileCount ??
1434
+ uploadedOption.tileset.columns * uploadedOption.tileset.rows;
1435
+ const tiles = tilesetPromptDefinitions(selectedTargetAssetId, tileCount);
1436
+ if (!tiles) {
1437
+ throw new Error("Add a prompt for every tile before uploading a tileset.");
565
1438
  }
566
- });
1439
+ uploadedOption.tileset = {
1440
+ ...uploadedOption.tileset,
1441
+ tiles
1442
+ };
1443
+ }
1444
+ renderGeneratedOptions(selectedTargetAssetId, [uploadedOption]);
567
1445
  selectedOption = uploadedOption;
1446
+ rememberPendingOption(selectedTargetAssetId, uploadedOption);
568
1447
  showOptionInCurrentPreview(uploadedOption);
569
- elements.promoteButton.disabled = false;
1448
+ elements.promoteButton.disabled = activePromotionId !== undefined;
570
1449
  if (isAudioAsset(asset)) {
571
1450
  options.onPreview(selectedTargetAssetId, uploadedOption.dataUrl, assetWithGeneratedGeometry(asset, uploadedOption));
572
1451
  }
@@ -599,7 +1478,7 @@ export function installAiAssetDesigner(options) {
599
1478
  prompt: elements.promptInput.value,
600
1479
  format: generationFormat,
601
1480
  onConfirm: async (deriveRequest) => {
602
- elements.options.innerHTML = "";
1481
+ clearGeneratedOptions();
603
1482
  elements.promoteButton.disabled = true;
604
1483
  selectedOption = undefined;
605
1484
  if (deriveRequest.strategy === "scale" ||
@@ -619,23 +1498,11 @@ export function installAiAssetDesigner(options) {
619
1498
  mirrorX: deriveRequest.mirrorX,
620
1499
  mirrorY: deriveRequest.mirrorY
621
1500
  });
622
- renderOptions({
623
- elements,
624
- generated: [option],
625
- scene: options.scene,
626
- manifest,
627
- assetId: derivationAssetId,
628
- designerOptions: options,
629
- onPreview: options.onPreview,
630
- onSelected(selected) {
631
- selectedOption = selected;
632
- showOptionInCurrentPreview(selected);
633
- elements.promoteButton.disabled = false;
634
- }
635
- });
1501
+ renderGeneratedOptions(derivationAssetId, [option]);
636
1502
  selectedOption = option;
1503
+ rememberPendingOption(derivationAssetId, option);
637
1504
  showOptionInCurrentPreview(option);
638
- elements.promoteButton.disabled = false;
1505
+ elements.promoteButton.disabled = activePromotionId !== undefined;
639
1506
  previewOption({
640
1507
  scene: options.scene,
641
1508
  manifest,
@@ -654,6 +1521,7 @@ export function installAiAssetDesigner(options) {
654
1521
  : elements.promptInput.value;
655
1522
  generationId = currentGenerationId;
656
1523
  activeGeneration = { controller, id: currentGenerationId };
1524
+ syncPromoteAllButton();
657
1525
  elements.regenerateButton.textContent = "Cancel";
658
1526
  setStatus(elements, "Generating options...", "busy");
659
1527
  lockGenerationStatus();
@@ -672,20 +1540,7 @@ export function installAiAssetDesigner(options) {
672
1540
  if (activeGeneration?.id !== currentGenerationId)
673
1541
  return;
674
1542
  streamedOptions.push(option);
675
- renderOptions({
676
- elements,
677
- generated: [...streamedOptions].sort((left, right) => left.index - right.index),
678
- scene: options.scene,
679
- manifest,
680
- assetId: derivationAssetId,
681
- designerOptions: options,
682
- onPreview: options.onPreview,
683
- onSelected(selected) {
684
- selectedOption = selected;
685
- showOptionInCurrentPreview(selected);
686
- elements.promoteButton.disabled = false;
687
- }
688
- });
1543
+ renderGeneratedOptions(derivationAssetId, [...streamedOptions].sort((left, right) => left.index - right.index));
689
1544
  }, {
690
1545
  signal: controller.signal
691
1546
  });
@@ -705,49 +1560,348 @@ export function installAiAssetDesigner(options) {
705
1560
  });
706
1561
  });
707
1562
  elements.promoteButton.addEventListener("click", async () => {
708
- if (!selectedOption)
1563
+ if (!selectedOption || activePromotionId !== undefined)
709
1564
  return;
1565
+ const currentPromotionId = promotionId + 1;
1566
+ promotionId = currentPromotionId;
1567
+ activePromotionId = currentPromotionId;
1568
+ syncPromoteAllButton();
1569
+ syncMixTilesetButton();
1570
+ const promotedAssetId = selectedTargetAssetId;
1571
+ const promotedOption = selectedOption;
1572
+ const promotedPending = pendingOptions.get(promotedAssetId);
1573
+ const promotionPanelRevision = panelRevision;
1574
+ const promotionSelectedAssetId = selectedAssetId;
1575
+ const promotionTargetId = selectedTargetId;
1576
+ const promotionAnimationKey = selectedTilesetAnimationKey;
1577
+ const inheritAnimations = Boolean(previewedVersionName);
1578
+ const isPromotionPanelCurrent = () => panelRevision === promotionPanelRevision &&
1579
+ selectedAssetId === promotionSelectedAssetId &&
1580
+ selectedTargetId === promotionTargetId &&
1581
+ selectedTargetAssetId === promotedAssetId &&
1582
+ selectedTilesetAnimationKey === promotionAnimationKey;
710
1583
  const versionName = `promoted-${Date.now()}`;
711
1584
  setStatus(elements, `Promoting ${versionName}...`, "busy");
712
1585
  elements.promoteButton.disabled = true;
1586
+ elements.panel.setAttribute("aria-busy", "true");
1587
+ elements.panel.setAttribute("inert", "");
1588
+ let saved;
1589
+ let promotedAsset;
1590
+ let promotedVersionName;
1591
+ try {
1592
+ const animationFrames = promotionAnimationKey
1593
+ ? promotedPending?.tilesetAnimations?.[promotionAnimationKey]
1594
+ : undefined;
1595
+ if (promotionAnimationKey &&
1596
+ animationFrames &&
1597
+ promotedPending?.animationOnlyKey === promotionAnimationKey) {
1598
+ const definition = promotedOption.tileset?.animations?.find((animation) => animation.key === promotionAnimationKey);
1599
+ const animationSaved = await client.saveTilesetAnimation({
1600
+ assetId: promotedAssetId,
1601
+ animationKey: promotionAnimationKey,
1602
+ frames: animationFrames,
1603
+ definition,
1604
+ versionName,
1605
+ notes: "Promoted from a selected or mixed tileset animation preview."
1606
+ });
1607
+ saved = animationSaved;
1608
+ manifest = animationSaved.manifest;
1609
+ promotedAsset = animationSaved.asset;
1610
+ promotedVersionName = animationSaved.versionName;
1611
+ }
1612
+ else {
1613
+ saved = await client.save({
1614
+ assetId: promotedAssetId,
1615
+ versionName,
1616
+ dataUrl: promotedOption.dataUrl,
1617
+ prompt: promotedOption.prompt,
1618
+ model: promotedOption.model,
1619
+ revisedPrompt: promotedOption.revisedPrompt,
1620
+ dimensions: promotedOption.dimensions,
1621
+ frameGrid: promotedOption.frameGrid,
1622
+ tileset: promotedOption.tileset,
1623
+ tilesetSourceDataUrl: promotedOption.tilesetSourceDataUrl,
1624
+ tilesetTransforms: promotedOption.tilesetTransforms,
1625
+ animations: assetWithGeneratedGeometry(manifest.assets[promotedAssetId], promotedOption, { inheritAnimations }).animations,
1626
+ settings: promotedOption.settings,
1627
+ audioSettings: promotedOption.audioSettings,
1628
+ audioPlayback: promotedOption.audioPlayback,
1629
+ voiceSettings: promotedOption.voiceSettings,
1630
+ durationSeconds: promotedOption.durationSeconds,
1631
+ activate: true,
1632
+ notes: "Promoted from the AI asset designer."
1633
+ });
1634
+ manifest = saved.manifest;
1635
+ promotedAsset = saved.asset;
1636
+ promotedVersionName = saved.versionName;
1637
+ for (const [animationKey, frames] of Object.entries(promotedPending?.tilesetAnimations ?? {})) {
1638
+ const animationSaved = await client.saveTilesetAnimation({
1639
+ assetId: promotedAssetId,
1640
+ animationKey,
1641
+ frames,
1642
+ definition: promotedOption.tileset?.animations?.find((animation) => animation.key === animationKey),
1643
+ notes: "Regenerated with the asset style during promotion."
1644
+ });
1645
+ manifest = animationSaved.manifest;
1646
+ promotedAsset = animationSaved.asset;
1647
+ promotedVersionName = animationSaved.versionName;
1648
+ }
1649
+ }
1650
+ for (const [animationKey, frames] of Object.entries(promotedPending?.tilesetAnimations ?? {})) {
1651
+ await installTilesetAnimationFrameTextures(frames, promotedAsset, (index) => aiTilesetAnimationTextureKey(promotedAssetId, animationKey, index));
1652
+ }
1653
+ }
1654
+ catch (error) {
1655
+ if (activePromotionId === currentPromotionId) {
1656
+ activePromotionId = undefined;
1657
+ }
1658
+ syncPromoteAllButton();
1659
+ syncMixTilesetButton();
1660
+ elements.panel.removeAttribute("aria-busy");
1661
+ elements.panel.removeAttribute("inert");
1662
+ if (isPromotionPanelCurrent()) {
1663
+ elements.promoteButton.disabled = activePromotionId !== undefined;
1664
+ setStatus(elements, `Promotion failed. ${errorMessage(error)}`, "error");
1665
+ }
1666
+ return;
1667
+ }
1668
+ forgetPendingOption(promotedAssetId);
1669
+ if (isDesignerTilesetAsset(promotedAsset)) {
1670
+ tilesetPromptDrafts.set(promotedAssetId, promotedAsset.tileset?.tiles?.map((tile) => tile.prompt) ?? []);
1671
+ }
1672
+ let promotedTextureKey;
1673
+ let liveRefreshError;
1674
+ const captureLiveRefreshError = (error) => {
1675
+ liveRefreshError ??= error;
1676
+ };
1677
+ if (promotedAsset.activeVersion !== promotedVersionName) {
1678
+ captureLiveRefreshError(new Error(`Saved version "${promotedVersionName}" was not returned as the active version.`));
1679
+ }
1680
+ else if (!isAudioAsset(promotedAsset)) {
1681
+ try {
1682
+ promotedTextureKey = (await installPromotedImageTexture({
1683
+ scene: options.scene,
1684
+ manifest,
1685
+ assetId: promotedAssetId,
1686
+ src: promotedOption.dataUrl,
1687
+ assetOverride: promotedAsset
1688
+ })).textureKey;
1689
+ }
1690
+ catch (generatedSourceError) {
1691
+ try {
1692
+ promotedTextureKey = (await installPromotedImageTexture({
1693
+ scene: options.scene,
1694
+ manifest,
1695
+ assetId: promotedAssetId,
1696
+ src: resolveAssetUrl(saved.file),
1697
+ assetOverride: promotedAsset
1698
+ })).textureKey;
1699
+ }
1700
+ catch (savedSourceError) {
1701
+ captureLiveRefreshError(new Error(`Could not install the promoted texture. ${errorMessage(savedSourceError)}`, { cause: generatedSourceError }));
1702
+ }
1703
+ }
1704
+ }
713
1705
  try {
714
- await client.save({
715
- assetId: selectedTargetAssetId,
716
- versionName,
717
- dataUrl: selectedOption.dataUrl,
718
- prompt: selectedOption.prompt,
719
- model: selectedOption.model,
720
- revisedPrompt: selectedOption.revisedPrompt,
721
- dimensions: selectedOption.dimensions,
722
- frameGrid: selectedOption.frameGrid,
723
- animations: assetWithGeneratedGeometry(manifest.assets[selectedTargetAssetId], selectedOption, { inheritAnimations: Boolean(previewedVersionName) }).animations,
724
- settings: selectedOption.settings,
725
- audioSettings: selectedOption.audioSettings,
726
- audioPlayback: selectedOption.audioPlayback,
727
- voiceSettings: selectedOption.voiceSettings,
728
- durationSeconds: selectedOption.durationSeconds,
729
- activate: true,
730
- notes: "Promoted from the AI asset designer."
731
- });
732
- manifest = await client.getManifest();
733
1706
  options.onManifestUpdated?.(manifest);
734
- formatDrafts.delete(selectedTargetAssetId);
735
- syncTargetAsset(selectedTargetAssetId);
736
- if (isAudioAsset(manifest.assets[selectedTargetAssetId])) {
737
- const promotedAsset = manifest.assets[selectedTargetAssetId];
738
- const promotedVersion = promotedAsset.versions[promotedAsset.activeVersion];
739
- if (promotedVersion?.file) {
740
- (options.onAssetReady ?? options.onPreview)(selectedTargetAssetId, resolveAssetUrl(promotedVersion.file), promotedAsset);
1707
+ }
1708
+ catch (error) {
1709
+ captureLiveRefreshError(error);
1710
+ }
1711
+ try {
1712
+ if (promotedTextureKey) {
1713
+ (options.onAssetReady ?? options.onPreview)(promotedAssetId, promotedTextureKey, promotedAsset);
1714
+ }
1715
+ else if (isAudioAsset(promotedAsset)) {
1716
+ (options.onAssetReady ?? options.onPreview)(promotedAssetId, resolveAssetUrl(saved.file), promotedAsset);
1717
+ }
1718
+ }
1719
+ catch (error) {
1720
+ captureLiveRefreshError(error);
1721
+ }
1722
+ formatDrafts.delete(promotedAssetId);
1723
+ if (isPromotionPanelCurrent()) {
1724
+ try {
1725
+ syncTargetAsset(promotedAssetId, { preserveOptions: true });
1726
+ }
1727
+ catch (error) {
1728
+ captureLiveRefreshError(error);
1729
+ }
1730
+ if (liveRefreshError) {
1731
+ setStatus(elements, `Promoted ${promotedAssetId}, but the live game could not refresh. ` +
1732
+ `Restart to load it. ${errorMessage(liveRefreshError)}`, "error");
1733
+ }
1734
+ else {
1735
+ setStatus(elements, `Promoted ${promotedAssetId} to ${promotedVersionName}.`, "success");
1736
+ }
1737
+ }
1738
+ if (activePromotionId === currentPromotionId) {
1739
+ activePromotionId = undefined;
1740
+ }
1741
+ syncPromoteAllButton();
1742
+ syncMixTilesetButton();
1743
+ elements.panel.removeAttribute("aria-busy");
1744
+ elements.panel.removeAttribute("inert");
1745
+ if (!isPromotionPanelCurrent() && selectedOption) {
1746
+ elements.promoteButton.disabled = activePromotionId !== undefined;
1747
+ }
1748
+ if (options.restartOnPromote) {
1749
+ window.location.reload();
1750
+ }
1751
+ });
1752
+ elements.promoteAllButton.addEventListener("click", async () => {
1753
+ if (pendingOptions.size === 0 || activePromotionId !== undefined)
1754
+ return;
1755
+ const entries = [...pendingOptions.entries()];
1756
+ const currentPromotionId = promotionId + 1;
1757
+ promotionId = currentPromotionId;
1758
+ activePromotionId = currentPromotionId;
1759
+ elements.panel.setAttribute("aria-busy", "true");
1760
+ elements.panel.setAttribute("inert", "");
1761
+ elements.promoteButton.disabled = true;
1762
+ syncPromoteAllButton();
1763
+ syncMixTilesetButton();
1764
+ let promotedCount = 0;
1765
+ let promotionError;
1766
+ const liveRefreshErrors = [];
1767
+ for (const [index, [assetId, pending]] of entries.entries()) {
1768
+ const asset = manifest.assets[assetId];
1769
+ if (!asset) {
1770
+ promotionError = { assetId, error: new Error("Asset no longer exists in the manifest.") };
1771
+ break;
1772
+ }
1773
+ const versionName = `promoted-${Date.now()}-${index + 1}`;
1774
+ setStatus(elements, `Promoting ${readableAssetName(assetId)} (${index + 1}/${entries.length})...`, "busy");
1775
+ try {
1776
+ let saved;
1777
+ let promotedAsset;
1778
+ const animationOnlyFrames = pending.animationOnlyKey
1779
+ ? pending.tilesetAnimations?.[pending.animationOnlyKey]
1780
+ : undefined;
1781
+ if (pending.animationOnlyKey && animationOnlyFrames) {
1782
+ saved = await client.saveTilesetAnimation({
1783
+ assetId,
1784
+ animationKey: pending.animationOnlyKey,
1785
+ frames: animationOnlyFrames,
1786
+ definition: pending.option.tileset?.animations?.find((animation) => animation.key === pending.animationOnlyKey),
1787
+ versionName,
1788
+ notes: "Promoted from a tileset animation preview with Promote all."
1789
+ });
1790
+ manifest = saved.manifest;
1791
+ promotedAsset = saved.asset;
1792
+ }
1793
+ else {
1794
+ saved = await client.save({
1795
+ assetId,
1796
+ versionName,
1797
+ dataUrl: pending.option.dataUrl,
1798
+ prompt: pending.option.prompt,
1799
+ model: pending.option.model,
1800
+ revisedPrompt: pending.option.revisedPrompt,
1801
+ dimensions: pending.option.dimensions,
1802
+ frameGrid: pending.option.frameGrid,
1803
+ tileset: pending.option.tileset,
1804
+ tilesetSourceDataUrl: pending.option.tilesetSourceDataUrl,
1805
+ tilesetTransforms: pending.option.tilesetTransforms,
1806
+ animations: assetWithGeneratedGeometry(asset, pending.option, {
1807
+ inheritAnimations: pending.inheritAnimations
1808
+ }).animations,
1809
+ settings: pending.option.settings,
1810
+ audioSettings: pending.option.audioSettings,
1811
+ audioPlayback: pending.option.audioPlayback,
1812
+ voiceSettings: pending.option.voiceSettings,
1813
+ durationSeconds: pending.option.durationSeconds,
1814
+ activate: true,
1815
+ notes: "Promoted from the AI asset designer with Promote all."
1816
+ });
1817
+ manifest = saved.manifest;
1818
+ promotedAsset = saved.asset;
1819
+ for (const [animationKey, frames] of Object.entries(pending.tilesetAnimations ?? {})) {
1820
+ const animationSaved = await client.saveTilesetAnimation({
1821
+ assetId,
1822
+ animationKey,
1823
+ frames,
1824
+ definition: pending.option.tileset?.animations?.find((animation) => animation.key === animationKey),
1825
+ notes: "Regenerated with the asset style during Promote all."
1826
+ });
1827
+ manifest = animationSaved.manifest;
1828
+ promotedAsset = animationSaved.asset;
1829
+ }
1830
+ }
1831
+ if (isDesignerTilesetAsset(promotedAsset)) {
1832
+ tilesetPromptDrafts.set(assetId, promotedAsset.tileset?.tiles?.map((tile) => tile.prompt) ?? []);
1833
+ }
1834
+ for (const [animationKey, frames] of Object.entries(pending.tilesetAnimations ?? {})) {
1835
+ await installTilesetAnimationFrameTextures(frames, promotedAsset, (frameIndex) => aiTilesetAnimationTextureKey(assetId, animationKey, frameIndex));
1836
+ }
1837
+ try {
1838
+ if (isAudioAsset(promotedAsset)) {
1839
+ (options.onAssetReady ?? options.onPreview)(assetId, resolveAssetUrl(saved.file), promotedAsset);
1840
+ }
1841
+ else {
1842
+ let installed;
1843
+ try {
1844
+ installed = await installPromotedImageTexture({
1845
+ scene: options.scene,
1846
+ manifest,
1847
+ assetId,
1848
+ src: pending.option.dataUrl,
1849
+ assetOverride: promotedAsset
1850
+ });
1851
+ }
1852
+ catch {
1853
+ installed = await installPromotedImageTexture({
1854
+ scene: options.scene,
1855
+ manifest,
1856
+ assetId,
1857
+ src: resolveAssetUrl(saved.file),
1858
+ assetOverride: promotedAsset
1859
+ });
1860
+ }
1861
+ (options.onAssetReady ?? options.onPreview)(assetId, installed.textureKey, promotedAsset);
1862
+ }
741
1863
  }
1864
+ catch (error) {
1865
+ liveRefreshErrors.push({ assetId, error });
1866
+ }
1867
+ pendingOptions.delete(assetId);
1868
+ formatDrafts.delete(assetId);
1869
+ promotedCount += 1;
742
1870
  }
743
- setStatus(elements, `Promoted ${selectedTargetAssetId} to ${versionName}.`, "success");
744
- if (options.restartOnPromote) {
745
- window.location.reload();
1871
+ catch (error) {
1872
+ promotionError = { assetId, error };
1873
+ break;
746
1874
  }
747
1875
  }
1876
+ try {
1877
+ if (promotedCount > 0) {
1878
+ options.onManifestUpdated?.(manifest);
1879
+ }
1880
+ syncTargetAsset(selectedTargetAssetId);
1881
+ renderAssetBrowser();
1882
+ }
748
1883
  catch (error) {
749
- elements.promoteButton.disabled = false;
750
- setStatus(elements, `Promotion failed. ${errorMessage(error)}`, "error");
1884
+ liveRefreshErrors.push({ assetId: selectedTargetAssetId, error });
1885
+ }
1886
+ activePromotionId = undefined;
1887
+ elements.panel.removeAttribute("aria-busy");
1888
+ elements.panel.removeAttribute("inert");
1889
+ syncPromoteAllButton();
1890
+ syncMixTilesetButton();
1891
+ if (promotionError) {
1892
+ setStatus(elements, `Promoted ${promotedCount}/${entries.length} assets. ` +
1893
+ `${readableAssetName(promotionError.assetId)} failed: ${errorMessage(promotionError.error)}`, "error");
1894
+ }
1895
+ else if (liveRefreshErrors.length > 0) {
1896
+ const firstError = liveRefreshErrors[0];
1897
+ setStatus(elements, `Promoted all ${promotedCount} assets, but ${readableAssetName(firstError.assetId)} ` +
1898
+ `could not refresh live. Restart to load it. ${errorMessage(firstError.error)}`, "error");
1899
+ }
1900
+ else {
1901
+ setStatus(elements, `Promoted all ${promotedCount} pending assets.`, "success");
1902
+ }
1903
+ if (options.restartOnPromote && promotedCount > 0) {
1904
+ window.location.reload();
751
1905
  }
752
1906
  });
753
1907
  elements.restartButton.addEventListener("click", () => {
@@ -762,30 +1916,45 @@ export function installAiAssetDesigner(options) {
762
1916
  resolveAssetUrl,
763
1917
  async onSelect(versionName, option) {
764
1918
  const optionAsset = assetWithGeneratedGeometry(asset, option, { inheritAnimations: true });
1919
+ const version = asset.versions[versionName];
1920
+ const tilesetSourceDataUrl = version?.tilesetSourceFile
1921
+ ? await imageSourceToDataUrl(resolveAssetUrl(version.tilesetSourceFile))
1922
+ : undefined;
1923
+ const versionOption = {
1924
+ ...option,
1925
+ tilesetSourceDataUrl,
1926
+ tilesetTransforms: tilesetSourceDataUrl
1927
+ ? cloneTilesetTransforms(version?.tilesetTransforms)
1928
+ : undefined
1929
+ };
765
1930
  for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
766
1931
  item.classList.remove("is-selected");
767
1932
  }
768
- selectedOption = option;
769
- showOptionInCurrentPreview(option, versionName);
1933
+ selectedOption = versionOption;
1934
+ rememberPendingOption(selectedTargetAssetId, versionOption, {
1935
+ inheritAnimations: true,
1936
+ previewedVersionName: versionName
1937
+ });
1938
+ showOptionInCurrentPreview(versionOption, versionName);
770
1939
  previewedVersionName = versionName;
771
1940
  elements.currentPreview.classList.add("is-selected");
772
- elements.promoteButton.disabled = false;
1941
+ elements.promoteButton.disabled = activePromotionId !== undefined;
773
1942
  if (isAudioAsset(asset)) {
774
1943
  renderAudioPlayer({
775
1944
  container: elements.currentAudio,
776
- src: option.dataUrl,
1945
+ src: versionOption.dataUrl,
777
1946
  label: readableAssetName(selectedTargetAssetId),
778
- playback: option.audioPlayback
1947
+ playback: versionOption.audioPlayback
779
1948
  });
780
- options.onPreview(selectedTargetAssetId, option.dataUrl, optionAsset);
1949
+ options.onPreview(selectedTargetAssetId, versionOption.dataUrl, optionAsset);
781
1950
  }
782
1951
  else {
783
- elements.currentImage.src = option.dataUrl;
1952
+ elements.currentImage.src = versionOption.dataUrl;
784
1953
  previewImageSource({
785
1954
  scene: options.scene,
786
1955
  manifest,
787
1956
  assetId: selectedTargetAssetId,
788
- src: option.dataUrl,
1957
+ src: versionOption.dataUrl,
789
1958
  textureKey: `ai-version-preview:${selectedTargetAssetId}:${versionName}:${Date.now()}`,
790
1959
  assetOverride: optionAsset,
791
1960
  onPreview: options.onPreview
@@ -801,6 +1970,7 @@ export function installAiAssetDesigner(options) {
801
1970
  });
802
1971
  options.onManifestUpdated?.(manifest);
803
1972
  if (previewedVersionName === versionName) {
1973
+ forgetPendingOption(selectedTargetAssetId);
804
1974
  syncTargetAsset(selectedTargetAssetId);
805
1975
  }
806
1976
  else {
@@ -816,6 +1986,113 @@ export function installAiAssetDesigner(options) {
816
1986
  }
817
1987
  });
818
1988
  });
1989
+ const previewBulkOption = (assetId, option) => new Promise((resolve, reject) => {
1990
+ const previewAsset = assetWithGeneratedGeometry(manifest.assets[assetId], option);
1991
+ previewImageSource({
1992
+ scene: options.scene,
1993
+ manifest,
1994
+ assetId,
1995
+ src: option.dataUrl,
1996
+ textureKey: `ai-bulk-preview:${assetId}:${Date.now()}`,
1997
+ assetOverride: previewAsset,
1998
+ onPreview(previewAssetId, textureKey, asset) {
1999
+ options.onPreview(previewAssetId, textureKey, asset);
2000
+ resolve(asset);
2001
+ },
2002
+ onError: reject
2003
+ });
2004
+ });
2005
+ const regenerateAllGraphicalAssets = async (styleGuide) => {
2006
+ if (activeGeneration || activePromotionId !== undefined)
2007
+ return;
2008
+ const assetIds = expandAiAssetIds(manifest, visibleAssetIds(), {
2009
+ includeLinkedAnimations: true,
2010
+ targetId: selectedTargetId
2011
+ }).filter((assetId) => {
2012
+ const asset = manifest.assets[assetId];
2013
+ return Boolean(asset && asset.kind !== "collection" && !isAudioAsset(asset));
2014
+ });
2015
+ if (assetIds.length === 0) {
2016
+ setStatus(elements, "There are no graphical assets to regenerate.", "info");
2017
+ return;
2018
+ }
2019
+ const controller = new AbortController();
2020
+ const currentGenerationId = generationId + 1;
2021
+ generationId = currentGenerationId;
2022
+ activeGeneration = { controller, id: currentGenerationId };
2023
+ clearGeneratedOptions();
2024
+ syncPromoteAllButton();
2025
+ const failures = [];
2026
+ let generatedCount = 0;
2027
+ for (const [index, assetId] of assetIds.entries()) {
2028
+ if (activeGeneration?.id !== currentGenerationId)
2029
+ return;
2030
+ setStatus(elements, `Regenerating ${readableAssetName(assetId)} (${index + 1}/${assetIds.length})...`, "busy");
2031
+ try {
2032
+ const generated = await client.generate({
2033
+ assetId,
2034
+ count: 1,
2035
+ styleGuide
2036
+ }, {
2037
+ signal: controller.signal
2038
+ });
2039
+ const option = generated[0];
2040
+ if (!option) {
2041
+ throw new Error("Generation finished without an option.");
2042
+ }
2043
+ rememberPendingOption(assetId, option);
2044
+ const previewAsset = await previewBulkOption(assetId, option);
2045
+ const generatedTilesetAnimations = {};
2046
+ for (const animation of previewAsset.tileset?.animations ?? []) {
2047
+ setStatus(elements, `Regenerating ${readableAssetName(assetId)} · ` +
2048
+ `${readableAssetName(animation.key)} (${index + 1}/${assetIds.length})...`, "busy");
2049
+ const candidates = await client.generateTilesetAnimationStream({
2050
+ assetId,
2051
+ animationKey: animation.key,
2052
+ prompt: animation.prompt,
2053
+ count: 1,
2054
+ baseDataUrl: option.dataUrl,
2055
+ styleGuide
2056
+ }, () => { }, {
2057
+ signal: controller.signal
2058
+ });
2059
+ const candidate = candidates[0];
2060
+ if (!candidate || candidate.frames.length !== animation.frameCount) {
2061
+ throw new Error(`${readableAssetName(animation.key)} did not return its ` +
2062
+ `${animation.frameCount} required frames.`);
2063
+ }
2064
+ const frameDataUrls = candidate.frames.map((frame) => frame.dataUrl);
2065
+ generatedTilesetAnimations[animation.key] = frameDataUrls;
2066
+ rememberPendingOption(assetId, option, {
2067
+ tilesetAnimations: { ...generatedTilesetAnimations }
2068
+ });
2069
+ await previewTilesetAnimationFrames(assetId, animation.key, frameDataUrls, previewAsset);
2070
+ }
2071
+ generatedCount += 1;
2072
+ if (selectedTargetAssetId === assetId) {
2073
+ selectedOption = option;
2074
+ showOptionInCurrentPreview(option, "regenerated preview");
2075
+ elements.currentPreview.classList.add("is-selected");
2076
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2077
+ }
2078
+ }
2079
+ catch (error) {
2080
+ if (isAbortError(error))
2081
+ return;
2082
+ failures.push({ assetId, error });
2083
+ }
2084
+ }
2085
+ if (!finishGeneration(currentGenerationId))
2086
+ return;
2087
+ if (failures.length > 0) {
2088
+ const firstFailure = failures[0];
2089
+ setStatus(elements, `Regenerated ${generatedCount}/${assetIds.length} graphical assets. ` +
2090
+ `${readableAssetName(firstFailure.assetId)} failed: ${errorMessage(firstFailure.error)}`, "error");
2091
+ return;
2092
+ }
2093
+ setStatus(elements, `Regenerated and previewed ${generatedCount} graphical assets. ` +
2094
+ "Use Promote all to save them.", "success");
2095
+ };
819
2096
  elements.styleButton.addEventListener("click", () => {
820
2097
  void openStyleGuideEditor({
821
2098
  root: elements.root,
@@ -838,16 +2115,62 @@ export function installAiAssetDesigner(options) {
838
2115
  setStatus(elements, `Style promotion failed. ${errorMessage(error)}`, "error");
839
2116
  throw error;
840
2117
  }
2118
+ },
2119
+ async onRegenerateAll(draft) {
2120
+ try {
2121
+ await regenerateAllGraphicalAssets(await styleGuideRequest(draft));
2122
+ }
2123
+ catch (error) {
2124
+ setStatus(elements, `Bulk regeneration failed. ${errorMessage(error)}`, "error");
2125
+ }
841
2126
  }
842
2127
  });
843
2128
  });
844
- elements.currentAnimationButton.addEventListener("click", (event) => {
2129
+ elements.currentAnimationButton.addEventListener("click", async (event) => {
845
2130
  event.stopPropagation();
846
2131
  const asset = manifest.assets[selectedTargetAssetId];
847
2132
  const activeVersion = asset.versions[asset.activeVersion];
848
2133
  const sourceOption = editedCurrentOption;
849
2134
  if (!sourceOption && !activeVersion?.file)
850
2135
  return;
2136
+ if (selectedTilesetAnimationKey && isDesignerTilesetAsset(asset)) {
2137
+ const assetId = selectedTargetAssetId;
2138
+ const animationKey = selectedTilesetAnimationKey;
2139
+ const definition = promptOnlyTilesetAnimationDefinition(assetId, animationKey);
2140
+ if (!definition) {
2141
+ setStatus(elements, "Add an animation prompt for every tile before editing the animation.", "error");
2142
+ return;
2143
+ }
2144
+ try {
2145
+ const pendingFrames = pendingOptions.get(assetId)?.tilesetAnimations?.[animationKey];
2146
+ const frames = pendingFrames ?? await activeTilesetAnimationFrames(assetId, animationKey);
2147
+ if (frames.length !== definition.frameCount) {
2148
+ throw new Error(`${readableAssetName(animationKey)} has ${frames.length} current frames, but ` +
2149
+ `${definition.frameCount} are configured. Generate new frames before editing.`);
2150
+ }
2151
+ const editorAsset = assetWithTilesetAnimationDefinition(asset, definition);
2152
+ await openTilesetAnimationEditor({
2153
+ root: elements.root,
2154
+ asset: editorAsset,
2155
+ assetId,
2156
+ animationKey,
2157
+ frames,
2158
+ onConfirm: async ({ frames: editedFrames, animation }) => {
2159
+ const currentPrompts = tilesetAnimationPromptDefinitions(assetId, animationKey);
2160
+ const editedDefinition = currentPrompts
2161
+ ? { ...animation, tiles: currentPrompts }
2162
+ : animation;
2163
+ await selectTilesetAnimationFrames(assetId, editedDefinition, editedFrames);
2164
+ elements.currentRevertButton.hidden = false;
2165
+ setStatus(elements, "Tileset animation edits applied. Promote to save them to code.", "success");
2166
+ }
2167
+ });
2168
+ }
2169
+ catch (error) {
2170
+ setStatus(elements, `Could not edit tileset animation. ${errorMessage(error)}`, "error");
2171
+ }
2172
+ return;
2173
+ }
851
2174
  if (isAudioAsset(asset)) {
852
2175
  void openAudioEditor({
853
2176
  root: elements.root,
@@ -877,7 +2200,11 @@ export function installAiAssetDesigner(options) {
877
2200
  durationSeconds: previousOption?.durationSeconds ?? activeVersion?.durationSeconds
878
2201
  };
879
2202
  selectedOption = editedCurrentOption;
880
- elements.promoteButton.disabled = false;
2203
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2204
+ inheritAnimations: Boolean(previewedVersionName),
2205
+ previewedVersionName
2206
+ });
2207
+ elements.promoteButton.disabled = activePromotionId !== undefined;
881
2208
  elements.currentRevertButton.hidden = false;
882
2209
  options.onPreview(selectedTargetAssetId, dataUrl, optionAsset);
883
2210
  renderAudioPlayer({
@@ -928,7 +2255,11 @@ export function installAiAssetDesigner(options) {
928
2255
  animations
929
2256
  };
930
2257
  selectedOption = editedCurrentOption;
931
- elements.promoteButton.disabled = false;
2258
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2259
+ inheritAnimations: Boolean(previewedVersionName),
2260
+ previewedVersionName
2261
+ });
2262
+ elements.promoteButton.disabled = activePromotionId !== undefined;
932
2263
  elements.currentRevertButton.hidden = false;
933
2264
  previewImageSource({
934
2265
  scene: options.scene,
@@ -948,17 +2279,94 @@ export function installAiAssetDesigner(options) {
948
2279
  const asset = manifest.assets[selectedTargetAssetId];
949
2280
  const activeVersion = asset.versions[asset.activeVersion];
950
2281
  const sourceOption = editedCurrentOption;
2282
+ const editorAsset = sourceOption
2283
+ ? assetWithGeneratedGeometry(asset, sourceOption)
2284
+ : asset;
951
2285
  if ((!sourceOption && !activeVersion?.file) ||
952
2286
  isAudioAsset(asset) ||
953
- asset.frameGrid ||
2287
+ editorAsset.frameGrid ||
954
2288
  sourceOption?.mimeType === "image/svg+xml" ||
955
2289
  (!sourceOption && (normalizeAssetFormat(asset.settings?.format) === "svg" ||
956
2290
  isSvgSource(activeVersion.file)))) {
957
2291
  return;
958
2292
  }
2293
+ if (isDesignerTilesetAsset(editorAsset)) {
2294
+ const assetId = selectedTargetAssetId;
2295
+ const editorTileset = tilesetMetadataForAsset(editorAsset);
2296
+ const draftTiles = tilesetPromptDefinitions(assetId, editorTileset.tileCount ?? editorTileset.columns * editorTileset.rows);
2297
+ const tilesetEditorAsset = draftTiles ? {
2298
+ ...editorAsset,
2299
+ tileset: {
2300
+ ...editorTileset,
2301
+ tiles: draftTiles
2302
+ }
2303
+ } : editorAsset;
2304
+ const renderedSrc = sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file);
2305
+ const editorInitialState = resolveTilesetEditorInitialState({
2306
+ renderedSrc,
2307
+ currentOption: sourceOption,
2308
+ activeVersion,
2309
+ resolveAssetUrl
2310
+ });
2311
+ void openTilesetEditor({
2312
+ root: elements.root,
2313
+ asset: tilesetEditorAsset,
2314
+ assetId,
2315
+ src: renderedSrc,
2316
+ sourceSrc: editorInitialState.sourceSrc,
2317
+ initialTransforms: editorInitialState.initialTransforms,
2318
+ regenerateTile: (tile, tileset, currentTileSrc) => regenerateTilesetTile(assetId, tile, tileset, currentTileSrc),
2319
+ onConfirm: async ({ dataUrl, sourceDataUrl, transforms }) => {
2320
+ const previousOption = editedCurrentOption;
2321
+ const optionAsset = {
2322
+ ...tilesetEditorAsset,
2323
+ dimensions: previousOption?.dimensions ?? tilesetEditorAsset.dimensions,
2324
+ tileset: tilesetEditorAsset.tileset,
2325
+ settings: {
2326
+ ...(previousOption?.settings ?? activeVersion?.settings ?? tilesetEditorAsset.settings),
2327
+ format: "png"
2328
+ }
2329
+ };
2330
+ editedCurrentOption = {
2331
+ ...previousOption,
2332
+ index: previousOption?.index ?? -1,
2333
+ dataUrl,
2334
+ mimeType: "image/png",
2335
+ prompt: previousOption?.prompt ?? activeVersion?.prompt ?? asset.prompt,
2336
+ model: previousOption?.model ?? activeVersion?.model,
2337
+ revisedPrompt: previousOption?.revisedPrompt ?? activeVersion?.revisedPrompt,
2338
+ dimensions: optionAsset.dimensions,
2339
+ tileset: optionAsset.tileset,
2340
+ tilesetSourceDataUrl: sourceDataUrl,
2341
+ tilesetTransforms: cloneTilesetTransforms(transforms),
2342
+ settings: optionAsset.settings
2343
+ };
2344
+ selectedOption = editedCurrentOption;
2345
+ rememberPendingOption(assetId, editedCurrentOption, {
2346
+ inheritAnimations: Boolean(previewedVersionName),
2347
+ previewedVersionName
2348
+ });
2349
+ elements.currentImage.src = dataUrl;
2350
+ elements.currentPreview.classList.add("is-selected");
2351
+ elements.currentRevertButton.hidden = false;
2352
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2353
+ previewImageSource({
2354
+ scene: options.scene,
2355
+ manifest,
2356
+ assetId,
2357
+ src: dataUrl,
2358
+ textureKey: `ai-current-tileset-edit:${assetId}:${Date.now()}`,
2359
+ assetOverride: optionAsset,
2360
+ onPreview: options.onPreview
2361
+ });
2362
+ setStatus(elements, "Tileset edits applied. Promote to save them to code.", "success");
2363
+ }
2364
+ });
2365
+ return;
2366
+ }
959
2367
  void openFrameTouchUpEditor({
960
2368
  root: elements.root,
961
- asset,
2369
+ asset: editorAsset,
962
2370
  title: readableAssetName(selectedTargetAssetId),
963
2371
  frameSrc: sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
964
2372
  displaySize: resolvePreviewDisplaySize(options, selectedTargetAssetId, asset),
@@ -980,10 +2388,14 @@ export function installAiAssetDesigner(options) {
980
2388
  settings: previousOption?.settings ?? activeVersion?.settings ?? asset.settings
981
2389
  };
982
2390
  selectedOption = editedCurrentOption;
2391
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2392
+ inheritAnimations: Boolean(previewedVersionName),
2393
+ previewedVersionName
2394
+ });
983
2395
  elements.currentImage.src = dataUrl;
984
2396
  elements.currentPreview.classList.add("is-selected");
985
2397
  elements.currentRevertButton.hidden = false;
986
- elements.promoteButton.disabled = false;
2398
+ elements.promoteButton.disabled = activePromotionId !== undefined;
987
2399
  previewImageSource({
988
2400
  scene: options.scene,
989
2401
  manifest,