@ai-game-assets/phaser 0.5.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +31 -4
  10. package/dist/designer-support.d.ts.map +1 -1
  11. package/dist/designer-support.js +732 -80
  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 +1735 -185
  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, 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,15 +100,107 @@ 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);
40
- const setOpen = (isOpen) => {
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
+ };
156
+ const applyOpenState = (isOpen) => {
41
157
  elements.root.dataset.open = String(isOpen);
42
158
  elements.toggle.setAttribute("aria-expanded", String(isOpen));
43
159
  };
160
+ const dockPanel = registerInGameDesignerPanel({
161
+ id: "ai-game-assets.assets",
162
+ label: "Assets",
163
+ panel: elements.panel,
164
+ dragHandle: elements.title,
165
+ button: elements.toggle,
166
+ order: 10,
167
+ ariaLabel: "Toggle AI asset designer",
168
+ onOpenChange: applyOpenState
169
+ });
170
+ const setOpen = (isOpen) => {
171
+ if (isOpen) {
172
+ dockPanel.open();
173
+ }
174
+ else {
175
+ dockPanel.close();
176
+ }
177
+ };
44
178
  const syncAnimationChoices = (assetId) => {
179
+ const resolvedAssetId = resolveTargetAssetId(manifest, assetId, selectedTargetId);
180
+ const resolvedAsset = manifest.assets[resolvedAssetId] ?? manifest.assets[assetId];
45
181
  const asset = manifest.assets[assetId];
46
182
  const linkedAnimations = Object.entries(asset.linkedAnimationAssets ?? {});
47
183
  const isVoice = asset.kind === "voice";
48
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;
49
204
  if (asset.kind !== "collection") {
50
205
  const baseOption = document.createElement("option");
51
206
  baseOption.value = resolveTargetAssetId(manifest, assetId, selectedTargetId);
@@ -65,17 +220,143 @@ export function installAiAssetDesigner(options) {
65
220
  : resolveTargetAssetId(manifest, assetId, selectedTargetId);
66
221
  elements.animationSelect.value = selectedTargetAssetId;
67
222
  };
68
- 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 = {}) => {
69
346
  const asset = manifest.assets[assetId];
70
347
  const activeVersion = asset.versions[asset.activeVersion];
71
348
  const isAudio = isAudioAsset(asset);
72
349
  const isVoice = isVoiceAsset(asset);
73
350
  const isVoiceLine = asset.kind === "voice-line";
351
+ const tileset = tilesetMetadataForAsset(asset);
352
+ const tilesetAnimation = selectedTilesetAnimationKey
353
+ ? tilesetAnimationForKey(asset, selectedTilesetAnimationKey)
354
+ : undefined;
74
355
  stopCurrentAnimationPreview?.();
75
356
  stopCurrentAnimationPreview = undefined;
76
- elements.promptInput.value = activeVersion?.prompt ?? asset.prompt;
77
- elements.widthInput.value = String(asset.frameGrid?.frameWidth ?? asset.dimensions?.width ?? 1);
78
- 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);
79
360
  elements.audioFormatSelect.value = asset.audioSettings?.format ?? "mp3";
80
361
  elements.audioDurationInput.value = String(asset.audioSettings?.durationSeconds ?? activeVersion?.durationSeconds ?? "");
81
362
  elements.audioLoopInput.checked = Boolean(asset.audioSettings?.loop);
@@ -92,14 +373,43 @@ export function installAiAssetDesigner(options) {
92
373
  elements.audioLoopField.hidden = !isAudio;
93
374
  elements.voiceTextField.hidden = !isVoice;
94
375
  elements.voiceTextField.firstChild.textContent = isVoiceLine ? "Text to say" : "Demo sentence";
95
- elements.formatField.hidden = isAudio || elements.formatField.hidden;
96
- 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;
97
388
  syncTargetVariantLabel(assetId);
98
- elements.frameCountInput.value = String(asset.frameGrid?.frameCount ??
99
- (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
+ }
100
407
  const activeVersionSource = activeVersion?.file ? resolveAssetUrl(activeVersion.file) : "";
101
- elements.frameCountField.hidden = asset.kind === "image" || !asset.frameGrid;
102
- 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}`;
103
413
  elements.currentImage.src = activeVersionSource;
104
414
  if (isAudio) {
105
415
  renderAudioPlayer({
@@ -118,23 +428,415 @@ export function installAiAssetDesigner(options) {
118
428
  elements.currentAnimation.hidden = true;
119
429
  elements.currentAudio.hidden = !isAudio;
120
430
  elements.currentImage.hidden = isAudio;
121
- elements.currentAnimationButton.textContent = "Edit...";
431
+ elements.currentAnimationButton.textContent = isTilesetAnimation
432
+ ? "Edit animation..."
433
+ : "Edit...";
434
+ elements.currentTouchUpButton.textContent = isBaseTileset ? "Edit tileset..." : "Touch up...";
435
+ elements.currentRevertButton.hidden = true;
122
436
  elements.currentPreview.classList.add("is-selected");
123
- elements.currentAnimationButton.hidden = !activeVersion?.file || (!asset.frameGrid && !isAudio);
437
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
438
+ ? !hasEditableTilesetAnimationFrames(assetId, selectedTilesetAnimationKey)
439
+ : !activeVersion?.file || (!asset.frameGrid && !isAudio);
124
440
  elements.currentTouchUpButton.hidden = !activeVersion?.file ||
441
+ Boolean(selectedTilesetAnimationKey) ||
125
442
  isAudio ||
126
443
  Boolean(asset.frameGrid) ||
127
444
  normalizeAssetFormat(asset.settings?.format) === "svg" ||
128
445
  isSvgSource(activeVersion?.file ?? "");
129
- elements.options.innerHTML = "";
130
- elements.options.classList.remove("is-audio");
446
+ if (syncOptions.preserveOptions && displayedOptions?.assetId === assetId) {
447
+ renderGeneratedOptions(assetId, displayedOptions.generated);
448
+ }
449
+ else {
450
+ clearGeneratedOptions();
451
+ }
131
452
  setStatus(elements, "", "idle");
132
453
  elements.promoteButton.disabled = true;
454
+ elements.uploadButton.disabled = Boolean(selectedTilesetAnimationKey);
133
455
  elements.versionsButton.disabled = Object.keys(asset.versions).length <= 1;
134
- elements.deriveButton.hidden = isAudio || deriveCandidatesForAsset(assetId).length === 0;
456
+ elements.deriveButton.hidden = Boolean(selectedTilesetAnimationKey) ||
457
+ isAudio || deriveCandidatesForAsset(assetId).length === 0;
135
458
  selectedOption = undefined;
136
459
  editedCurrentOption = undefined;
137
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();
472
+ };
473
+ const showOptionInCurrentPreview = (option, label = "selected option") => {
474
+ const asset = manifest.assets[selectedTargetAssetId];
475
+ const optionAsset = assetWithGeneratedGeometry(asset, option);
476
+ const isAudio = isAudioAsset(asset);
477
+ const isTilesetAnimation = Boolean(selectedTilesetAnimationKey && isDesignerTilesetAsset(optionAsset));
478
+ stopCurrentAnimationPreview?.();
479
+ stopCurrentAnimationPreview = undefined;
480
+ editedCurrentOption = option;
481
+ previewedVersionName = undefined;
482
+ elements.currentPreview.hidden = false;
483
+ elements.currentPreview.setAttribute("aria-label", `Preview ${label} for ${readableAssetName(selectedTargetAssetId)}`);
484
+ elements.currentAnimation.hidden = true;
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...";
494
+ elements.currentTouchUpButton.hidden =
495
+ isAudio ||
496
+ isTilesetAnimation ||
497
+ Boolean(optionAsset.frameGrid) ||
498
+ option.mimeType === "image/svg+xml";
499
+ elements.currentRevertButton.hidden = false;
500
+ elements.currentAudio.hidden = !isAudio;
501
+ elements.currentImage.hidden = isAudio;
502
+ if (isAudio) {
503
+ renderAudioPlayer({
504
+ container: elements.currentAudio,
505
+ src: option.dataUrl,
506
+ label: readableAssetName(selectedTargetAssetId),
507
+ playback: option.audioPlayback
508
+ });
509
+ }
510
+ else {
511
+ elements.currentAudio.innerHTML = "";
512
+ elements.currentImage.src = option.dataUrl;
513
+ elements.currentImage.alt = `${readableAssetName(selectedTargetAssetId)} ${label}`;
514
+ }
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
+ };
772
+ const restoreActivePreview = () => {
773
+ const asset = manifest.assets[selectedTargetAssetId];
774
+ const activeVersion = asset.versions[asset.activeVersion];
775
+ if (!activeVersion?.file)
776
+ return;
777
+ stopCurrentAnimationPreview?.();
778
+ stopCurrentAnimationPreview = undefined;
779
+ editedCurrentOption = undefined;
780
+ previewedVersionName = undefined;
781
+ selectedOption = undefined;
782
+ forgetPendingOption(selectedTargetAssetId);
783
+ for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
784
+ item.classList.remove("is-selected");
785
+ }
786
+ const activeVersionSource = resolveAssetUrl(activeVersion.file);
787
+ const isAudio = isAudioAsset(asset);
788
+ const isTilesetAnimation = Boolean(selectedTilesetAnimationKey && isDesignerTilesetAsset(asset));
789
+ elements.currentPreview.classList.add("is-selected");
790
+ elements.currentPreview.setAttribute("aria-label", `Preview active ${readableAssetName(selectedTargetAssetId)} version`);
791
+ elements.currentAnimation.hidden = true;
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...";
801
+ elements.currentTouchUpButton.hidden =
802
+ isAudio ||
803
+ isTilesetAnimation ||
804
+ Boolean(asset.frameGrid) ||
805
+ normalizeAssetFormat(asset.settings?.format) === "svg" ||
806
+ isSvgSource(activeVersion.file);
807
+ elements.currentRevertButton.hidden = true;
808
+ elements.currentAudio.hidden = !isAudio;
809
+ elements.currentImage.hidden = isAudio;
810
+ elements.promoteButton.disabled = true;
811
+ if (isAudio) {
812
+ renderAudioPlayer({
813
+ container: elements.currentAudio,
814
+ src: activeVersionSource,
815
+ label: readableAssetName(selectedTargetAssetId),
816
+ playback: activeVersion.audioPlayback
817
+ });
818
+ (options.onAssetReady ?? options.onPreview)(selectedTargetAssetId, activeVersionSource, asset);
819
+ setStatus(elements, "Reverted preview to the active version.", "info");
820
+ }
821
+ else {
822
+ elements.currentAudio.innerHTML = "";
823
+ elements.currentImage.src = activeVersionSource;
824
+ elements.currentImage.alt = `${readableAssetName(selectedTargetAssetId)} active version`;
825
+ setStatus(elements, "Reverting preview in the game...", "busy");
826
+ previewCurrentAsset({
827
+ scene: options.scene,
828
+ manifest,
829
+ assetId: selectedTargetAssetId,
830
+ src: activeVersionSource,
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
+ }
838
+ });
839
+ }
138
840
  };
139
841
  const syncAsset = (assetId) => {
140
842
  syncAnimationChoices(assetId);
@@ -230,6 +932,7 @@ export function installAiAssetDesigner(options) {
230
932
  selectedAssetId,
231
933
  assetIds: visibleAssetIds(),
232
934
  onSelect(assetId) {
935
+ invalidatePanelWork();
233
936
  selectedAssetId = assetId;
234
937
  syncAsset(selectedAssetId);
235
938
  }
@@ -257,16 +960,117 @@ export function installAiAssetDesigner(options) {
257
960
  activeGeneration = undefined;
258
961
  elements.regenerateButton.textContent = "Regenerate";
259
962
  unlockGenerationStatus();
963
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
964
+ syncPromoteAllButton();
965
+ syncMixTilesetButton();
260
966
  return true;
261
967
  };
262
- elements.toggle.addEventListener("click", () => {
263
- setOpen(elements.root.dataset.open !== "true");
264
- });
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
+ };
265
1067
  elements.assetSelect.addEventListener("change", () => {
1068
+ invalidatePanelWork();
266
1069
  selectedAssetId = elements.assetSelect.value;
267
1070
  syncAsset(selectedAssetId);
268
1071
  });
269
1072
  elements.targetSelect.addEventListener("change", () => {
1073
+ invalidatePanelWork();
270
1074
  selectedTargetId = elements.targetSelect.value || undefined;
271
1075
  syncAsset(selectedAssetId);
272
1076
  setStatus(elements, selectedTargetId
@@ -274,9 +1078,27 @@ export function installAiAssetDesigner(options) {
274
1078
  : "Editing default target.", "info");
275
1079
  });
276
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;
277
1091
  selectedTargetAssetId = elements.animationSelect.value;
278
1092
  syncTargetAsset(selectedTargetAssetId);
279
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);
280
1102
  elements.formatSelect.addEventListener("change", () => {
281
1103
  if (canEditGenerationFormat(manifest, selectedAssetId, selectedTargetAssetId)) {
282
1104
  formatDrafts.set(selectedTargetAssetId, normalizeAssetFormat(elements.formatSelect.value));
@@ -292,18 +1114,41 @@ export function installAiAssetDesigner(options) {
292
1114
  elements.currentPreview.addEventListener("click", () => {
293
1115
  const asset = manifest.assets[selectedTargetAssetId];
294
1116
  const activeVersion = asset.versions[asset.activeVersion];
295
- if (!activeVersion?.file)
1117
+ if (!editedCurrentOption && !activeVersion?.file)
296
1118
  return;
297
1119
  for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
298
1120
  item.classList.remove("is-selected");
299
1121
  }
300
1122
  elements.currentPreview.classList.add("is-selected");
301
- if (previewedVersionName) {
302
- editedCurrentOption = undefined;
303
- previewedVersionName = undefined;
304
- }
305
1123
  selectedOption = editedCurrentOption;
306
1124
  elements.promoteButton.disabled = !selectedOption;
1125
+ if (editedCurrentOption) {
1126
+ const optionAsset = assetWithGeneratedGeometry(asset, editedCurrentOption, {
1127
+ inheritAnimations: Boolean(previewedVersionName)
1128
+ });
1129
+ if (isAudioAsset(asset)) {
1130
+ renderAudioPlayer({
1131
+ container: elements.currentAudio,
1132
+ src: editedCurrentOption.dataUrl,
1133
+ label: readableAssetName(selectedTargetAssetId),
1134
+ playback: editedCurrentOption.audioPlayback
1135
+ });
1136
+ options.onPreview(selectedTargetAssetId, editedCurrentOption.dataUrl, optionAsset);
1137
+ }
1138
+ else {
1139
+ previewImageSource({
1140
+ scene: options.scene,
1141
+ manifest,
1142
+ assetId: selectedTargetAssetId,
1143
+ src: editedCurrentOption.dataUrl,
1144
+ textureKey: `ai-current-option:${selectedTargetAssetId}:${Date.now()}`,
1145
+ assetOverride: optionAsset,
1146
+ onPreview: options.onPreview
1147
+ });
1148
+ }
1149
+ setStatus(elements, "Previewing the selected option. Promote to save it to code.", "info");
1150
+ return;
1151
+ }
307
1152
  if (isAudioAsset(asset)) {
308
1153
  const activeVersionSource = resolveAssetUrl(activeVersion.file);
309
1154
  renderAudioPlayer({
@@ -327,65 +1172,224 @@ export function installAiAssetDesigner(options) {
327
1172
  }
328
1173
  setStatus(elements, "Previewing active version.", "info");
329
1174
  });
1175
+ elements.currentRevertButton.addEventListener("click", (event) => {
1176
+ event.stopPropagation();
1177
+ restoreActivePreview();
1178
+ });
330
1179
  elements.currentPreview.addEventListener("keydown", (event) => {
1180
+ if (event.target !== elements.currentPreview)
1181
+ return;
331
1182
  if (event.key !== "Enter" && event.key !== " ")
332
1183
  return;
333
1184
  event.preventDefault();
334
1185
  elements.currentPreview.click();
335
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
+ });
336
1331
  elements.regenerateButton.addEventListener("click", async () => {
337
1332
  if (activeGeneration) {
338
1333
  activeGeneration.controller.abort();
339
1334
  activeGeneration = undefined;
340
1335
  elements.regenerateButton.textContent = "Regenerate";
341
1336
  unlockGenerationStatus();
1337
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
1338
+ syncPromoteAllButton();
342
1339
  setStatus(elements, "Generation cancelled.", "info");
343
1340
  return;
344
1341
  }
1342
+ if (selectedTilesetAnimationKey) {
1343
+ await regenerateTilesetAnimation(selectedTilesetAnimationKey);
1344
+ return;
1345
+ }
345
1346
  const controller = new AbortController();
346
1347
  const currentGenerationId = generationId + 1;
347
1348
  const generationAssetId = selectedTargetAssetId;
1349
+ const generationAsset = manifest.assets[generationAssetId];
348
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
+ }
349
1367
  generationId = currentGenerationId;
350
1368
  activeGeneration = { controller, id: currentGenerationId };
1369
+ syncPromoteAllButton();
351
1370
  setStatus(elements, "Generating options...", "busy");
352
1371
  lockGenerationStatus();
353
1372
  elements.promoteButton.disabled = true;
354
1373
  elements.regenerateButton.textContent = "Cancel";
355
- elements.options.innerHTML = "";
356
- selectedOption = undefined;
357
- editedCurrentOption = undefined;
358
- previewedVersionName = undefined;
1374
+ clearGeneratedOptions();
1375
+ selectedOption = editedCurrentOption;
359
1376
  const streamedOptions = [];
360
1377
  try {
361
1378
  const generationRequest = {
362
1379
  assetId: generationAssetId,
363
- prompt: elements.promptInput.value,
1380
+ prompt: isBaseTilesetGeneration ? undefined : elements.promptInput.value,
364
1381
  count: options.optionCount ?? 3,
365
1382
  format: generationFormat,
366
1383
  audioSettings: audioGenerationOverridesFromInputs(elements, manifest.assets[generationAssetId]),
367
1384
  voiceSettings: voiceGenerationOverridesFromInputs(elements, manifest.assets[generationAssetId]),
368
1385
  styleGuide: await styleGuideRequest(styleGuideDraft),
369
- ...generationOverridesFromInputs(elements, manifest.assets[generationAssetId], generationFormat)
1386
+ ...generationOverrides
370
1387
  };
371
1388
  await client.generateStream(generationRequest, (option) => {
372
1389
  if (activeGeneration?.id !== currentGenerationId)
373
1390
  return;
374
1391
  streamedOptions.push(option);
375
- renderOptions({
376
- elements,
377
- generated: [...streamedOptions].sort((left, right) => left.index - right.index),
378
- scene: options.scene,
379
- manifest,
380
- assetId: generationAssetId,
381
- designerOptions: options,
382
- onPreview: options.onPreview,
383
- onSelected(selected) {
384
- selectedOption = selected;
385
- previewedVersionName = undefined;
386
- elements.promoteButton.disabled = false;
387
- }
388
- });
1392
+ renderGeneratedOptions(generationAssetId, [...streamedOptions].sort((left, right) => left.index - right.index));
389
1393
  }, {
390
1394
  signal: controller.signal
391
1395
  });
@@ -393,7 +1397,11 @@ export function installAiAssetDesigner(options) {
393
1397
  return;
394
1398
  }
395
1399
  finishGeneration(currentGenerationId);
396
- 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");
397
1405
  }
398
1406
  catch (error) {
399
1407
  if (isAbortError(error)) {
@@ -401,7 +1409,7 @@ export function installAiAssetDesigner(options) {
401
1409
  }
402
1410
  finishGeneration(currentGenerationId);
403
1411
  if (streamedOptions.length === 0) {
404
- elements.options.innerHTML = "";
1412
+ clearGeneratedOptions();
405
1413
  }
406
1414
  setStatus(elements, `Generation failed. ${errorMessage(error)}`, "error");
407
1415
  }
@@ -421,23 +1429,23 @@ export function installAiAssetDesigner(options) {
421
1429
  elements,
422
1430
  prompt: elements.promptInput.value
423
1431
  });
424
- renderOptions({
425
- elements,
426
- generated: [uploadedOption],
427
- scene: options.scene,
428
- manifest,
429
- assetId: selectedTargetAssetId,
430
- designerOptions: options,
431
- onPreview: options.onPreview,
432
- onSelected(option) {
433
- selectedOption = option;
434
- previewedVersionName = undefined;
435
- 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.");
436
1438
  }
437
- });
1439
+ uploadedOption.tileset = {
1440
+ ...uploadedOption.tileset,
1441
+ tiles
1442
+ };
1443
+ }
1444
+ renderGeneratedOptions(selectedTargetAssetId, [uploadedOption]);
438
1445
  selectedOption = uploadedOption;
439
- previewedVersionName = undefined;
440
- elements.promoteButton.disabled = false;
1446
+ rememberPendingOption(selectedTargetAssetId, uploadedOption);
1447
+ showOptionInCurrentPreview(uploadedOption);
1448
+ elements.promoteButton.disabled = activePromotionId !== undefined;
441
1449
  if (isAudioAsset(asset)) {
442
1450
  options.onPreview(selectedTargetAssetId, uploadedOption.dataUrl, assetWithGeneratedGeometry(asset, uploadedOption));
443
1451
  }
@@ -470,11 +1478,9 @@ export function installAiAssetDesigner(options) {
470
1478
  prompt: elements.promptInput.value,
471
1479
  format: generationFormat,
472
1480
  onConfirm: async (deriveRequest) => {
473
- elements.options.innerHTML = "";
1481
+ clearGeneratedOptions();
474
1482
  elements.promoteButton.disabled = true;
475
1483
  selectedOption = undefined;
476
- editedCurrentOption = undefined;
477
- previewedVersionName = undefined;
478
1484
  if (deriveRequest.strategy === "scale" ||
479
1485
  deriveRequest.strategy === "tile" ||
480
1486
  deriveRequest.strategy === "crop") {
@@ -492,22 +1498,11 @@ export function installAiAssetDesigner(options) {
492
1498
  mirrorX: deriveRequest.mirrorX,
493
1499
  mirrorY: deriveRequest.mirrorY
494
1500
  });
495
- renderOptions({
496
- elements,
497
- generated: [option],
498
- scene: options.scene,
499
- manifest,
500
- assetId: derivationAssetId,
501
- designerOptions: options,
502
- onPreview: options.onPreview,
503
- onSelected(selected) {
504
- selectedOption = selected;
505
- previewedVersionName = undefined;
506
- elements.promoteButton.disabled = false;
507
- }
508
- });
1501
+ renderGeneratedOptions(derivationAssetId, [option]);
509
1502
  selectedOption = option;
510
- elements.promoteButton.disabled = false;
1503
+ rememberPendingOption(derivationAssetId, option);
1504
+ showOptionInCurrentPreview(option);
1505
+ elements.promoteButton.disabled = activePromotionId !== undefined;
511
1506
  previewOption({
512
1507
  scene: options.scene,
513
1508
  manifest,
@@ -526,6 +1521,7 @@ export function installAiAssetDesigner(options) {
526
1521
  : elements.promptInput.value;
527
1522
  generationId = currentGenerationId;
528
1523
  activeGeneration = { controller, id: currentGenerationId };
1524
+ syncPromoteAllButton();
529
1525
  elements.regenerateButton.textContent = "Cancel";
530
1526
  setStatus(elements, "Generating options...", "busy");
531
1527
  lockGenerationStatus();
@@ -544,20 +1540,7 @@ export function installAiAssetDesigner(options) {
544
1540
  if (activeGeneration?.id !== currentGenerationId)
545
1541
  return;
546
1542
  streamedOptions.push(option);
547
- renderOptions({
548
- elements,
549
- generated: [...streamedOptions].sort((left, right) => left.index - right.index),
550
- scene: options.scene,
551
- manifest,
552
- assetId: derivationAssetId,
553
- designerOptions: options,
554
- onPreview: options.onPreview,
555
- onSelected(selected) {
556
- selectedOption = selected;
557
- previewedVersionName = undefined;
558
- elements.promoteButton.disabled = false;
559
- }
560
- });
1543
+ renderGeneratedOptions(derivationAssetId, [...streamedOptions].sort((left, right) => left.index - right.index));
561
1544
  }, {
562
1545
  signal: controller.signal
563
1546
  });
@@ -577,49 +1560,348 @@ export function installAiAssetDesigner(options) {
577
1560
  });
578
1561
  });
579
1562
  elements.promoteButton.addEventListener("click", async () => {
580
- if (!selectedOption)
1563
+ if (!selectedOption || activePromotionId !== undefined)
581
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;
582
1583
  const versionName = `promoted-${Date.now()}`;
583
1584
  setStatus(elements, `Promoting ${versionName}...`, "busy");
584
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
+ }
585
1705
  try {
586
- await client.save({
587
- assetId: selectedTargetAssetId,
588
- versionName,
589
- dataUrl: selectedOption.dataUrl,
590
- prompt: selectedOption.prompt,
591
- model: selectedOption.model,
592
- revisedPrompt: selectedOption.revisedPrompt,
593
- dimensions: selectedOption.dimensions,
594
- frameGrid: selectedOption.frameGrid,
595
- animations: assetWithGeneratedGeometry(manifest.assets[selectedTargetAssetId], selectedOption).animations,
596
- settings: selectedOption.settings,
597
- audioSettings: selectedOption.audioSettings,
598
- audioPlayback: selectedOption.audioPlayback,
599
- voiceSettings: selectedOption.voiceSettings,
600
- durationSeconds: selectedOption.durationSeconds,
601
- activate: true,
602
- notes: "Promoted from the AI asset designer."
603
- });
604
- manifest = await client.getManifest();
605
1706
  options.onManifestUpdated?.(manifest);
606
- formatDrafts.delete(selectedTargetAssetId);
607
- syncTargetAsset(selectedTargetAssetId);
608
- if (isAudioAsset(manifest.assets[selectedTargetAssetId])) {
609
- const promotedAsset = manifest.assets[selectedTargetAssetId];
610
- const promotedVersion = promotedAsset.versions[promotedAsset.activeVersion];
611
- if (promotedVersion?.file) {
612
- (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) ?? []);
613
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
+ }
1863
+ }
1864
+ catch (error) {
1865
+ liveRefreshErrors.push({ assetId, error });
1866
+ }
1867
+ pendingOptions.delete(assetId);
1868
+ formatDrafts.delete(assetId);
1869
+ promotedCount += 1;
614
1870
  }
615
- setStatus(elements, `Promoted ${selectedTargetAssetId} to ${versionName}.`, "success");
616
- if (options.restartOnPromote) {
617
- window.location.reload();
1871
+ catch (error) {
1872
+ promotionError = { assetId, error };
1873
+ break;
618
1874
  }
619
1875
  }
1876
+ try {
1877
+ if (promotedCount > 0) {
1878
+ options.onManifestUpdated?.(manifest);
1879
+ }
1880
+ syncTargetAsset(selectedTargetAssetId);
1881
+ renderAssetBrowser();
1882
+ }
620
1883
  catch (error) {
621
- elements.promoteButton.disabled = false;
622
- 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();
623
1905
  }
624
1906
  });
625
1907
  elements.restartButton.addEventListener("click", () => {
@@ -634,30 +1916,45 @@ export function installAiAssetDesigner(options) {
634
1916
  resolveAssetUrl,
635
1917
  async onSelect(versionName, option) {
636
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
+ };
637
1930
  for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
638
1931
  item.classList.remove("is-selected");
639
1932
  }
640
- selectedOption = option;
641
- editedCurrentOption = option;
1933
+ selectedOption = versionOption;
1934
+ rememberPendingOption(selectedTargetAssetId, versionOption, {
1935
+ inheritAnimations: true,
1936
+ previewedVersionName: versionName
1937
+ });
1938
+ showOptionInCurrentPreview(versionOption, versionName);
642
1939
  previewedVersionName = versionName;
643
1940
  elements.currentPreview.classList.add("is-selected");
644
- elements.promoteButton.disabled = false;
1941
+ elements.promoteButton.disabled = activePromotionId !== undefined;
645
1942
  if (isAudioAsset(asset)) {
646
1943
  renderAudioPlayer({
647
1944
  container: elements.currentAudio,
648
- src: option.dataUrl,
1945
+ src: versionOption.dataUrl,
649
1946
  label: readableAssetName(selectedTargetAssetId),
650
- playback: option.audioPlayback
1947
+ playback: versionOption.audioPlayback
651
1948
  });
652
- options.onPreview(selectedTargetAssetId, option.dataUrl, optionAsset);
1949
+ options.onPreview(selectedTargetAssetId, versionOption.dataUrl, optionAsset);
653
1950
  }
654
1951
  else {
655
- elements.currentImage.src = option.dataUrl;
1952
+ elements.currentImage.src = versionOption.dataUrl;
656
1953
  previewImageSource({
657
1954
  scene: options.scene,
658
1955
  manifest,
659
1956
  assetId: selectedTargetAssetId,
660
- src: option.dataUrl,
1957
+ src: versionOption.dataUrl,
661
1958
  textureKey: `ai-version-preview:${selectedTargetAssetId}:${versionName}:${Date.now()}`,
662
1959
  assetOverride: optionAsset,
663
1960
  onPreview: options.onPreview
@@ -673,6 +1970,7 @@ export function installAiAssetDesigner(options) {
673
1970
  });
674
1971
  options.onManifestUpdated?.(manifest);
675
1972
  if (previewedVersionName === versionName) {
1973
+ forgetPendingOption(selectedTargetAssetId);
676
1974
  syncTargetAsset(selectedTargetAssetId);
677
1975
  }
678
1976
  else {
@@ -688,6 +1986,113 @@ export function installAiAssetDesigner(options) {
688
1986
  }
689
1987
  });
690
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
+ };
691
2096
  elements.styleButton.addEventListener("click", () => {
692
2097
  void openStyleGuideEditor({
693
2098
  root: elements.root,
@@ -710,53 +2115,97 @@ export function installAiAssetDesigner(options) {
710
2115
  setStatus(elements, `Style promotion failed. ${errorMessage(error)}`, "error");
711
2116
  throw error;
712
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
+ }
713
2126
  }
714
2127
  });
715
2128
  });
716
- elements.currentAnimationButton.addEventListener("click", (event) => {
2129
+ elements.currentAnimationButton.addEventListener("click", async (event) => {
717
2130
  event.stopPropagation();
718
2131
  const asset = manifest.assets[selectedTargetAssetId];
719
2132
  const activeVersion = asset.versions[asset.activeVersion];
720
- if (!activeVersion?.file)
2133
+ const sourceOption = editedCurrentOption;
2134
+ if (!sourceOption && !activeVersion?.file)
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
+ }
721
2172
  return;
2173
+ }
722
2174
  if (isAudioAsset(asset)) {
723
2175
  void openAudioEditor({
724
2176
  root: elements.root,
725
2177
  asset,
726
2178
  assetId: selectedTargetAssetId,
727
- src: editedCurrentOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
728
- initialPlayback: editedCurrentOption?.audioPlayback ?? activeVersion.audioPlayback,
2179
+ src: sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
2180
+ initialPlayback: sourceOption?.audioPlayback ?? activeVersion?.audioPlayback,
729
2181
  onConfirm: async (audioPlayback) => {
730
- const dataUrl = editedCurrentOption?.dataUrl ??
2182
+ const previousOption = editedCurrentOption;
2183
+ const dataUrl = previousOption?.dataUrl ??
731
2184
  await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
732
2185
  const optionAsset = {
733
2186
  ...asset,
734
2187
  audioPlayback
735
2188
  };
736
2189
  editedCurrentOption = {
737
- index: -1,
2190
+ ...previousOption,
2191
+ index: previousOption?.index ?? -1,
738
2192
  dataUrl,
739
2193
  mimeType: mimeTypeFromDataUrl(dataUrl),
740
- prompt: activeVersion.prompt ?? asset.prompt,
741
- model: activeVersion.model,
742
- revisedPrompt: activeVersion.revisedPrompt,
743
- audioSettings: activeVersion.audioSettings ?? asset.audioSettings,
2194
+ prompt: previousOption?.prompt ?? activeVersion?.prompt ?? asset.prompt,
2195
+ model: previousOption?.model ?? activeVersion?.model,
2196
+ revisedPrompt: previousOption?.revisedPrompt ?? activeVersion?.revisedPrompt,
2197
+ audioSettings: previousOption?.audioSettings ?? activeVersion?.audioSettings ?? asset.audioSettings,
744
2198
  audioPlayback,
745
- voiceSettings: activeVersion.voiceSettings ?? asset.voiceSettings,
746
- durationSeconds: activeVersion.durationSeconds
2199
+ voiceSettings: previousOption?.voiceSettings ?? activeVersion?.voiceSettings ?? asset.voiceSettings,
2200
+ durationSeconds: previousOption?.durationSeconds ?? activeVersion?.durationSeconds
747
2201
  };
748
2202
  selectedOption = editedCurrentOption;
749
- elements.promoteButton.disabled = false;
750
- manifest.assets[selectedTargetAssetId] = {
751
- ...optionAsset,
752
- versions: {
753
- ...asset.versions,
754
- [asset.activeVersion]: {
755
- ...activeVersion,
756
- audioPlayback
757
- }
758
- }
759
- };
2203
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2204
+ inheritAnimations: Boolean(previewedVersionName),
2205
+ previewedVersionName
2206
+ });
2207
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2208
+ elements.currentRevertButton.hidden = false;
760
2209
  options.onPreview(selectedTargetAssetId, dataUrl, optionAsset);
761
2210
  renderAudioPlayer({
762
2211
  container: elements.currentAudio,
@@ -769,36 +2218,49 @@ export function installAiAssetDesigner(options) {
769
2218
  });
770
2219
  return;
771
2220
  }
772
- if (!asset.frameGrid)
2221
+ const editorAsset = sourceOption
2222
+ ? assetWithGeneratedGeometry(asset, sourceOption, {
2223
+ inheritAnimations: Boolean(previewedVersionName)
2224
+ })
2225
+ : asset;
2226
+ if (!editorAsset.frameGrid)
773
2227
  return;
774
2228
  void openAnimationEditor({
775
2229
  root: elements.root,
776
- asset,
2230
+ asset: editorAsset,
777
2231
  assetId: selectedTargetAssetId,
778
- src: editedCurrentOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
779
- displaySize: resolvePreviewDisplaySize(options, selectedTargetAssetId, asset),
780
- initialAnimations: editedCurrentOption?.animations ?? asset.animations,
2232
+ src: sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
2233
+ displaySize: resolvePreviewDisplaySize(options, selectedTargetAssetId, editorAsset),
2234
+ initialAnimations: sourceOption?.animations ?? editorAsset.animations,
781
2235
  onConfirm: async ({ animations, dataUrl: editedDataUrl }) => {
2236
+ const previousOption = editedCurrentOption;
782
2237
  const dataUrl = editedDataUrl ??
783
- editedCurrentOption?.dataUrl ??
2238
+ previousOption?.dataUrl ??
784
2239
  await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
785
2240
  const optionAsset = {
786
- ...asset,
2241
+ ...editorAsset,
787
2242
  animations
788
2243
  };
789
2244
  editedCurrentOption = {
790
- index: -1,
2245
+ ...previousOption,
2246
+ index: previousOption?.index ?? -1,
791
2247
  dataUrl,
792
2248
  mimeType: mimeTypeFromDataUrl(dataUrl),
793
- prompt: activeVersion.prompt ?? asset.prompt,
794
- model: activeVersion.model,
795
- revisedPrompt: activeVersion.revisedPrompt,
796
- dimensions: asset.dimensions,
797
- frameGrid: asset.frameGrid,
2249
+ prompt: previousOption?.prompt ?? activeVersion?.prompt ?? asset.prompt,
2250
+ model: previousOption?.model ?? activeVersion?.model,
2251
+ revisedPrompt: previousOption?.revisedPrompt ?? activeVersion?.revisedPrompt,
2252
+ dimensions: previousOption?.dimensions ?? editorAsset.dimensions,
2253
+ frameGrid: previousOption?.frameGrid ?? editorAsset.frameGrid,
2254
+ settings: previousOption?.settings ?? activeVersion?.settings ?? asset.settings,
798
2255
  animations
799
2256
  };
800
2257
  selectedOption = editedCurrentOption;
801
- elements.promoteButton.disabled = false;
2258
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2259
+ inheritAnimations: Boolean(previewedVersionName),
2260
+ previewedVersionName
2261
+ });
2262
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2263
+ elements.currentRevertButton.hidden = false;
802
2264
  previewImageSource({
803
2265
  scene: options.scene,
804
2266
  manifest,
@@ -816,37 +2278,124 @@ export function installAiAssetDesigner(options) {
816
2278
  event.stopPropagation();
817
2279
  const asset = manifest.assets[selectedTargetAssetId];
818
2280
  const activeVersion = asset.versions[asset.activeVersion];
819
- if (!activeVersion?.file ||
2281
+ const sourceOption = editedCurrentOption;
2282
+ const editorAsset = sourceOption
2283
+ ? assetWithGeneratedGeometry(asset, sourceOption)
2284
+ : asset;
2285
+ if ((!sourceOption && !activeVersion?.file) ||
820
2286
  isAudioAsset(asset) ||
821
- asset.frameGrid ||
822
- normalizeAssetFormat(asset.settings?.format) === "svg" ||
823
- isSvgSource(activeVersion.file)) {
2287
+ editorAsset.frameGrid ||
2288
+ sourceOption?.mimeType === "image/svg+xml" ||
2289
+ (!sourceOption && (normalizeAssetFormat(asset.settings?.format) === "svg" ||
2290
+ isSvgSource(activeVersion.file)))) {
2291
+ return;
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
+ });
824
2365
  return;
825
2366
  }
826
2367
  void openFrameTouchUpEditor({
827
2368
  root: elements.root,
828
- asset,
2369
+ asset: editorAsset,
829
2370
  title: readableAssetName(selectedTargetAssetId),
830
- frameSrc: editedCurrentOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
2371
+ frameSrc: sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
831
2372
  displaySize: resolvePreviewDisplaySize(options, selectedTargetAssetId, asset),
832
2373
  onSave: async (dataUrl) => {
2374
+ const previousOption = editedCurrentOption;
833
2375
  const optionAsset = {
834
2376
  ...asset,
835
- dimensions: asset.dimensions
2377
+ dimensions: previousOption?.dimensions ?? asset.dimensions
836
2378
  };
837
2379
  editedCurrentOption = {
838
- index: -1,
2380
+ ...previousOption,
2381
+ index: previousOption?.index ?? -1,
839
2382
  dataUrl,
840
2383
  mimeType: mimeTypeFromDataUrl(dataUrl),
841
- prompt: activeVersion.prompt ?? asset.prompt,
842
- model: activeVersion.model,
843
- revisedPrompt: activeVersion.revisedPrompt,
844
- dimensions: asset.dimensions
2384
+ prompt: previousOption?.prompt ?? activeVersion?.prompt ?? asset.prompt,
2385
+ model: previousOption?.model ?? activeVersion?.model,
2386
+ revisedPrompt: previousOption?.revisedPrompt ?? activeVersion?.revisedPrompt,
2387
+ dimensions: previousOption?.dimensions ?? asset.dimensions,
2388
+ settings: previousOption?.settings ?? activeVersion?.settings ?? asset.settings
845
2389
  };
846
2390
  selectedOption = editedCurrentOption;
2391
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2392
+ inheritAnimations: Boolean(previewedVersionName),
2393
+ previewedVersionName
2394
+ });
847
2395
  elements.currentImage.src = dataUrl;
848
2396
  elements.currentPreview.classList.add("is-selected");
849
- elements.promoteButton.disabled = false;
2397
+ elements.currentRevertButton.hidden = false;
2398
+ elements.promoteButton.disabled = activePromotionId !== undefined;
850
2399
  previewImageSource({
851
2400
  scene: options.scene,
852
2401
  manifest,
@@ -895,6 +2444,7 @@ export function installAiAssetDesigner(options) {
895
2444
  destroy: () => {
896
2445
  activeGeneration?.controller.abort();
897
2446
  stopStatusAnimation(elements.status);
2447
+ dockPanel.destroy();
898
2448
  elements.root.remove();
899
2449
  }
900
2450
  };