@ai-game-assets/phaser 0.6.0 → 0.8.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 (49) 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 +55 -2
  6. package/dist/debug-client.d.ts.map +1 -1
  7. package/dist/debug-client.js +77 -0
  8. package/dist/debug-client.js.map +1 -1
  9. package/dist/designer-support.d.ts +29 -3
  10. package/dist/designer-support.d.ts.map +1 -1
  11. package/dist/designer-support.js +557 -29
  12. package/dist/designer-support.js.map +1 -1
  13. package/dist/designer.d.ts +19 -2
  14. package/dist/designer.d.ts.map +1 -1
  15. package/dist/designer.js +1897 -143
  16. package/dist/designer.js.map +1 -1
  17. package/dist/first-drafts.d.ts.map +1 -1
  18. package/dist/first-drafts.js +68 -40
  19. package/dist/first-drafts.js.map +1 -1
  20. package/dist/index.d.ts +9 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/keys.d.ts +2 -0
  25. package/dist/keys.d.ts.map +1 -1
  26. package/dist/keys.js +6 -0
  27. package/dist/keys.js.map +1 -1
  28. package/dist/loader.d.ts.map +1 -1
  29. package/dist/loader.js +50 -3
  30. package/dist/loader.js.map +1 -1
  31. package/dist/phaser-types.d.ts +1 -0
  32. package/dist/phaser-types.d.ts.map +1 -1
  33. package/dist/runtime.d.ts +30 -2
  34. package/dist/runtime.d.ts.map +1 -1
  35. package/dist/runtime.js +235 -8
  36. package/dist/runtime.js.map +1 -1
  37. package/dist/tileset-dialog.d.ts +121 -0
  38. package/dist/tileset-dialog.d.ts.map +1 -0
  39. package/dist/tileset-dialog.js +1594 -0
  40. package/dist/tileset-dialog.js.map +1 -0
  41. package/dist/tilesets.d.ts +13 -0
  42. package/dist/tilesets.d.ts.map +1 -0
  43. package/dist/tilesets.js +60 -0
  44. package/dist/tilesets.js.map +1 -0
  45. package/dist/voice-line-regeneration.d.ts +47 -0
  46. package/dist/voice-line-regeneration.d.ts.map +1 -0
  47. package/dist/voice-line-regeneration.js +185 -0
  48. package/dist/voice-line-regeneration.js.map +1 -0
  49. package/package.json +3 -2
package/dist/designer.js CHANGED
@@ -1,8 +1,40 @@
1
- import { linkedAnimationAssetIds, registerInGameDesignerPanel, resolveTargetAssetId, topLevelAiAssetIds } from "@ai-game-assets/core";
1
+ import { expandAiAssetIds, linkedAnimationAssetIds, registerInGameDesignerPanel, resolveTargetAssetId, topLevelAiAssetIds } from "@ai-game-assets/core";
2
2
  import { AiAssetDebugClient } from "./debug-client.js";
3
3
  import { ensureMissingAiAssetFirstDrafts } from "./first-drafts.js";
4
4
  import { createLocalDerivedOption, openDeriveDialog, referenceImageForCandidate } from "./derive-dialog.js";
5
- import { assetWithGeneratedGeometry, audioGenerationOverridesFromInputs, bindKeyboardCapture, canEditGenerationFormat, createDesignerElements, effectiveGenerationFormat, ensureDesignerStyles, errorMessage, generationOverridesFromInputs, hasStyleGuide, imageSourceToDataUrl, isAbortError, isAudioAsset, isSvgSource, isVoiceAsset, mimeTypeFromDataUrl, normalizeAssetFormat, normalizeAudioFormat, openAnimationEditor, openAssetVersionsDialog, openAudioEditor, openFrameTouchUpEditor, openStyleGuideEditor, pickUploadFile, previewCurrentAsset, previewImageSource, previewOption, readableAssetName, renderAssetFolderBrowser, renderAudioPlayer, renderOptions, resolvePreviewDisplaySize, setStatus, stopStatusAnimation, styleGuideDraftFromManifest, styleGuideRequest, uploadedOptionFromFile, voiceGenerationOverridesFromInputs } from "./designer-support.js";
5
+ import { createMixedTilesetOption, isDesignerTilesetAsset, openTilesetBaseMixerDialog, openTilesetAnimationEditor, openTilesetAnimationMixerDialog, openTilesetEditor, tilesetAnimationForKey, resolveTilesetBaseMixCurrent, tilesetMetadataForAsset, tilesetTileGenerationOverride } from "./tileset-dialog.js";
6
+ import { aiTilesetAnimationTextureKey } from "./keys.js";
7
+ import { promotedVoiceId, regenerateAndPromoteVoiceLines, voiceLineRegenerationPlan } from "./voice-line-regeneration.js";
8
+ /**
9
+ * Resolves the canonical, untransformed source used by Edit Tileset. A pending
10
+ * option is its own editing lineage: when it has no saved source metadata, its
11
+ * rendered image becomes the new source instead of borrowing the active
12
+ * version's unrelated source sheet.
13
+ */
14
+ export function resolveTilesetEditorInitialState(options) {
15
+ if (options.currentOption) {
16
+ const sourceSrc = options.currentOption.tilesetSourceDataUrl;
17
+ return {
18
+ sourceSrc: sourceSrc ?? options.renderedSrc,
19
+ initialTransforms: sourceSrc
20
+ ? cloneTilesetTransforms(options.currentOption.tilesetTransforms)
21
+ : undefined
22
+ };
23
+ }
24
+ const sourceFile = options.activeVersion?.tilesetSourceFile;
25
+ return {
26
+ sourceSrc: sourceFile
27
+ ? options.resolveAssetUrl(sourceFile)
28
+ : options.renderedSrc,
29
+ initialTransforms: sourceFile
30
+ ? cloneTilesetTransforms(options.activeVersion?.tilesetTransforms)
31
+ : undefined
32
+ };
33
+ }
34
+ function cloneTilesetTransforms(transforms) {
35
+ return transforms?.map((transform) => ({ ...transform }));
36
+ }
37
+ 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
38
  export function installAiAssetDesigner(options) {
7
39
  ensureDesignerStyles();
8
40
  const client = options.client ?? new AiAssetDebugClient();
@@ -12,14 +44,53 @@ export function installAiAssetDesigner(options) {
12
44
  let selectedAssetId = visibleAssetIds()[0] ?? Object.keys(manifest.assets)[0];
13
45
  let selectedTargetId = options.targetId;
14
46
  let selectedTargetAssetId = selectedAssetId;
47
+ let selectedTilesetAnimationKey;
15
48
  let selectedOption;
49
+ let displayedOptions;
16
50
  let stopCurrentAnimationPreview;
17
51
  let editedCurrentOption;
18
52
  let previewedVersionName;
53
+ const pendingOptions = new Map();
19
54
  let styleGuideDraft = styleGuideDraftFromManifest(manifest, resolveAssetUrl);
20
55
  const formatDrafts = new Map();
56
+ const tilesetPromptDrafts = new Map();
57
+ const tilesetAnimationPromptDrafts = new Map();
58
+ const tilesetAnimationFrameDrafts = new Map();
59
+ let tilesetAnimationPromptRevision = 0;
60
+ let displayedTilesetAnimationOptions;
61
+ let stopTilesetAnimationOptionPreviews;
21
62
  let activeGeneration;
22
63
  let generationId = 0;
64
+ let panelRevision = 0;
65
+ let promotionId = 0;
66
+ let activePromotionId;
67
+ let activeVoiceLineRegeneration;
68
+ let activeFirstDrafts = options.autoFirstDrafts !== false;
69
+ let destroyed = false;
70
+ let deferredVoiceLineManifestSync = false;
71
+ const deferredVoiceLinePendingAssetIds = new Set();
72
+ let deferredVoiceLineManifestSyncPromise;
73
+ let voiceLineBatchControlStates;
74
+ let mixingTileset = false;
75
+ const regenerateTilesetTile = async (assetId, tile, tileset, currentTileSrc) => {
76
+ const guide = await styleGuideRequest(styleGuideDraft);
77
+ return client.generate({
78
+ assetId,
79
+ count: 3,
80
+ format: effectiveGenerationFormat(manifest, formatDrafts, selectedAssetId, assetId),
81
+ tileset,
82
+ styleGuide: {
83
+ ...guide,
84
+ images: [
85
+ ...guide.images,
86
+ {
87
+ name: `${assetId}-current-tile-${tile + 1}-style.png`,
88
+ dataUrl: currentTileSrc
89
+ }
90
+ ]
91
+ }
92
+ });
93
+ };
23
94
  if (!selectedAssetId) {
24
95
  throw new Error("AI asset designer requires at least one asset.");
25
96
  }
@@ -37,6 +108,180 @@ export function installAiAssetDesigner(options) {
37
108
  const mount = options.mount ?? document.body;
38
109
  mount.append(elements.root);
39
110
  bindKeyboardCapture(elements.root, options.scene);
111
+ const setVoiceLineBatchControlsLocked = (locked) => {
112
+ if (locked) {
113
+ if (voiceLineBatchControlStates)
114
+ return;
115
+ const controls = Array.from(elements.panel.querySelectorAll("button, input, select, textarea")).filter((control) => control !== elements.regenerateAllLinesButton);
116
+ voiceLineBatchControlStates = controls.map((control) => [control, control.disabled]);
117
+ for (const control of controls)
118
+ control.disabled = true;
119
+ elements.assetBrowser.setAttribute("inert", "");
120
+ elements.currentPreview.setAttribute("inert", "");
121
+ elements.options.setAttribute("inert", "");
122
+ return;
123
+ }
124
+ for (const [control, wasDisabled] of voiceLineBatchControlStates ?? []) {
125
+ control.disabled = wasDisabled;
126
+ }
127
+ voiceLineBatchControlStates = undefined;
128
+ elements.assetBrowser.removeAttribute("inert");
129
+ elements.currentPreview.removeAttribute("inert");
130
+ elements.options.removeAttribute("inert");
131
+ };
132
+ const syncRegenerateAllLinesButton = () => {
133
+ if (activeVoiceLineRegeneration) {
134
+ const stopping = activeVoiceLineRegeneration.controller.signal.aborted;
135
+ elements.regenerateAllLinesButton.hidden = false;
136
+ elements.regenerateAllLinesButton.disabled = stopping;
137
+ elements.regenerateAllLinesButton.textContent = stopping
138
+ ? "Stopping after current line..."
139
+ : "Stop after current line";
140
+ elements.regenerateAllLinesButton.title = stopping
141
+ ? "The batch will stop after the current line is promoted."
142
+ : "Finish and promote the current line, then stop before generating another.";
143
+ return;
144
+ }
145
+ const selectedAsset = manifest.assets[selectedAssetId];
146
+ const plan = voiceLineRegenerationPlan(manifest, selectedAssetId, selectedTargetId);
147
+ const { baseVoiceAssetId, lineAssetIds, missingTargetLineAssetIds } = plan;
148
+ const isBaseVoiceView = selectedAsset?.kind === "voice" &&
149
+ selectedTargetAssetId === baseVoiceAssetId &&
150
+ !selectedTilesetAnimationKey;
151
+ const hasPromotedVoice = Boolean(promotedVoiceId(manifest, baseVoiceAssetId));
152
+ const hasPendingBaseVoice = pendingOptions.has(baseVoiceAssetId);
153
+ const pendingLineCount = lineAssetIds.filter((assetId) => pendingOptions.has(assetId)).length;
154
+ const hasAnyPendingOption = pendingOptions.size > 0;
155
+ elements.regenerateAllLinesButton.textContent = "Regenerate all lines";
156
+ elements.regenerateAllLinesButton.hidden = !isBaseVoiceView;
157
+ elements.regenerateAllLinesButton.disabled =
158
+ !isBaseVoiceView ||
159
+ !hasPromotedVoice ||
160
+ hasPendingBaseVoice ||
161
+ pendingLineCount > 0 ||
162
+ hasAnyPendingOption ||
163
+ missingTargetLineAssetIds.length > 0 ||
164
+ lineAssetIds.length === 0 ||
165
+ activeFirstDrafts ||
166
+ Boolean(activeGeneration) ||
167
+ activePromotionId !== undefined;
168
+ elements.regenerateAllLinesButton.title = !isBaseVoiceView
169
+ ? ""
170
+ : hasPendingBaseVoice
171
+ ? "Promote the selected base voice before regenerating its lines."
172
+ : missingTargetLineAssetIds.length > 0
173
+ ? `Create target variants for ${missingTargetLineAssetIds.length} linked line${missingTargetLineAssetIds.length === 1 ? "" : "s"} before regenerating this target voice.`
174
+ : pendingLineCount > 0
175
+ ? `Promote or revert the ${pendingLineCount} pending linked line${pendingLineCount === 1 ? "" : "s"} before regenerating all lines.`
176
+ : hasAnyPendingOption
177
+ ? "Promote or revert all pending asset options before regenerating voice lines."
178
+ : activeFirstDrafts
179
+ ? "Wait for automatic first drafts to finish before regenerating voice lines."
180
+ : !hasPromotedVoice
181
+ ? "Promote a base voice before regenerating its lines."
182
+ : lineAssetIds.length === 0
183
+ ? "This voice has no linked lines to regenerate."
184
+ : `Generate and promote one new option for ${lineAssetIds.length} linked line${lineAssetIds.length === 1 ? "" : "s"}.`;
185
+ };
186
+ const syncPromoteAllButton = () => {
187
+ elements.promoteAllButton.disabled =
188
+ pendingOptions.size === 0 ||
189
+ Boolean(activeGeneration) ||
190
+ activePromotionId !== undefined;
191
+ syncRegenerateAllLinesButton();
192
+ };
193
+ const rememberPendingOption = (assetId, option, pending = {}) => {
194
+ pendingOptions.set(assetId, {
195
+ option,
196
+ inheritAnimations: Boolean(pending.inheritAnimations),
197
+ previewedVersionName: pending.previewedVersionName,
198
+ tilesetAnimations: pending.tilesetAnimations,
199
+ animationOnlyKey: pending.animationOnlyKey
200
+ });
201
+ syncPromoteAllButton();
202
+ };
203
+ const flushDeferredVoiceLineManifest = () => {
204
+ if (!deferredVoiceLineManifestSync)
205
+ return Promise.resolve(true);
206
+ if ([...deferredVoiceLinePendingAssetIds].some((assetId) => pendingOptions.has(assetId))) {
207
+ return Promise.resolve(false);
208
+ }
209
+ if (deferredVoiceLineManifestSyncPromise) {
210
+ return deferredVoiceLineManifestSyncPromise;
211
+ }
212
+ deferredVoiceLineManifestSyncPromise = (async () => {
213
+ try {
214
+ const syncedManifest = await client.syncManifestModule();
215
+ manifest = syncedManifest;
216
+ deferredVoiceLineManifestSync = false;
217
+ deferredVoiceLinePendingAssetIds.clear();
218
+ if (!destroyed) {
219
+ try {
220
+ options.onManifestUpdated?.(manifest);
221
+ }
222
+ catch (error) {
223
+ setStatus(elements, `Manifest refreshed, but the live game could not adopt it. ${errorMessage(error)}`, "error");
224
+ }
225
+ }
226
+ return true;
227
+ }
228
+ catch (error) {
229
+ if (!destroyed) {
230
+ setStatus(elements, `Could not refresh the generated manifest module. ${errorMessage(error)}`, "error");
231
+ }
232
+ return false;
233
+ }
234
+ finally {
235
+ deferredVoiceLineManifestSyncPromise = undefined;
236
+ }
237
+ })();
238
+ return deferredVoiceLineManifestSyncPromise;
239
+ };
240
+ const resolveDeferredVoiceLinePendingOption = (assetId) => {
241
+ if (!deferredVoiceLinePendingAssetIds.delete(assetId))
242
+ return;
243
+ if (deferredVoiceLinePendingAssetIds.size === 0) {
244
+ void flushDeferredVoiceLineManifest();
245
+ }
246
+ };
247
+ const forgetPendingOption = (assetId) => {
248
+ pendingOptions.delete(assetId);
249
+ resolveDeferredVoiceLinePendingOption(assetId);
250
+ syncPromoteAllButton();
251
+ };
252
+ const hasEditableTilesetAnimationFrames = (assetId, animationKey) => {
253
+ const pendingFrames = pendingOptions.get(assetId)?.tilesetAnimations?.[animationKey];
254
+ if (pendingFrames?.length)
255
+ return true;
256
+ const asset = manifest.assets[assetId];
257
+ const activeVersion = asset?.versions[asset.activeVersion];
258
+ return Boolean(activeVersion?.tilesetAnimations?.[animationKey]?.files.length);
259
+ };
260
+ const installTilesetAnimationFrameTextures = async (frames, asset, textureKeyForFrame) => {
261
+ const tileset = tilesetMetadataForAsset(asset);
262
+ if (!tileset || !options.scene.textures.addSpriteSheet)
263
+ return [];
264
+ const images = await Promise.all(frames.map((frame) => loadImageElement(frame)));
265
+ const textureKeys = images.map((image, index) => {
266
+ const textureKey = textureKeyForFrame(index);
267
+ if (options.scene.textures.exists(textureKey)) {
268
+ options.scene.textures.remove(textureKey);
269
+ }
270
+ options.scene.textures.addSpriteSheet?.(textureKey, image, {
271
+ frameWidth: tileset.tileWidth,
272
+ frameHeight: tileset.tileHeight,
273
+ margin: tileset.margin,
274
+ spacing: tileset.spacing
275
+ });
276
+ return textureKey;
277
+ });
278
+ return textureKeys;
279
+ };
280
+ const previewTilesetAnimationFrames = async (assetId, animationKey, frames, asset) => {
281
+ const previewId = Date.now();
282
+ const textureKeys = await installTilesetAnimationFrameTextures(frames, asset, (index) => `ai-preview:${assetId}:tileset:${encodeURIComponent(animationKey)}:${index}:${previewId}`);
283
+ options.onTilesetAnimationPreview?.(assetId, animationKey, textureKeys, asset);
284
+ };
40
285
  const applyOpenState = (isOpen) => {
41
286
  elements.root.dataset.open = String(isOpen);
42
287
  elements.toggle.setAttribute("aria-expanded", String(isOpen));
@@ -60,10 +305,31 @@ export function installAiAssetDesigner(options) {
60
305
  }
61
306
  };
62
307
  const syncAnimationChoices = (assetId) => {
308
+ const resolvedAssetId = resolveTargetAssetId(manifest, assetId, selectedTargetId);
309
+ const resolvedAsset = manifest.assets[resolvedAssetId] ?? manifest.assets[assetId];
63
310
  const asset = manifest.assets[assetId];
64
311
  const linkedAnimations = Object.entries(asset.linkedAnimationAssets ?? {});
65
312
  const isVoice = asset.kind === "voice";
66
313
  elements.animationSelect.innerHTML = "";
314
+ if (isDesignerTilesetAsset(resolvedAsset)) {
315
+ const baseOption = document.createElement("option");
316
+ baseOption.value = "tileset:base";
317
+ baseOption.textContent = "Base tileset";
318
+ elements.animationSelect.append(baseOption);
319
+ for (const animation of tilesetMetadataForAsset(resolvedAsset)?.animations ?? []) {
320
+ const option = document.createElement("option");
321
+ option.value = `tileset:animation:${encodeURIComponent(animation.key)}`;
322
+ option.textContent = readableAssetName(animation.key);
323
+ elements.animationSelect.append(option);
324
+ }
325
+ selectedTargetAssetId = resolvedAssetId;
326
+ selectedTilesetAnimationKey = undefined;
327
+ elements.animationField.hidden = false;
328
+ elements.animationField.firstElementChild.textContent = "Tileset view";
329
+ elements.animationSelect.value = "tileset:base";
330
+ return;
331
+ }
332
+ selectedTilesetAnimationKey = undefined;
67
333
  if (asset.kind !== "collection") {
68
334
  const baseOption = document.createElement("option");
69
335
  baseOption.value = resolveTargetAssetId(manifest, assetId, selectedTargetId);
@@ -83,17 +349,143 @@ export function installAiAssetDesigner(options) {
83
349
  : resolveTargetAssetId(manifest, assetId, selectedTargetId);
84
350
  elements.animationSelect.value = selectedTargetAssetId;
85
351
  };
86
- const syncTargetAsset = (assetId) => {
352
+ const effectiveTilesetTileCount = (asset) => {
353
+ const tileset = tilesetMetadataForAsset(asset);
354
+ if (!tileset)
355
+ return 0;
356
+ const capacity = tileset.columns * tileset.rows;
357
+ return positiveIntegerInput(elements.frameCountInput, Math.min(tileset.tileCount ?? capacity, capacity));
358
+ };
359
+ const tilePromptDraftsForAsset = (assetId) => {
360
+ const existing = tilesetPromptDrafts.get(assetId);
361
+ if (existing)
362
+ return existing;
363
+ const configured = tilesetMetadataForAsset(manifest.assets[assetId])?.tiles
364
+ ?.map((tile) => tile.prompt) ?? [];
365
+ tilesetPromptDrafts.set(assetId, configured);
366
+ return configured;
367
+ };
368
+ const tilesetAnimationPromptDraftKey = (assetId, animationKey) => (`${assetId}\u0000${animationKey}`);
369
+ const animationTilePromptDraftsForAsset = (assetId, animationKey) => {
370
+ const draftKey = tilesetAnimationPromptDraftKey(assetId, animationKey);
371
+ const existing = tilesetAnimationPromptDrafts.get(draftKey);
372
+ if (existing)
373
+ return existing;
374
+ const asset = manifest.assets[assetId];
375
+ const tileset = tilesetMetadataForAsset(asset);
376
+ const animation = tilesetAnimationForKey(asset, animationKey);
377
+ const tileCount = tileset
378
+ ? Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows)
379
+ : 0;
380
+ const configured = animation?.tiles?.map((tile) => tile.prompt) ??
381
+ Array.from({ length: tileCount }, (_, tile) => {
382
+ const basePrompt = tileset?.tiles?.[tile]?.prompt?.trim();
383
+ return basePrompt
384
+ ? `Keep this ${basePrompt} tile unchanged unless this animation needs it to move.`
385
+ : "Keep this tile unchanged unless this animation needs it to move.";
386
+ });
387
+ tilesetAnimationPromptDrafts.set(draftKey, configured);
388
+ return configured;
389
+ };
390
+ const renderTilesetPromptInputs = (assetId) => {
391
+ const asset = manifest.assets[assetId];
392
+ const tileset = tilesetMetadataForAsset(asset);
393
+ const isAnimation = Boolean(selectedTilesetAnimationKey);
394
+ const tileCount = isAnimation && tileset
395
+ ? Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows)
396
+ : effectiveTilesetTileCount(asset);
397
+ const drafts = selectedTilesetAnimationKey
398
+ ? animationTilePromptDraftsForAsset(assetId, selectedTilesetAnimationKey)
399
+ : tilePromptDraftsForAsset(assetId);
400
+ if (!isAnimation)
401
+ elements.frameCountInput.value = String(tileCount);
402
+ elements.tilesetPromptsList.replaceChildren();
403
+ const title = elements.tilesetPromptsField.querySelector(".ai-game-assets-designer__tile-prompts-title");
404
+ const hint = elements.tilesetPromptsField.querySelector(".ai-game-assets-designer__tile-prompts-hint");
405
+ if (title)
406
+ title.textContent = isAnimation ? "Tile animation prompts" : "Tile prompts";
407
+ if (hint) {
408
+ hint.textContent = isAnimation
409
+ ? "Describe how every tile behaves in this animation, in exact sheet order."
410
+ : "Describe each tile in exact sheet order.";
411
+ }
412
+ for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
413
+ const input = document.createElement("textarea");
414
+ input.rows = 3;
415
+ input.value = drafts[tileIndex] ?? "";
416
+ input.setAttribute("aria-label", `Tile ${tileIndex + 1} prompt`);
417
+ input.addEventListener("input", () => {
418
+ drafts[tileIndex] = input.value;
419
+ if (isAnimation && selectedTilesetAnimationKey) {
420
+ elements.promoteButton.disabled = true;
421
+ void stageTilesetAnimationPromptDraft(assetId, selectedTilesetAnimationKey);
422
+ }
423
+ });
424
+ const label = document.createElement("label");
425
+ label.className = "ai-game-assets-designer__field";
426
+ const labelText = document.createElement("span");
427
+ labelText.textContent = `Tile ${tileIndex + 1}`;
428
+ label.append(labelText, input);
429
+ elements.tilesetPromptsList.append(label);
430
+ }
431
+ };
432
+ const tilesetAnimationPromptDefinitions = (assetId, animationKey) => {
433
+ const tileset = tilesetMetadataForAsset(manifest.assets[assetId]);
434
+ if (!tileset)
435
+ return undefined;
436
+ const tileCount = Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows);
437
+ const prompts = animationTilePromptDraftsForAsset(assetId, animationKey)
438
+ .slice(0, tileCount)
439
+ .map((prompt) => prompt.trim());
440
+ if (prompts.length !== tileCount || prompts.some((prompt) => !prompt))
441
+ return undefined;
442
+ return prompts.map((prompt) => ({ prompt }));
443
+ };
444
+ const tilesetAnimationDefinitionFromInputs = (assetId, animationKey) => {
445
+ const animation = tilesetAnimationForKey(manifest.assets[assetId], animationKey);
446
+ const tiles = tilesetAnimationPromptDefinitions(assetId, animationKey);
447
+ if (!animation || !tiles)
448
+ return undefined;
449
+ const frameCount = positiveIntegerInput(elements.frameCountInput, animation.frameCount);
450
+ const defaultDelayMs = Math.round(1000 / Math.max(1, animation.frameRate));
451
+ return {
452
+ ...animation,
453
+ frameCount,
454
+ tiles,
455
+ frameTimings: Array.from({ length: frameCount }, (_, index) => (animation.frameTimings?.[index] ?? { delayMs: defaultDelayMs }))
456
+ };
457
+ };
458
+ const assetWithTilesetAnimationDefinition = (asset, definition) => ({
459
+ ...asset,
460
+ tileset: asset.tileset ? {
461
+ ...asset.tileset,
462
+ animations: (asset.tileset.animations ?? []).map((animation) => (animation.key === definition.key ? definition : animation))
463
+ } : asset.tileset
464
+ });
465
+ const tilesetPromptDefinitions = (assetId, tileCount) => {
466
+ const prompts = tilePromptDraftsForAsset(assetId)
467
+ .slice(0, tileCount)
468
+ .map((prompt) => prompt.trim());
469
+ if (prompts.length !== tileCount || prompts.some((prompt) => !prompt)) {
470
+ return undefined;
471
+ }
472
+ return prompts.map((prompt) => ({ prompt }));
473
+ };
474
+ const syncTargetAsset = (assetId, syncOptions = {}) => {
87
475
  const asset = manifest.assets[assetId];
88
476
  const activeVersion = asset.versions[asset.activeVersion];
89
477
  const isAudio = isAudioAsset(asset);
90
478
  const isVoice = isVoiceAsset(asset);
91
479
  const isVoiceLine = asset.kind === "voice-line";
480
+ const tileset = tilesetMetadataForAsset(asset);
481
+ const tilesetAnimation = selectedTilesetAnimationKey
482
+ ? tilesetAnimationForKey(asset, selectedTilesetAnimationKey)
483
+ : undefined;
92
484
  stopCurrentAnimationPreview?.();
93
485
  stopCurrentAnimationPreview = undefined;
94
- elements.promptInput.value = activeVersion?.prompt ?? asset.prompt;
95
- elements.widthInput.value = String(asset.frameGrid?.frameWidth ?? asset.dimensions?.width ?? 1);
96
- elements.heightInput.value = String(asset.frameGrid?.frameHeight ?? asset.dimensions?.height ?? 1);
486
+ elements.promptInput.value = tilesetAnimation?.prompt ?? activeVersion?.prompt ?? asset.prompt;
487
+ elements.widthInput.value = String(asset.frameGrid?.frameWidth ?? tileset?.tileWidth ?? asset.dimensions?.width ?? 1);
488
+ elements.heightInput.value = String(asset.frameGrid?.frameHeight ?? tileset?.tileHeight ?? asset.dimensions?.height ?? 1);
97
489
  elements.audioFormatSelect.value = asset.audioSettings?.format ?? "mp3";
98
490
  elements.audioDurationInput.value = String(asset.audioSettings?.durationSeconds ?? activeVersion?.durationSeconds ?? "");
99
491
  elements.audioLoopInput.checked = Boolean(asset.audioSettings?.loop);
@@ -110,14 +502,43 @@ export function installAiAssetDesigner(options) {
110
502
  elements.audioLoopField.hidden = !isAudio;
111
503
  elements.voiceTextField.hidden = !isVoice;
112
504
  elements.voiceTextField.firstChild.textContent = isVoiceLine ? "Text to say" : "Demo sentence";
113
- elements.formatField.hidden = isAudio || elements.formatField.hidden;
114
- elements.dimensionGrid.hidden = isAudio;
505
+ elements.formatField.hidden = isAudio || Boolean(selectedTilesetAnimationKey) ||
506
+ elements.formatField.hidden;
507
+ const isTilesetAnimation = Boolean(tileset && selectedTilesetAnimationKey);
508
+ const isBaseTileset = Boolean(tileset && !selectedTilesetAnimationKey);
509
+ elements.promptField.hidden = Boolean(tileset);
510
+ elements.tilesetPromptsField.hidden = !tileset;
511
+ elements.widthField.firstElementChild.textContent = tileset ? "Tile width" : "Width";
512
+ elements.heightField.firstElementChild.textContent = tileset ? "Tile height" : "Height";
513
+ elements.frameCountField.firstElementChild.textContent = isTilesetAnimation
514
+ ? "Animation frames"
515
+ : tileset ? "Tiles" : "Frames";
516
+ elements.dimensionGrid.hidden = isAudio || isTilesetAnimation;
115
517
  syncTargetVariantLabel(assetId);
116
- elements.frameCountInput.value = String(asset.frameGrid?.frameCount ??
117
- (asset.frameGrid ? asset.frameGrid.columns * asset.frameGrid.rows : 1));
518
+ elements.frameCountInput.value = String(isTilesetAnimation
519
+ ? tilesetAnimation?.frameCount ?? 1
520
+ : asset.frameGrid?.frameCount ??
521
+ (asset.frameGrid
522
+ ? asset.frameGrid.columns * asset.frameGrid.rows
523
+ : tileset?.tileCount ?? (tileset ? tileset.columns * tileset.rows : 1)));
524
+ if (tileset && !isTilesetAnimation) {
525
+ elements.frameCountInput.max = String(tileset.columns * tileset.rows);
526
+ }
527
+ else {
528
+ elements.frameCountInput.removeAttribute("max");
529
+ }
530
+ if (tileset) {
531
+ renderTilesetPromptInputs(assetId);
532
+ }
533
+ else {
534
+ elements.tilesetPromptsList.replaceChildren();
535
+ }
118
536
  const activeVersionSource = activeVersion?.file ? resolveAssetUrl(activeVersion.file) : "";
119
- elements.frameCountField.hidden = asset.kind === "image" || !asset.frameGrid;
120
- elements.versionLabel.textContent = `Active ${readableAssetName(assetId)}: ${asset.activeVersion}`;
537
+ elements.frameCountField.hidden = !isTilesetAnimation &&
538
+ (!tileset && (asset.kind === "image" || !asset.frameGrid));
539
+ elements.versionLabel.textContent = selectedTilesetAnimationKey
540
+ ? `Active ${readableAssetName(assetId)} animation: ${readableAssetName(selectedTilesetAnimationKey)}`
541
+ : `Active ${readableAssetName(assetId)}: ${asset.activeVersion}`;
121
542
  elements.currentImage.src = activeVersionSource;
122
543
  if (isAudio) {
123
544
  renderAudioPlayer({
@@ -136,29 +557,53 @@ export function installAiAssetDesigner(options) {
136
557
  elements.currentAnimation.hidden = true;
137
558
  elements.currentAudio.hidden = !isAudio;
138
559
  elements.currentImage.hidden = isAudio;
139
- elements.currentAnimationButton.textContent = "Edit...";
560
+ elements.currentAnimationButton.textContent = isTilesetAnimation
561
+ ? "Edit animation..."
562
+ : "Edit...";
563
+ elements.currentTouchUpButton.textContent = isBaseTileset ? "Edit tileset..." : "Touch up...";
140
564
  elements.currentRevertButton.hidden = true;
141
565
  elements.currentPreview.classList.add("is-selected");
142
- elements.currentAnimationButton.hidden = !activeVersion?.file || (!asset.frameGrid && !isAudio);
566
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
567
+ ? !hasEditableTilesetAnimationFrames(assetId, selectedTilesetAnimationKey)
568
+ : !activeVersion?.file || (!asset.frameGrid && !isAudio);
143
569
  elements.currentTouchUpButton.hidden = !activeVersion?.file ||
570
+ Boolean(selectedTilesetAnimationKey) ||
144
571
  isAudio ||
145
572
  Boolean(asset.frameGrid) ||
146
573
  normalizeAssetFormat(asset.settings?.format) === "svg" ||
147
574
  isSvgSource(activeVersion?.file ?? "");
148
- elements.options.innerHTML = "";
149
- elements.options.classList.remove("is-audio");
575
+ if (syncOptions.preserveOptions && displayedOptions?.assetId === assetId) {
576
+ renderGeneratedOptions(assetId, displayedOptions.generated);
577
+ }
578
+ else {
579
+ clearGeneratedOptions();
580
+ }
150
581
  setStatus(elements, "", "idle");
151
582
  elements.promoteButton.disabled = true;
583
+ elements.uploadButton.disabled = Boolean(selectedTilesetAnimationKey);
152
584
  elements.versionsButton.disabled = Object.keys(asset.versions).length <= 1;
153
- elements.deriveButton.hidden = isAudio || deriveCandidatesForAsset(assetId).length === 0;
585
+ elements.deriveButton.hidden = Boolean(selectedTilesetAnimationKey) ||
586
+ isAudio || deriveCandidatesForAsset(assetId).length === 0;
154
587
  selectedOption = undefined;
155
588
  editedCurrentOption = undefined;
156
589
  previewedVersionName = undefined;
590
+ const pending = pendingOptions.get(assetId);
591
+ if (pending) {
592
+ selectedOption = pending.option;
593
+ editedCurrentOption = pending.option;
594
+ previewedVersionName = pending.previewedVersionName;
595
+ showOptionInCurrentPreview(pending.option, "pending preview");
596
+ previewedVersionName = pending.previewedVersionName;
597
+ elements.currentPreview.classList.add("is-selected");
598
+ elements.promoteButton.disabled = activePromotionId !== undefined;
599
+ }
600
+ syncPromoteAllButton();
157
601
  };
158
602
  const showOptionInCurrentPreview = (option, label = "selected option") => {
159
603
  const asset = manifest.assets[selectedTargetAssetId];
160
604
  const optionAsset = assetWithGeneratedGeometry(asset, option);
161
605
  const isAudio = isAudioAsset(asset);
606
+ const isTilesetAnimation = Boolean(selectedTilesetAnimationKey && isDesignerTilesetAsset(optionAsset));
162
607
  stopCurrentAnimationPreview?.();
163
608
  stopCurrentAnimationPreview = undefined;
164
609
  editedCurrentOption = option;
@@ -166,10 +611,18 @@ export function installAiAssetDesigner(options) {
166
611
  elements.currentPreview.hidden = false;
167
612
  elements.currentPreview.setAttribute("aria-label", `Preview ${label} for ${readableAssetName(selectedTargetAssetId)}`);
168
613
  elements.currentAnimation.hidden = true;
169
- elements.currentAnimationButton.textContent = "Edit...";
170
- elements.currentAnimationButton.hidden = !optionAsset.frameGrid && !isAudio;
614
+ elements.currentAnimationButton.textContent = isTilesetAnimation
615
+ ? "Edit animation..."
616
+ : "Edit...";
617
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
618
+ ? !hasEditableTilesetAnimationFrames(selectedTargetAssetId, selectedTilesetAnimationKey)
619
+ : !optionAsset.frameGrid && !isAudio;
620
+ elements.currentTouchUpButton.textContent = isDesignerTilesetAsset(optionAsset)
621
+ ? "Edit tileset..."
622
+ : "Touch up...";
171
623
  elements.currentTouchUpButton.hidden =
172
624
  isAudio ||
625
+ isTilesetAnimation ||
173
626
  Boolean(optionAsset.frameGrid) ||
174
627
  option.mimeType === "image/svg+xml";
175
628
  elements.currentRevertButton.hidden = false;
@@ -189,6 +642,262 @@ export function installAiAssetDesigner(options) {
189
642
  elements.currentImage.alt = `${readableAssetName(selectedTargetAssetId)} ${label}`;
190
643
  }
191
644
  };
645
+ const syncMixTilesetButton = () => {
646
+ const session = displayedOptions;
647
+ const asset = session ? manifest.assets[session.assetId] : undefined;
648
+ const baseVisible = Boolean(session &&
649
+ session.assetId === selectedTargetAssetId &&
650
+ session.generated.length === 3 &&
651
+ isDesignerTilesetAsset(asset) &&
652
+ !selectedTilesetAnimationKey);
653
+ const animationSession = displayedTilesetAnimationOptions;
654
+ const animationVisible = Boolean(animationSession &&
655
+ animationSession.assetId === selectedTargetAssetId &&
656
+ animationSession.animationKey === selectedTilesetAnimationKey &&
657
+ animationSession.generated.length === 3);
658
+ const visible = baseVisible || animationVisible;
659
+ elements.mixTilesetButton.textContent = animationVisible ? "Mix tilesets" : "Mix tileset";
660
+ elements.mixTilesetButton.hidden = !visible;
661
+ elements.mixTilesetButton.disabled = !visible || Boolean(activeGeneration || activePromotionId !== undefined || mixingTileset);
662
+ };
663
+ const renderGeneratedOptions = (assetId, generated) => {
664
+ stopTilesetAnimationOptionPreviews?.();
665
+ stopTilesetAnimationOptionPreviews = undefined;
666
+ displayedTilesetAnimationOptions = undefined;
667
+ displayedOptions = {
668
+ assetId,
669
+ generated: [...generated]
670
+ };
671
+ renderOptions({
672
+ elements,
673
+ generated,
674
+ scene: options.scene,
675
+ manifest,
676
+ assetId,
677
+ designerOptions: options,
678
+ onPreview: options.onPreview,
679
+ onSelected(option) {
680
+ selectedOption = option;
681
+ rememberPendingOption(assetId, option);
682
+ showOptionInCurrentPreview(option);
683
+ elements.promoteButton.disabled = activePromotionId !== undefined;
684
+ }
685
+ });
686
+ syncMixTilesetButton();
687
+ };
688
+ const currentTilesetOption = async (assetId, definition) => {
689
+ const asset = manifest.assets[assetId];
690
+ const pending = pendingOptions.get(assetId)?.option;
691
+ const activeVersion = asset.versions[asset.activeVersion];
692
+ const dataUrl = pending?.dataUrl ??
693
+ await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
694
+ const tilesetSourceDataUrl = pending
695
+ ? pending.tilesetSourceDataUrl
696
+ : activeVersion.tilesetSourceFile
697
+ ? await imageSourceToDataUrl(resolveAssetUrl(activeVersion.tilesetSourceFile))
698
+ : undefined;
699
+ return {
700
+ ...(pending ?? {
701
+ index: -1,
702
+ dataUrl,
703
+ mimeType: mimeTypeFromDataUrl(dataUrl),
704
+ prompt: activeVersion.prompt ?? asset.prompt,
705
+ model: activeVersion.model,
706
+ revisedPrompt: activeVersion.revisedPrompt,
707
+ dimensions: asset.dimensions,
708
+ settings: activeVersion.settings ?? asset.settings
709
+ }),
710
+ dataUrl,
711
+ tilesetSourceDataUrl,
712
+ tilesetTransforms: tilesetSourceDataUrl
713
+ ? cloneTilesetTransforms(pending ? pending.tilesetTransforms : activeVersion.tilesetTransforms)
714
+ : undefined,
715
+ tileset: assetWithTilesetAnimationDefinition(asset, definition).tileset
716
+ };
717
+ };
718
+ const activeTilesetAnimationFrames = (assetId, animationKey) => {
719
+ const asset = manifest.assets[assetId];
720
+ const activeVersion = asset?.versions[asset.activeVersion];
721
+ const files = activeVersion?.tilesetAnimations?.[animationKey]?.files;
722
+ if (!asset || !activeVersion || !files?.length) {
723
+ return Promise.reject(new Error(`Generate ${readableAssetName(animationKey)} animation frames before promoting its prompts.`));
724
+ }
725
+ const cacheKey = `${assetId}\u0000${asset.activeVersion}\u0000${animationKey}`;
726
+ const existing = tilesetAnimationFrameDrafts.get(cacheKey);
727
+ if (existing)
728
+ return existing;
729
+ const loading = Promise.all(files.map((file) => imageSourceToDataUrl(resolveAssetUrl(file))));
730
+ tilesetAnimationFrameDrafts.set(cacheKey, loading);
731
+ void loading.catch(() => {
732
+ if (tilesetAnimationFrameDrafts.get(cacheKey) === loading) {
733
+ tilesetAnimationFrameDrafts.delete(cacheKey);
734
+ }
735
+ });
736
+ return loading;
737
+ };
738
+ const promptOnlyTilesetAnimationDefinition = (assetId, animationKey, pending = pendingOptions.get(assetId)) => {
739
+ const tiles = tilesetAnimationPromptDefinitions(assetId, animationKey);
740
+ const declared = tilesetAnimationForKey(manifest.assets[assetId], animationKey);
741
+ const pendingDefinition = pending?.option.tileset?.animations?.find((animation) => animation.key === animationKey);
742
+ const source = pendingDefinition ?? declared;
743
+ return source && tiles ? { ...source, tiles } : undefined;
744
+ };
745
+ const stageTilesetAnimationPromptDraft = async (assetId, animationKey) => {
746
+ const revision = tilesetAnimationPromptRevision + 1;
747
+ tilesetAnimationPromptRevision = revision;
748
+ if (!promptOnlyTilesetAnimationDefinition(assetId, animationKey)) {
749
+ elements.promoteButton.disabled = true;
750
+ return;
751
+ }
752
+ try {
753
+ const pendingBeforeLoad = pendingOptions.get(assetId);
754
+ const loadedFrames = pendingBeforeLoad?.tilesetAnimations?.[animationKey] ??
755
+ await activeTilesetAnimationFrames(assetId, animationKey);
756
+ if (revision !== tilesetAnimationPromptRevision ||
757
+ selectedTargetAssetId !== assetId ||
758
+ selectedTilesetAnimationKey !== animationKey)
759
+ return;
760
+ const pending = pendingOptions.get(assetId);
761
+ const frames = pending?.tilesetAnimations?.[animationKey] ?? loadedFrames;
762
+ const definition = promptOnlyTilesetAnimationDefinition(assetId, animationKey, pending);
763
+ if (!definition) {
764
+ elements.promoteButton.disabled = true;
765
+ return;
766
+ }
767
+ if (frames.length !== definition.frameCount) {
768
+ throw new Error(`${readableAssetName(animationKey)} has ${frames.length} current frames, but ` +
769
+ `${definition.frameCount} are configured. Generate new frames before promoting.`);
770
+ }
771
+ const option = await currentTilesetOption(assetId, definition);
772
+ if (revision !== tilesetAnimationPromptRevision ||
773
+ selectedTargetAssetId !== assetId ||
774
+ selectedTilesetAnimationKey !== animationKey)
775
+ return;
776
+ const latestPending = pendingOptions.get(assetId);
777
+ selectedOption = option;
778
+ editedCurrentOption = option;
779
+ rememberPendingOption(assetId, option, {
780
+ inheritAnimations: latestPending?.inheritAnimations,
781
+ previewedVersionName: latestPending?.previewedVersionName,
782
+ tilesetAnimations: {
783
+ ...(latestPending?.tilesetAnimations ?? {}),
784
+ [animationKey]: latestPending?.tilesetAnimations?.[animationKey] ?? frames
785
+ },
786
+ animationOnlyKey: latestPending?.animationOnlyKey ??
787
+ (latestPending ? undefined : animationKey)
788
+ });
789
+ if (displayedTilesetAnimationOptions?.assetId === assetId &&
790
+ displayedTilesetAnimationOptions.animationKey === animationKey) {
791
+ displayedTilesetAnimationOptions.animation = {
792
+ ...displayedTilesetAnimationOptions.animation,
793
+ tiles: definition.tiles
794
+ };
795
+ }
796
+ elements.promoteButton.disabled = activePromotionId !== undefined;
797
+ setStatus(elements, "Animation prompt changes ready. Promote to save them.", "info");
798
+ }
799
+ catch (error) {
800
+ if (revision !== tilesetAnimationPromptRevision ||
801
+ selectedTargetAssetId !== assetId ||
802
+ selectedTilesetAnimationKey !== animationKey)
803
+ return;
804
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
805
+ setStatus(elements, `Could not stage animation prompts. ${errorMessage(error)}`, "error");
806
+ }
807
+ };
808
+ const selectTilesetAnimationFrames = async (assetId, definition, frames, card) => {
809
+ const currentPrompts = tilesetAnimationPromptDefinitions(assetId, definition.key);
810
+ const selectedDefinition = currentPrompts
811
+ ? { ...definition, tiles: currentPrompts }
812
+ : definition;
813
+ const option = await currentTilesetOption(assetId, selectedDefinition);
814
+ const pending = pendingOptions.get(assetId);
815
+ selectedOption = option;
816
+ rememberPendingOption(assetId, option, {
817
+ inheritAnimations: pending?.inheritAnimations,
818
+ previewedVersionName: pending?.previewedVersionName,
819
+ tilesetAnimations: {
820
+ ...(pending?.tilesetAnimations ?? {}),
821
+ [selectedDefinition.key]: frames
822
+ },
823
+ animationOnlyKey: pending?.animationOnlyKey ??
824
+ (pending ? undefined : selectedDefinition.key)
825
+ });
826
+ for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
827
+ item.classList.toggle("is-selected", item === card);
828
+ }
829
+ elements.currentPreview.classList.remove("is-selected");
830
+ elements.promoteButton.disabled = activePromotionId !== undefined;
831
+ await previewTilesetAnimationFrames(assetId, selectedDefinition.key, frames, assetWithTilesetAnimationDefinition(manifest.assets[assetId], selectedDefinition));
832
+ };
833
+ const renderTilesetAnimationOptions = (assetId, definition, generated) => {
834
+ stopTilesetAnimationOptionPreviews?.();
835
+ displayedOptions = undefined;
836
+ displayedTilesetAnimationOptions = {
837
+ assetId,
838
+ animationKey: definition.key,
839
+ animation: definition,
840
+ generated: [...generated]
841
+ };
842
+ elements.options.replaceChildren();
843
+ elements.options.classList.remove("is-audio");
844
+ const stops = [];
845
+ const delayForFrame = (index) => (definition.frameTimings?.[index]?.delayMs ??
846
+ Math.round(1000 / Math.max(1, definition.frameRate)));
847
+ for (const candidate of generated) {
848
+ const card = document.createElement("div");
849
+ card.className = "ai-game-assets-designer__option";
850
+ const button = document.createElement("button");
851
+ button.type = "button";
852
+ button.className = "ai-game-assets-designer__option-select";
853
+ button.setAttribute("aria-label", `Select animation option ${candidate.index + 1}`);
854
+ const image = document.createElement("img");
855
+ image.alt = `${assetId} animation option ${candidate.index + 1}`;
856
+ const label = document.createElement("span");
857
+ label.textContent = `Option ${candidate.index + 1}`;
858
+ button.append(image, label);
859
+ card.append(button);
860
+ elements.options.append(card);
861
+ let frame = 0;
862
+ let timeout;
863
+ let stopped = false;
864
+ const animate = () => {
865
+ if (stopped || candidate.frames.length === 0)
866
+ return;
867
+ image.src = candidate.frames[frame % candidate.frames.length].dataUrl;
868
+ const currentFrame = frame;
869
+ frame += 1;
870
+ timeout = window.setTimeout(animate, delayForFrame(currentFrame));
871
+ };
872
+ animate();
873
+ stops.push(() => {
874
+ stopped = true;
875
+ if (timeout !== undefined)
876
+ window.clearTimeout(timeout);
877
+ });
878
+ button.addEventListener("click", async () => {
879
+ setStatus(elements, `Applying animation option ${candidate.index + 1}...`, "busy");
880
+ try {
881
+ await selectTilesetAnimationFrames(assetId, definition, candidate.frames.map((item) => item.dataUrl), card);
882
+ setStatus(elements, `Previewing animation option ${candidate.index + 1}. Promote to save it.`, "info");
883
+ }
884
+ catch (error) {
885
+ setStatus(elements, `Could not preview animation. ${errorMessage(error)}`, "error");
886
+ }
887
+ });
888
+ }
889
+ stopTilesetAnimationOptionPreviews = () => stops.forEach((stop) => stop());
890
+ syncMixTilesetButton();
891
+ };
892
+ const clearGeneratedOptions = () => {
893
+ stopTilesetAnimationOptionPreviews?.();
894
+ stopTilesetAnimationOptionPreviews = undefined;
895
+ displayedTilesetAnimationOptions = undefined;
896
+ displayedOptions = undefined;
897
+ elements.options.innerHTML = "";
898
+ elements.options.classList.remove("is-audio");
899
+ syncMixTilesetButton();
900
+ };
192
901
  const restoreActivePreview = () => {
193
902
  const asset = manifest.assets[selectedTargetAssetId];
194
903
  const activeVersion = asset.versions[asset.activeVersion];
@@ -199,18 +908,28 @@ export function installAiAssetDesigner(options) {
199
908
  editedCurrentOption = undefined;
200
909
  previewedVersionName = undefined;
201
910
  selectedOption = undefined;
911
+ forgetPendingOption(selectedTargetAssetId);
202
912
  for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
203
913
  item.classList.remove("is-selected");
204
914
  }
205
915
  const activeVersionSource = resolveAssetUrl(activeVersion.file);
206
916
  const isAudio = isAudioAsset(asset);
917
+ const isTilesetAnimation = Boolean(selectedTilesetAnimationKey && isDesignerTilesetAsset(asset));
207
918
  elements.currentPreview.classList.add("is-selected");
208
919
  elements.currentPreview.setAttribute("aria-label", `Preview active ${readableAssetName(selectedTargetAssetId)} version`);
209
920
  elements.currentAnimation.hidden = true;
210
- elements.currentAnimationButton.textContent = "Edit...";
211
- elements.currentAnimationButton.hidden = !asset.frameGrid && !isAudio;
921
+ elements.currentAnimationButton.textContent = isTilesetAnimation
922
+ ? "Edit animation..."
923
+ : "Edit...";
924
+ elements.currentAnimationButton.hidden = isTilesetAnimation && selectedTilesetAnimationKey
925
+ ? !hasEditableTilesetAnimationFrames(selectedTargetAssetId, selectedTilesetAnimationKey)
926
+ : !asset.frameGrid && !isAudio;
927
+ elements.currentTouchUpButton.textContent = isDesignerTilesetAsset(asset)
928
+ ? "Edit tileset..."
929
+ : "Touch up...";
212
930
  elements.currentTouchUpButton.hidden =
213
931
  isAudio ||
932
+ isTilesetAnimation ||
214
933
  Boolean(asset.frameGrid) ||
215
934
  normalizeAssetFormat(asset.settings?.format) === "svg" ||
216
935
  isSvgSource(activeVersion.file);
@@ -225,21 +944,28 @@ export function installAiAssetDesigner(options) {
225
944
  label: readableAssetName(selectedTargetAssetId),
226
945
  playback: activeVersion.audioPlayback
227
946
  });
228
- options.onPreview(selectedTargetAssetId, activeVersionSource, asset);
947
+ (options.onAssetReady ?? options.onPreview)(selectedTargetAssetId, activeVersionSource, asset);
948
+ setStatus(elements, "Reverted preview to the active version.", "info");
229
949
  }
230
950
  else {
231
951
  elements.currentAudio.innerHTML = "";
232
952
  elements.currentImage.src = activeVersionSource;
233
953
  elements.currentImage.alt = `${readableAssetName(selectedTargetAssetId)} active version`;
954
+ setStatus(elements, "Reverting preview in the game...", "busy");
234
955
  previewCurrentAsset({
235
956
  scene: options.scene,
236
957
  manifest,
237
958
  assetId: selectedTargetAssetId,
238
959
  src: activeVersionSource,
239
- onPreview: options.onPreview
960
+ onPreview(assetId, textureKey, previewAsset) {
961
+ (options.onAssetReady ?? options.onPreview)(assetId, textureKey, previewAsset);
962
+ setStatus(elements, "Reverted preview to the active version.", "info");
963
+ },
964
+ onError(error) {
965
+ setStatus(elements, `Could not revert the game preview. ${error.message}`, "error");
966
+ }
240
967
  });
241
968
  }
242
- setStatus(elements, "Reverted preview to the active version.", "info");
243
969
  };
244
970
  const syncAsset = (assetId) => {
245
971
  syncAnimationChoices(assetId);
@@ -335,6 +1061,7 @@ export function installAiAssetDesigner(options) {
335
1061
  selectedAssetId,
336
1062
  assetIds: visibleAssetIds(),
337
1063
  onSelect(assetId) {
1064
+ invalidatePanelWork();
338
1065
  selectedAssetId = assetId;
339
1066
  syncAsset(selectedAssetId);
340
1067
  }
@@ -362,13 +1089,117 @@ export function installAiAssetDesigner(options) {
362
1089
  activeGeneration = undefined;
363
1090
  elements.regenerateButton.textContent = "Regenerate";
364
1091
  unlockGenerationStatus();
1092
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
1093
+ syncPromoteAllButton();
1094
+ syncMixTilesetButton();
365
1095
  return true;
366
1096
  };
1097
+ const invalidatePanelWork = () => {
1098
+ panelRevision += 1;
1099
+ if (!activeGeneration)
1100
+ return;
1101
+ const { controller, id } = activeGeneration;
1102
+ controller.abort();
1103
+ finishGeneration(id);
1104
+ };
1105
+ const regenerateTilesetAnimation = async (animationKey) => {
1106
+ const assetId = selectedTargetAssetId;
1107
+ const workflowPanelRevision = panelRevision;
1108
+ const isWorkflowPanelCurrent = () => panelRevision === workflowPanelRevision &&
1109
+ selectedTargetAssetId === assetId &&
1110
+ selectedTilesetAnimationKey === animationKey;
1111
+ const asset = manifest.assets[assetId];
1112
+ const animation = tilesetAnimationForKey(asset, animationKey);
1113
+ const activeVersion = asset?.versions[asset.activeVersion];
1114
+ const definition = tilesetAnimationDefinitionFromInputs(assetId, animationKey);
1115
+ if (!asset || !animation || !activeVersion?.file) {
1116
+ setStatus(elements, `Tileset animation "${animationKey}" is not ready to generate.`, "error");
1117
+ return;
1118
+ }
1119
+ if (!definition) {
1120
+ setStatus(elements, "Add an animation prompt for every tile before generating.", "error");
1121
+ return;
1122
+ }
1123
+ const controller = new AbortController();
1124
+ const currentGenerationId = generationId + 1;
1125
+ generationId = currentGenerationId;
1126
+ activeGeneration = { controller, id: currentGenerationId };
1127
+ syncPromoteAllButton();
1128
+ elements.regenerateButton.textContent = "Cancel generation";
1129
+ clearGeneratedOptions();
1130
+ elements.promoteButton.disabled = true;
1131
+ setStatus(elements, "Generating options...", "busy");
1132
+ lockGenerationStatus();
1133
+ try {
1134
+ const candidates = await client.generateTilesetAnimationStream({
1135
+ assetId,
1136
+ animationKey,
1137
+ frameCount: definition.frameCount,
1138
+ tiles: definition.tiles,
1139
+ count: 3
1140
+ }, (candidate) => {
1141
+ if (activeGeneration?.id !== currentGenerationId)
1142
+ return;
1143
+ if (candidate.frames.length !== definition.frameCount) {
1144
+ throw new Error(`Requested ${definition.frameCount} frames, but the generation server returned ` +
1145
+ `${candidate.frames.length} for option ${candidate.index + 1}. ` +
1146
+ "Restart the AI asset dev server and regenerate the options.");
1147
+ }
1148
+ const session = displayedTilesetAnimationOptions;
1149
+ const generated = [
1150
+ ...(session?.assetId === assetId && session.animationKey === animationKey
1151
+ ? session.generated
1152
+ : []),
1153
+ candidate
1154
+ ].sort((left, right) => left.index - right.index);
1155
+ renderTilesetAnimationOptions(assetId, definition, generated);
1156
+ setStatus(elements, `Generated option ${candidate.index + 1}. Waiting for all 3 animation sequences...`, "busy");
1157
+ }, {
1158
+ signal: controller.signal
1159
+ });
1160
+ if (!finishGeneration(currentGenerationId))
1161
+ return;
1162
+ if (candidates.length !== 3) {
1163
+ throw new Error(`Expected 3 tileset animation candidates, received ${candidates.length}.`);
1164
+ }
1165
+ const mismatchedCandidate = candidates.find((candidate) => (candidate.frames.length !== definition.frameCount));
1166
+ if (mismatchedCandidate) {
1167
+ throw new Error(`Requested ${definition.frameCount} frames, but option ` +
1168
+ `${mismatchedCandidate.index + 1} contains ${mismatchedCandidate.frames.length}. ` +
1169
+ "Regenerate the options for the current frame count.");
1170
+ }
1171
+ if (!isWorkflowPanelCurrent())
1172
+ return;
1173
+ renderTilesetAnimationOptions(assetId, definition, candidates);
1174
+ setStatus(elements, "Three animation options ready. Preview one or mix tilesets.", "info");
1175
+ }
1176
+ catch (error) {
1177
+ finishGeneration(currentGenerationId);
1178
+ if (isAbortError(error)) {
1179
+ if (isWorkflowPanelCurrent()) {
1180
+ setStatus(elements, "Generation cancelled.", "info");
1181
+ }
1182
+ return;
1183
+ }
1184
+ if (isWorkflowPanelCurrent()) {
1185
+ if (displayedTilesetAnimationOptions?.assetId === assetId &&
1186
+ displayedTilesetAnimationOptions.animationKey === animationKey) {
1187
+ clearGeneratedOptions();
1188
+ }
1189
+ setStatus(elements, `Tileset animation generation failed. ${errorMessage(error)}`, "error");
1190
+ }
1191
+ }
1192
+ finally {
1193
+ finishGeneration(currentGenerationId);
1194
+ }
1195
+ };
367
1196
  elements.assetSelect.addEventListener("change", () => {
1197
+ invalidatePanelWork();
368
1198
  selectedAssetId = elements.assetSelect.value;
369
1199
  syncAsset(selectedAssetId);
370
1200
  });
371
1201
  elements.targetSelect.addEventListener("change", () => {
1202
+ invalidatePanelWork();
372
1203
  selectedTargetId = elements.targetSelect.value || undefined;
373
1204
  syncAsset(selectedAssetId);
374
1205
  setStatus(elements, selectedTargetId
@@ -376,9 +1207,27 @@ export function installAiAssetDesigner(options) {
376
1207
  : "Editing default target.", "info");
377
1208
  });
378
1209
  elements.animationSelect.addEventListener("change", () => {
1210
+ invalidatePanelWork();
1211
+ if (isDesignerTilesetAsset(manifest.assets[selectedTargetAssetId])) {
1212
+ const prefix = "tileset:animation:";
1213
+ selectedTilesetAnimationKey = elements.animationSelect.value.startsWith(prefix)
1214
+ ? decodeURIComponent(elements.animationSelect.value.slice(prefix.length))
1215
+ : undefined;
1216
+ syncTargetAsset(selectedTargetAssetId);
1217
+ return;
1218
+ }
1219
+ selectedTilesetAnimationKey = undefined;
379
1220
  selectedTargetAssetId = elements.animationSelect.value;
380
1221
  syncTargetAsset(selectedTargetAssetId);
381
1222
  });
1223
+ const syncTilesetPromptCount = () => {
1224
+ const asset = manifest.assets[selectedTargetAssetId];
1225
+ if (isDesignerTilesetAsset(asset) && !selectedTilesetAnimationKey) {
1226
+ renderTilesetPromptInputs(selectedTargetAssetId);
1227
+ }
1228
+ };
1229
+ elements.frameCountInput.addEventListener("change", syncTilesetPromptCount);
1230
+ elements.frameCountInput.addEventListener("blur", syncTilesetPromptCount);
382
1231
  elements.formatSelect.addEventListener("change", () => {
383
1232
  if (canEditGenerationFormat(manifest, selectedAssetId, selectedTargetAssetId)) {
384
1233
  formatDrafts.set(selectedTargetAssetId, normalizeAssetFormat(elements.formatSelect.value));
@@ -464,57 +1313,212 @@ export function installAiAssetDesigner(options) {
464
1313
  event.preventDefault();
465
1314
  elements.currentPreview.click();
466
1315
  });
1316
+ elements.mixTilesetButton.addEventListener("click", async () => {
1317
+ const session = displayedOptions;
1318
+ const animationSession = displayedTilesetAnimationOptions;
1319
+ const assetId = selectedTargetAssetId;
1320
+ const asset = manifest.assets[assetId];
1321
+ const activeVersion = asset?.versions[asset.activeVersion];
1322
+ if (animationSession &&
1323
+ animationSession.assetId === assetId &&
1324
+ animationSession.animationKey === selectedTilesetAnimationKey &&
1325
+ animationSession.generated.length === 3 &&
1326
+ asset?.tileset &&
1327
+ activeVersion?.file &&
1328
+ !mixingTileset &&
1329
+ !activeGeneration &&
1330
+ activePromotionId === undefined) {
1331
+ const mixPanelRevision = panelRevision;
1332
+ const definition = animationSession.animation;
1333
+ const draftAsset = assetWithTilesetAnimationDefinition(asset, definition);
1334
+ const pending = pendingOptions.get(assetId);
1335
+ mixingTileset = true;
1336
+ syncMixTilesetButton();
1337
+ setStatus(elements, "Opening tileset animation mixer...", "busy");
1338
+ try {
1339
+ const baseSheetSrc = pending?.option.dataUrl ??
1340
+ await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
1341
+ const currentFrames = pending?.tilesetAnimations?.[definition.key] ??
1342
+ await Promise.all((activeVersion.tilesetAnimations?.[definition.key]?.files ?? [])
1343
+ .map((file) => imageSourceToDataUrl(resolveAssetUrl(file))));
1344
+ if (panelRevision !== mixPanelRevision ||
1345
+ selectedTargetAssetId !== assetId ||
1346
+ selectedTilesetAnimationKey !== definition.key)
1347
+ return;
1348
+ const mixed = await openTilesetAnimationMixerDialog({
1349
+ root: elements.root,
1350
+ asset: draftAsset,
1351
+ assetId,
1352
+ animationKey: definition.key,
1353
+ baseSheetSrc,
1354
+ baseAnimationFrameSrcs: currentFrames,
1355
+ candidates: animationSession.generated,
1356
+ tilePrompts: definition.tiles,
1357
+ regenerateTile: async (tile, currentTileSrc) => {
1358
+ const prompt = definition.tiles?.[tile];
1359
+ if (!prompt)
1360
+ throw new Error(`Tile ${tile + 1} requires an animation prompt.`);
1361
+ return client.generateTilesetAnimationStream({
1362
+ assetId,
1363
+ animationKey: definition.key,
1364
+ count: 3,
1365
+ frameCount: definition.frameCount,
1366
+ tiles: [prompt],
1367
+ tileset: tilesetTileGenerationOverride(asset.tileset, tile),
1368
+ baseDataUrl: currentTileSrc,
1369
+ styleGuide: await styleGuideRequest(styleGuideDraft)
1370
+ }, () => undefined);
1371
+ }
1372
+ });
1373
+ if (panelRevision !== mixPanelRevision ||
1374
+ selectedTargetAssetId !== assetId ||
1375
+ selectedTilesetAnimationKey !== definition.key)
1376
+ return;
1377
+ if (!mixed) {
1378
+ setStatus(elements, "Tileset animation mixing cancelled. The three options are still available.", "info");
1379
+ return;
1380
+ }
1381
+ await selectTilesetAnimationFrames(assetId, definition, mixed.frames);
1382
+ setStatus(elements, "Mixed tileset animation ready. Promote to save the selected tile combination.", "success");
1383
+ }
1384
+ catch (error) {
1385
+ setStatus(elements, `Could not mix tileset animation. ${errorMessage(error)}`, "error");
1386
+ }
1387
+ finally {
1388
+ mixingTileset = false;
1389
+ syncMixTilesetButton();
1390
+ }
1391
+ return;
1392
+ }
1393
+ if (mixingTileset ||
1394
+ activeGeneration ||
1395
+ activePromotionId !== undefined ||
1396
+ !session ||
1397
+ session.assetId !== assetId ||
1398
+ session.generated.length !== 3 ||
1399
+ !isDesignerTilesetAsset(asset) ||
1400
+ selectedTilesetAnimationKey ||
1401
+ !activeVersion?.file) {
1402
+ return;
1403
+ }
1404
+ const mixPanelRevision = panelRevision;
1405
+ const candidates = [...session.generated].sort((left, right) => left.index - right.index);
1406
+ mixingTileset = true;
1407
+ syncMixTilesetButton();
1408
+ setStatus(elements, "Opening tileset mixer...", "busy");
1409
+ try {
1410
+ const currentOption = editedCurrentOption;
1411
+ const activeSheetSrc = currentOption?.dataUrl ??
1412
+ await imageSourceToDataUrl(resolveAssetUrl(activeVersion.file));
1413
+ const current = resolveTilesetBaseMixCurrent(asset, activeSheetSrc, currentOption);
1414
+ if (panelRevision !== mixPanelRevision ||
1415
+ selectedTargetAssetId !== assetId ||
1416
+ selectedTilesetAnimationKey) {
1417
+ return;
1418
+ }
1419
+ const mixed = await openTilesetBaseMixerDialog({
1420
+ root: elements.root,
1421
+ asset: current.asset,
1422
+ assetId,
1423
+ baseSheetSrc: current.sheetSrc,
1424
+ candidates,
1425
+ regenerateTile: (tile, tileset, currentTileSrc) => regenerateTilesetTile(assetId, tile, tileset, currentTileSrc)
1426
+ });
1427
+ if (panelRevision !== mixPanelRevision ||
1428
+ selectedTargetAssetId !== assetId ||
1429
+ selectedTilesetAnimationKey) {
1430
+ return;
1431
+ }
1432
+ if (!mixed) {
1433
+ setStatus(elements, "Tileset mixing cancelled. The three options are still available.", "info");
1434
+ return;
1435
+ }
1436
+ const template = candidates[0];
1437
+ const mixedOption = createMixedTilesetOption(template, mixed, template.prompt || asset.prompt, Math.max(...candidates.map((option) => option.index)) + 1);
1438
+ selectedOption = mixedOption;
1439
+ rememberPendingOption(assetId, mixedOption);
1440
+ showOptionInCurrentPreview(mixedOption, "mixed tileset");
1441
+ elements.currentPreview.classList.add("is-selected");
1442
+ elements.promoteButton.disabled = activePromotionId !== undefined;
1443
+ previewOption({
1444
+ scene: options.scene,
1445
+ manifest,
1446
+ assetId,
1447
+ option: mixedOption,
1448
+ onPreview: options.onPreview
1449
+ });
1450
+ setStatus(elements, "Mixed tileset ready. Promote to save the selected tile combination.", "success");
1451
+ }
1452
+ catch (error) {
1453
+ setStatus(elements, `Could not mix tileset. ${errorMessage(error)}`, "error");
1454
+ }
1455
+ finally {
1456
+ mixingTileset = false;
1457
+ syncMixTilesetButton();
1458
+ }
1459
+ });
467
1460
  elements.regenerateButton.addEventListener("click", async () => {
468
1461
  if (activeGeneration) {
469
1462
  activeGeneration.controller.abort();
470
1463
  activeGeneration = undefined;
471
1464
  elements.regenerateButton.textContent = "Regenerate";
472
1465
  unlockGenerationStatus();
1466
+ elements.promoteButton.disabled = !selectedOption || activePromotionId !== undefined;
1467
+ syncPromoteAllButton();
473
1468
  setStatus(elements, "Generation cancelled.", "info");
474
1469
  return;
475
1470
  }
1471
+ if (selectedTilesetAnimationKey) {
1472
+ await regenerateTilesetAnimation(selectedTilesetAnimationKey);
1473
+ return;
1474
+ }
476
1475
  const controller = new AbortController();
477
1476
  const currentGenerationId = generationId + 1;
478
1477
  const generationAssetId = selectedTargetAssetId;
1478
+ const generationAsset = manifest.assets[generationAssetId];
479
1479
  const generationFormat = effectiveGenerationFormat(manifest, formatDrafts, selectedAssetId, generationAssetId);
1480
+ const generationOverrides = generationOverridesFromInputs(elements, generationAsset, generationFormat);
1481
+ const isBaseTilesetGeneration = isDesignerTilesetAsset(generationAsset);
1482
+ if (isBaseTilesetGeneration && generationOverrides.tileset) {
1483
+ renderTilesetPromptInputs(generationAssetId);
1484
+ const tileCount = generationOverrides.tileset.tileCount ??
1485
+ effectiveTilesetTileCount(generationAsset);
1486
+ const tiles = tilesetPromptDefinitions(generationAssetId, tileCount);
1487
+ if (!tiles) {
1488
+ setStatus(elements, "Add a prompt for every tile before generating.", "error");
1489
+ return;
1490
+ }
1491
+ generationOverrides.tileset = {
1492
+ ...generationOverrides.tileset,
1493
+ tiles
1494
+ };
1495
+ }
480
1496
  generationId = currentGenerationId;
481
1497
  activeGeneration = { controller, id: currentGenerationId };
1498
+ syncPromoteAllButton();
482
1499
  setStatus(elements, "Generating options...", "busy");
483
1500
  lockGenerationStatus();
484
1501
  elements.promoteButton.disabled = true;
485
1502
  elements.regenerateButton.textContent = "Cancel";
486
- elements.options.innerHTML = "";
487
- selectedOption = undefined;
1503
+ clearGeneratedOptions();
1504
+ selectedOption = editedCurrentOption;
488
1505
  const streamedOptions = [];
489
1506
  try {
490
1507
  const generationRequest = {
491
1508
  assetId: generationAssetId,
492
- prompt: elements.promptInput.value,
1509
+ prompt: isBaseTilesetGeneration ? undefined : elements.promptInput.value,
493
1510
  count: options.optionCount ?? 3,
494
1511
  format: generationFormat,
495
1512
  audioSettings: audioGenerationOverridesFromInputs(elements, manifest.assets[generationAssetId]),
496
1513
  voiceSettings: voiceGenerationOverridesFromInputs(elements, manifest.assets[generationAssetId]),
497
1514
  styleGuide: await styleGuideRequest(styleGuideDraft),
498
- ...generationOverridesFromInputs(elements, manifest.assets[generationAssetId], generationFormat)
1515
+ ...generationOverrides
499
1516
  };
500
1517
  await client.generateStream(generationRequest, (option) => {
501
1518
  if (activeGeneration?.id !== currentGenerationId)
502
1519
  return;
503
1520
  streamedOptions.push(option);
504
- renderOptions({
505
- elements,
506
- generated: [...streamedOptions].sort((left, right) => left.index - right.index),
507
- scene: options.scene,
508
- manifest,
509
- assetId: generationAssetId,
510
- designerOptions: options,
511
- onPreview: options.onPreview,
512
- onSelected(selected) {
513
- selectedOption = selected;
514
- showOptionInCurrentPreview(selected);
515
- elements.promoteButton.disabled = false;
516
- }
517
- });
1521
+ renderGeneratedOptions(generationAssetId, [...streamedOptions].sort((left, right) => left.index - right.index));
518
1522
  }, {
519
1523
  signal: controller.signal
520
1524
  });
@@ -522,7 +1526,11 @@ export function installAiAssetDesigner(options) {
522
1526
  return;
523
1527
  }
524
1528
  finishGeneration(currentGenerationId);
525
- setStatus(elements, streamedOptions.length > 0 ? "Pick an option to preview it." : "Generation finished with no options.", streamedOptions.length > 0 ? "info" : "error");
1529
+ setStatus(elements, streamedOptions.length === 3 && isBaseTilesetGeneration
1530
+ ? "Three tileset options ready. Preview one or mix tiles."
1531
+ : streamedOptions.length > 0
1532
+ ? "Pick an option to preview it."
1533
+ : "Generation finished with no options.", streamedOptions.length > 0 ? "info" : "error");
526
1534
  }
527
1535
  catch (error) {
528
1536
  if (isAbortError(error)) {
@@ -530,7 +1538,7 @@ export function installAiAssetDesigner(options) {
530
1538
  }
531
1539
  finishGeneration(currentGenerationId);
532
1540
  if (streamedOptions.length === 0) {
533
- elements.options.innerHTML = "";
1541
+ clearGeneratedOptions();
534
1542
  }
535
1543
  setStatus(elements, `Generation failed. ${errorMessage(error)}`, "error");
536
1544
  }
@@ -538,6 +1546,189 @@ export function installAiAssetDesigner(options) {
538
1546
  finishGeneration(currentGenerationId);
539
1547
  }
540
1548
  });
1549
+ elements.regenerateAllLinesButton.addEventListener("click", async () => {
1550
+ if (activeVoiceLineRegeneration) {
1551
+ if (!activeVoiceLineRegeneration.controller.signal.aborted) {
1552
+ activeVoiceLineRegeneration.controller.abort();
1553
+ syncRegenerateAllLinesButton();
1554
+ setStatus(elements, "Stopping after the current line is promoted...", "busy");
1555
+ }
1556
+ return;
1557
+ }
1558
+ const voiceAssetId = selectedAssetId;
1559
+ const voice = manifest.assets[voiceAssetId];
1560
+ const plan = voiceLineRegenerationPlan(manifest, voiceAssetId, selectedTargetId);
1561
+ const { baseVoiceAssetId, lineAssetIds, missingTargetLineAssetIds } = plan;
1562
+ if (voice?.kind !== "voice" ||
1563
+ selectedTargetAssetId !== baseVoiceAssetId ||
1564
+ selectedTilesetAnimationKey ||
1565
+ activeFirstDrafts ||
1566
+ activeGeneration ||
1567
+ activePromotionId !== undefined) {
1568
+ return;
1569
+ }
1570
+ if (pendingOptions.has(baseVoiceAssetId)) {
1571
+ setStatus(elements, "Promote the selected base voice before regenerating its lines.", "error");
1572
+ return;
1573
+ }
1574
+ if (!promotedVoiceId(manifest, baseVoiceAssetId)) {
1575
+ setStatus(elements, "Promote a base voice before regenerating its lines.", "error");
1576
+ return;
1577
+ }
1578
+ if (missingTargetLineAssetIds.length > 0) {
1579
+ setStatus(elements, `Create target variants for ${missingTargetLineAssetIds.map(readableAssetName).join(", ")} before regenerating this target voice.`, "error");
1580
+ return;
1581
+ }
1582
+ if (lineAssetIds.length === 0) {
1583
+ setStatus(elements, "This voice has no linked lines to regenerate.", "error");
1584
+ return;
1585
+ }
1586
+ const pendingLineAssetIds = lineAssetIds.filter((assetId) => pendingOptions.has(assetId));
1587
+ if (pendingLineAssetIds.length > 0) {
1588
+ setStatus(elements, `Promote or revert pending options for ${pendingLineAssetIds.map(readableAssetName).join(", ")} before regenerating all lines.`, "error");
1589
+ return;
1590
+ }
1591
+ if (pendingOptions.size > 0) {
1592
+ setStatus(elements, "Promote or revert all pending asset options before regenerating voice lines.", "error");
1593
+ return;
1594
+ }
1595
+ const targetLabel = selectedTargetId
1596
+ ? manifest.targets?.[selectedTargetId]?.label ?? readableAssetName(selectedTargetId)
1597
+ : "Default";
1598
+ const confirmed = window.confirm(`Regenerate and promote one new option for all ${lineAssetIds.length} linked line${lineAssetIds.length === 1 ? "" : "s"} of ${readableAssetName(baseVoiceAssetId)} (${targetLabel})?\n\nThis uses paid generation and replaces each line's active version as it completes.`);
1599
+ if (!confirmed)
1600
+ return;
1601
+ const currentPromotionId = promotionId + 1;
1602
+ const controller = new AbortController();
1603
+ promotionId = currentPromotionId;
1604
+ activePromotionId = currentPromotionId;
1605
+ activeVoiceLineRegeneration = { controller, id: currentPromotionId };
1606
+ elements.panel.setAttribute("aria-busy", "true");
1607
+ setVoiceLineBatchControlsLocked(true);
1608
+ syncPromoteAllButton();
1609
+ syncMixTilesetButton();
1610
+ let promotedCount = 0;
1611
+ let liveRefreshError;
1612
+ let batchCompletedCleanly = false;
1613
+ try {
1614
+ const result = await regenerateAndPromoteVoiceLines({
1615
+ manifest,
1616
+ voiceAssetId,
1617
+ targetId: selectedTargetId,
1618
+ client,
1619
+ signal: controller.signal,
1620
+ onProgress(progress) {
1621
+ if (activeVoiceLineRegeneration?.id !== currentPromotionId)
1622
+ return;
1623
+ const action = controller.signal.aborted
1624
+ ? "Finishing"
1625
+ : progress.phase === "generation" ? "Generating" : "Promoting";
1626
+ setStatus(elements, `${action} ${readableAssetName(progress.assetId)} (${progress.index + 1}/${progress.total})...`, "busy");
1627
+ }
1628
+ });
1629
+ manifest = result.manifest;
1630
+ promotedCount = result.promoted.length;
1631
+ if (destroyed || activeVoiceLineRegeneration?.id !== currentPromotionId) {
1632
+ return;
1633
+ }
1634
+ if (result.manifestModuleSyncDeferred || result.manifestModuleSyncError) {
1635
+ deferredVoiceLineManifestSync = true;
1636
+ }
1637
+ if (result.manifestModuleSyncDeferred) {
1638
+ for (const failure of result.failures) {
1639
+ if (failure.option)
1640
+ deferredVoiceLinePendingAssetIds.add(failure.assetId);
1641
+ }
1642
+ if (result.cancelled?.option) {
1643
+ deferredVoiceLinePendingAssetIds.add(result.cancelled.assetId);
1644
+ }
1645
+ }
1646
+ for (const saved of result.promoted) {
1647
+ pendingOptions.delete(saved.asset.id);
1648
+ resolveDeferredVoiceLinePendingOption(saved.asset.id);
1649
+ formatDrafts.delete(saved.asset.id);
1650
+ try {
1651
+ (options.onAssetReady ?? options.onPreview)(saved.asset.id, resolveAssetUrl(saved.file), saved.asset);
1652
+ }
1653
+ catch (error) {
1654
+ liveRefreshError ??= { assetId: saved.asset.id, error };
1655
+ }
1656
+ }
1657
+ for (const failure of result.failures) {
1658
+ if (failure.option) {
1659
+ rememberPendingOption(failure.assetId, failure.option);
1660
+ }
1661
+ }
1662
+ if (result.cancelled?.option) {
1663
+ rememberPendingOption(result.cancelled.assetId, result.cancelled.option);
1664
+ }
1665
+ batchCompletedCleanly =
1666
+ promotedCount > 0 &&
1667
+ result.failures.length === 0 &&
1668
+ !result.cancelled &&
1669
+ !result.manifestModuleSyncError &&
1670
+ !result.manifestModuleSyncDeferred &&
1671
+ pendingOptions.size === 0;
1672
+ if (promotedCount > 0) {
1673
+ try {
1674
+ options.onManifestUpdated?.(manifest);
1675
+ }
1676
+ catch (error) {
1677
+ liveRefreshError ??= { assetId: baseVoiceAssetId, error };
1678
+ }
1679
+ }
1680
+ syncTargetAsset(baseVoiceAssetId);
1681
+ renderAssetBrowser();
1682
+ const firstFailure = result.failures[0];
1683
+ const processedCount = result.promoted.length + result.failures.length;
1684
+ if (result.cancelled) {
1685
+ setStatus(elements, `Stopped after ${processedCount}/${lineAssetIds.length} lines; ${promotedCount} promoted` +
1686
+ `${result.failures.length > 0 ? ` and ${result.failures.length} failed` : ""}.`, result.failures.length > 0 ? "error" : "success");
1687
+ }
1688
+ else if (firstFailure) {
1689
+ const unattemptedCount = lineAssetIds.length - processedCount;
1690
+ setStatus(elements, `Regenerated and promoted ${promotedCount}/${lineAssetIds.length} lines. ` +
1691
+ `${result.failures.length} failed; first failure was ${readableAssetName(firstFailure.assetId)} ` +
1692
+ `during ${firstFailure.phase}: ${errorMessage(firstFailure.error)}` +
1693
+ `${firstFailure.phase === "promotion" && unattemptedCount > 0
1694
+ ? ` ${unattemptedCount} remaining line${unattemptedCount === 1 ? " was" : "s were"} not generated to avoid further paid requests while promotion is unavailable.`
1695
+ : ""}` +
1696
+ `${result.manifestModuleSyncDeferred
1697
+ ? " The generated option remains selected; promote or revert it before restarting."
1698
+ : ""}`, "error");
1699
+ }
1700
+ else if (result.manifestModuleSyncError) {
1701
+ setStatus(elements, `Regenerated and promoted all ${promotedCount} lines, but the generated manifest module could not be refreshed. ${errorMessage(result.manifestModuleSyncError)}`, "error");
1702
+ }
1703
+ else if (liveRefreshError) {
1704
+ setStatus(elements, `Regenerated all ${promotedCount} lines, but ${readableAssetName(liveRefreshError.assetId)} ` +
1705
+ `could not refresh live. Restart to load it. ${errorMessage(liveRefreshError.error)}`, "error");
1706
+ }
1707
+ else {
1708
+ setStatus(elements, `Regenerated and promoted all ${promotedCount} lines.`, "success");
1709
+ }
1710
+ }
1711
+ catch (error) {
1712
+ setStatus(elements, `Could not regenerate voice lines. ${errorMessage(error)}`, "error");
1713
+ }
1714
+ finally {
1715
+ if (activeVoiceLineRegeneration?.id === currentPromotionId) {
1716
+ activeVoiceLineRegeneration = undefined;
1717
+ }
1718
+ if (activePromotionId === currentPromotionId) {
1719
+ activePromotionId = undefined;
1720
+ }
1721
+ if (!destroyed) {
1722
+ elements.panel.removeAttribute("aria-busy");
1723
+ setVoiceLineBatchControlsLocked(false);
1724
+ syncPromoteAllButton();
1725
+ syncMixTilesetButton();
1726
+ }
1727
+ }
1728
+ if (!destroyed && options.restartOnPromote && batchCompletedCleanly) {
1729
+ window.location.reload();
1730
+ }
1731
+ });
541
1732
  elements.uploadButton.addEventListener("click", async () => {
542
1733
  const asset = manifest.assets[selectedTargetAssetId];
543
1734
  try {
@@ -550,23 +1741,23 @@ export function installAiAssetDesigner(options) {
550
1741
  elements,
551
1742
  prompt: elements.promptInput.value
552
1743
  });
553
- renderOptions({
554
- elements,
555
- generated: [uploadedOption],
556
- scene: options.scene,
557
- manifest,
558
- assetId: selectedTargetAssetId,
559
- designerOptions: options,
560
- onPreview: options.onPreview,
561
- onSelected(option) {
562
- selectedOption = option;
563
- showOptionInCurrentPreview(option);
564
- elements.promoteButton.disabled = false;
1744
+ if (isDesignerTilesetAsset(asset) && uploadedOption.tileset) {
1745
+ const tileCount = uploadedOption.tileset.tileCount ??
1746
+ uploadedOption.tileset.columns * uploadedOption.tileset.rows;
1747
+ const tiles = tilesetPromptDefinitions(selectedTargetAssetId, tileCount);
1748
+ if (!tiles) {
1749
+ throw new Error("Add a prompt for every tile before uploading a tileset.");
565
1750
  }
566
- });
1751
+ uploadedOption.tileset = {
1752
+ ...uploadedOption.tileset,
1753
+ tiles
1754
+ };
1755
+ }
1756
+ renderGeneratedOptions(selectedTargetAssetId, [uploadedOption]);
567
1757
  selectedOption = uploadedOption;
1758
+ rememberPendingOption(selectedTargetAssetId, uploadedOption);
568
1759
  showOptionInCurrentPreview(uploadedOption);
569
- elements.promoteButton.disabled = false;
1760
+ elements.promoteButton.disabled = activePromotionId !== undefined;
570
1761
  if (isAudioAsset(asset)) {
571
1762
  options.onPreview(selectedTargetAssetId, uploadedOption.dataUrl, assetWithGeneratedGeometry(asset, uploadedOption));
572
1763
  }
@@ -599,7 +1790,7 @@ export function installAiAssetDesigner(options) {
599
1790
  prompt: elements.promptInput.value,
600
1791
  format: generationFormat,
601
1792
  onConfirm: async (deriveRequest) => {
602
- elements.options.innerHTML = "";
1793
+ clearGeneratedOptions();
603
1794
  elements.promoteButton.disabled = true;
604
1795
  selectedOption = undefined;
605
1796
  if (deriveRequest.strategy === "scale" ||
@@ -619,23 +1810,11 @@ export function installAiAssetDesigner(options) {
619
1810
  mirrorX: deriveRequest.mirrorX,
620
1811
  mirrorY: deriveRequest.mirrorY
621
1812
  });
622
- renderOptions({
623
- elements,
624
- generated: [option],
625
- scene: options.scene,
626
- manifest,
627
- assetId: derivationAssetId,
628
- designerOptions: options,
629
- onPreview: options.onPreview,
630
- onSelected(selected) {
631
- selectedOption = selected;
632
- showOptionInCurrentPreview(selected);
633
- elements.promoteButton.disabled = false;
634
- }
635
- });
1813
+ renderGeneratedOptions(derivationAssetId, [option]);
636
1814
  selectedOption = option;
1815
+ rememberPendingOption(derivationAssetId, option);
637
1816
  showOptionInCurrentPreview(option);
638
- elements.promoteButton.disabled = false;
1817
+ elements.promoteButton.disabled = activePromotionId !== undefined;
639
1818
  previewOption({
640
1819
  scene: options.scene,
641
1820
  manifest,
@@ -654,6 +1833,7 @@ export function installAiAssetDesigner(options) {
654
1833
  : elements.promptInput.value;
655
1834
  generationId = currentGenerationId;
656
1835
  activeGeneration = { controller, id: currentGenerationId };
1836
+ syncPromoteAllButton();
657
1837
  elements.regenerateButton.textContent = "Cancel";
658
1838
  setStatus(elements, "Generating options...", "busy");
659
1839
  lockGenerationStatus();
@@ -672,20 +1852,7 @@ export function installAiAssetDesigner(options) {
672
1852
  if (activeGeneration?.id !== currentGenerationId)
673
1853
  return;
674
1854
  streamedOptions.push(option);
675
- renderOptions({
676
- elements,
677
- generated: [...streamedOptions].sort((left, right) => left.index - right.index),
678
- scene: options.scene,
679
- manifest,
680
- assetId: derivationAssetId,
681
- designerOptions: options,
682
- onPreview: options.onPreview,
683
- onSelected(selected) {
684
- selectedOption = selected;
685
- showOptionInCurrentPreview(selected);
686
- elements.promoteButton.disabled = false;
687
- }
688
- });
1855
+ renderGeneratedOptions(derivationAssetId, [...streamedOptions].sort((left, right) => left.index - right.index));
689
1856
  }, {
690
1857
  signal: controller.signal
691
1858
  });
@@ -705,53 +1872,364 @@ export function installAiAssetDesigner(options) {
705
1872
  });
706
1873
  });
707
1874
  elements.promoteButton.addEventListener("click", async () => {
708
- if (!selectedOption)
1875
+ if (!selectedOption || activePromotionId !== undefined)
709
1876
  return;
1877
+ const currentPromotionId = promotionId + 1;
1878
+ promotionId = currentPromotionId;
1879
+ activePromotionId = currentPromotionId;
1880
+ syncPromoteAllButton();
1881
+ syncMixTilesetButton();
1882
+ const promotedAssetId = selectedTargetAssetId;
1883
+ const promotedOption = selectedOption;
1884
+ const promotedPending = pendingOptions.get(promotedAssetId);
1885
+ const promotionPanelRevision = panelRevision;
1886
+ const promotionSelectedAssetId = selectedAssetId;
1887
+ const promotionTargetId = selectedTargetId;
1888
+ const promotionAnimationKey = selectedTilesetAnimationKey;
1889
+ const inheritAnimations = Boolean(previewedVersionName);
1890
+ const isPromotionPanelCurrent = () => panelRevision === promotionPanelRevision &&
1891
+ selectedAssetId === promotionSelectedAssetId &&
1892
+ selectedTargetId === promotionTargetId &&
1893
+ selectedTargetAssetId === promotedAssetId &&
1894
+ selectedTilesetAnimationKey === promotionAnimationKey;
710
1895
  const versionName = `promoted-${Date.now()}`;
711
1896
  setStatus(elements, `Promoting ${versionName}...`, "busy");
712
1897
  elements.promoteButton.disabled = true;
1898
+ elements.panel.setAttribute("aria-busy", "true");
1899
+ elements.panel.setAttribute("inert", "");
1900
+ let saved;
1901
+ let promotedAsset;
1902
+ let promotedVersionName;
1903
+ try {
1904
+ const animationFrames = promotionAnimationKey
1905
+ ? promotedPending?.tilesetAnimations?.[promotionAnimationKey]
1906
+ : undefined;
1907
+ if (promotionAnimationKey &&
1908
+ animationFrames &&
1909
+ promotedPending?.animationOnlyKey === promotionAnimationKey) {
1910
+ const definition = promotedOption.tileset?.animations?.find((animation) => animation.key === promotionAnimationKey);
1911
+ const animationSaved = await client.saveTilesetAnimation({
1912
+ assetId: promotedAssetId,
1913
+ animationKey: promotionAnimationKey,
1914
+ frames: animationFrames,
1915
+ definition,
1916
+ versionName,
1917
+ notes: "Promoted from a selected or mixed tileset animation preview."
1918
+ });
1919
+ saved = animationSaved;
1920
+ manifest = animationSaved.manifest;
1921
+ promotedAsset = animationSaved.asset;
1922
+ promotedVersionName = animationSaved.versionName;
1923
+ }
1924
+ else {
1925
+ saved = await client.save({
1926
+ assetId: promotedAssetId,
1927
+ versionName,
1928
+ dataUrl: promotedOption.dataUrl,
1929
+ prompt: promotedOption.prompt,
1930
+ model: promotedOption.model,
1931
+ revisedPrompt: promotedOption.revisedPrompt,
1932
+ dimensions: promotedOption.dimensions,
1933
+ frameGrid: promotedOption.frameGrid,
1934
+ tileset: promotedOption.tileset,
1935
+ tilesetSourceDataUrl: promotedOption.tilesetSourceDataUrl,
1936
+ tilesetTransforms: promotedOption.tilesetTransforms,
1937
+ animations: assetWithGeneratedGeometry(manifest.assets[promotedAssetId], promotedOption, { inheritAnimations }).animations,
1938
+ settings: promotedOption.settings,
1939
+ audioSettings: promotedOption.audioSettings,
1940
+ audioPlayback: promotedOption.audioPlayback,
1941
+ voiceSettings: promotedOption.voiceSettings,
1942
+ durationSeconds: promotedOption.durationSeconds,
1943
+ activate: true,
1944
+ notes: "Promoted from the AI asset designer."
1945
+ });
1946
+ manifest = saved.manifest;
1947
+ promotedAsset = saved.asset;
1948
+ promotedVersionName = saved.versionName;
1949
+ for (const [animationKey, frames] of Object.entries(promotedPending?.tilesetAnimations ?? {})) {
1950
+ const animationSaved = await client.saveTilesetAnimation({
1951
+ assetId: promotedAssetId,
1952
+ animationKey,
1953
+ frames,
1954
+ definition: promotedOption.tileset?.animations?.find((animation) => animation.key === animationKey),
1955
+ notes: "Regenerated with the asset style during promotion."
1956
+ });
1957
+ manifest = animationSaved.manifest;
1958
+ promotedAsset = animationSaved.asset;
1959
+ promotedVersionName = animationSaved.versionName;
1960
+ }
1961
+ }
1962
+ for (const [animationKey, frames] of Object.entries(promotedPending?.tilesetAnimations ?? {})) {
1963
+ await installTilesetAnimationFrameTextures(frames, promotedAsset, (index) => aiTilesetAnimationTextureKey(promotedAssetId, animationKey, index));
1964
+ }
1965
+ }
1966
+ catch (error) {
1967
+ if (activePromotionId === currentPromotionId) {
1968
+ activePromotionId = undefined;
1969
+ }
1970
+ syncPromoteAllButton();
1971
+ syncMixTilesetButton();
1972
+ elements.panel.removeAttribute("aria-busy");
1973
+ elements.panel.removeAttribute("inert");
1974
+ if (isPromotionPanelCurrent()) {
1975
+ elements.promoteButton.disabled = activePromotionId !== undefined;
1976
+ setStatus(elements, `Promotion failed. ${errorMessage(error)}`, "error");
1977
+ }
1978
+ return;
1979
+ }
1980
+ forgetPendingOption(promotedAssetId);
1981
+ if (isDesignerTilesetAsset(promotedAsset)) {
1982
+ tilesetPromptDrafts.set(promotedAssetId, promotedAsset.tileset?.tiles?.map((tile) => tile.prompt) ?? []);
1983
+ }
1984
+ let promotedTextureKey;
1985
+ let liveRefreshError;
1986
+ const captureLiveRefreshError = (error) => {
1987
+ liveRefreshError ??= error;
1988
+ };
1989
+ if (promotedAsset.activeVersion !== promotedVersionName) {
1990
+ captureLiveRefreshError(new Error(`Saved version "${promotedVersionName}" was not returned as the active version.`));
1991
+ }
1992
+ else if (!isAudioAsset(promotedAsset)) {
1993
+ try {
1994
+ promotedTextureKey = (await installPromotedImageTexture({
1995
+ scene: options.scene,
1996
+ manifest,
1997
+ assetId: promotedAssetId,
1998
+ src: promotedOption.dataUrl,
1999
+ assetOverride: promotedAsset
2000
+ })).textureKey;
2001
+ }
2002
+ catch (generatedSourceError) {
2003
+ try {
2004
+ promotedTextureKey = (await installPromotedImageTexture({
2005
+ scene: options.scene,
2006
+ manifest,
2007
+ assetId: promotedAssetId,
2008
+ src: resolveAssetUrl(saved.file),
2009
+ assetOverride: promotedAsset
2010
+ })).textureKey;
2011
+ }
2012
+ catch (savedSourceError) {
2013
+ captureLiveRefreshError(new Error(`Could not install the promoted texture. ${errorMessage(savedSourceError)}`, { cause: generatedSourceError }));
2014
+ }
2015
+ }
2016
+ }
713
2017
  try {
714
- await client.save({
715
- assetId: selectedTargetAssetId,
716
- versionName,
717
- dataUrl: selectedOption.dataUrl,
718
- prompt: selectedOption.prompt,
719
- model: selectedOption.model,
720
- revisedPrompt: selectedOption.revisedPrompt,
721
- dimensions: selectedOption.dimensions,
722
- frameGrid: selectedOption.frameGrid,
723
- animations: assetWithGeneratedGeometry(manifest.assets[selectedTargetAssetId], selectedOption, { inheritAnimations: Boolean(previewedVersionName) }).animations,
724
- settings: selectedOption.settings,
725
- audioSettings: selectedOption.audioSettings,
726
- audioPlayback: selectedOption.audioPlayback,
727
- voiceSettings: selectedOption.voiceSettings,
728
- durationSeconds: selectedOption.durationSeconds,
729
- activate: true,
730
- notes: "Promoted from the AI asset designer."
731
- });
732
- manifest = await client.getManifest();
733
2018
  options.onManifestUpdated?.(manifest);
734
- formatDrafts.delete(selectedTargetAssetId);
735
- syncTargetAsset(selectedTargetAssetId);
736
- if (isAudioAsset(manifest.assets[selectedTargetAssetId])) {
737
- const promotedAsset = manifest.assets[selectedTargetAssetId];
738
- const promotedVersion = promotedAsset.versions[promotedAsset.activeVersion];
739
- if (promotedVersion?.file) {
740
- (options.onAssetReady ?? options.onPreview)(selectedTargetAssetId, resolveAssetUrl(promotedVersion.file), promotedAsset);
2019
+ }
2020
+ catch (error) {
2021
+ captureLiveRefreshError(error);
2022
+ }
2023
+ try {
2024
+ if (promotedTextureKey) {
2025
+ (options.onAssetReady ?? options.onPreview)(promotedAssetId, promotedTextureKey, promotedAsset);
2026
+ }
2027
+ else if (isAudioAsset(promotedAsset)) {
2028
+ (options.onAssetReady ?? options.onPreview)(promotedAssetId, resolveAssetUrl(saved.file), promotedAsset);
2029
+ }
2030
+ }
2031
+ catch (error) {
2032
+ captureLiveRefreshError(error);
2033
+ }
2034
+ formatDrafts.delete(promotedAssetId);
2035
+ if (isPromotionPanelCurrent()) {
2036
+ try {
2037
+ syncTargetAsset(promotedAssetId, { preserveOptions: true });
2038
+ }
2039
+ catch (error) {
2040
+ captureLiveRefreshError(error);
2041
+ }
2042
+ if (liveRefreshError) {
2043
+ setStatus(elements, `Promoted ${promotedAssetId}, but the live game could not refresh. ` +
2044
+ `Restart to load it. ${errorMessage(liveRefreshError)}`, "error");
2045
+ }
2046
+ else {
2047
+ setStatus(elements, `Promoted ${promotedAssetId} to ${promotedVersionName}.`, "success");
2048
+ }
2049
+ }
2050
+ if (activePromotionId === currentPromotionId) {
2051
+ activePromotionId = undefined;
2052
+ }
2053
+ syncPromoteAllButton();
2054
+ syncMixTilesetButton();
2055
+ elements.panel.removeAttribute("aria-busy");
2056
+ elements.panel.removeAttribute("inert");
2057
+ if (!isPromotionPanelCurrent() && selectedOption) {
2058
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2059
+ }
2060
+ if (options.restartOnPromote) {
2061
+ window.location.reload();
2062
+ }
2063
+ });
2064
+ elements.promoteAllButton.addEventListener("click", async () => {
2065
+ if (pendingOptions.size === 0 || activePromotionId !== undefined)
2066
+ return;
2067
+ const entries = [...pendingOptions.entries()];
2068
+ const currentPromotionId = promotionId + 1;
2069
+ promotionId = currentPromotionId;
2070
+ activePromotionId = currentPromotionId;
2071
+ elements.panel.setAttribute("aria-busy", "true");
2072
+ elements.panel.setAttribute("inert", "");
2073
+ elements.promoteButton.disabled = true;
2074
+ syncPromoteAllButton();
2075
+ syncMixTilesetButton();
2076
+ let promotedCount = 0;
2077
+ let promotionError;
2078
+ const liveRefreshErrors = [];
2079
+ for (const [index, [assetId, pending]] of entries.entries()) {
2080
+ const asset = manifest.assets[assetId];
2081
+ if (!asset) {
2082
+ promotionError = { assetId, error: new Error("Asset no longer exists in the manifest.") };
2083
+ break;
2084
+ }
2085
+ const versionName = `promoted-${Date.now()}-${index + 1}`;
2086
+ setStatus(elements, `Promoting ${readableAssetName(assetId)} (${index + 1}/${entries.length})...`, "busy");
2087
+ try {
2088
+ let saved;
2089
+ let promotedAsset;
2090
+ const animationOnlyFrames = pending.animationOnlyKey
2091
+ ? pending.tilesetAnimations?.[pending.animationOnlyKey]
2092
+ : undefined;
2093
+ if (pending.animationOnlyKey && animationOnlyFrames) {
2094
+ saved = await client.saveTilesetAnimation({
2095
+ assetId,
2096
+ animationKey: pending.animationOnlyKey,
2097
+ frames: animationOnlyFrames,
2098
+ definition: pending.option.tileset?.animations?.find((animation) => animation.key === pending.animationOnlyKey),
2099
+ versionName,
2100
+ notes: "Promoted from a tileset animation preview with Promote all."
2101
+ });
2102
+ manifest = saved.manifest;
2103
+ promotedAsset = saved.asset;
2104
+ }
2105
+ else {
2106
+ saved = await client.save({
2107
+ assetId,
2108
+ versionName,
2109
+ dataUrl: pending.option.dataUrl,
2110
+ prompt: pending.option.prompt,
2111
+ model: pending.option.model,
2112
+ revisedPrompt: pending.option.revisedPrompt,
2113
+ dimensions: pending.option.dimensions,
2114
+ frameGrid: pending.option.frameGrid,
2115
+ tileset: pending.option.tileset,
2116
+ tilesetSourceDataUrl: pending.option.tilesetSourceDataUrl,
2117
+ tilesetTransforms: pending.option.tilesetTransforms,
2118
+ animations: assetWithGeneratedGeometry(asset, pending.option, {
2119
+ inheritAnimations: pending.inheritAnimations
2120
+ }).animations,
2121
+ settings: pending.option.settings,
2122
+ audioSettings: pending.option.audioSettings,
2123
+ audioPlayback: pending.option.audioPlayback,
2124
+ voiceSettings: pending.option.voiceSettings,
2125
+ durationSeconds: pending.option.durationSeconds,
2126
+ activate: true,
2127
+ notes: "Promoted from the AI asset designer with Promote all."
2128
+ });
2129
+ manifest = saved.manifest;
2130
+ promotedAsset = saved.asset;
2131
+ for (const [animationKey, frames] of Object.entries(pending.tilesetAnimations ?? {})) {
2132
+ const animationSaved = await client.saveTilesetAnimation({
2133
+ assetId,
2134
+ animationKey,
2135
+ frames,
2136
+ definition: pending.option.tileset?.animations?.find((animation) => animation.key === animationKey),
2137
+ notes: "Regenerated with the asset style during Promote all."
2138
+ });
2139
+ manifest = animationSaved.manifest;
2140
+ promotedAsset = animationSaved.asset;
2141
+ }
2142
+ }
2143
+ if (isDesignerTilesetAsset(promotedAsset)) {
2144
+ tilesetPromptDrafts.set(assetId, promotedAsset.tileset?.tiles?.map((tile) => tile.prompt) ?? []);
741
2145
  }
2146
+ for (const [animationKey, frames] of Object.entries(pending.tilesetAnimations ?? {})) {
2147
+ await installTilesetAnimationFrameTextures(frames, promotedAsset, (frameIndex) => aiTilesetAnimationTextureKey(assetId, animationKey, frameIndex));
2148
+ }
2149
+ try {
2150
+ if (isAudioAsset(promotedAsset)) {
2151
+ (options.onAssetReady ?? options.onPreview)(assetId, resolveAssetUrl(saved.file), promotedAsset);
2152
+ }
2153
+ else {
2154
+ let installed;
2155
+ try {
2156
+ installed = await installPromotedImageTexture({
2157
+ scene: options.scene,
2158
+ manifest,
2159
+ assetId,
2160
+ src: pending.option.dataUrl,
2161
+ assetOverride: promotedAsset
2162
+ });
2163
+ }
2164
+ catch {
2165
+ installed = await installPromotedImageTexture({
2166
+ scene: options.scene,
2167
+ manifest,
2168
+ assetId,
2169
+ src: resolveAssetUrl(saved.file),
2170
+ assetOverride: promotedAsset
2171
+ });
2172
+ }
2173
+ (options.onAssetReady ?? options.onPreview)(assetId, installed.textureKey, promotedAsset);
2174
+ }
2175
+ }
2176
+ catch (error) {
2177
+ liveRefreshErrors.push({ assetId, error });
2178
+ }
2179
+ pendingOptions.delete(assetId);
2180
+ resolveDeferredVoiceLinePendingOption(assetId);
2181
+ formatDrafts.delete(assetId);
2182
+ promotedCount += 1;
2183
+ }
2184
+ catch (error) {
2185
+ promotionError = { assetId, error };
2186
+ break;
742
2187
  }
743
- setStatus(elements, `Promoted ${selectedTargetAssetId} to ${versionName}.`, "success");
744
- if (options.restartOnPromote) {
745
- window.location.reload();
2188
+ }
2189
+ try {
2190
+ if (promotedCount > 0) {
2191
+ options.onManifestUpdated?.(manifest);
746
2192
  }
2193
+ syncTargetAsset(selectedTargetAssetId);
2194
+ renderAssetBrowser();
747
2195
  }
748
2196
  catch (error) {
749
- elements.promoteButton.disabled = false;
750
- setStatus(elements, `Promotion failed. ${errorMessage(error)}`, "error");
2197
+ liveRefreshErrors.push({ assetId: selectedTargetAssetId, error });
2198
+ }
2199
+ activePromotionId = undefined;
2200
+ elements.panel.removeAttribute("aria-busy");
2201
+ elements.panel.removeAttribute("inert");
2202
+ syncPromoteAllButton();
2203
+ syncMixTilesetButton();
2204
+ if (promotionError) {
2205
+ setStatus(elements, `Promoted ${promotedCount}/${entries.length} assets. ` +
2206
+ `${readableAssetName(promotionError.assetId)} failed: ${errorMessage(promotionError.error)}`, "error");
2207
+ }
2208
+ else if (liveRefreshErrors.length > 0) {
2209
+ const firstError = liveRefreshErrors[0];
2210
+ setStatus(elements, `Promoted all ${promotedCount} assets, but ${readableAssetName(firstError.assetId)} ` +
2211
+ `could not refresh live. Restart to load it. ${errorMessage(firstError.error)}`, "error");
2212
+ }
2213
+ else {
2214
+ setStatus(elements, `Promoted all ${promotedCount} pending assets.`, "success");
2215
+ }
2216
+ if (options.restartOnPromote && promotedCount > 0) {
2217
+ window.location.reload();
751
2218
  }
752
2219
  });
753
- elements.restartButton.addEventListener("click", () => {
754
- window.location.reload();
2220
+ elements.restartButton.addEventListener("click", async () => {
2221
+ if (deferredVoiceLineManifestSync &&
2222
+ [...deferredVoiceLinePendingAssetIds].some((assetId) => pendingOptions.has(assetId))) {
2223
+ setStatus(elements, "Promote or revert the pending generated voice line before restarting.", "error");
2224
+ return;
2225
+ }
2226
+ if (deferredVoiceLineManifestSync) {
2227
+ setStatus(elements, "Refreshing the generated manifest module...", "busy");
2228
+ if (!await flushDeferredVoiceLineManifest())
2229
+ return;
2230
+ }
2231
+ if (!destroyed)
2232
+ window.location.reload();
755
2233
  });
756
2234
  elements.versionsButton.addEventListener("click", () => {
757
2235
  const asset = manifest.assets[selectedTargetAssetId];
@@ -762,30 +2240,45 @@ export function installAiAssetDesigner(options) {
762
2240
  resolveAssetUrl,
763
2241
  async onSelect(versionName, option) {
764
2242
  const optionAsset = assetWithGeneratedGeometry(asset, option, { inheritAnimations: true });
2243
+ const version = asset.versions[versionName];
2244
+ const tilesetSourceDataUrl = version?.tilesetSourceFile
2245
+ ? await imageSourceToDataUrl(resolveAssetUrl(version.tilesetSourceFile))
2246
+ : undefined;
2247
+ const versionOption = {
2248
+ ...option,
2249
+ tilesetSourceDataUrl,
2250
+ tilesetTransforms: tilesetSourceDataUrl
2251
+ ? cloneTilesetTransforms(version?.tilesetTransforms)
2252
+ : undefined
2253
+ };
765
2254
  for (const item of elements.options.querySelectorAll(".ai-game-assets-designer__option")) {
766
2255
  item.classList.remove("is-selected");
767
2256
  }
768
- selectedOption = option;
769
- showOptionInCurrentPreview(option, versionName);
2257
+ selectedOption = versionOption;
2258
+ rememberPendingOption(selectedTargetAssetId, versionOption, {
2259
+ inheritAnimations: true,
2260
+ previewedVersionName: versionName
2261
+ });
2262
+ showOptionInCurrentPreview(versionOption, versionName);
770
2263
  previewedVersionName = versionName;
771
2264
  elements.currentPreview.classList.add("is-selected");
772
- elements.promoteButton.disabled = false;
2265
+ elements.promoteButton.disabled = activePromotionId !== undefined;
773
2266
  if (isAudioAsset(asset)) {
774
2267
  renderAudioPlayer({
775
2268
  container: elements.currentAudio,
776
- src: option.dataUrl,
2269
+ src: versionOption.dataUrl,
777
2270
  label: readableAssetName(selectedTargetAssetId),
778
- playback: option.audioPlayback
2271
+ playback: versionOption.audioPlayback
779
2272
  });
780
- options.onPreview(selectedTargetAssetId, option.dataUrl, optionAsset);
2273
+ options.onPreview(selectedTargetAssetId, versionOption.dataUrl, optionAsset);
781
2274
  }
782
2275
  else {
783
- elements.currentImage.src = option.dataUrl;
2276
+ elements.currentImage.src = versionOption.dataUrl;
784
2277
  previewImageSource({
785
2278
  scene: options.scene,
786
2279
  manifest,
787
2280
  assetId: selectedTargetAssetId,
788
- src: option.dataUrl,
2281
+ src: versionOption.dataUrl,
789
2282
  textureKey: `ai-version-preview:${selectedTargetAssetId}:${versionName}:${Date.now()}`,
790
2283
  assetOverride: optionAsset,
791
2284
  onPreview: options.onPreview
@@ -801,6 +2294,7 @@ export function installAiAssetDesigner(options) {
801
2294
  });
802
2295
  options.onManifestUpdated?.(manifest);
803
2296
  if (previewedVersionName === versionName) {
2297
+ forgetPendingOption(selectedTargetAssetId);
804
2298
  syncTargetAsset(selectedTargetAssetId);
805
2299
  }
806
2300
  else {
@@ -816,6 +2310,113 @@ export function installAiAssetDesigner(options) {
816
2310
  }
817
2311
  });
818
2312
  });
2313
+ const previewBulkOption = (assetId, option) => new Promise((resolve, reject) => {
2314
+ const previewAsset = assetWithGeneratedGeometry(manifest.assets[assetId], option);
2315
+ previewImageSource({
2316
+ scene: options.scene,
2317
+ manifest,
2318
+ assetId,
2319
+ src: option.dataUrl,
2320
+ textureKey: `ai-bulk-preview:${assetId}:${Date.now()}`,
2321
+ assetOverride: previewAsset,
2322
+ onPreview(previewAssetId, textureKey, asset) {
2323
+ options.onPreview(previewAssetId, textureKey, asset);
2324
+ resolve(asset);
2325
+ },
2326
+ onError: reject
2327
+ });
2328
+ });
2329
+ const regenerateAllGraphicalAssets = async (styleGuide) => {
2330
+ if (activeGeneration || activePromotionId !== undefined)
2331
+ return;
2332
+ const assetIds = expandAiAssetIds(manifest, visibleAssetIds(), {
2333
+ includeLinkedAnimations: true,
2334
+ targetId: selectedTargetId
2335
+ }).filter((assetId) => {
2336
+ const asset = manifest.assets[assetId];
2337
+ return Boolean(asset && asset.kind !== "collection" && !isAudioAsset(asset));
2338
+ });
2339
+ if (assetIds.length === 0) {
2340
+ setStatus(elements, "There are no graphical assets to regenerate.", "info");
2341
+ return;
2342
+ }
2343
+ const controller = new AbortController();
2344
+ const currentGenerationId = generationId + 1;
2345
+ generationId = currentGenerationId;
2346
+ activeGeneration = { controller, id: currentGenerationId };
2347
+ clearGeneratedOptions();
2348
+ syncPromoteAllButton();
2349
+ const failures = [];
2350
+ let generatedCount = 0;
2351
+ for (const [index, assetId] of assetIds.entries()) {
2352
+ if (activeGeneration?.id !== currentGenerationId)
2353
+ return;
2354
+ setStatus(elements, `Regenerating ${readableAssetName(assetId)} (${index + 1}/${assetIds.length})...`, "busy");
2355
+ try {
2356
+ const generated = await client.generate({
2357
+ assetId,
2358
+ count: 1,
2359
+ styleGuide
2360
+ }, {
2361
+ signal: controller.signal
2362
+ });
2363
+ const option = generated[0];
2364
+ if (!option) {
2365
+ throw new Error("Generation finished without an option.");
2366
+ }
2367
+ rememberPendingOption(assetId, option);
2368
+ const previewAsset = await previewBulkOption(assetId, option);
2369
+ const generatedTilesetAnimations = {};
2370
+ for (const animation of previewAsset.tileset?.animations ?? []) {
2371
+ setStatus(elements, `Regenerating ${readableAssetName(assetId)} · ` +
2372
+ `${readableAssetName(animation.key)} (${index + 1}/${assetIds.length})...`, "busy");
2373
+ const candidates = await client.generateTilesetAnimationStream({
2374
+ assetId,
2375
+ animationKey: animation.key,
2376
+ prompt: animation.prompt,
2377
+ count: 1,
2378
+ baseDataUrl: option.dataUrl,
2379
+ styleGuide
2380
+ }, () => { }, {
2381
+ signal: controller.signal
2382
+ });
2383
+ const candidate = candidates[0];
2384
+ if (!candidate || candidate.frames.length !== animation.frameCount) {
2385
+ throw new Error(`${readableAssetName(animation.key)} did not return its ` +
2386
+ `${animation.frameCount} required frames.`);
2387
+ }
2388
+ const frameDataUrls = candidate.frames.map((frame) => frame.dataUrl);
2389
+ generatedTilesetAnimations[animation.key] = frameDataUrls;
2390
+ rememberPendingOption(assetId, option, {
2391
+ tilesetAnimations: { ...generatedTilesetAnimations }
2392
+ });
2393
+ await previewTilesetAnimationFrames(assetId, animation.key, frameDataUrls, previewAsset);
2394
+ }
2395
+ generatedCount += 1;
2396
+ if (selectedTargetAssetId === assetId) {
2397
+ selectedOption = option;
2398
+ showOptionInCurrentPreview(option, "regenerated preview");
2399
+ elements.currentPreview.classList.add("is-selected");
2400
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2401
+ }
2402
+ }
2403
+ catch (error) {
2404
+ if (isAbortError(error))
2405
+ return;
2406
+ failures.push({ assetId, error });
2407
+ }
2408
+ }
2409
+ if (!finishGeneration(currentGenerationId))
2410
+ return;
2411
+ if (failures.length > 0) {
2412
+ const firstFailure = failures[0];
2413
+ setStatus(elements, `Regenerated ${generatedCount}/${assetIds.length} graphical assets. ` +
2414
+ `${readableAssetName(firstFailure.assetId)} failed: ${errorMessage(firstFailure.error)}`, "error");
2415
+ return;
2416
+ }
2417
+ setStatus(elements, `Regenerated and previewed ${generatedCount} graphical assets. ` +
2418
+ "Use Promote all to save them.", "success");
2419
+ };
819
2420
  elements.styleButton.addEventListener("click", () => {
820
2421
  void openStyleGuideEditor({
821
2422
  root: elements.root,
@@ -838,16 +2439,62 @@ export function installAiAssetDesigner(options) {
838
2439
  setStatus(elements, `Style promotion failed. ${errorMessage(error)}`, "error");
839
2440
  throw error;
840
2441
  }
2442
+ },
2443
+ async onRegenerateAll(draft) {
2444
+ try {
2445
+ await regenerateAllGraphicalAssets(await styleGuideRequest(draft));
2446
+ }
2447
+ catch (error) {
2448
+ setStatus(elements, `Bulk regeneration failed. ${errorMessage(error)}`, "error");
2449
+ }
841
2450
  }
842
2451
  });
843
2452
  });
844
- elements.currentAnimationButton.addEventListener("click", (event) => {
2453
+ elements.currentAnimationButton.addEventListener("click", async (event) => {
845
2454
  event.stopPropagation();
846
2455
  const asset = manifest.assets[selectedTargetAssetId];
847
2456
  const activeVersion = asset.versions[asset.activeVersion];
848
2457
  const sourceOption = editedCurrentOption;
849
2458
  if (!sourceOption && !activeVersion?.file)
850
2459
  return;
2460
+ if (selectedTilesetAnimationKey && isDesignerTilesetAsset(asset)) {
2461
+ const assetId = selectedTargetAssetId;
2462
+ const animationKey = selectedTilesetAnimationKey;
2463
+ const definition = promptOnlyTilesetAnimationDefinition(assetId, animationKey);
2464
+ if (!definition) {
2465
+ setStatus(elements, "Add an animation prompt for every tile before editing the animation.", "error");
2466
+ return;
2467
+ }
2468
+ try {
2469
+ const pendingFrames = pendingOptions.get(assetId)?.tilesetAnimations?.[animationKey];
2470
+ const frames = pendingFrames ?? await activeTilesetAnimationFrames(assetId, animationKey);
2471
+ if (frames.length !== definition.frameCount) {
2472
+ throw new Error(`${readableAssetName(animationKey)} has ${frames.length} current frames, but ` +
2473
+ `${definition.frameCount} are configured. Generate new frames before editing.`);
2474
+ }
2475
+ const editorAsset = assetWithTilesetAnimationDefinition(asset, definition);
2476
+ await openTilesetAnimationEditor({
2477
+ root: elements.root,
2478
+ asset: editorAsset,
2479
+ assetId,
2480
+ animationKey,
2481
+ frames,
2482
+ onConfirm: async ({ frames: editedFrames, animation }) => {
2483
+ const currentPrompts = tilesetAnimationPromptDefinitions(assetId, animationKey);
2484
+ const editedDefinition = currentPrompts
2485
+ ? { ...animation, tiles: currentPrompts }
2486
+ : animation;
2487
+ await selectTilesetAnimationFrames(assetId, editedDefinition, editedFrames);
2488
+ elements.currentRevertButton.hidden = false;
2489
+ setStatus(elements, "Tileset animation edits applied. Promote to save them to code.", "success");
2490
+ }
2491
+ });
2492
+ }
2493
+ catch (error) {
2494
+ setStatus(elements, `Could not edit tileset animation. ${errorMessage(error)}`, "error");
2495
+ }
2496
+ return;
2497
+ }
851
2498
  if (isAudioAsset(asset)) {
852
2499
  void openAudioEditor({
853
2500
  root: elements.root,
@@ -877,7 +2524,11 @@ export function installAiAssetDesigner(options) {
877
2524
  durationSeconds: previousOption?.durationSeconds ?? activeVersion?.durationSeconds
878
2525
  };
879
2526
  selectedOption = editedCurrentOption;
880
- elements.promoteButton.disabled = false;
2527
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2528
+ inheritAnimations: Boolean(previewedVersionName),
2529
+ previewedVersionName
2530
+ });
2531
+ elements.promoteButton.disabled = activePromotionId !== undefined;
881
2532
  elements.currentRevertButton.hidden = false;
882
2533
  options.onPreview(selectedTargetAssetId, dataUrl, optionAsset);
883
2534
  renderAudioPlayer({
@@ -928,7 +2579,11 @@ export function installAiAssetDesigner(options) {
928
2579
  animations
929
2580
  };
930
2581
  selectedOption = editedCurrentOption;
931
- elements.promoteButton.disabled = false;
2582
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2583
+ inheritAnimations: Boolean(previewedVersionName),
2584
+ previewedVersionName
2585
+ });
2586
+ elements.promoteButton.disabled = activePromotionId !== undefined;
932
2587
  elements.currentRevertButton.hidden = false;
933
2588
  previewImageSource({
934
2589
  scene: options.scene,
@@ -948,17 +2603,94 @@ export function installAiAssetDesigner(options) {
948
2603
  const asset = manifest.assets[selectedTargetAssetId];
949
2604
  const activeVersion = asset.versions[asset.activeVersion];
950
2605
  const sourceOption = editedCurrentOption;
2606
+ const editorAsset = sourceOption
2607
+ ? assetWithGeneratedGeometry(asset, sourceOption)
2608
+ : asset;
951
2609
  if ((!sourceOption && !activeVersion?.file) ||
952
2610
  isAudioAsset(asset) ||
953
- asset.frameGrid ||
2611
+ editorAsset.frameGrid ||
954
2612
  sourceOption?.mimeType === "image/svg+xml" ||
955
2613
  (!sourceOption && (normalizeAssetFormat(asset.settings?.format) === "svg" ||
956
2614
  isSvgSource(activeVersion.file)))) {
957
2615
  return;
958
2616
  }
2617
+ if (isDesignerTilesetAsset(editorAsset)) {
2618
+ const assetId = selectedTargetAssetId;
2619
+ const editorTileset = tilesetMetadataForAsset(editorAsset);
2620
+ const draftTiles = tilesetPromptDefinitions(assetId, editorTileset.tileCount ?? editorTileset.columns * editorTileset.rows);
2621
+ const tilesetEditorAsset = draftTiles ? {
2622
+ ...editorAsset,
2623
+ tileset: {
2624
+ ...editorTileset,
2625
+ tiles: draftTiles
2626
+ }
2627
+ } : editorAsset;
2628
+ const renderedSrc = sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file);
2629
+ const editorInitialState = resolveTilesetEditorInitialState({
2630
+ renderedSrc,
2631
+ currentOption: sourceOption,
2632
+ activeVersion,
2633
+ resolveAssetUrl
2634
+ });
2635
+ void openTilesetEditor({
2636
+ root: elements.root,
2637
+ asset: tilesetEditorAsset,
2638
+ assetId,
2639
+ src: renderedSrc,
2640
+ sourceSrc: editorInitialState.sourceSrc,
2641
+ initialTransforms: editorInitialState.initialTransforms,
2642
+ regenerateTile: (tile, tileset, currentTileSrc) => regenerateTilesetTile(assetId, tile, tileset, currentTileSrc),
2643
+ onConfirm: async ({ dataUrl, sourceDataUrl, transforms }) => {
2644
+ const previousOption = editedCurrentOption;
2645
+ const optionAsset = {
2646
+ ...tilesetEditorAsset,
2647
+ dimensions: previousOption?.dimensions ?? tilesetEditorAsset.dimensions,
2648
+ tileset: tilesetEditorAsset.tileset,
2649
+ settings: {
2650
+ ...(previousOption?.settings ?? activeVersion?.settings ?? tilesetEditorAsset.settings),
2651
+ format: "png"
2652
+ }
2653
+ };
2654
+ editedCurrentOption = {
2655
+ ...previousOption,
2656
+ index: previousOption?.index ?? -1,
2657
+ dataUrl,
2658
+ mimeType: "image/png",
2659
+ prompt: previousOption?.prompt ?? activeVersion?.prompt ?? asset.prompt,
2660
+ model: previousOption?.model ?? activeVersion?.model,
2661
+ revisedPrompt: previousOption?.revisedPrompt ?? activeVersion?.revisedPrompt,
2662
+ dimensions: optionAsset.dimensions,
2663
+ tileset: optionAsset.tileset,
2664
+ tilesetSourceDataUrl: sourceDataUrl,
2665
+ tilesetTransforms: cloneTilesetTransforms(transforms),
2666
+ settings: optionAsset.settings
2667
+ };
2668
+ selectedOption = editedCurrentOption;
2669
+ rememberPendingOption(assetId, editedCurrentOption, {
2670
+ inheritAnimations: Boolean(previewedVersionName),
2671
+ previewedVersionName
2672
+ });
2673
+ elements.currentImage.src = dataUrl;
2674
+ elements.currentPreview.classList.add("is-selected");
2675
+ elements.currentRevertButton.hidden = false;
2676
+ elements.promoteButton.disabled = activePromotionId !== undefined;
2677
+ previewImageSource({
2678
+ scene: options.scene,
2679
+ manifest,
2680
+ assetId,
2681
+ src: dataUrl,
2682
+ textureKey: `ai-current-tileset-edit:${assetId}:${Date.now()}`,
2683
+ assetOverride: optionAsset,
2684
+ onPreview: options.onPreview
2685
+ });
2686
+ setStatus(elements, "Tileset edits applied. Promote to save them to code.", "success");
2687
+ }
2688
+ });
2689
+ return;
2690
+ }
959
2691
  void openFrameTouchUpEditor({
960
2692
  root: elements.root,
961
- asset,
2693
+ asset: editorAsset,
962
2694
  title: readableAssetName(selectedTargetAssetId),
963
2695
  frameSrc: sourceOption?.dataUrl ?? resolveAssetUrl(activeVersion.file),
964
2696
  displaySize: resolvePreviewDisplaySize(options, selectedTargetAssetId, asset),
@@ -980,10 +2712,14 @@ export function installAiAssetDesigner(options) {
980
2712
  settings: previousOption?.settings ?? activeVersion?.settings ?? asset.settings
981
2713
  };
982
2714
  selectedOption = editedCurrentOption;
2715
+ rememberPendingOption(selectedTargetAssetId, editedCurrentOption, {
2716
+ inheritAnimations: Boolean(previewedVersionName),
2717
+ previewedVersionName
2718
+ });
983
2719
  elements.currentImage.src = dataUrl;
984
2720
  elements.currentPreview.classList.add("is-selected");
985
2721
  elements.currentRevertButton.hidden = false;
986
- elements.promoteButton.disabled = false;
2722
+ elements.promoteButton.disabled = activePromotionId !== undefined;
987
2723
  previewImageSource({
988
2724
  scene: options.scene,
989
2725
  manifest,
@@ -1007,22 +2743,38 @@ export function installAiAssetDesigner(options) {
1007
2743
  continueOnError: true,
1008
2744
  onManifestUpdated: (updatedManifest) => {
1009
2745
  manifest = updatedManifest;
1010
- options.onManifestUpdated?.(updatedManifest);
1011
- syncAsset(selectedAssetId);
2746
+ if (!destroyed) {
2747
+ options.onManifestUpdated?.(updatedManifest);
2748
+ syncAsset(selectedAssetId);
2749
+ }
1012
2750
  },
1013
2751
  onAssetReady: (assetId, textureKey, asset) => {
1014
- (options.onAssetReady ?? options.onPreview)(assetId, textureKey, asset);
2752
+ if (!destroyed) {
2753
+ (options.onAssetReady ?? options.onPreview)(assetId, textureKey, asset);
2754
+ }
2755
+ },
2756
+ onProgress: (progress) => {
2757
+ if (!destroyed)
2758
+ handleFirstDraftProgress(progress);
1015
2759
  },
1016
- onProgress: handleFirstDraftProgress,
1017
2760
  onError: (error, assetId) => {
1018
- setStatus(elements, `First draft failed for ${readableAssetName(assetId)}: ${errorMessage(error)}`, "error");
2761
+ if (!destroyed) {
2762
+ setStatus(elements, `First draft failed for ${readableAssetName(assetId)}: ${errorMessage(error)}`, "error");
2763
+ }
1019
2764
  }
1020
2765
  }).then((result) => {
1021
- if (result.generatedAssetIds.length > 0 && result.errors.length === 0) {
2766
+ if (!destroyed && result.generatedAssetIds.length > 0 && result.errors.length === 0) {
1022
2767
  setStatus(elements, "First drafts generated.", "success");
1023
2768
  }
1024
2769
  }).catch((error) => {
1025
- setStatus(elements, `First draft failed: ${errorMessage(error)}`, "error");
2770
+ if (!destroyed) {
2771
+ setStatus(elements, `First draft failed: ${errorMessage(error)}`, "error");
2772
+ }
2773
+ }).finally(() => {
2774
+ activeFirstDrafts = false;
2775
+ if (!destroyed) {
2776
+ syncPromoteAllButton();
2777
+ }
1026
2778
  });
1027
2779
  }
1028
2780
  return {
@@ -1030,7 +2782,9 @@ export function installAiAssetDesigner(options) {
1030
2782
  open: () => setOpen(true),
1031
2783
  close: () => setOpen(false),
1032
2784
  destroy: () => {
2785
+ destroyed = true;
1033
2786
  activeGeneration?.controller.abort();
2787
+ activeVoiceLineRegeneration?.controller.abort();
1034
2788
  stopStatusAnimation(elements.status);
1035
2789
  dockPanel.destroy();
1036
2790
  elements.root.remove();