@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
@@ -10,16 +10,31 @@
10
10
  import { execFile } from 'node:child_process';
11
11
  import { createHash } from 'node:crypto';
12
12
  import { createWriteStream } from 'node:fs';
13
- import { copyFile, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
13
+ import {
14
+ copyFile,
15
+ mkdir,
16
+ mkdtemp,
17
+ readdir,
18
+ readFile,
19
+ rename,
20
+ rm,
21
+ stat,
22
+ unlink,
23
+ writeFile,
24
+ } from 'node:fs/promises';
14
25
  import { basename, dirname, extname, join, relative } from 'node:path';
15
26
  import { Readable, Transform } from 'node:stream';
16
27
  import { pipeline } from 'node:stream/promises';
17
28
  import type { Router } from 'express';
18
29
  import express from 'express';
30
+ import { ProjectProvenanceDocumentSchema } from '../../vgai-sdk/src/project/provenance';
31
+ import { AssetHistorySnapshots } from './asset-history-snapshots';
32
+ import { type CatalogRecipeSource, writeCatalogImportRecipe } from './catalog-import-recipe';
33
+ import { reimportLocalCatalogAsset } from './catalog-reimport';
19
34
  import {
20
35
  cloudAssetObjectUrl,
21
36
  findCloudAsset,
22
- loadCloudAssetManifest,
37
+ findCloudAssets,
23
38
  parseCloudAssetObjectUrl,
24
39
  } from './cloud-asset-catalog';
25
40
  import { broadcast } from './editor-sse';
@@ -36,6 +51,11 @@ import {
36
51
  resolveLocalAssetThumbnail,
37
52
  searchLocalCatalog,
38
53
  } from './local-asset-catalog';
54
+ import {
55
+ convertStagedModelToGlb,
56
+ SERVER_CONVERTIBLE_MODEL_FORMATS,
57
+ } from './model-import-conversion';
58
+ import { commitStagedProjectDirectory } from './project-output-writer';
39
59
  import { isAllowedAssetHost, isAllowedAssetSource, isPathInside } from './server-utils';
40
60
 
41
61
  // ---------------------------------------------------------------------------
@@ -103,6 +123,64 @@ interface CacheEntry<T> {
103
123
 
104
124
  const cache = new Map<string, CacheEntry<unknown>>();
105
125
 
126
+ export async function beginAssetStaging(destination: string): Promise<string> {
127
+ await mkdir(dirname(destination), { recursive: true });
128
+ const exists = await stat(destination).then(
129
+ () => true,
130
+ (error: NodeJS.ErrnoException) => {
131
+ if (error.code === 'ENOENT') return false;
132
+ throw error;
133
+ },
134
+ );
135
+ if (exists) throw new Error(`Destination already exists and was not overwritten: ${destination}`);
136
+ return mkdtemp(join(dirname(destination), `.${basename(destination)}.staging-`));
137
+ }
138
+
139
+ export async function commitAssetStaging(staging: string, destination: string): Promise<void> {
140
+ await rename(staging, destination);
141
+ }
142
+
143
+ async function commitCatalogAsset(
144
+ publicRoot: string,
145
+ staging: string,
146
+ destination: string,
147
+ input: Record<string, unknown>,
148
+ ) {
149
+ return commitStagedProjectDirectory({
150
+ projectRoot: dirname(publicRoot),
151
+ stagedDirectory: staging,
152
+ destination,
153
+ provenance: {
154
+ operationName: 'catalog.asset.acquire',
155
+ operationSource: 'vgai-catalog',
156
+ input,
157
+ },
158
+ });
159
+ }
160
+
161
+ async function centralCatalogIds(projectRoot: string): Promise<Map<string, string>> {
162
+ try {
163
+ const document = ProjectProvenanceDocumentSchema.parse(
164
+ JSON.parse(await readFile(join(projectRoot, '.vgai', 'provenance.json'), 'utf8')),
165
+ );
166
+ const ids = new Map<string, string>();
167
+ for (const operation of Object.values(document.operations)) {
168
+ if (operation.operation.name !== 'catalog.asset.acquire') continue;
169
+ const input = operation.input;
170
+ if (!input || typeof input !== 'object' || Array.isArray(input)) continue;
171
+ const id = input['id'];
172
+ if (typeof id !== 'string') continue;
173
+ for (const output of operation.outputs) {
174
+ const match = /^public\/asset-library\/([^/]+)\/([^/]+)\//.exec(output.path);
175
+ if (match?.[1] && match[2]) ids.set(`${match[1]}/${match[2]}`, id);
176
+ }
177
+ }
178
+ return ids;
179
+ } catch {
180
+ return new Map();
181
+ }
182
+ }
183
+
106
184
  const LOCAL_ASSET_PLACEHOLDER_SVG =
107
185
  '<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" fill="#181818"/><path d="M64 82l64-36 64 36v76l-64 38-64-38z" fill="#252a31" stroke="#579eff" stroke-width="8"/><path d="M64 82l64 38 64-38M128 120v76" fill="none" stroke="#579eff" stroke-width="8"/><text x="128" y="226" text-anchor="middle" fill="#9aa4b2" font-family="sans-serif" font-size="18">PREVIEW PENDING</text></svg>';
108
186
 
@@ -110,6 +188,15 @@ function sendLocalPlaceholder(res: import('express').Response): void {
110
188
  res.set('Cache-Control', 'no-store').type('image/svg+xml').send(LOCAL_ASSET_PLACEHOLDER_SVG);
111
189
  }
112
190
 
191
+ function dependencyRole(path: string): 'dependency' | 'material' | 'texture' {
192
+ const extension = extname(path).toLowerCase();
193
+ if (extension === '.mtl') return 'material';
194
+ if (['.png', '.jpg', '.jpeg', '.webp', '.ktx', '.ktx2', '.dds', '.tga'].includes(extension)) {
195
+ return 'texture';
196
+ }
197
+ return 'dependency';
198
+ }
199
+
113
200
  function getCached<T>(key: string): T | null {
114
201
  const entry = cache.get(key);
115
202
  if (!entry) return null;
@@ -353,6 +440,59 @@ function getAmbientCGFiles(id: string): AssetFileOption[] {
353
440
 
354
441
  export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
355
442
  const router = express.Router();
443
+ const historySnapshots = new Map<string, AssetHistorySnapshots>();
444
+ const assetHistory = () => {
445
+ const publicRoot = getPublicRoot();
446
+ const existing = historySnapshots.get(publicRoot);
447
+ if (existing) return existing;
448
+ const created = new AssetHistorySnapshots(publicRoot);
449
+ historySnapshots.set(publicRoot, created);
450
+ return created;
451
+ };
452
+ const captureCommittedHistory = async (assetPath: string, beforeToken: string) => {
453
+ try {
454
+ return {
455
+ assetPath,
456
+ beforeToken,
457
+ afterToken: await assetHistory().capture(assetPath),
458
+ };
459
+ } catch (error) {
460
+ await assetHistory().restore(assetPath, beforeToken);
461
+ throw error;
462
+ }
463
+ };
464
+
465
+ router.post('/history/capture', async (req, res) => {
466
+ const assetPath = (req.body as { assetPath?: unknown }).assetPath;
467
+ if (typeof assetPath !== 'string') {
468
+ res.status(400).json({ error: 'Asset history capture requires an asset path.' });
469
+ return;
470
+ }
471
+ try {
472
+ res.json({ ok: true, token: await assetHistory().capture(assetPath) });
473
+ } catch (error) {
474
+ res.status(400).json({
475
+ error: `Asset history capture failed: ${error instanceof Error ? error.message : String(error)}`,
476
+ });
477
+ }
478
+ });
479
+
480
+ router.post('/history/restore', async (req, res) => {
481
+ const body = req.body as { assetPath?: unknown; token?: unknown };
482
+ if (typeof body.assetPath !== 'string' || typeof body.token !== 'string') {
483
+ res.status(400).json({ error: 'Asset history restore requires an asset path and token.' });
484
+ return;
485
+ }
486
+ try {
487
+ await assetHistory().restore(body.assetPath, body.token);
488
+ broadcast('assets-changed', {});
489
+ res.json({ ok: true });
490
+ } catch (error) {
491
+ res.status(400).json({
492
+ error: `Asset history restore failed: ${error instanceof Error ? error.message : String(error)}`,
493
+ });
494
+ }
495
+ });
356
496
  router.use(express.json());
357
497
 
358
498
  // ---- Search ----
@@ -374,8 +514,6 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
374
514
 
375
515
  if (source === 'local') {
376
516
  const catalog = await loadLocalAssetCatalog();
377
- const cloudManifest = await loadCloudAssetManifest();
378
- const cloudIds = new Set(cloudManifest?.assets.map((asset) => asset.id) ?? []);
379
517
  const localResult = searchLocalCatalog(catalog, {
380
518
  q,
381
519
  type,
@@ -383,6 +521,10 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
383
521
  limit,
384
522
  ...(category ? { category } : {}),
385
523
  });
524
+ const cloudAssets = await findCloudAssets(
525
+ localResult.assets.flatMap((asset) => asset.variants.map((variant) => variant.id)),
526
+ );
527
+ const cloudIds = new Set(cloudAssets.map((asset) => asset.id));
386
528
  result = {
387
529
  total: localResult.total,
388
530
  assets: localResult.assets.map((asset) => ({
@@ -530,10 +672,14 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
530
672
  return;
531
673
  }
532
674
  const sourceDirectory = dirname(file.path);
533
- const allowedPath = [file.path, ...(file.dependencies ?? [])].find(
675
+ const exactPath = [file.path, ...(file.dependencies ?? [])].find(
534
676
  (candidate) =>
535
677
  relative(sourceDirectory, candidate).replaceAll('\\', '/') === requestedRelativePath,
536
678
  );
679
+ const basenameMatches = (file.dependencies ?? []).filter(
680
+ (candidate) => basename(candidate) === basename(requestedRelativePath),
681
+ );
682
+ const allowedPath = exactPath ?? (basenameMatches.length === 1 ? basenameMatches[0] : null);
537
683
  if (!allowedPath) {
538
684
  res.status(404).json({ error: 'Catalog preview dependency not found.' });
539
685
  return;
@@ -639,9 +785,34 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
639
785
  name: string;
640
786
  url: string;
641
787
  format: string;
788
+ jobId?: string;
642
789
  includes?: { relativePath: string; url: string; size: number }[];
643
790
  };
644
791
  const { source, id, name, url, format } = body;
792
+ const cancellation = new AbortController();
793
+ req.once('aborted', () =>
794
+ cancellation.abort(new DOMException('Import cancelled', 'AbortError')),
795
+ );
796
+ res.once('close', () => {
797
+ if (!res.writableEnded)
798
+ cancellation.abort(new DOMException('Import cancelled', 'AbortError'));
799
+ });
800
+ const throwIfCancelled = () => cancellation.signal.throwIfAborted();
801
+ const progress = (
802
+ stage: 'fetch' | 'convert' | 'validate' | 'commit',
803
+ loaded: number,
804
+ total: number,
805
+ message?: string,
806
+ ) => {
807
+ if (!body.jobId) return;
808
+ broadcast('asset-import-progress', {
809
+ jobId: body.jobId,
810
+ stage,
811
+ loaded,
812
+ total,
813
+ ...(message ? { message } : {}),
814
+ });
815
+ };
645
816
  const includes = body.includes ?? [];
646
817
 
647
818
  if (!source || !id || !url) {
@@ -656,6 +827,7 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
656
827
  }
657
828
 
658
829
  if (source === 'local') {
830
+ let stagingDir: string | null = null;
659
831
  try {
660
832
  const family = await findLocalAssetFamily(id);
661
833
  const asset = family ? await findLocalAsset(family.id) : null;
@@ -680,8 +852,11 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
680
852
  res.status(404).json({ error: 'Cloud asset file not found.' });
681
853
  return;
682
854
  }
683
- const mainDestination = join(destDir, cloudFile.main.relativePath);
684
- if (!isPathInside(destDir, mainDestination)) {
855
+ stagingDir = await beginAssetStaging(destDir);
856
+ const mainDestination = join(stagingDir, cloudFile.main.relativePath);
857
+ if (!isPathInside(stagingDir, mainDestination)) {
858
+ await rm(stagingDir, { recursive: true, force: true });
859
+ stagingDir = null;
685
860
  res.status(400).json({ error: 'Invalid cloud asset path.' });
686
861
  return;
687
862
  }
@@ -690,10 +865,14 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
690
865
  mainDestination,
691
866
  cloudFile.main.sha256,
692
867
  cloudFile.main.sizeBytes,
868
+ (loaded, total) => progress('fetch', loaded, total),
869
+ cancellation.signal,
693
870
  );
694
871
  for (const [dependencyIndex, dependency] of cloudFile.dependencies.entries()) {
695
- const destination = join(destDir, dependency.relativePath);
696
- if (!isPathInside(destDir, destination)) {
872
+ const destination = join(stagingDir, dependency.relativePath);
873
+ if (!isPathInside(stagingDir, destination)) {
874
+ await rm(stagingDir, { recursive: true, force: true });
875
+ stagingDir = null;
697
876
  res
698
877
  .status(400)
699
878
  .json({ error: `Invalid dependency path: ${dependency.relativePath}` });
@@ -704,10 +883,51 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
704
883
  destination,
705
884
  dependency.sha256,
706
885
  dependency.sizeBytes,
886
+ (loaded, total) =>
887
+ progress('fetch', loaded, total, `Dependency ${dependencyIndex + 1}`),
888
+ cancellation.signal,
707
889
  );
708
890
  }
709
- await copyLocalAssetThumbnail(asset, destDir);
710
- await writeAssetProvenance(destDir, {
891
+ const cloudSources: CatalogRecipeSource[] = [
892
+ {
893
+ stagedPath: mainDestination,
894
+ path: cloudFile.main.relativePath,
895
+ url,
896
+ role: 'primary' as const,
897
+ },
898
+ ...cloudFile.dependencies.map((dependency, dependencyIndex) => ({
899
+ stagedPath: join(stagingDir!, dependency.relativePath),
900
+ path: dependency.relativePath,
901
+ url: cloudAssetObjectUrl(id, cloudRoute.fileIndex, dependencyIndex),
902
+ role: dependencyRole(dependency.relativePath),
903
+ })),
904
+ ];
905
+ const cloudResultPath = `asset-library/local/${assetSlug}/${cloudFile.main.relativePath}`;
906
+ await writeCatalogImportRecipe({
907
+ stagingDirectory: stagingDir,
908
+ source: 'local',
909
+ familyId: family.id,
910
+ variantId: cloudAsset.id,
911
+ deliveryId: `cloud:${cloudAsset.id}`,
912
+ displayName: asset.name,
913
+ destination: cloudResultPath,
914
+ recipeRelativePath: `${cloudFile.main.relativePath}.vgai-import.json`,
915
+ sources: cloudSources,
916
+ contentKind: cloudFile.format.toLowerCase() === 'bvh' ? 'animation-only' : 'model',
917
+ runtimeReady: ['glb', 'gltf'].includes(cloudFile.format.toLowerCase()),
918
+ backend: 'native',
919
+ attribution: {
920
+ sourceUrl: asset.sourceUrl,
921
+ author: asset.author,
922
+ license: asset.license,
923
+ text: cloudAsset.attribution ?? `${asset.name} by ${asset.author} (${asset.license})`,
924
+ },
925
+ });
926
+ progress('validate', cloudFile.main.sizeBytes, cloudFile.main.sizeBytes);
927
+ throwIfCancelled();
928
+ await copyLocalAssetThumbnail(asset, stagingDir);
929
+ const beforeToken = await assetHistory().capture(cloudResultPath);
930
+ const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
711
931
  id: family.id,
712
932
  variantId: cloudVariantId,
713
933
  name: asset.name,
@@ -719,9 +939,17 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
719
939
  attribution: cloudAsset.attribution,
720
940
  sha256: cloudFile.main.sha256,
721
941
  });
722
- const resultPath = `asset-library/local/${assetSlug}/${cloudFile.main.relativePath}`;
942
+ progress('commit', 1, 1);
943
+ stagingDir = null;
944
+ const resultPath = cloudResultPath;
945
+ const history = await captureCommittedHistory(resultPath, beforeToken);
723
946
  broadcast('assets-changed', {});
724
- res.json({ ok: true, path: resultPath });
947
+ res.json({
948
+ ok: true,
949
+ path: resultPath,
950
+ provenanceOperationId: committed.provenanceOperationId,
951
+ history,
952
+ });
725
953
  return;
726
954
  }
727
955
 
@@ -738,18 +966,75 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
738
966
  const libraryRoot = getLocalAssetLibraryRoot();
739
967
  const mainSource = resolveCatalogFile(libraryRoot, selectedFile.path);
740
968
  const sourceDirectory = dirname(mainSource);
741
- await mkdir(destDir, { recursive: true });
742
- await copyFile(mainSource, join(destDir, basename(mainSource)));
969
+ stagingDir = await beginAssetStaging(destDir);
970
+ const primaryRelative = basename(mainSource);
971
+ const stagedPrimary = join(stagingDir, primaryRelative);
972
+ progress('fetch', 0, selectedFile.sizeBytes);
973
+ await copyFile(mainSource, stagedPrimary);
974
+ progress('fetch', selectedFile.sizeBytes, selectedFile.sizeBytes);
975
+ throwIfCancelled();
976
+ const recipeSources: CatalogRecipeSource[] = [
977
+ {
978
+ stagedPath: stagedPrimary,
979
+ path: primaryRelative,
980
+ url,
981
+ role: 'primary' as const,
982
+ },
983
+ ];
743
984
  for (const dependency of selectedFile.dependencies ?? []) {
744
985
  const dependencySource = resolveCatalogFile(libraryRoot, dependency);
745
- const dependencyRelative = relative(sourceDirectory, dependencySource);
746
- if (dependencyRelative.startsWith('..')) continue;
747
- const dependencyDestination = join(destDir, dependencyRelative);
986
+ const sourceRelative = relative(sourceDirectory, dependencySource);
987
+ const dependencyRelative = sourceRelative.startsWith('..')
988
+ ? basename(dependencySource)
989
+ : sourceRelative;
990
+ const dependencyDestination = join(stagingDir, dependencyRelative);
748
991
  await mkdir(dirname(dependencyDestination), { recursive: true });
749
992
  await copyFile(dependencySource, dependencyDestination);
993
+ recipeSources.push({
994
+ stagedPath: dependencyDestination,
995
+ path: dependencyRelative.replaceAll('\\', '/'),
996
+ url: `catalog:${dependency}`,
997
+ role: dependencyRole(dependency),
998
+ });
999
+ }
1000
+ let resultFile = primaryRelative;
1001
+ let backend: 'native' | 'threejs' = 'native';
1002
+ const format = selectedFile.format.toLowerCase();
1003
+ if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
1004
+ progress('convert', 0, 1);
1005
+ resultFile = `${basename(primaryRelative, extname(primaryRelative))}.glb`;
1006
+ const converted = await convertStagedModelToGlb(stagedPrimary, format);
1007
+ await writeFile(join(stagingDir, resultFile), converted);
1008
+ backend = 'threejs';
1009
+ progress('convert', 1, 1);
1010
+ throwIfCancelled();
750
1011
  }
751
- await copyLocalAssetThumbnail(asset, destDir);
752
- await writeAssetProvenance(destDir, {
1012
+ const resultPath = `asset-library/local/${assetSlug}/${resultFile}`;
1013
+ await writeCatalogImportRecipe({
1014
+ stagingDirectory: stagingDir,
1015
+ source: 'local',
1016
+ familyId: family.id,
1017
+ variantId: selectedAsset.id,
1018
+ deliveryId: `local:${selectedAsset.id}`,
1019
+ displayName: asset.name,
1020
+ destination: resultPath,
1021
+ sources: recipeSources,
1022
+ contentKind: format === 'bvh' ? 'animation-only' : 'model',
1023
+ generatedFiles: backend === 'threejs' ? [resultPath] : [],
1024
+ runtimeReady: ['glb', 'gltf'].includes(format) || backend === 'threejs',
1025
+ backend,
1026
+ attribution: {
1027
+ sourceUrl: asset.sourceUrl,
1028
+ author: asset.author,
1029
+ license: asset.license,
1030
+ text: `${asset.name} by ${asset.author} (${asset.license})`,
1031
+ },
1032
+ });
1033
+ progress('validate', 1, 1);
1034
+ throwIfCancelled();
1035
+ await copyLocalAssetThumbnail(asset, stagingDir);
1036
+ const beforeToken = await assetHistory().capture(resultPath);
1037
+ const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
753
1038
  id: family.id,
754
1039
  variantId: selectedAsset.id,
755
1040
  name: asset.name,
@@ -760,11 +1045,18 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
760
1045
  delivery: 'local-ssd',
761
1046
  originalPath: selectedFile.path,
762
1047
  });
763
-
764
- const resultPath = `asset-library/local/${assetSlug}/${basename(mainSource)}`;
1048
+ progress('commit', 1, 1);
1049
+ stagingDir = null;
1050
+ const history = await captureCommittedHistory(resultPath, beforeToken);
765
1051
  broadcast('assets-changed', {});
766
- res.json({ ok: true, path: resultPath });
1052
+ res.json({
1053
+ ok: true,
1054
+ path: resultPath,
1055
+ provenanceOperationId: committed.provenanceOperationId,
1056
+ history,
1057
+ });
767
1058
  } catch (error) {
1059
+ if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
768
1060
  res.status(500).json({ error: `Local asset copy failed: ${error}` });
769
1061
  }
770
1062
  return;
@@ -808,73 +1100,124 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
808
1100
  // Doesn't exist yet — continue
809
1101
  }
810
1102
 
1103
+ let stagingDir: string | null = null;
811
1104
  try {
812
- await mkdir(destDir, { recursive: true });
1105
+ stagingDir = await beginAssetStaging(destDir);
813
1106
 
814
1107
  /** Download a single file to a destination path. */
815
- async function downloadFile(fileUrl: string, destPath: string): Promise<void> {
1108
+ async function downloadFile(
1109
+ fileUrl: string,
1110
+ destPath: string,
1111
+ expectedBytes?: number,
1112
+ ): Promise<void> {
816
1113
  await mkdir(join(destPath, '..'), { recursive: true });
817
- const response = await fetch(fileUrl);
1114
+ const response = await fetch(fileUrl, { signal: cancellation.signal });
818
1115
  if (!response.ok || !response.body) {
819
1116
  throw new Error(`Download failed: ${response.status} for ${fileUrl}`);
820
1117
  }
821
1118
  const nodeStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
822
- await pipeline(nodeStream, createWriteStream(destPath));
1119
+ const total =
1120
+ expectedBytes ?? Number.parseInt(response.headers.get('content-length') ?? '0', 10);
1121
+ let loaded = 0;
1122
+ const reporter = new Transform({
1123
+ transform(chunk: Buffer, _encoding, callback) {
1124
+ loaded += chunk.length;
1125
+ progress('fetch', loaded, total);
1126
+ callback(null, chunk);
1127
+ },
1128
+ });
1129
+ await pipeline(nodeStream, reporter, createWriteStream(destPath));
823
1130
  }
824
1131
 
825
1132
  const isZip = format === 'zip' || url.endsWith('.zip');
826
1133
  const ext = isZip ? 'zip' : url.split('.').pop() || format || 'bin';
827
1134
  const fileName = `${slug}.${ext}`;
828
- const filePath = join(destDir, fileName);
1135
+ const filePath = join(stagingDir, fileName);
829
1136
 
830
1137
  // Download main file
831
1138
  await downloadFile(url, filePath);
832
1139
 
833
1140
  // Download included files (GLTF bin + textures)
834
1141
  for (const inc of includes) {
835
- const incPath = join(destDir, inc.relativePath);
1142
+ const incPath = join(stagingDir, inc.relativePath);
836
1143
  // S1: ensure each relativePath stays inside destDir (reject `..` escapes).
837
- if (!isPathInside(destDir, incPath)) {
1144
+ if (!isPathInside(stagingDir, incPath)) {
1145
+ await rm(stagingDir, { recursive: true, force: true });
1146
+ stagingDir = null;
838
1147
  res.status(400).json({ error: `Invalid include path: ${inc.relativePath}` });
839
1148
  return;
840
1149
  }
841
- await downloadFile(inc.url, incPath);
1150
+ await downloadFile(inc.url, incPath, inc.size);
842
1151
  }
843
1152
 
844
1153
  let resultPath = `asset-library/${source}/${slug}/${fileName}`;
845
1154
 
846
1155
  // Extract ZIPs
847
1156
  if (isZip) {
1157
+ progress('convert', 0, 1);
848
1158
  await new Promise<void>((resolve, reject) => {
849
- execFile('unzip', ['-o', filePath, '-d', destDir], (err) => {
1159
+ execFile('unzip', ['-o', filePath, '-d', stagingDir!], (err) => {
850
1160
  if (err) reject(err);
851
1161
  else resolve();
852
1162
  });
853
1163
  });
854
1164
  await unlink(filePath).catch(() => {});
855
- const extracted = await readdir(destDir);
1165
+ const extracted = await readdir(stagingDir);
856
1166
  const mainFile = extracted.find((f) => !f.endsWith('.zip')) ?? extracted[0];
857
1167
  if (mainFile) {
858
1168
  resultPath = `asset-library/${source}/${slug}/${mainFile}`;
859
1169
  }
1170
+ progress('convert', 1, 1);
860
1171
  }
861
1172
 
862
- await writeAssetProvenance(destDir, {
1173
+ progress('validate', 1, 1);
1174
+ throwIfCancelled();
1175
+ const beforeToken = await assetHistory().capture(resultPath);
1176
+ const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
863
1177
  id,
864
1178
  name,
865
1179
  source,
866
1180
  sourceUrl: url,
867
1181
  delivery: source,
868
1182
  });
869
-
1183
+ progress('commit', 1, 1);
1184
+ stagingDir = null;
1185
+ const history = await captureCommittedHistory(resultPath, beforeToken);
870
1186
  broadcast('assets-changed', {});
871
1187
 
872
- res.json({ ok: true, path: resultPath });
1188
+ res.json({
1189
+ ok: true,
1190
+ path: resultPath,
1191
+ provenanceOperationId: committed.provenanceOperationId,
1192
+ history,
1193
+ });
873
1194
  } catch (err) {
1195
+ if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
874
1196
  res.status(500).json({ error: `Download failed: ${err}` });
875
1197
  }
876
1198
  });
877
1199
 
1200
+ router.post('/reimport', async (req, res) => {
1201
+ const body = req.body as { assetPath?: unknown; plan?: unknown };
1202
+ if (typeof body.assetPath !== 'string' || !body.plan) {
1203
+ res.status(400).json({ error: 'Reimport requires an asset path and saved import plan.' });
1204
+ return;
1205
+ }
1206
+ try {
1207
+ const result = await reimportLocalCatalogAsset(
1208
+ getPublicRoot(),
1209
+ body.assetPath,
1210
+ body.plan as Parameters<typeof reimportLocalCatalogAsset>[2],
1211
+ );
1212
+ broadcast('assets-changed', {});
1213
+ res.json({ ok: true, ...result });
1214
+ } catch (error) {
1215
+ res.status(500).json({
1216
+ error: `Reimport failed: ${error instanceof Error ? error.message : String(error)}`,
1217
+ });
1218
+ }
1219
+ });
1220
+
878
1221
  // ---- Categories for Poly Haven ----
879
1222
  router.get('/categories', async (req, res) => {
880
1223
  const source = req.query['source'] as string | undefined;
@@ -951,6 +1294,7 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
951
1294
  const publicRoot = getPublicRoot();
952
1295
  const libraryDir = join(publicRoot, 'asset-library');
953
1296
  const downloaded: { source: string; id: string; path: string }[] = [];
1297
+ const centralIds = await centralCatalogIds(dirname(publicRoot));
954
1298
 
955
1299
  try {
956
1300
  const sources = await readdir(libraryDir).catch(() => [] as string[]);
@@ -969,9 +1313,15 @@ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
969
1313
  const provenance = await readFile(join(slugDir, '_VGAI_ASSET.json'), 'utf8')
970
1314
  .then((text) => JSON.parse(text) as { id?: unknown })
971
1315
  .catch(() => null);
972
- const id = typeof provenance?.id === 'string' ? provenance.id : slug;
1316
+ const id =
1317
+ centralIds.get(`${source}/${slug}`) ??
1318
+ (typeof provenance?.id === 'string' ? provenance.id : slug);
973
1319
  // Find the main asset file
974
- const mainFile = files.find((f) => /\.(gltf|glb|hdr|exr)$/i.test(f)) ?? files[0];
1320
+ const assetFiles = files.filter(
1321
+ (file) => file !== '_VGAI_ASSET.json' && file !== '.vgai-thumbnail.webp',
1322
+ );
1323
+ const mainFile =
1324
+ assetFiles.find((file) => /\.(gltf|glb|hdr|exr)$/i.test(file)) ?? assetFiles[0];
975
1325
  if (mainFile) {
976
1326
  downloaded.push({
977
1327
  source,
@@ -996,9 +1346,15 @@ export async function downloadRemoteFile(
996
1346
  destination: string,
997
1347
  expectedSha256: string,
998
1348
  expectedSizeBytes: number,
1349
+ onProgress?: (loaded: number, total: number) => void,
1350
+ signal?: AbortSignal,
999
1351
  ): Promise<void> {
1000
1352
  await mkdir(dirname(destination), { recursive: true });
1001
- const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
1353
+ const response = await fetch(url, {
1354
+ signal: signal
1355
+ ? AbortSignal.any([signal, AbortSignal.timeout(120_000)])
1356
+ : AbortSignal.timeout(120_000),
1357
+ });
1002
1358
  if (!response.ok || !response.body) {
1003
1359
  throw new Error(`Download failed: ${response.status} for ${url}`);
1004
1360
  }
@@ -1006,9 +1362,15 @@ export async function downloadRemoteFile(
1006
1362
  let sizeBytes = 0;
1007
1363
  const verifier = new Transform({
1008
1364
  transform(chunk: Buffer, _encoding, callback) {
1009
- hash.update(chunk);
1010
- sizeBytes += chunk.length;
1011
- callback(null, chunk);
1365
+ try {
1366
+ signal?.throwIfAborted();
1367
+ hash.update(chunk);
1368
+ sizeBytes += chunk.length;
1369
+ onProgress?.(sizeBytes, expectedSizeBytes);
1370
+ callback(null, chunk);
1371
+ } catch (error) {
1372
+ callback(error as Error);
1373
+ }
1012
1374
  },
1013
1375
  });
1014
1376
  const nodeStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
@@ -1026,17 +1388,6 @@ export async function downloadRemoteFile(
1026
1388
  }
1027
1389
  }
1028
1390
 
1029
- async function writeAssetProvenance(
1030
- destination: string,
1031
- provenance: Record<string, unknown>,
1032
- ): Promise<void> {
1033
- await mkdir(destination, { recursive: true });
1034
- await writeFile(
1035
- join(destination, '_VGAI_ASSET.json'),
1036
- `${JSON.stringify(provenance, null, 2)}\n`,
1037
- );
1038
- }
1039
-
1040
1391
  async function copyLocalAssetThumbnail(
1041
1392
  asset: NonNullable<Awaited<ReturnType<typeof findLocalAsset>>>,
1042
1393
  destination: string,