@volter/editor-core 0.5.57

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 (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,1595 @@
1
+ /**
2
+ * Asset Library — server proxy routes for browsing and downloading
3
+ * CC0 assets from Poly Haven and ambientCG.
4
+ *
5
+ * Mounted as an Express Router under /__editor/asset-library/*.
6
+ * Proxies external API calls (avoids CORS) and streams downloads
7
+ * into the project's public/asset-library/ directory.
8
+ */
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import { createHash } from 'node:crypto';
12
+ import { createWriteStream } from 'node:fs';
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';
25
+ import { basename, dirname, extname, join, relative } from 'node:path';
26
+ import { Readable, Transform } from 'node:stream';
27
+ import { pipeline } from 'node:stream/promises';
28
+ import type { Router } from 'express';
29
+ import express from 'express';
30
+ import { ProjectProvenanceDocumentSchema } from './support/project/provenance';
31
+ import { assetKey } from '../src/asset-workflow/asset-ledger';
32
+ import {
33
+ type CloudAssetRecord,
34
+ cloudAssetMatchesType,
35
+ cloudAssetSlug,
36
+ } from '../src/asset-workflow/cloud-asset-client';
37
+ import { AssetHistorySnapshots } from './asset-history-snapshots';
38
+ import { recordAssetMaterialization } from './asset-ledger-store';
39
+ import {
40
+ cloudAssetObjectUrl,
41
+ findCloudAsset,
42
+ findCloudAssets,
43
+ getCloudAssetBaseUrl,
44
+ loadCloudAssetManifest,
45
+ parseCloudAssetObjectUrl,
46
+ } from './cloud-asset-catalog';
47
+ import { broadcast } from './editor-sse';
48
+ import {
49
+ findLocalAsset,
50
+ findLocalAssetFamily,
51
+ getLocalAssetLibraryRoot,
52
+ LocalAssetCatalogUnavailableError,
53
+ loadLocalAssetCatalog,
54
+ localAssetCapabilities,
55
+ localAssetMatchesType,
56
+ localAssetPreviewVariants,
57
+ localAssetSlug,
58
+ requireCatalogFile,
59
+ requireLocalAssetThumbnail,
60
+ searchLocalCatalog,
61
+ } from './local-asset-catalog';
62
+ import {
63
+ convertStagedModelToGlb,
64
+ SERVER_CONVERTIBLE_MODEL_FORMATS,
65
+ } from './model-import-conversion';
66
+ import { commitStagedProjectDirectory } from './project-output-writer';
67
+ import { isAllowedAssetHost, isAllowedAssetSource, isPathInside } from './server-utils';
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Types
71
+ // ---------------------------------------------------------------------------
72
+
73
+ // The asset-library wire is declared ONCE, on the CLIENT side of its own
74
+ // route (`../src/api/assets.ts`) — this producer imports the contract it
75
+ // must satisfy, so the two sides of `/__editor/assets/*` cannot drift.
76
+ import type { AssetFileOption, OnlineAsset } from '../src/api/asset-library-wire';
77
+
78
+ const LOCAL_ASSET_LAB_FORMATS = new Set([
79
+ 'glb',
80
+ 'gltf',
81
+ 'fbx',
82
+ 'obj',
83
+ 'dae',
84
+ 'stl',
85
+ 'ply',
86
+ 'bvh',
87
+ '3ds',
88
+ ]);
89
+
90
+ function localPreviewFileUrl(assetId: string, fileIndex: number, relativePath: string): string {
91
+ const encodedPath = relativePath.split('/').map(encodeURIComponent).join('/');
92
+ return `/__editor/asset-library/preview-files/${assetId}/${fileIndex}/${encodedPath}`;
93
+ }
94
+
95
+ function cloudPreviewFileUrl(assetId: string, fileIndex: number, relativePath: string): string {
96
+ const encodedPath = relativePath.split('/').map(encodeURIComponent).join('/');
97
+ return `/__editor/asset-library/cloud-preview-files/${assetId}/${fileIndex}/${encodedPath}`;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // In-memory cache with TTL
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
105
+
106
+ interface CacheEntry<T> {
107
+ data: T;
108
+ timestamp: number;
109
+ }
110
+
111
+ const cache = new Map<string, CacheEntry<unknown>>();
112
+
113
+ export async function beginAssetStaging(destination: string): Promise<string> {
114
+ await mkdir(dirname(destination), { recursive: true });
115
+ const exists = await stat(destination).then(
116
+ () => true,
117
+ (error: NodeJS.ErrnoException) => {
118
+ if (error.code === 'ENOENT') return false;
119
+ throw error;
120
+ },
121
+ );
122
+ if (exists) throw new Error(`Destination already exists and was not overwritten: ${destination}`);
123
+ return mkdtemp(join(dirname(destination), `.${basename(destination)}.staging-`));
124
+ }
125
+
126
+ export async function commitAssetStaging(staging: string, destination: string): Promise<void> {
127
+ await rename(staging, destination);
128
+ }
129
+
130
+ async function commitCatalogAsset(
131
+ publicRoot: string,
132
+ staging: string,
133
+ destination: string,
134
+ input: Record<string, unknown>,
135
+ ) {
136
+ return commitStagedProjectDirectory({
137
+ projectRoot: dirname(publicRoot),
138
+ stagedDirectory: staging,
139
+ destination,
140
+ provenance: {
141
+ operationName: 'catalog.asset.acquire',
142
+ operationSource: 'vgai-catalog',
143
+ input,
144
+ },
145
+ });
146
+ }
147
+
148
+ async function centralCatalogIds(projectRoot: string): Promise<Map<string, string>> {
149
+ try {
150
+ const document = ProjectProvenanceDocumentSchema.parse(
151
+ JSON.parse(await readFile(join(projectRoot, '.vgai', 'provenance.json'), 'utf8')),
152
+ );
153
+ const ids = new Map<string, string>();
154
+ for (const operation of Object.values(document.operations)) {
155
+ if (operation.operation.name !== 'catalog.asset.acquire') continue;
156
+ const input = operation.input;
157
+ if (!input || typeof input !== 'object' || Array.isArray(input)) continue;
158
+ const id = input['id'];
159
+ if (typeof id !== 'string') continue;
160
+ for (const output of operation.outputs) {
161
+ const match = /^public\/asset-library\/([^/]+)\/([^/]+)\//.exec(output.path);
162
+ if (match?.[1] && match[2]) ids.set(`${match[1]}/${match[2]}`, id);
163
+ }
164
+ }
165
+ return ids;
166
+ } catch {
167
+ return new Map();
168
+ }
169
+ }
170
+
171
+ const LOCAL_ASSET_PLACEHOLDER_SVG =
172
+ '<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>';
173
+
174
+ function sendLocalPlaceholder(res: import('express').Response): void {
175
+ res.set('Cache-Control', 'no-store').type('image/svg+xml').send(LOCAL_ASSET_PLACEHOLDER_SVG);
176
+ }
177
+
178
+ async function pipeCloudThumbnail(id: string, res: import('express').Response): Promise<boolean> {
179
+ try {
180
+ const response = await fetch(
181
+ `${getCloudAssetBaseUrl()}/v1/assets/${encodeURIComponent(id)}/thumbnail`,
182
+ { signal: AbortSignal.timeout(15_000) },
183
+ );
184
+ if (!response.ok || !response.body) return false;
185
+ const length = response.headers.get('Content-Length');
186
+ if (length) res.set('Content-Length', length);
187
+ res
188
+ .set('Cache-Control', response.headers.get('Cache-Control') ?? 'public, max-age=86400')
189
+ .type(response.headers.get('Content-Type') ?? 'image/webp');
190
+ await pipeline(Readable.fromWeb(response.body as import('stream/web').ReadableStream), res);
191
+ return true;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ function getCached<T>(key: string): T | null {
198
+ const entry = cache.get(key);
199
+ if (!entry) return null;
200
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
201
+ cache.delete(key);
202
+ return null;
203
+ }
204
+ return entry.data as T;
205
+ }
206
+
207
+ function setCache<T>(key: string, data: T): void {
208
+ cache.set(key, { data, timestamp: Date.now() });
209
+ }
210
+
211
+ function cloudCatalogUnavailableMessage(): string {
212
+ return (
213
+ 'The hosted asset catalog did not answer with a readable manifest. ' +
214
+ 'Check the network connection and retry.'
215
+ );
216
+ }
217
+
218
+ async function searchCloudCatalog(input: {
219
+ q: string;
220
+ type: string;
221
+ category?: string;
222
+ offset: number;
223
+ limit: number;
224
+ }): Promise<{ assets: OnlineAsset[]; total: number }> {
225
+ const manifest = await loadCloudAssetManifest();
226
+ if (!manifest) throw new Error(cloudCatalogUnavailableMessage());
227
+ const query = input.q.trim().toLowerCase();
228
+ const matches = manifest.assets.filter((asset) => {
229
+ if (!cloudAssetMatchesType(asset, input.type)) return false;
230
+ if (input.category && !asset.categories.includes(input.category)) return false;
231
+ if (query) {
232
+ const haystack = `${asset.name} ${asset.tags.join(' ')}`.toLowerCase();
233
+ if (!haystack.includes(query)) return false;
234
+ }
235
+ return true;
236
+ });
237
+ return {
238
+ total: matches.length,
239
+ assets: matches.slice(input.offset, input.offset + input.limit).map((asset) => ({
240
+ id: asset.id,
241
+ source: 'local',
242
+ name: asset.name,
243
+ type: asset.type as OnlineAsset['type'],
244
+ thumbnailUrl: `/__editor/asset-library/thumbnails/${asset.id}.webp`,
245
+ categories: asset.categories,
246
+ tags: asset.tags,
247
+ license: asset.license,
248
+ author: asset.author,
249
+ sourceUrl: asset.sourceUrl,
250
+ cloudHosted: true,
251
+ variantId: asset.id,
252
+ variantCount: 1,
253
+ sizeBytes: asset.files.reduce(
254
+ (total, file) =>
255
+ total +
256
+ file.main.sizeBytes +
257
+ file.dependencies.reduce((dependencyTotal, dependency) => {
258
+ return dependencyTotal + dependency.sizeBytes;
259
+ }, 0),
260
+ 0,
261
+ ),
262
+ deliveryKinds: ['cloud'],
263
+ addedAt: manifest.generatedAt,
264
+ })),
265
+ };
266
+ }
267
+
268
+ function cloudAssetFileOptions(asset: CloudAssetRecord): AssetFileOption[] {
269
+ return asset.files.map((file, fileIndex) => ({
270
+ label: `${asset.label} · Cloud · ${file.label}`,
271
+ format: file.format,
272
+ url: cloudAssetObjectUrl(asset.id, fileIndex),
273
+ sizeBytes: file.main.sizeBytes,
274
+ ...(file.dependencies.length > 0
275
+ ? {
276
+ includes: file.dependencies.map((dependency, dependencyIndex) => ({
277
+ relativePath: dependency.relativePath,
278
+ url: cloudAssetObjectUrl(asset.id, fileIndex, dependencyIndex),
279
+ size: dependency.sizeBytes,
280
+ })),
281
+ }
282
+ : {}),
283
+ }));
284
+ }
285
+
286
+ async function resolveCloudAssetPreview(assetId: string): Promise<{
287
+ path: string;
288
+ format: string;
289
+ label: string;
290
+ sourceAssetId: string;
291
+ materialPath?: string;
292
+ } | null> {
293
+ const asset = await findCloudAsset(assetId);
294
+ if (!asset) return null;
295
+ for (const [fileIndex, file] of asset.files.entries()) {
296
+ const format = file.format.toLowerCase();
297
+ if (!LOCAL_ASSET_LAB_FORMATS.has(format)) continue;
298
+ const material = file.dependencies.find(
299
+ (dependency) => extname(dependency.relativePath).toLowerCase() === '.mtl',
300
+ );
301
+ return {
302
+ path: cloudPreviewFileUrl(asset.id, fileIndex, file.main.relativePath),
303
+ format,
304
+ label: file.label,
305
+ sourceAssetId: asset.id,
306
+ ...(material
307
+ ? { materialPath: cloudPreviewFileUrl(asset.id, fileIndex, material.relativePath) }
308
+ : {}),
309
+ };
310
+ }
311
+ return null;
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Poly Haven helpers
316
+ // ---------------------------------------------------------------------------
317
+
318
+ const PH_HEADERS = { 'User-Agent': 'Volter-Editor/1.0' };
319
+
320
+ /** Map Poly Haven asset type strings to our unified types. */
321
+ function phTypeToUnified(phType: string): OnlineAsset['type'] {
322
+ if (phType === 'hdris') return 'hdri';
323
+ if (phType === 'textures') return 'texture';
324
+ return 'model';
325
+ }
326
+
327
+ interface PHAssetEntry {
328
+ name: string;
329
+ type: number; // 0=hdri, 1=texture, 2=model — but we use the query type
330
+ categories: string[];
331
+ tags: string[];
332
+ thumbnail_url?: string;
333
+ download_count?: number;
334
+ }
335
+
336
+ async function searchPolyHaven(
337
+ type: string,
338
+ category?: string,
339
+ ): Promise<{ assets: OnlineAsset[]; total: number }> {
340
+ const cacheKey = `ph:${type}:${category ?? ''}`;
341
+ const cached = getCached<{ assets: OnlineAsset[]; total: number }>(cacheKey);
342
+ if (cached) return cached;
343
+
344
+ let url = `https://api.polyhaven.com/assets?type=${type}`;
345
+ if (category) url += `&categories=${category}`;
346
+
347
+ const res = await fetch(url, { headers: PH_HEADERS });
348
+ if (!res.ok) throw new Error(`Poly Haven API error: ${res.status}`);
349
+
350
+ const data = (await res.json()) as Record<string, PHAssetEntry>;
351
+ const assets: OnlineAsset[] = Object.entries(data).map(([id, entry]) => ({
352
+ id,
353
+ source: 'polyhaven' as const,
354
+ name: entry.name,
355
+ type: phTypeToUnified(type),
356
+ thumbnailUrl: `https://cdn.polyhaven.com/asset_img/thumbs/${id}.png?width=256`,
357
+ categories: entry.categories ?? [],
358
+ tags: entry.tags ?? [],
359
+ ...(entry.download_count != null ? { downloadCount: entry.download_count } : {}),
360
+ }));
361
+
362
+ const result = { assets, total: assets.length };
363
+ setCache(cacheKey, result);
364
+ return result;
365
+ }
366
+
367
+ interface PHFileInfo {
368
+ url: string;
369
+ size: number;
370
+ md5?: string;
371
+ include?: Record<string, { url: string; size: number }>;
372
+ }
373
+
374
+ type PHFilesResponse = Record<string, Record<string, Record<string, PHFileInfo>>>;
375
+
376
+ async function getPolyHavenFiles(id: string): Promise<AssetFileOption[]> {
377
+ const cacheKey = `ph-files:${id}`;
378
+ const cached = getCached<AssetFileOption[]>(cacheKey);
379
+ if (cached) return cached;
380
+
381
+ const res = await fetch(`https://api.polyhaven.com/files/${id}`, { headers: PH_HEADERS });
382
+ if (!res.ok) throw new Error(`Poly Haven files API error: ${res.status}`);
383
+
384
+ const data = (await res.json()) as PHFilesResponse;
385
+ const files: AssetFileOption[] = [];
386
+
387
+ // Models: GLTF with included files (bin + textures)
388
+ if (data['gltf']) {
389
+ for (const [resolution, formats] of Object.entries(data['gltf'])) {
390
+ const gltfInfo = formats['gltf'];
391
+ if (!gltfInfo) continue;
392
+
393
+ const includes: { relativePath: string; url: string; size: number }[] = [];
394
+ let totalSize = gltfInfo.size;
395
+
396
+ if (gltfInfo.include) {
397
+ for (const [relPath, fileInfo] of Object.entries(gltfInfo.include)) {
398
+ includes.push({ relativePath: relPath, url: fileInfo.url, size: fileInfo.size });
399
+ totalSize += fileInfo.size;
400
+ }
401
+ }
402
+
403
+ const entry: AssetFileOption = {
404
+ label: `GLTF ${resolution}`,
405
+ format: 'gltf',
406
+ url: gltfInfo.url,
407
+ sizeBytes: totalSize,
408
+ };
409
+ if (includes.length > 0) entry.includes = includes;
410
+ files.push(entry);
411
+ }
412
+ }
413
+
414
+ // HDRIs
415
+ if (data['hdri']) {
416
+ for (const [resolution, formats] of Object.entries(data['hdri'])) {
417
+ for (const [fmt, info] of Object.entries(formats)) {
418
+ files.push({
419
+ label: `${fmt.toUpperCase()} ${resolution}`,
420
+ format: fmt,
421
+ url: info.url,
422
+ sizeBytes: info.size,
423
+ });
424
+ }
425
+ }
426
+ }
427
+
428
+ setCache(cacheKey, files);
429
+ return files;
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // ambientCG helpers
434
+ // ---------------------------------------------------------------------------
435
+
436
+ interface ACGAsset {
437
+ assetId: string;
438
+ displayName: string;
439
+ dataType: string;
440
+ tags: string[];
441
+ previewImage?: { [key: string]: string };
442
+ downloadFolders?: Record<
443
+ string,
444
+ {
445
+ downloadFiletypeCategories: Record<
446
+ string,
447
+ {
448
+ downloads: Array<{
449
+ attribute: string;
450
+ downloadLink: string;
451
+ zipContent?: string[];
452
+ fileSizeInBytes?: number;
453
+ fileName?: string;
454
+ }>;
455
+ }
456
+ >;
457
+ }
458
+ >;
459
+ }
460
+
461
+ interface ACGResponse {
462
+ foundAssets: ACGAsset[];
463
+ numberOfResults: number;
464
+ }
465
+
466
+ function acgTypeParam(type: string): string {
467
+ // ambientCG types: Material, HDRI, etc.
468
+ if (type === 'materials') return 'Material';
469
+ if (type === 'hdris') return 'HDRI';
470
+ return type;
471
+ }
472
+
473
+ async function searchAmbientCG(
474
+ type: string,
475
+ q?: string,
476
+ offset = 0,
477
+ limit = 48,
478
+ ): Promise<{ assets: OnlineAsset[]; total: number }> {
479
+ const cacheKey = `acg:${type}:${q ?? ''}:${offset}:${limit}`;
480
+ const cached = getCached<{ assets: OnlineAsset[]; total: number }>(cacheKey);
481
+ if (cached) return cached;
482
+
483
+ const params = new URLSearchParams({
484
+ type: acgTypeParam(type),
485
+ limit: String(limit),
486
+ offset: String(offset),
487
+ include: 'previewData,downloadData,tagData',
488
+ sort: 'Popular',
489
+ });
490
+ if (q) params.set('q', q);
491
+
492
+ const res = await fetch(`https://ambientcg.com/api/v2/full_json?${params}`);
493
+ if (!res.ok) throw new Error(`ambientCG API error: ${res.status}`);
494
+
495
+ const data = (await res.json()) as ACGResponse;
496
+ const assets: OnlineAsset[] = data.foundAssets.map((a) => ({
497
+ id: a.assetId,
498
+ source: 'ambientcg' as const,
499
+ name: a.displayName,
500
+ type: type === 'hdris' ? ('hdri' as const) : ('material' as const),
501
+ thumbnailUrl: a.previewImage?.['256-PNG'] ?? '',
502
+ categories: [],
503
+ tags: a.tags ?? [],
504
+ }));
505
+
506
+ // Cache raw data for file extraction later
507
+ for (const a of data.foundAssets) {
508
+ setCache(`acg-raw:${a.assetId}`, a);
509
+ }
510
+
511
+ const result = { assets, total: data.numberOfResults };
512
+ setCache(cacheKey, result);
513
+ return result;
514
+ }
515
+
516
+ function getAmbientCGFiles(id: string): AssetFileOption[] {
517
+ const raw = getCached<ACGAsset>(`acg-raw:${id}`);
518
+ if (!raw || !raw.downloadFolders) return [];
519
+
520
+ const files: AssetFileOption[] = [];
521
+ for (const folder of Object.values(raw.downloadFolders)) {
522
+ for (const [_category, cat] of Object.entries(folder.downloadFiletypeCategories)) {
523
+ for (const dl of cat.downloads) {
524
+ const entry: AssetFileOption = {
525
+ label: dl.attribute || dl.fileName || 'Download',
526
+ format: dl.downloadLink.endsWith('.zip') ? 'zip' : 'unknown',
527
+ url: dl.downloadLink,
528
+ };
529
+ if (dl.fileSizeInBytes != null) entry.sizeBytes = dl.fileSizeInBytes;
530
+ files.push(entry);
531
+ }
532
+ }
533
+ }
534
+ return files;
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Router factory
539
+ // ---------------------------------------------------------------------------
540
+
541
+ export function createAssetLibraryRouter(getPublicRoot: () => string): Router {
542
+ const router = express.Router();
543
+ const historySnapshots = new Map<string, AssetHistorySnapshots>();
544
+ const assetHistory = () => {
545
+ const publicRoot = getPublicRoot();
546
+ const existing = historySnapshots.get(publicRoot);
547
+ if (existing) return existing;
548
+ const created = new AssetHistorySnapshots(publicRoot);
549
+ historySnapshots.set(publicRoot, created);
550
+ return created;
551
+ };
552
+ const captureCommittedHistory = async (assetPath: string, beforeToken: string) => {
553
+ try {
554
+ return {
555
+ assetPath,
556
+ beforeToken,
557
+ afterToken: await assetHistory().capture(assetPath),
558
+ };
559
+ } catch (error) {
560
+ await assetHistory().restore(assetPath, beforeToken);
561
+ throw error;
562
+ }
563
+ };
564
+
565
+ /**
566
+ * Record a completed materialization in `.vgai/assets.json` (D-AP3).
567
+ *
568
+ * `resultPath` is public-root-relative (what the route returns); the ledger is
569
+ * keyed project-relative, hence the `public/` prefix.
570
+ *
571
+ * The bytes are already committed by the time we get here, so a ledger
572
+ * failure must NOT be reported as a failed import. It degrades LOUDLY
573
+ * instead: logged on the server and surfaced to the client as `ledgerError`.
574
+ */
575
+ const recordLedger = async (
576
+ resultPath: string,
577
+ entry: { key: string; license?: string | undefined; sourceSha256?: string | undefined },
578
+ ): Promise<string | undefined> => {
579
+ try {
580
+ await recordAssetMaterialization({
581
+ projectRoot: dirname(getPublicRoot()),
582
+ destPath: `public/${resultPath}`,
583
+ ...entry,
584
+ });
585
+ return undefined;
586
+ } catch (error) {
587
+ const message = error instanceof Error ? error.message : String(error);
588
+ // biome-ignore lint/suspicious/noConsole: the dev server's terminal is its own report channel for a ledger write that failed after the import succeeded.
589
+ console.error(
590
+ `[asset-ledger] ${resultPath} was imported but its provenance could not be recorded ` +
591
+ `in .vgai/assets.json: ${message}`,
592
+ );
593
+ return message;
594
+ }
595
+ };
596
+
597
+ router.post('/history/capture', async (req, res) => {
598
+ const assetPath = (req.body as { assetPath?: unknown }).assetPath;
599
+ if (typeof assetPath !== 'string') {
600
+ res.status(400).json({ error: 'Asset history capture requires an asset path.' });
601
+ return;
602
+ }
603
+ try {
604
+ res.json({ ok: true, token: await assetHistory().capture(assetPath) });
605
+ } catch (error) {
606
+ res.status(400).json({
607
+ error: `Asset history capture failed: ${error instanceof Error ? error.message : String(error)}`,
608
+ });
609
+ }
610
+ });
611
+
612
+ router.post('/history/restore', async (req, res) => {
613
+ const body = req.body as { assetPath?: unknown; token?: unknown };
614
+ if (typeof body.assetPath !== 'string' || typeof body.token !== 'string') {
615
+ res.status(400).json({ error: 'Asset history restore requires an asset path and token.' });
616
+ return;
617
+ }
618
+ try {
619
+ await assetHistory().restore(body.assetPath, body.token);
620
+ broadcast('assets-changed', {});
621
+ res.json({ ok: true });
622
+ } catch (error) {
623
+ res.status(400).json({
624
+ error: `Asset history restore failed: ${error instanceof Error ? error.message : String(error)}`,
625
+ });
626
+ }
627
+ });
628
+ router.use(express.json());
629
+
630
+ // ---- Search ----
631
+ router.get('/search', async (req, res) => {
632
+ const source = req.query['source'] as string;
633
+ const type = req.query['type'] as string;
634
+ const q = (req.query['q'] as string) ?? '';
635
+ const category = req.query['category'] as string | undefined;
636
+ const offset = Number.parseInt((req.query['offset'] as string) ?? '0', 10);
637
+ const limit = Number.parseInt((req.query['limit'] as string) ?? '48', 10);
638
+
639
+ if (!source || !type) {
640
+ res.status(400).json({ error: 'Missing source or type.' });
641
+ return;
642
+ }
643
+
644
+ try {
645
+ let result: { assets: OnlineAsset[]; total: number };
646
+
647
+ if (source === 'local') {
648
+ try {
649
+ const catalog = await loadLocalAssetCatalog();
650
+ const localResult = searchLocalCatalog(catalog, {
651
+ q,
652
+ type,
653
+ offset,
654
+ limit,
655
+ ...(category ? { category } : {}),
656
+ });
657
+ const cloudAssets = await findCloudAssets(
658
+ localResult.assets.flatMap((asset) => asset.variants.map((variant) => variant.id)),
659
+ );
660
+ const cloudIds = new Set(cloudAssets.map((asset) => asset.id));
661
+ result = {
662
+ total: localResult.total,
663
+ assets: localResult.assets.map((asset) => {
664
+ const hostedVariant = asset.variants.find((variant) => cloudIds.has(variant.id));
665
+ return {
666
+ id: asset.familyId,
667
+ source: 'local' as const,
668
+ name: asset.name,
669
+ type: asset.type,
670
+ thumbnailUrl: `/__editor/asset-library/thumbnails/${hostedVariant?.id ?? asset.familyId}.webp`,
671
+ categories: asset.categories,
672
+ tags: asset.tags,
673
+ license: asset.license,
674
+ author: asset.author,
675
+ sourceUrl: asset.sourceUrl,
676
+ cloudHosted: Boolean(hostedVariant),
677
+ variantId: asset.id,
678
+ variantCount: asset.variants.length,
679
+ sizeBytes: asset.variants.reduce(
680
+ (total, variant) =>
681
+ total +
682
+ variant.files.reduce((variantTotal, file) => variantTotal + file.sizeBytes, 0),
683
+ 0,
684
+ ),
685
+ animationCount: asset.variants.reduce(
686
+ (total, variant) => total + (variant.animationCount ?? 0),
687
+ 0,
688
+ ),
689
+ deliveryKinds: [
690
+ ...new Set(
691
+ asset.variants.flatMap((variant) =>
692
+ variant.deliveries
693
+ .filter((delivery) => delivery.available)
694
+ .map((delivery) => delivery.kind),
695
+ ),
696
+ ),
697
+ ],
698
+ addedAt: catalog.generatedAt,
699
+ capabilities: localAssetCapabilities(asset),
700
+ };
701
+ }),
702
+ };
703
+ } catch (error) {
704
+ if (!(error instanceof LocalAssetCatalogUnavailableError)) throw error;
705
+ result = await searchCloudCatalog({
706
+ q,
707
+ type,
708
+ offset,
709
+ limit,
710
+ ...(category ? { category } : {}),
711
+ });
712
+ }
713
+ } else if (source === 'polyhaven') {
714
+ result = await searchPolyHaven(type, category);
715
+ // Client-side text filter (PH API doesn't have text search)
716
+ if (q) {
717
+ const lower = q.toLowerCase();
718
+ result = {
719
+ assets: result.assets.filter(
720
+ (a) =>
721
+ a.name.toLowerCase().includes(lower) ||
722
+ a.tags.some((t) => t.toLowerCase().includes(lower)),
723
+ ),
724
+ total: 0, // will be set below
725
+ };
726
+ result.total = result.assets.length;
727
+ }
728
+ // Manual pagination for PH (returns all at once)
729
+ result = {
730
+ assets: result.assets.slice(offset, offset + limit),
731
+ total: result.total,
732
+ };
733
+ } else if (source === 'ambientcg') {
734
+ result = await searchAmbientCG(type, q || undefined, offset, limit);
735
+ } else {
736
+ res.status(400).json({ error: `Unknown source: ${source}` });
737
+ return;
738
+ }
739
+
740
+ res.json(result);
741
+ } catch (err) {
742
+ // A LOCAL host that carries no asset catalog is not an upstream fault —
743
+ // saying `Upstream API error: ENOENT` for a file that was never on this
744
+ // disk sends the reader to the network for a realm problem.
745
+ if (err instanceof LocalAssetCatalogUnavailableError) {
746
+ res.status(501).json({ error: err.message });
747
+ return;
748
+ }
749
+ res.status(502).json({ error: `Upstream API error: ${err}` });
750
+ }
751
+ });
752
+
753
+ // ---- Read-only Asset Lab preview for the SSD catalog ----
754
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the route visibly walks ranked format variants and reports each distinct availability outcome.
755
+ router.get('/preview', async (req, res) => {
756
+ const source = req.query['source'] as string;
757
+ const id = req.query['id'] as string;
758
+ if (source !== 'local' || !/^[a-f0-9]{24}$/.test(id)) {
759
+ res.status(400).json({ error: 'A valid local catalog asset id is required.' });
760
+ return;
761
+ }
762
+
763
+ try {
764
+ const catalog = await loadLocalAssetCatalog();
765
+ const selectedAsset = await findLocalAsset(id);
766
+ if (!selectedAsset) {
767
+ const cloudPreview = await resolveCloudAssetPreview(id);
768
+ if (cloudPreview) res.json(cloudPreview);
769
+ else res.status(404).json({ error: 'Catalog asset not found.' });
770
+ return;
771
+ }
772
+ const libraryRoot = getLocalAssetLibraryRoot();
773
+ for (const candidate of localAssetPreviewVariants(selectedAsset, catalog.assets)) {
774
+ for (const [fileIndex, file] of candidate.files.entries()) {
775
+ const format = file.format.toLowerCase();
776
+ if (!LOCAL_ASSET_LAB_FORMATS.has(format)) continue;
777
+ try {
778
+ await stat(requireCatalogFile(libraryRoot, file.path));
779
+ } catch {
780
+ continue;
781
+ }
782
+ const sourceDirectory = dirname(file.path);
783
+ const mainRelativePath = relative(sourceDirectory, file.path).replaceAll('\\', '/');
784
+ const materialPath = file.dependencies?.find(
785
+ (dependency) => extname(dependency).toLowerCase() === '.mtl',
786
+ );
787
+ const materialRelativePath = materialPath
788
+ ? relative(sourceDirectory, materialPath).replaceAll('\\', '/')
789
+ : undefined;
790
+ res.json({
791
+ path: localPreviewFileUrl(candidate.id, fileIndex, mainRelativePath),
792
+ format,
793
+ label: file.label,
794
+ sourceAssetId: candidate.id,
795
+ ...(materialRelativePath
796
+ ? {
797
+ materialPath: localPreviewFileUrl(candidate.id, fileIndex, materialRelativePath),
798
+ }
799
+ : {}),
800
+ });
801
+ return;
802
+ }
803
+ }
804
+ for (const candidate of localAssetPreviewVariants(selectedAsset, catalog.assets)) {
805
+ const cloudPreview = await resolveCloudAssetPreview(candidate.id);
806
+ if (!cloudPreview) continue;
807
+ res.json(cloudPreview);
808
+ return;
809
+ }
810
+ res.status(404).json({ error: 'No Asset Lab-compatible variant is available.' });
811
+ } catch (error) {
812
+ if (error instanceof LocalAssetCatalogUnavailableError) {
813
+ const cloudPreview = await resolveCloudAssetPreview(id);
814
+ if (cloudPreview) res.json(cloudPreview);
815
+ else res.status(404).json({ error: 'No cloud Asset Lab-compatible variant is available.' });
816
+ return;
817
+ }
818
+ res.status(500).json({ error: `Local Asset Lab preview failed: ${error}` });
819
+ }
820
+ });
821
+
822
+ router.get('/preview-files/:id/:fileIndex/*path', async (req, res) => {
823
+ const id = req.params['id'];
824
+ const fileIndex = Number.parseInt(req.params['fileIndex'] ?? '', 10);
825
+ const pathParam = req.params['path'] as unknown;
826
+ const requestedRelativePath = (Array.isArray(pathParam) ? pathParam : [pathParam])
827
+ .filter((segment): segment is string => typeof segment === 'string' && segment.length > 0)
828
+ .join('/');
829
+ if (!id || !/^[a-f0-9]{24}$/.test(id) || !Number.isInteger(fileIndex)) {
830
+ res.status(400).json({ error: 'Invalid catalog preview path.' });
831
+ return;
832
+ }
833
+
834
+ try {
835
+ const asset = await findLocalAsset(id);
836
+ const file = asset?.files[fileIndex];
837
+ if (!file) {
838
+ res.status(404).json({ error: 'Catalog preview file not found.' });
839
+ return;
840
+ }
841
+ const sourceDirectory = dirname(file.path);
842
+ const exactPath = [file.path, ...(file.dependencies ?? [])].find(
843
+ (candidate) =>
844
+ relative(sourceDirectory, candidate).replaceAll('\\', '/') === requestedRelativePath,
845
+ );
846
+ const basenameMatches = (file.dependencies ?? []).filter(
847
+ (candidate) => basename(candidate) === basename(requestedRelativePath),
848
+ );
849
+ const allowedPath = exactPath ?? (basenameMatches.length === 1 ? basenameMatches[0] : null);
850
+ if (!allowedPath) {
851
+ res.status(404).json({ error: 'Catalog preview dependency not found.' });
852
+ return;
853
+ }
854
+ const absolutePath = requireCatalogFile(getLocalAssetLibraryRoot(), allowedPath);
855
+ await stat(absolutePath);
856
+ res.set('Cache-Control', 'private, max-age=3600').sendFile(absolutePath);
857
+ } catch {
858
+ res.status(404).json({ error: 'Catalog preview file not found.' });
859
+ }
860
+ });
861
+
862
+ // Cloud previews stay same-origin so glTF relative dependencies resolve
863
+ // through this validated route instead of guessing the Worker's object URL.
864
+ router.get('/cloud-preview-files/:id/:fileIndex/*path', async (req, res) => {
865
+ const id = req.params['id'];
866
+ const fileIndex = Number.parseInt(req.params['fileIndex'] ?? '', 10);
867
+ const pathParam = req.params['path'] as unknown;
868
+ const requestedRelativePath = (Array.isArray(pathParam) ? pathParam : [pathParam])
869
+ .filter((segment): segment is string => typeof segment === 'string' && segment.length > 0)
870
+ .join('/');
871
+ if (!id || !/^[a-f0-9]{24}$/.test(id) || !Number.isInteger(fileIndex)) {
872
+ res.status(400).json({ error: 'Invalid cloud catalog preview path.' });
873
+ return;
874
+ }
875
+
876
+ try {
877
+ const asset = await findCloudAsset(id);
878
+ const file = asset?.files[fileIndex];
879
+ if (!file) {
880
+ res.status(404).json({ error: 'Cloud catalog preview file not found.' });
881
+ return;
882
+ }
883
+ const objects = [file.main, ...file.dependencies];
884
+ const objectIndex = objects.findIndex(
885
+ (candidate) => candidate.relativePath === requestedRelativePath,
886
+ );
887
+ const object = objects[objectIndex];
888
+ if (!object) {
889
+ res.status(404).json({ error: 'Cloud catalog preview dependency not found.' });
890
+ return;
891
+ }
892
+ const dependencyIndex = objectIndex === 0 ? undefined : objectIndex - 1;
893
+ const response = await fetch(cloudAssetObjectUrl(id, fileIndex, dependencyIndex), {
894
+ signal: AbortSignal.timeout(120_000),
895
+ });
896
+ if (!response.ok || !response.body) {
897
+ res.status(502).json({ error: `Cloud preview delivery failed (${response.status}).` });
898
+ return;
899
+ }
900
+ res
901
+ .set('Cache-Control', 'private, max-age=3600')
902
+ .set('Content-Length', String(object.sizeBytes))
903
+ .type(object.contentType);
904
+ await pipeline(Readable.fromWeb(response.body as import('stream/web').ReadableStream), res);
905
+ } catch (error) {
906
+ if (!res.headersSent) {
907
+ res.status(502).json({ error: `Cloud catalog preview failed: ${error}` });
908
+ }
909
+ }
910
+ });
911
+
912
+ // ---- File options for an asset ----
913
+ router.get('/files', async (req, res) => {
914
+ const source = req.query['source'] as string;
915
+ const id = req.query['id'] as string;
916
+
917
+ if (!source || !id) {
918
+ res.status(400).json({ error: 'Missing source or id.' });
919
+ return;
920
+ }
921
+
922
+ try {
923
+ let files: AssetFileOption[];
924
+
925
+ if (source === 'local') {
926
+ let family: Awaited<ReturnType<typeof findLocalAssetFamily>> = null;
927
+ try {
928
+ family = await findLocalAssetFamily(id);
929
+ } catch (error) {
930
+ if (!(error instanceof LocalAssetCatalogUnavailableError)) throw error;
931
+ }
932
+ if (!family) {
933
+ const cloudAsset = await findCloudAsset(id);
934
+ if (!cloudAsset) {
935
+ res.status(404).json({ error: 'Catalog asset not found.' });
936
+ return;
937
+ }
938
+ res.json({ files: cloudAssetFileOptions(cloudAsset) });
939
+ return;
940
+ }
941
+ const cloudFiles: AssetFileOption[] = [];
942
+ const localFiles: AssetFileOption[] = [];
943
+ for (const variant of family?.variants ?? []) {
944
+ const cloudAsset = await findCloudAsset(variant.id);
945
+ if (cloudAsset) {
946
+ cloudFiles.push(...cloudAssetFileOptions(cloudAsset));
947
+ }
948
+ const variantAsset = await findLocalAsset(variant.id);
949
+ const localDeliveryAvailable = variant.deliveries.some(
950
+ (delivery) => delivery.kind === 'local-ssd' && delivery.available,
951
+ );
952
+ if (variantAsset && localDeliveryAvailable) {
953
+ localFiles.push(
954
+ ...(
955
+ await Promise.all(
956
+ variantAsset.files.map(async (file, index): Promise<AssetFileOption | null> => {
957
+ try {
958
+ await stat(requireCatalogFile(getLocalAssetLibraryRoot(), file.path));
959
+ return {
960
+ label: `${variant.label} · SSD · ${file.label}`,
961
+ format: file.format,
962
+ url: `vgai-local:${variant.id}:${index}`,
963
+ sizeBytes: file.sizeBytes,
964
+ };
965
+ } catch {
966
+ return null;
967
+ }
968
+ }),
969
+ )
970
+ ).filter((file): file is AssetFileOption => file !== null),
971
+ );
972
+ }
973
+ }
974
+ files = [...cloudFiles, ...localFiles];
975
+ } else if (source === 'polyhaven') {
976
+ files = await getPolyHavenFiles(id);
977
+ } else if (source === 'ambientcg') {
978
+ files = getAmbientCGFiles(id);
979
+ // If not cached, do a fresh search to populate
980
+ if (files.length === 0) {
981
+ await searchAmbientCG('materials', id, 0, 1);
982
+ files = getAmbientCGFiles(id);
983
+ }
984
+ } else {
985
+ res.status(400).json({ error: `Unknown source: ${source}` });
986
+ return;
987
+ }
988
+
989
+ res.json({ files });
990
+ } catch (err) {
991
+ // A LOCAL host that carries no asset catalog is not an upstream fault —
992
+ // saying `Upstream API error: ENOENT` for a file that was never on this
993
+ // disk sends the reader to the network for a realm problem.
994
+ if (err instanceof LocalAssetCatalogUnavailableError) {
995
+ res.status(501).json({ error: err.message });
996
+ return;
997
+ }
998
+ res.status(502).json({ error: `Upstream API error: ${err}` });
999
+ }
1000
+ });
1001
+
1002
+ // ---- Download ----
1003
+ router.post('/download', async (req, res) => {
1004
+ const body = req.body as {
1005
+ source: string;
1006
+ id: string;
1007
+ name: string;
1008
+ url: string;
1009
+ format: string;
1010
+ jobId?: string;
1011
+ includes?: { relativePath: string; url: string; size: number }[];
1012
+ /** License the CLIENT already read off the library's own search result.
1013
+ * Used only for the remote (polyhaven/ambientcg) proxy, whose upstream
1014
+ * APIs are not re-queried here; the local/cloud catalog paths resolve the
1015
+ * license server-side and ignore this. */
1016
+ license?: string;
1017
+ };
1018
+ const { source, id, name, url, format } = body;
1019
+ const cancellation = new AbortController();
1020
+ req.once('aborted', () =>
1021
+ cancellation.abort(new DOMException('Import cancelled', 'AbortError')),
1022
+ );
1023
+ res.once('close', () => {
1024
+ if (!res.writableEnded)
1025
+ cancellation.abort(new DOMException('Import cancelled', 'AbortError'));
1026
+ });
1027
+ const throwIfCancelled = () => cancellation.signal.throwIfAborted();
1028
+ const progress = (
1029
+ stage: 'fetch' | 'convert' | 'validate' | 'commit',
1030
+ loaded: number,
1031
+ total: number,
1032
+ message?: string,
1033
+ ) => {
1034
+ if (!body.jobId) return;
1035
+ broadcast('asset-import-progress', {
1036
+ jobId: body.jobId,
1037
+ stage,
1038
+ loaded,
1039
+ total,
1040
+ ...(message ? { message } : {}),
1041
+ });
1042
+ };
1043
+ const includes = body.includes ?? [];
1044
+
1045
+ if (!source || !id || !url) {
1046
+ res.status(400).json({ error: 'Missing required fields.' });
1047
+ return;
1048
+ }
1049
+
1050
+ // S1: only allow known sources — `source` is used as a path segment below.
1051
+ if (!isAllowedAssetSource(source)) {
1052
+ res.status(400).json({ error: `Unknown source: ${source}` });
1053
+ return;
1054
+ }
1055
+
1056
+ if (source === 'local') {
1057
+ let stagingDir: string | null = null;
1058
+ try {
1059
+ let family: Awaited<ReturnType<typeof findLocalAssetFamily>> = null;
1060
+ let asset: Awaited<ReturnType<typeof findLocalAsset>> = null;
1061
+ try {
1062
+ family = await findLocalAssetFamily(id);
1063
+ asset = family ? await findLocalAsset(family.id) : null;
1064
+ } catch (error) {
1065
+ if (!(error instanceof LocalAssetCatalogUnavailableError)) throw error;
1066
+ }
1067
+ const publicRoot = getPublicRoot();
1068
+ const cloudVariantId = /\/v1\/assets\/([a-f0-9]{24})\/files\//.exec(url)?.[1];
1069
+ const cloudRoute = cloudVariantId ? parseCloudAssetObjectUrl(url, cloudVariantId) : null;
1070
+ if (cloudRoute && cloudRoute.dependencyIndex === undefined) {
1071
+ const cloudAsset = await findCloudAsset(cloudVariantId!);
1072
+ const cloudFile = cloudAsset?.files[cloudRoute.fileIndex];
1073
+ const matchesCatalogIdentity = family
1074
+ ? family.variants.some((variant) => variant.id === cloudVariantId)
1075
+ : cloudAsset?.id === id || cloudAsset?.familyId === id;
1076
+ if (
1077
+ !cloudAsset ||
1078
+ !cloudFile ||
1079
+ (asset !== null && cloudAsset.source !== asset.source) ||
1080
+ !matchesCatalogIdentity
1081
+ ) {
1082
+ res.status(404).json({ error: 'Cloud asset file not found.' });
1083
+ return;
1084
+ }
1085
+ const assetSlug = asset ? localAssetSlug(asset) : cloudAssetSlug(cloudAsset);
1086
+ const destDir = join(publicRoot, 'asset-library', 'local', assetSlug);
1087
+ stagingDir = await beginAssetStaging(destDir);
1088
+ const mainDestination = join(stagingDir, cloudFile.main.relativePath);
1089
+ if (!isPathInside(stagingDir, mainDestination)) {
1090
+ await rm(stagingDir, { recursive: true, force: true });
1091
+ stagingDir = null;
1092
+ res.status(400).json({ error: 'Invalid cloud asset path.' });
1093
+ return;
1094
+ }
1095
+ await downloadRemoteFile(
1096
+ url,
1097
+ mainDestination,
1098
+ cloudFile.main.sha256,
1099
+ cloudFile.main.sizeBytes,
1100
+ (loaded, total) => progress('fetch', loaded, total),
1101
+ cancellation.signal,
1102
+ );
1103
+ for (const [dependencyIndex, dependency] of cloudFile.dependencies.entries()) {
1104
+ const destination = join(stagingDir, dependency.relativePath);
1105
+ if (!isPathInside(stagingDir, destination)) {
1106
+ await rm(stagingDir, { recursive: true, force: true });
1107
+ stagingDir = null;
1108
+ res
1109
+ .status(400)
1110
+ .json({ error: `Invalid dependency path: ${dependency.relativePath}` });
1111
+ return;
1112
+ }
1113
+ await downloadRemoteFile(
1114
+ cloudAssetObjectUrl(cloudVariantId!, cloudRoute.fileIndex, dependencyIndex),
1115
+ destination,
1116
+ dependency.sha256,
1117
+ dependency.sizeBytes,
1118
+ (loaded, total) =>
1119
+ progress('fetch', loaded, total, `Dependency ${dependencyIndex + 1}`),
1120
+ cancellation.signal,
1121
+ );
1122
+ }
1123
+ const cloudResultPath = `asset-library/local/${assetSlug}/${cloudFile.main.relativePath}`;
1124
+ progress('validate', cloudFile.main.sizeBytes, cloudFile.main.sizeBytes);
1125
+ throwIfCancelled();
1126
+ if (asset) await copyLocalAssetThumbnail(asset, stagingDir);
1127
+ const beforeToken = await assetHistory().capture(cloudResultPath);
1128
+ const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
1129
+ id: family?.id ?? id,
1130
+ variantId: cloudVariantId,
1131
+ name: asset?.name ?? cloudAsset.name,
1132
+ source: asset?.source ?? cloudAsset.source,
1133
+ sourceUrl: asset?.sourceUrl ?? cloudAsset.sourceUrl,
1134
+ author: asset?.author ?? cloudAsset.author,
1135
+ license: asset?.license ?? cloudAsset.license,
1136
+ delivery: 'cloudflare-r2',
1137
+ attribution: cloudAsset.attribution,
1138
+ sha256: cloudFile.main.sha256,
1139
+ });
1140
+ progress('commit', 1, 1);
1141
+ stagingDir = null;
1142
+ const resultPath = cloudResultPath;
1143
+ const history = await captureCommittedHistory(resultPath, beforeToken);
1144
+ const ledgerError = await recordLedger(resultPath, {
1145
+ key: assetKey(source, id),
1146
+ license: asset?.license ?? cloudAsset.license,
1147
+ sourceSha256: cloudFile.main.sha256,
1148
+ });
1149
+ broadcast('assets-changed', {});
1150
+ res.json({
1151
+ ok: true,
1152
+ path: resultPath,
1153
+ provenanceOperationId: committed.provenanceOperationId,
1154
+ history,
1155
+ ...(ledgerError ? { ledgerError } : {}),
1156
+ });
1157
+ return;
1158
+ }
1159
+
1160
+ if (!asset || !family) {
1161
+ res.status(404).json({
1162
+ error:
1163
+ 'This asset has no hosted delivery, and the optional SSD archive is not available.',
1164
+ });
1165
+ return;
1166
+ }
1167
+ const assetSlug = localAssetSlug(asset);
1168
+ const destDir = join(publicRoot, 'asset-library', 'local', assetSlug);
1169
+
1170
+ const match = url.match(/^vgai-local:([a-f0-9]{24}):(\d+)$/);
1171
+ const selectedAsset = match?.[1] ? await findLocalAsset(match[1]) : null;
1172
+ const selectedFile =
1173
+ selectedAsset && selectedAsset.familyId === family.id
1174
+ ? selectedAsset.files[Number(match?.[2])]
1175
+ : undefined;
1176
+ if (!selectedFile || !selectedAsset) {
1177
+ res.status(404).json({ error: 'Local asset file not found.' });
1178
+ return;
1179
+ }
1180
+ const libraryRoot = getLocalAssetLibraryRoot();
1181
+ const mainSource = requireCatalogFile(libraryRoot, selectedFile.path);
1182
+ const sourceDirectory = dirname(mainSource);
1183
+ stagingDir = await beginAssetStaging(destDir);
1184
+ const primaryRelative = basename(mainSource);
1185
+ const stagedPrimary = join(stagingDir, primaryRelative);
1186
+ progress('fetch', 0, selectedFile.sizeBytes);
1187
+ await copyFile(mainSource, stagedPrimary);
1188
+ progress('fetch', selectedFile.sizeBytes, selectedFile.sizeBytes);
1189
+ throwIfCancelled();
1190
+ for (const dependency of selectedFile.dependencies ?? []) {
1191
+ const dependencySource = requireCatalogFile(libraryRoot, dependency);
1192
+ const sourceRelative = relative(sourceDirectory, dependencySource);
1193
+ const dependencyRelative = sourceRelative.startsWith('..')
1194
+ ? basename(dependencySource)
1195
+ : sourceRelative;
1196
+ const dependencyDestination = join(stagingDir, dependencyRelative);
1197
+ await mkdir(dirname(dependencyDestination), { recursive: true });
1198
+ await copyFile(dependencySource, dependencyDestination);
1199
+ }
1200
+ let resultFile = primaryRelative;
1201
+ let backend: 'native' | 'three' = 'native';
1202
+ const format = selectedFile.format.toLowerCase();
1203
+ if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
1204
+ progress('convert', 0, 1);
1205
+ resultFile = `${basename(primaryRelative, extname(primaryRelative))}.glb`;
1206
+ const converted = await convertStagedModelToGlb(stagedPrimary, format);
1207
+ await writeFile(join(stagingDir, resultFile), converted);
1208
+ backend = 'three';
1209
+ progress('convert', 1, 1);
1210
+ throwIfCancelled();
1211
+ }
1212
+ const resultPath = `asset-library/local/${assetSlug}/${resultFile}`;
1213
+ progress('validate', 1, 1);
1214
+ throwIfCancelled();
1215
+ await copyLocalAssetThumbnail(asset, stagingDir);
1216
+ const beforeToken = await assetHistory().capture(resultPath);
1217
+ const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
1218
+ id: family.id,
1219
+ variantId: selectedAsset.id,
1220
+ name: asset.name,
1221
+ source: asset.source,
1222
+ sourceUrl: asset.sourceUrl,
1223
+ author: asset.author,
1224
+ license: asset.license,
1225
+ delivery: 'local-ssd',
1226
+ originalPath: selectedFile.path,
1227
+ });
1228
+ progress('commit', 1, 1);
1229
+ stagingDir = null;
1230
+ const history = await captureCommittedHistory(resultPath, beforeToken);
1231
+ // A converted copy is DERIVED, not a byte-copy of the library's file, so
1232
+ // the catalog digest would never match it — omit rather than record a
1233
+ // hash that reads as permanent drift.
1234
+ const ledgerError = await recordLedger(resultPath, {
1235
+ key: assetKey(source, id),
1236
+ license: asset.license,
1237
+ sourceSha256: backend === 'native' ? selectedFile.sha256 : undefined,
1238
+ });
1239
+ broadcast('assets-changed', {});
1240
+ res.json({
1241
+ ok: true,
1242
+ path: resultPath,
1243
+ provenanceOperationId: committed.provenanceOperationId,
1244
+ history,
1245
+ ...(ledgerError ? { ledgerError } : {}),
1246
+ });
1247
+ } catch (error) {
1248
+ if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
1249
+ res.status(500).json({ error: `Local asset copy failed: ${error}` });
1250
+ }
1251
+ return;
1252
+ }
1253
+
1254
+ // S2: restrict the main download URL (and includes) to allowlisted CDNs,
1255
+ // blocking SSRF to private / link-local addresses.
1256
+ if (!isAllowedAssetHost(url)) {
1257
+ res.status(400).json({ error: 'Download URL host is not allowed.' });
1258
+ return;
1259
+ }
1260
+ for (const inc of includes) {
1261
+ if (!isAllowedAssetHost(inc.url)) {
1262
+ res.status(400).json({ error: 'Included file URL host is not allowed.' });
1263
+ return;
1264
+ }
1265
+ }
1266
+
1267
+ const publicRoot = getPublicRoot();
1268
+ const slug = (name || id)
1269
+ .toLowerCase()
1270
+ .replace(/[^a-z0-9]+/g, '-')
1271
+ .replace(/^-|-$/g, '');
1272
+ const destDir = join(publicRoot, 'asset-library', source, slug);
1273
+
1274
+ // Skip if already downloaded
1275
+ try {
1276
+ await stat(destDir);
1277
+ const existing = await readdir(destDir);
1278
+ if (existing.length > 0) {
1279
+ // Find the main file (gltf/glb/hdr/exr, not textures)
1280
+ const mainFile = existing.find((f) => /\.(gltf|glb|hdr|exr)$/i.test(f)) ?? existing[0]!;
1281
+ res.json({
1282
+ ok: true,
1283
+ path: `asset-library/${source}/${slug}/${mainFile}`,
1284
+ alreadyExists: true,
1285
+ });
1286
+ return;
1287
+ }
1288
+ } catch {
1289
+ // Doesn't exist yet — continue
1290
+ }
1291
+
1292
+ let stagingDir: string | null = null;
1293
+ try {
1294
+ stagingDir = await beginAssetStaging(destDir);
1295
+
1296
+ /** Download a single file to a destination path. */
1297
+ async function downloadFile(
1298
+ fileUrl: string,
1299
+ destPath: string,
1300
+ expectedBytes?: number,
1301
+ ): Promise<void> {
1302
+ await mkdir(join(destPath, '..'), { recursive: true });
1303
+ const response = await fetch(fileUrl, { signal: cancellation.signal });
1304
+ if (!response.ok || !response.body) {
1305
+ throw new Error(`Download failed: ${response.status} for ${fileUrl}`);
1306
+ }
1307
+ const nodeStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
1308
+ const total =
1309
+ expectedBytes ?? Number.parseInt(response.headers.get('content-length') ?? '0', 10);
1310
+ let loaded = 0;
1311
+ const reporter = new Transform({
1312
+ transform(chunk: Buffer, _encoding, callback) {
1313
+ loaded += chunk.length;
1314
+ progress('fetch', loaded, total);
1315
+ callback(null, chunk);
1316
+ },
1317
+ });
1318
+ await pipeline(nodeStream, reporter, createWriteStream(destPath));
1319
+ }
1320
+
1321
+ const isZip = format === 'zip' || url.endsWith('.zip');
1322
+ const ext = isZip ? 'zip' : url.split('.').pop() || format || 'bin';
1323
+ const fileName = `${slug}.${ext}`;
1324
+ const filePath = join(stagingDir, fileName);
1325
+
1326
+ // Download main file
1327
+ await downloadFile(url, filePath);
1328
+
1329
+ // Download included files (GLTF bin + textures)
1330
+ for (const inc of includes) {
1331
+ const incPath = join(stagingDir, inc.relativePath);
1332
+ // S1: ensure each relativePath stays inside destDir (reject `..` escapes).
1333
+ if (!isPathInside(stagingDir, incPath)) {
1334
+ await rm(stagingDir, { recursive: true, force: true });
1335
+ stagingDir = null;
1336
+ res.status(400).json({ error: `Invalid include path: ${inc.relativePath}` });
1337
+ return;
1338
+ }
1339
+ await downloadFile(inc.url, incPath, inc.size);
1340
+ }
1341
+
1342
+ let resultPath = `asset-library/${source}/${slug}/${fileName}`;
1343
+
1344
+ // Extract ZIPs
1345
+ if (isZip) {
1346
+ progress('convert', 0, 1);
1347
+ await new Promise<void>((resolve, reject) => {
1348
+ execFile('unzip', ['-o', filePath, '-d', stagingDir!], (err) => {
1349
+ if (err) reject(err);
1350
+ else resolve();
1351
+ });
1352
+ });
1353
+ await unlink(filePath).catch(() => {});
1354
+ const extracted = await readdir(stagingDir);
1355
+ const mainFile = extracted.find((f) => !f.endsWith('.zip')) ?? extracted[0];
1356
+ if (mainFile) {
1357
+ resultPath = `asset-library/${source}/${slug}/${mainFile}`;
1358
+ }
1359
+ progress('convert', 1, 1);
1360
+ }
1361
+
1362
+ progress('validate', 1, 1);
1363
+ throwIfCancelled();
1364
+ const beforeToken = await assetHistory().capture(resultPath);
1365
+ const committed = await commitCatalogAsset(publicRoot, stagingDir, destDir, {
1366
+ id,
1367
+ name,
1368
+ source,
1369
+ sourceUrl: url,
1370
+ delivery: source,
1371
+ });
1372
+ progress('commit', 1, 1);
1373
+ stagingDir = null;
1374
+ const history = await captureCommittedHistory(resultPath, beforeToken);
1375
+ // No `sourceSha256`: the Poly Haven / ambientCG APIs publish no per-file
1376
+ // digest, so this copy has no library hash to drift against. Recording a
1377
+ // hash of the bytes we just wrote would fabricate a pristine-forever
1378
+ // record — the anti-shim rule forbids it.
1379
+ const ledgerError = await recordLedger(resultPath, {
1380
+ key: assetKey(source, id),
1381
+ license: body.license,
1382
+ });
1383
+ broadcast('assets-changed', {});
1384
+
1385
+ res.json({
1386
+ ok: true,
1387
+ path: resultPath,
1388
+ provenanceOperationId: committed.provenanceOperationId,
1389
+ history,
1390
+ ...(ledgerError ? { ledgerError } : {}),
1391
+ });
1392
+ } catch (err) {
1393
+ if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
1394
+ res.status(500).json({ error: `Download failed: ${err}` });
1395
+ }
1396
+ });
1397
+
1398
+ // ---- Categories for Poly Haven ----
1399
+ router.get('/categories', async (req, res) => {
1400
+ const source = req.query['source'] as string | undefined;
1401
+ const type = req.query['type'] as string;
1402
+ if (!type) {
1403
+ res.status(400).json({ error: 'Missing type.' });
1404
+ return;
1405
+ }
1406
+
1407
+ if (source === 'local') {
1408
+ try {
1409
+ let categories: string[];
1410
+ try {
1411
+ const catalog = await loadLocalAssetCatalog();
1412
+ categories = [
1413
+ ...new Set(
1414
+ catalog.assets
1415
+ .filter((asset) => localAssetMatchesType(asset, type))
1416
+ .flatMap((asset) => asset.categories),
1417
+ ),
1418
+ ].sort();
1419
+ } catch (error) {
1420
+ if (!(error instanceof LocalAssetCatalogUnavailableError)) throw error;
1421
+ const manifest = await loadCloudAssetManifest();
1422
+ if (!manifest) throw new Error(cloudCatalogUnavailableMessage());
1423
+ categories = [
1424
+ ...new Set(
1425
+ manifest.assets
1426
+ .filter((asset) => cloudAssetMatchesType(asset, type))
1427
+ .flatMap((asset) => asset.categories),
1428
+ ),
1429
+ ].sort();
1430
+ }
1431
+ res.json({ categories });
1432
+ } catch (error) {
1433
+ res.status(502).json({ error: `Asset catalog unavailable: ${error}` });
1434
+ }
1435
+ return;
1436
+ }
1437
+
1438
+ const cacheKey = `ph-cats:${type}`;
1439
+ const cached = getCached<string[]>(cacheKey);
1440
+ if (cached) {
1441
+ res.json({ categories: cached });
1442
+ return;
1443
+ }
1444
+
1445
+ try {
1446
+ const response = await fetch(`https://api.polyhaven.com/categories/${type}`, {
1447
+ headers: PH_HEADERS,
1448
+ });
1449
+ if (!response.ok) throw new Error(`${response.status}`);
1450
+ const data = (await response.json()) as Record<string, number>;
1451
+ const categories = Object.keys(data).sort();
1452
+ setCache(cacheKey, categories);
1453
+ res.json({ categories });
1454
+ } catch (err) {
1455
+ // A LOCAL host that carries no asset catalog is not an upstream fault —
1456
+ // saying `Upstream API error: ENOENT` for a file that was never on this
1457
+ // disk sends the reader to the network for a realm problem.
1458
+ if (err instanceof LocalAssetCatalogUnavailableError) {
1459
+ res.status(501).json({ error: err.message });
1460
+ return;
1461
+ }
1462
+ res.status(502).json({ error: `Upstream API error: ${err}` });
1463
+ }
1464
+ });
1465
+
1466
+ router.get('/thumbnails/:id.webp', async (req, res) => {
1467
+ const id = req.params['id'];
1468
+ if (!id || !/^[a-f0-9]{24}$/.test(id)) {
1469
+ res.status(400).json({ error: 'Invalid local asset id.' });
1470
+ return;
1471
+ }
1472
+ const asset = await findLocalAsset(id).catch(() => null);
1473
+ if (asset) {
1474
+ try {
1475
+ const thumbnailPath = requireLocalAssetThumbnail(getLocalAssetLibraryRoot(), asset);
1476
+ const bytes = await readFile(thumbnailPath);
1477
+ res.set('Cache-Control', 'public, max-age=86400').type(extname(thumbnailPath)).send(bytes);
1478
+ return;
1479
+ } catch {
1480
+ // This checkout can carry the catalog index without the SSD thumbnail
1481
+ // bytes. The hosted variant remains the canonical portable fallback.
1482
+ }
1483
+ }
1484
+ const delivered = await pipeCloudThumbnail(asset?.id ?? id, res);
1485
+ if (!delivered && !res.headersSent) sendLocalPlaceholder(res);
1486
+ });
1487
+
1488
+ router.get('/local-placeholder.svg', (_req, res) => {
1489
+ sendLocalPlaceholder(res);
1490
+ });
1491
+
1492
+ // ---- List already-downloaded assets ----
1493
+ router.get('/downloaded', async (_req, res) => {
1494
+ const publicRoot = getPublicRoot();
1495
+ const libraryDir = join(publicRoot, 'asset-library');
1496
+ const downloaded: { source: string; id: string; path: string }[] = [];
1497
+ const centralIds = await centralCatalogIds(dirname(publicRoot));
1498
+
1499
+ try {
1500
+ const sources = await readdir(libraryDir).catch(() => [] as string[]);
1501
+ for (const source of sources) {
1502
+ const sourceDir = join(libraryDir, source);
1503
+ const sourceStat = await stat(sourceDir).catch(() => null);
1504
+ if (!sourceStat?.isDirectory()) continue;
1505
+
1506
+ const slugs = await readdir(sourceDir).catch(() => [] as string[]);
1507
+ for (const slug of slugs) {
1508
+ const slugDir = join(libraryDir, source, slug);
1509
+ const slugStat = await stat(slugDir).catch(() => null);
1510
+ if (!slugStat?.isDirectory()) continue;
1511
+
1512
+ const files = await readdir(slugDir).catch(() => [] as string[]);
1513
+ const id = centralIds.get(`${source}/${slug}`) ?? slug;
1514
+ // Find the main asset file
1515
+ const assetFiles = files.filter((file) => file !== '.vgai-thumbnail.webp');
1516
+ const mainFile =
1517
+ assetFiles.find((file) => /\.(gltf|glb|hdr|exr)$/i.test(file)) ?? assetFiles[0];
1518
+ if (mainFile) {
1519
+ downloaded.push({
1520
+ source,
1521
+ id,
1522
+ path: `asset-library/${source}/${slug}/${mainFile}`,
1523
+ });
1524
+ }
1525
+ }
1526
+ }
1527
+ } catch {
1528
+ // Directory might not exist yet
1529
+ }
1530
+
1531
+ res.json({ downloaded });
1532
+ });
1533
+
1534
+ return router;
1535
+ }
1536
+
1537
+ export async function downloadRemoteFile(
1538
+ url: string,
1539
+ destination: string,
1540
+ expectedSha256: string,
1541
+ expectedSizeBytes: number,
1542
+ onProgress?: (loaded: number, total: number) => void,
1543
+ signal?: AbortSignal,
1544
+ ): Promise<void> {
1545
+ await mkdir(dirname(destination), { recursive: true });
1546
+ const response = await fetch(url, {
1547
+ signal: signal
1548
+ ? AbortSignal.any([signal, AbortSignal.timeout(120_000)])
1549
+ : AbortSignal.timeout(120_000),
1550
+ });
1551
+ if (!response.ok || !response.body) {
1552
+ throw new Error(`Download failed: ${response.status} for ${url}`);
1553
+ }
1554
+ const hash = createHash('sha256');
1555
+ let sizeBytes = 0;
1556
+ const verifier = new Transform({
1557
+ transform(chunk: Buffer, _encoding, callback) {
1558
+ try {
1559
+ signal?.throwIfAborted();
1560
+ hash.update(chunk);
1561
+ sizeBytes += chunk.length;
1562
+ onProgress?.(sizeBytes, expectedSizeBytes);
1563
+ callback(null, chunk);
1564
+ } catch (error) {
1565
+ callback(error as Error);
1566
+ }
1567
+ },
1568
+ });
1569
+ const nodeStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
1570
+ try {
1571
+ await pipeline(nodeStream, verifier, createWriteStream(destination));
1572
+ const actualSha256 = hash.digest('hex');
1573
+ if (actualSha256 !== expectedSha256 || sizeBytes !== expectedSizeBytes) {
1574
+ throw new Error(
1575
+ `Cloud asset integrity mismatch: expected ${expectedSha256}/${expectedSizeBytes}, got ${actualSha256}/${sizeBytes}`,
1576
+ );
1577
+ }
1578
+ } catch (error) {
1579
+ await unlink(destination).catch(() => undefined);
1580
+ throw error;
1581
+ }
1582
+ }
1583
+
1584
+ async function copyLocalAssetThumbnail(
1585
+ asset: NonNullable<Awaited<ReturnType<typeof findLocalAsset>>>,
1586
+ destination: string,
1587
+ ): Promise<void> {
1588
+ try {
1589
+ const source = requireLocalAssetThumbnail(getLocalAssetLibraryRoot(), asset);
1590
+ if (extname(source).toLowerCase() !== '.webp') return;
1591
+ await copyFile(source, join(destination, '.vgai-thumbnail.webp'));
1592
+ } catch {
1593
+ // A thumbnail is presentation cache, never a reason to fail an asset download.
1594
+ }
1595
+ }