@vgai/editor 0.4.0 → 0.4.1

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 (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -1,8 +1,166 @@
1
+ import {
2
+ type AssetImportPlan,
3
+ assetImportPlanSchema,
4
+ importRecipePath,
5
+ } from '../asset-workflow/import-contract';
6
+ import {
7
+ type AppliedAssetImportHistory,
8
+ captureProjectAssetHistory,
9
+ type DownloadOnlineAssetResult,
10
+ downloadOnlineAsset,
11
+ reimportProjectAsset,
12
+ restoreProjectAssetHistory,
13
+ } from '../editor-api';
14
+ import { editorConsole } from '../editor-console';
15
+ import type { HistoryService } from '../history/history-service';
1
16
  import { getProjectFileHistory } from '../history/project-file-history';
2
- import type { ResourceKind } from '../history/types';
17
+ import { sha256Hex } from '../history/snapshot-store';
18
+ import type { ResourceDriver, ResourceKind } from '../history/types';
3
19
  import { getStorageBackend } from '../storage';
4
20
  import { workspaceHistoryService } from './workspace-history';
5
21
 
22
+ interface ImportedAssetHistoryResource {
23
+ driver: ResourceDriver;
24
+ revision: number;
25
+ }
26
+
27
+ const importedAssetHistoryResources = new WeakMap<
28
+ HistoryService,
29
+ Map<string, ImportedAssetHistoryResource>
30
+ >();
31
+
32
+ function notifyAssetChanged(assetPath: string): void {
33
+ if (typeof window === 'undefined') return;
34
+ window.dispatchEvent(
35
+ new CustomEvent('editor:assets-changed', {
36
+ detail: { paths: [projectAssetPath(assetPath)] },
37
+ }),
38
+ );
39
+ window.dispatchEvent(
40
+ new CustomEvent('editor:asset-mutation', {
41
+ detail: { type: 'change', paths: [projectAssetPath(assetPath)] },
42
+ }),
43
+ );
44
+ }
45
+
46
+ function importedAssetHistoryResource(
47
+ history: HistoryService,
48
+ assetPath: string,
49
+ ): ImportedAssetHistoryResource {
50
+ const normalized = projectAssetPath(assetPath);
51
+ let resources = importedAssetHistoryResources.get(history);
52
+ if (!resources) {
53
+ resources = new Map();
54
+ importedAssetHistoryResources.set(history, resources);
55
+ }
56
+ const existing = resources.get(normalized);
57
+ if (existing) return existing;
58
+ const descriptor = history.registry.registerProject(
59
+ 'project-file',
60
+ `.vgai/history/imported-assets/${normalized}`,
61
+ normalized.split('/').pop(),
62
+ );
63
+ const resource: ImportedAssetHistoryResource = {
64
+ revision: 0,
65
+ driver: undefined as unknown as ResourceDriver,
66
+ };
67
+ const capture = async () => {
68
+ const token = await captureProjectAssetHistory(normalized);
69
+ const bytes = new TextEncoder().encode(token);
70
+ return {
71
+ revision: resource.revision,
72
+ contentType: 'application/vnd.vgai.asset-history-token',
73
+ bytes,
74
+ sha256: await sha256Hex(bytes),
75
+ };
76
+ };
77
+ resource.driver = {
78
+ descriptor,
79
+ capture,
80
+ preflight: async (expected) => {
81
+ const current = await capture();
82
+ return current.sha256 === expected.sha256
83
+ ? { ok: true }
84
+ : {
85
+ ok: false,
86
+ reason: 'content-conflict',
87
+ actualRevision: resource.revision,
88
+ actualSha256: current.sha256,
89
+ };
90
+ },
91
+ restore: async (snapshot) => {
92
+ const token = new TextDecoder().decode(history.snapshots.read(snapshot).bytes);
93
+ await restoreProjectAssetHistory(normalized, token);
94
+ resource.revision++;
95
+ notifyAssetChanged(normalized);
96
+ },
97
+ estimateBytes: (snapshot) => snapshot.byteLength,
98
+ };
99
+ history.registerDriver(resource.driver);
100
+ resources.set(normalized, resource);
101
+ return resource;
102
+ }
103
+
104
+ function historyTokenBytes(token: string): Uint8Array {
105
+ return new TextEncoder().encode(token);
106
+ }
107
+
108
+ /** Journal already-committed server acquisitions as one exact project transaction. */
109
+ export async function recordAppliedAssetImportsWithHistory(
110
+ imports: readonly AppliedAssetImportHistory[],
111
+ label: string,
112
+ ): Promise<void> {
113
+ if (imports.length === 0) return;
114
+ const history = workspaceHistoryService();
115
+ if (!history) return;
116
+ const resources = imports.map((entry) => ({
117
+ entry,
118
+ resource: importedAssetHistoryResource(history, entry.assetPath),
119
+ }));
120
+ try {
121
+ await history.recordAppliedTransaction(
122
+ {
123
+ label,
124
+ detail: imports.map((entry) => projectAssetPath(entry.assetPath)).join(', '),
125
+ resources: resources.map(({ resource }) => resource.driver.descriptor.key),
126
+ scope: 'project',
127
+ },
128
+ resources.map(({ entry, resource }) => ({
129
+ resource: resource.driver.descriptor.key,
130
+ beforeBytes: historyTokenBytes(entry.beforeToken),
131
+ afterBytes: historyTokenBytes(entry.afterToken),
132
+ contentType: 'application/vnd.vgai.asset-history-token',
133
+ })),
134
+ );
135
+ } catch (error) {
136
+ const compensation = await Promise.allSettled(
137
+ imports.map((entry) => restoreProjectAssetHistory(entry.assetPath, entry.beforeToken)),
138
+ );
139
+ for (const entry of imports) notifyAssetChanged(entry.assetPath);
140
+ const compensationFailure = compensation.find((result) => result.status === 'rejected');
141
+ if (compensationFailure?.status === 'rejected') {
142
+ throw new Error(
143
+ `Project history could not record undo/redo and rollback failed: ${compensationFailure.reason instanceof Error ? compensationFailure.reason.message : String(compensationFailure.reason)}`,
144
+ );
145
+ }
146
+ throw new Error(
147
+ `Assets were rolled back because project history could not record undo/redo: ${error instanceof Error ? error.message : String(error)}`,
148
+ );
149
+ }
150
+ }
151
+
152
+ /** Acquire one catalog asset and add the committed mutation to project history. */
153
+ export async function downloadOnlineAssetWithHistory(
154
+ params: Parameters<typeof downloadOnlineAsset>[0],
155
+ label = `Import ${params.name}`,
156
+ ): Promise<DownloadOnlineAssetResult> {
157
+ const result = await downloadOnlineAsset(params);
158
+ if (result.ok && result.history) {
159
+ await recordAppliedAssetImportsWithHistory([result.history], label);
160
+ }
161
+ return result;
162
+ }
163
+
6
164
  /** Convert an asset's public serving URL into the StorageBackend path. */
7
165
  export function projectAssetPath(assetPath: string): string {
8
166
  const path = assetPath.split(/[?#]/, 1)[0]!.replace(/^\/+/, '');
@@ -10,6 +168,122 @@ export function projectAssetPath(assetPath: string): string {
10
168
  return path;
11
169
  }
12
170
 
171
+ /** Load and validate the durable recipe adjacent to an imported project asset. */
172
+ export async function readProjectAssetImportPlan(assetPath: string): Promise<AssetImportPlan> {
173
+ const recipe = await getStorageBackend().read(importRecipePath(projectAssetPath(assetPath)));
174
+ return assetImportPlanSchema.parse(JSON.parse(recipe));
175
+ }
176
+
177
+ /** Reacquire and atomically rebuild a project asset from its durable recipe. */
178
+ export async function reimportStoredProjectAsset(assetPath: string): Promise<{
179
+ path?: string;
180
+ provenanceOperationId?: string;
181
+ }> {
182
+ const plan = await readProjectAssetImportPlan(assetPath);
183
+ return reimportProjectAssetWithHistory(assetPath, plan);
184
+ }
185
+
186
+ /** Reimport multiple recipe-backed assets as one compensating history transaction. */
187
+ export async function reimportStoredProjectAssets(assetPaths: readonly string[]): Promise<void> {
188
+ const paths = [...new Set(assetPaths.map(projectAssetPath))];
189
+ if (paths.length === 0) return;
190
+ const plans = await Promise.all(paths.map(readProjectAssetImportPlan));
191
+ const beforeTokens = await Promise.all(paths.map(captureProjectAssetHistory));
192
+ try {
193
+ for (let index = 0; index < paths.length; index++) {
194
+ const result = await reimportProjectAsset(paths[index]!, plans[index]!);
195
+ if (!result.ok) throw new Error(result.error ?? `Reimport failed for ${paths[index]}.`);
196
+ }
197
+ const afterTokens = await Promise.all(paths.map(captureProjectAssetHistory));
198
+ await recordAppliedAssetImportsWithHistory(
199
+ paths.map((assetPath, index) => ({
200
+ assetPath,
201
+ beforeToken: beforeTokens[index]!,
202
+ afterToken: afterTokens[index]!,
203
+ })),
204
+ `Reimport ${paths.length} assets`,
205
+ );
206
+ } catch (error) {
207
+ await Promise.allSettled(
208
+ paths.map((assetPath, index) => restoreProjectAssetHistory(assetPath, beforeTokens[index]!)),
209
+ );
210
+ throw error;
211
+ }
212
+ for (const path of paths) notifyAssetChanged(path);
213
+ }
214
+
215
+ /** Reimport a validated draft and journal the already-applied server mutation. */
216
+ export async function reimportProjectAssetWithHistory(
217
+ assetPath: string,
218
+ plan: AssetImportPlan,
219
+ ): Promise<{ path?: string; provenanceOperationId?: string }> {
220
+ const normalized = projectAssetPath(assetPath);
221
+ const history = workspaceHistoryService();
222
+ const resource = history ? importedAssetHistoryResource(history, normalized) : null;
223
+ const before = resource ? await resource.driver.capture() : null;
224
+ const result = await reimportProjectAsset(normalized, plan);
225
+ if (!result.ok) throw new Error(result.error ?? 'Reimport was rejected.');
226
+ if (history && resource && before) {
227
+ const after = await resource.driver.capture();
228
+ try {
229
+ await history.recordAppliedTransaction(
230
+ {
231
+ label: `Reimport ${plan.identity.displayName ?? plan.identity.familyId}`,
232
+ detail: normalized,
233
+ resources: [resource.driver.descriptor.key],
234
+ scope: 'project',
235
+ },
236
+ [
237
+ {
238
+ resource: resource.driver.descriptor.key,
239
+ beforeBytes: before.bytes,
240
+ afterBytes: after.bytes,
241
+ contentType: after.contentType,
242
+ },
243
+ ],
244
+ );
245
+ } catch (error) {
246
+ throw new Error(
247
+ `Reimport committed, but project history could not record undo/redo: ${error instanceof Error ? error.message : String(error)}`,
248
+ );
249
+ }
250
+ }
251
+ notifyAssetChanged(normalized);
252
+ return {
253
+ ...(result.path ? { path: result.path } : {}),
254
+ ...(result.provenanceOperationId
255
+ ? { provenanceOperationId: result.provenanceOperationId }
256
+ : {}),
257
+ };
258
+ }
259
+
260
+ /** Install the shared command/event bridge used by Project Assets and the Inspector. */
261
+ export function connectProjectAssetReimportEvents(): () => void {
262
+ if (typeof window === 'undefined') return () => {};
263
+ const onReimport = (event: Event) => {
264
+ const detail = (event as CustomEvent<{ path?: string; plan?: unknown }>).detail;
265
+ if (!detail?.path) return;
266
+ const path = detail.path;
267
+ void (async () => {
268
+ try {
269
+ if (detail.plan) {
270
+ await reimportProjectAssetWithHistory(path, assetImportPlanSchema.parse(detail.plan));
271
+ } else {
272
+ await reimportStoredProjectAsset(path);
273
+ }
274
+ editorConsole.log(`Reimported ${projectAssetPath(path)}.`, 'assets');
275
+ } catch (error) {
276
+ editorConsole.error(
277
+ `Could not reimport ${projectAssetPath(path)}: ${error instanceof Error ? error.message : String(error)}`,
278
+ 'assets',
279
+ );
280
+ }
281
+ })();
282
+ };
283
+ window.addEventListener('editor:reimport-project-asset', onReimport);
284
+ return () => window.removeEventListener('editor:reimport-project-asset', onReimport);
285
+ }
286
+
13
287
  /** Persist one authored asset through the project-wide undo history. */
14
288
  export async function writeProjectAsset(
15
289
  assetPath: string,
@@ -36,9 +310,30 @@ export async function writeProjectAsset(
36
310
  /** Refresh an open editor after undo/redo or another surface writes its file. */
37
311
  export function subscribeProjectAsset(assetPath: string, listener: () => void): () => void {
38
312
  const history = workspaceHistoryService();
39
- if (!history) return () => {};
40
313
  const path = projectAssetPath(assetPath);
41
- return getProjectFileHistory(history).subscribeAll((changedPath) => {
42
- if (changedPath === path) listener();
43
- });
314
+ let queued = false;
315
+ const notify = () => {
316
+ if (queued) return;
317
+ queued = true;
318
+ queueMicrotask(() => {
319
+ queued = false;
320
+ listener();
321
+ });
322
+ };
323
+ const unsubscribeHistory = history
324
+ ? getProjectFileHistory(history).subscribeAll((changedPath) => {
325
+ if (changedPath === path) notify();
326
+ })
327
+ : () => {};
328
+ const onAssetChanged = (event: Event) => {
329
+ const paths = (event as CustomEvent<{ paths?: readonly string[] }>).detail?.paths;
330
+ if (!paths || paths.some((candidate) => projectAssetPath(candidate) === path)) notify();
331
+ };
332
+ if (typeof window !== 'undefined')
333
+ window.addEventListener('editor:assets-changed', onAssetChanged);
334
+ return () => {
335
+ unsubscribeHistory();
336
+ if (typeof window !== 'undefined')
337
+ window.removeEventListener('editor:assets-changed', onAssetChanged);
338
+ };
44
339
  }
@@ -25,9 +25,10 @@ import { useEffect, useState } from 'react';
25
25
  import { getSelectedAsset, type SelectedAsset, setSelectedAsset } from '../asset-selection';
26
26
  import { assetDocumentKind } from '../asset-workflow/asset-capabilities';
27
27
  import { PROJECT_ASSET_COMMANDS } from '../asset-workflow/project-asset-commands';
28
- import { Button, bg, border, radius, text } from '../design-system';
28
+ import { Button, bg, border, danger, radius, text } from '../design-system';
29
29
  import { registerInspectorSections } from '../inspector-section-registry';
30
30
  import { getModelThumbnailRenderer } from '../model-thumbnail';
31
+ import { reimportStoredProjectAsset } from './asset-editor-persistence';
31
32
  import { validateInputMapForViewer } from './asset-viewers/InputMapViewer';
32
33
  import { OnlineAssetDetail } from './asset-viewers/OnlineAssetDetail';
33
34
 
@@ -226,6 +227,12 @@ function MaterialSummary({ asset }: { asset: SelectedAsset }) {
226
227
  * (the registration's `match` gates on the same store read). */
227
228
  export function AssetSelectionSection() {
228
229
  const asset = getSelectedAsset();
230
+ const [reimportStatus, setReimportStatus] = useState<'idle' | 'running' | 'done'>('idle');
231
+ const [reimportError, setReimportError] = useState<string | null>(null);
232
+ useEffect(() => {
233
+ setReimportStatus('idle');
234
+ setReimportError(null);
235
+ }, [asset?.path]);
229
236
  if (!asset) return null;
230
237
  const ext = asset.name.includes('.') ? (asset.name.split('.').pop() ?? '') : '';
231
238
  if (asset.online) {
@@ -244,6 +251,7 @@ export function AssetSelectionSection() {
244
251
  <OnlineAssetDetail
245
252
  online={online}
246
253
  mode="inspector"
254
+ showPreview
247
255
  onResolved={(localPath) => setSelectedAsset({ ...asset, path: localPath })}
248
256
  />
249
257
  <div style={{ padding: '0 8px 8px' }}>
@@ -286,7 +294,15 @@ export function AssetSelectionSection() {
286
294
  )}
287
295
  <MetaRow label="Path" value={asset.path} />
288
296
  {asset.selectionCount && asset.selectionCount > 1 && (
289
- <MetaRow label="Selection" value={`${asset.selectionCount} assets`} />
297
+ <>
298
+ <MetaRow label="Selection" value={`${asset.selectionCount} assets`} />
299
+ {asset.selectionTotalBytes !== undefined && (
300
+ <MetaRow label="Total size" value={formatSize(asset.selectionTotalBytes)} />
301
+ )}
302
+ {asset.selectionFormats && (
303
+ <MetaRow label="Formats" value={asset.selectionFormats.join(', ')} />
304
+ )}
305
+ </>
290
306
  )}
291
307
  {ext && <MetaRow label="Format" value={ext.toUpperCase()} />}
292
308
  {asset.sizeBytes !== undefined && (
@@ -342,17 +358,75 @@ export function AssetSelectionSection() {
342
358
  <Button
343
359
  type="button"
344
360
  data-command-id={PROJECT_ASSET_COMMANDS.reimport.id}
345
- disabled={!asset.sourcePath}
346
- title={asset.sourcePath ? 'Run the saved import recipe again' : 'No import recipe'}
347
- onClick={() =>
348
- window.dispatchEvent(
349
- new CustomEvent('editor:reimport-project-asset', { detail: { path: asset.path } }),
350
- )
361
+ disabled={
362
+ asset.selectionCount && asset.selectionCount > 1
363
+ ? !asset.selectionReimportableCount
364
+ : !asset.sourcePath || reimportStatus === 'running'
351
365
  }
366
+ title={asset.sourcePath ? 'Run the saved import recipe again' : 'No import recipe'}
367
+ onClick={async () => {
368
+ if (asset.selectionCount && asset.selectionCount > 1) {
369
+ window.dispatchEvent(
370
+ new CustomEvent('editor:project-asset-command', {
371
+ detail: { id: PROJECT_ASSET_COMMANDS.reimport.id },
372
+ }),
373
+ );
374
+ return;
375
+ }
376
+ setReimportStatus('running');
377
+ setReimportError(null);
378
+ try {
379
+ await reimportStoredProjectAsset(asset.path);
380
+ setReimportStatus('done');
381
+ } catch (cause) {
382
+ setReimportStatus('idle');
383
+ setReimportError(cause instanceof Error ? cause.message : String(cause));
384
+ }
385
+ }}
352
386
  >
353
- Reimport
387
+ {reimportStatus === 'running'
388
+ ? 'Reimporting…'
389
+ : asset.selectionCount && asset.selectionCount > 1
390
+ ? `Reimport ${asset.selectionReimportableCount ?? 0}`
391
+ : 'Reimport'}
354
392
  </Button>
393
+ {asset.selectionCount && asset.selectionCount > 1 && (
394
+ <>
395
+ <Button
396
+ type="button"
397
+ data-command-id={PROJECT_ASSET_COMMANDS.move.id}
398
+ onClick={() =>
399
+ window.dispatchEvent(
400
+ new CustomEvent('editor:project-asset-command', {
401
+ detail: { id: PROJECT_ASSET_COMMANDS.move.id },
402
+ }),
403
+ )
404
+ }
405
+ >
406
+ Move selected
407
+ </Button>
408
+ <Button
409
+ type="button"
410
+ data-command-id={PROJECT_ASSET_COMMANDS.delete.id}
411
+ onClick={() =>
412
+ window.dispatchEvent(
413
+ new CustomEvent('editor:project-asset-command', {
414
+ detail: { id: PROJECT_ASSET_COMMANDS.delete.id },
415
+ }),
416
+ )
417
+ }
418
+ >
419
+ Delete selected
420
+ </Button>
421
+ </>
422
+ )}
355
423
  </div>
424
+ {reimportStatus === 'done' && <div role="status">Reimport committed.</div>}
425
+ {reimportError && (
426
+ <div role="alert" style={{ color: danger, fontSize: 11 }}>
427
+ {reimportError}
428
+ </div>
429
+ )}
356
430
  <div style={{ fontSize: 10, color: text[3] }}>
357
431
  Double-click (or press Enter) in the asset browser to open the full viewer.
358
432
  </div>
@@ -69,12 +69,10 @@ export function AudioViewer({ assetPath }: { assetPath: string }) {
69
69
  >
70
70
  {/* Audio icon */}
71
71
  <svg viewBox="0 0 24 24" width={48} height={48}>
72
- {/* #34d399 = the asset-category audio green (AssetBrowser's assetIconColors)
73
- — a deliberate category-palette literal, not chrome. */}
74
- <rect x="3" y="3" width="18" height="18" rx="2" fill="#34d399" />
72
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="var(--asset-icon-audio)" />
75
73
  <path
76
74
  d="M9 8l4-2v12l-4-2H7V10h2zm6 1v6m2-5v4"
77
- stroke="#fff"
75
+ stroke="var(--vgai-text-1)"
78
76
  strokeWidth="1.5"
79
77
  fill="none"
80
78
  strokeLinecap="round"
@@ -116,8 +114,7 @@ export function AudioViewer({ assetPath }: { assetPath: string }) {
116
114
  style={{
117
115
  width: `${(currentTime / duration) * 100}%`,
118
116
  height: '100%',
119
- // Same category-audio green as the icon above.
120
- background: '#34d399',
117
+ background: 'var(--asset-icon-audio)',
121
118
  borderRadius: themeVars.shape.small,
122
119
  }}
123
120
  />
@@ -137,7 +134,7 @@ export function AudioViewer({ assetPath }: { assetPath: string }) {
137
134
  style={{
138
135
  flex: 1,
139
136
  height: `${20 + ((index * 37) % 75)}%`,
140
- background: '#34d399',
137
+ background: 'var(--asset-icon-audio)',
141
138
  opacity: index / 48 <= currentTime / (duration || 1) ? 1 : 0.3,
142
139
  }}
143
140
  />