@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.
- package/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- 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
|
+
}
|