@volter/editor-game 0.5.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Asset Budget analysis core (W4a M1, roadmap F10 / eco B1 / critique P5).
3
+ *
4
+ * Headless, pure-input model: given a project file snapshot (paths + sizes +
5
+ * byte/text readers — the storage seam's vocabulary, mirroring
6
+ * `asset-workflow/project-asset-health.ts`), produce the per-asset budget:
7
+ *
8
+ * - size + content hash for every asset row (duplicate detection)
9
+ * - gltf-transform `inspect()` stats for GLB/GLTF models — triangle/vertex
10
+ * counts, texture dims/formats, geometry + texture VRAM estimates
11
+ * - header-parsed dimensions + VRAM estimate for standalone images
12
+ * - the project REFERENCE GRAPH: which manifest entries, code files, or
13
+ * sibling assets mention each asset ("what uses this", critique P5),
14
+ * the unused list derived from it, and duplicate groups by sha256
15
+ * - provenance stamps projected from `.vgai/provenance.json`
16
+ *
17
+ * Anti-shim: anything unmeasurable is reported as absent/unknown with a
18
+ * reason (`inspectError`, `gpuBytes: null`, `unknownTextureCount`) — never a
19
+ * fabricated number. VRAM figures are labeled estimates: uncompressed RGBA
20
+ * upload with a full mip chain for textures (inspect()'s own convention) plus
21
+ * raw attribute/index bytes for geometry.
22
+ */
23
+
24
+ import { sha256Hex } from '@volter/editor-core/bytes-codec';
25
+ import type { StorageBackend } from '@volter/editor-core/storage/types';
26
+ import type { Document } from '@gltf-transform/core';
27
+ import { inspect } from '@gltf-transform/functions';
28
+ import { getGltfIO } from './gltf-io';
29
+ import { estimateImageGpuBytes, parseImageDims } from './image-dims';
30
+
31
+ // --- Categories -------------------------------------------------------------
32
+
33
+ /**
34
+ * What a budget row IS. `classifyAssetCategory` below is the only producer, so
35
+ * every member here is a branch of it — a category it cannot return is a
36
+ * column header, a rollup row and a per-entry label nobody can ever see.
37
+ */
38
+ export type AssetBudgetCategory = 'model' | 'image' | 'audio' | 'data' | 'other';
39
+
40
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g|webp|gif|svg|ktx2|basis|bmp|avif)$/i;
41
+ const AUDIO_EXTENSIONS = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/i;
42
+ const MODEL_EXTENSIONS = /\.(glb|gltf)$/i;
43
+
44
+ export function classifyAssetCategory(path: string): AssetBudgetCategory {
45
+ const clean = path.toLowerCase();
46
+ if (MODEL_EXTENSIONS.test(clean)) return 'model';
47
+ if (IMAGE_EXTENSIONS.test(clean)) return 'image';
48
+ if (AUDIO_EXTENSIONS.test(clean)) return 'audio';
49
+ if (clean.endsWith('.json') || clean.endsWith('.bin')) return 'data';
50
+ return 'other';
51
+ }
52
+
53
+ // --- Report types -----------------------------------------------------------
54
+
55
+ /**
56
+ * Who holds a reference to an asset. One member per REAL producer below:
57
+ * `manifest` (`scanJsonOwnerReferences`), `code` (`scanCodeOwnerReferences`),
58
+ * `asset` (`scanGltfDependencyReferences`). Nothing else in a project owns an
59
+ * asset reference today — do not add a member ahead of the scan that emits it,
60
+ * because the detail pane prints this string verbatim and an unreachable
61
+ * member is a label nobody can ever see.
62
+ */
63
+ export type AssetReferenceKind = 'manifest' | 'code' | 'asset';
64
+
65
+ export interface AssetReference {
66
+ /** Project-relative path of the document/file holding the reference. */
67
+ readonly ownerPath: string;
68
+ readonly kind: AssetReferenceKind;
69
+ /** JSON path inside the owner document (JSON owners only). */
70
+ readonly jsonPath?: string;
71
+ }
72
+
73
+ export interface AssetTextureStat {
74
+ readonly name: string;
75
+ readonly mimeType: string;
76
+ readonly resolution: string;
77
+ readonly slots: readonly string[];
78
+ readonly bytes: number;
79
+ /** `null` when inspect() cannot estimate (e.g. KTX2 without transcoding). */
80
+ readonly gpuBytes: number | null;
81
+ }
82
+
83
+ export interface AssetModelStats {
84
+ readonly meshCount: number;
85
+ readonly materialCount: number;
86
+ readonly textureCount: number;
87
+ readonly animationCount: number;
88
+ /** Total GL primitives (triangles for triangle meshes). */
89
+ readonly glPrimitives: number;
90
+ readonly vertices: number;
91
+ /** Raw attribute+index bytes (upload estimate for geometry). */
92
+ readonly geometryBytes: number;
93
+ /** Sum of known texture gpuSize estimates. */
94
+ readonly textureGpuBytes: number;
95
+ /** Textures whose GPU size could not be estimated (lower-bound marker). */
96
+ readonly unknownTextureCount: number;
97
+ /** geometryBytes + textureGpuBytes — a LOWER BOUND when
98
+ * unknownTextureCount > 0. */
99
+ readonly vramBytes: number;
100
+ readonly textures: readonly AssetTextureStat[];
101
+ /** Named mesh-bearing node names (LOD-generation candidates). */
102
+ readonly meshNames: readonly string[];
103
+ }
104
+
105
+ export interface AssetImageStats {
106
+ readonly width: number;
107
+ readonly height: number;
108
+ /** Uncompressed RGBA + mips upload estimate. */
109
+ readonly gpuBytes: number;
110
+ }
111
+
112
+ export interface AssetProvenanceStamp {
113
+ readonly operationId: string;
114
+ readonly operationName: string;
115
+ readonly createdAt: string;
116
+ }
117
+
118
+ export interface AssetBudgetEntry {
119
+ /** Storage path (project-root-relative, e.g. `public/models/crate.glb`). */
120
+ readonly path: string;
121
+ /** Runtime serving path for `public/` assets (`/models/crate.glb`), else null. */
122
+ readonly servingPath: string | null;
123
+ readonly category: AssetBudgetCategory;
124
+ readonly bytes: number;
125
+ readonly sha256: string;
126
+ readonly model?: AssetModelStats;
127
+ readonly image?: AssetImageStats;
128
+ /** Honest inspection failure (unreadable/undecodable model) — never hidden. */
129
+ readonly inspectError?: string;
130
+ readonly references: readonly AssetReference[];
131
+ readonly provenance?: AssetProvenanceStamp;
132
+ /**
133
+ * Engine-vendored runtime infrastructure (Three.js `examples/jsm` served at
134
+ * `/jsm/…` — the DRACO decoder wasm/js, loaders/decoders). Loaded by the
135
+ * ENGINE at runtime (`loader.ts` wires `dracoLoader.setDecoderPath(
136
+ * '/jsm/libs/draco/gltf/')`), never referenced by project content, so the
137
+ * reference scan legitimately finds nothing for it in EVERY mode. Marked so
138
+ * it is kept out of the "unused"/deletable set and labeled in the detail
139
+ * pane — it is required infrastructure, not a project asset.
140
+ */
141
+ readonly engineVendored?: boolean;
142
+ }
143
+
144
+ export interface AssetBudgetDuplicateGroup {
145
+ readonly sha256: string;
146
+ readonly bytes: number;
147
+ readonly paths: readonly string[];
148
+ /** bytes × (copies − 1). */
149
+ readonly wastedBytes: number;
150
+ }
151
+
152
+ export interface AssetBudgetCategoryTotal {
153
+ readonly category: AssetBudgetCategory;
154
+ readonly count: number;
155
+ readonly bytes: number;
156
+ readonly vramBytes: number;
157
+ }
158
+
159
+ export interface AssetBudgetReport {
160
+ readonly entries: readonly AssetBudgetEntry[];
161
+ readonly totalBytes: number;
162
+ readonly totalVramBytes: number;
163
+ readonly categories: readonly AssetBudgetCategoryTotal[];
164
+ /** Paths of `public/` assets nothing references. */
165
+ readonly unused: readonly string[];
166
+ readonly duplicates: readonly AssetBudgetDuplicateGroup[];
167
+ /**
168
+ * Whether project SOURCE CODE (`src/**`) was reachable through the snapshot
169
+ * and therefore scanned for asset references. FALSE in server mode: the live
170
+ * `HttpStorage` seam is hard-rooted at `public/`, so `src/**` is unreachable
171
+ * and a code-only-referenced asset (input maps, code-loaded models) has NO
172
+ * discoverable reference — it surfaces as "unused" even though it is used.
173
+ * The UI must caveat the Unused/zero-reference surfaces accordingly: with
174
+ * this false, "unused" means "unreferenced by authored content", not "safe to
175
+ * delete". A REAL structural signal (the seam knows its root), never a guess.
176
+ */
177
+ readonly codeReferencesScanned: boolean;
178
+ }
179
+
180
+ // --- Source (the storage seam's vocabulary, injectable for tests) -----------
181
+
182
+ export interface AssetBudgetFile {
183
+ readonly path: string;
184
+ readonly size: number;
185
+ }
186
+
187
+ export interface AssetBudgetSource {
188
+ readonly files: readonly AssetBudgetFile[];
189
+ /**
190
+ * Reference-owner documents living OUTSIDE the walked snapshot, scanned for
191
+ * references but never budget rows. Server mode uses this for the project
192
+ * manifest: `HttpStorage` is hard-rooted at `public/` (its own doc comment;
193
+ * the T3.3 "two-`.vgai`-dirs" finding), so `vgai.project.json` is unreachable
194
+ * through the seam and is fetched over the dev server instead — without it,
195
+ * every manifest-referenced asset (input maps, scenes) would be reported
196
+ * unused, a fabricated finding.
197
+ */
198
+ readonly ownerFiles?: readonly AssetBudgetFile[];
199
+ /**
200
+ * True when the snapshot root IS the served assets root (server-mode
201
+ * `HttpStorage`): paths carry no `public/` prefix and every walked file is
202
+ * a served asset — so every file is a budget row and `/`-prefixed serving
203
+ * paths derive directly from entry paths.
204
+ */
205
+ readonly servingRooted?: boolean;
206
+ /**
207
+ * Whether project source code (`src/**`) is reachable through this snapshot.
208
+ * FALSE for the server-mode `HttpStorage` seam (hard-rooted at `public/`, so
209
+ * `src/**` cannot be walked); TRUE for project-rooted snapshots whose walk
210
+ * includes `src/`. Absent is treated as reachable. Projected onto the report
211
+ * as `codeReferencesScanned` so the UI can caveat "unused" honestly.
212
+ */
213
+ readonly codeReferencesScannable?: boolean;
214
+ readBytes(path: string): Promise<Uint8Array>;
215
+ readText(path: string): Promise<string>;
216
+ /** Parsed `.vgai/provenance.json` (or null/undefined when absent). */
217
+ readonly provenance?: unknown;
218
+ }
219
+
220
+ // --- Provenance projection (same structural read as project-provenance.ts) --
221
+
222
+ function asRecord(value: unknown): Record<string, unknown> | null {
223
+ return value && typeof value === 'object' && !Array.isArray(value)
224
+ ? (value as Record<string, unknown>)
225
+ : null;
226
+ }
227
+
228
+ function operationOutputPaths(rawOperation: unknown): {
229
+ stamp: Omit<AssetProvenanceStamp, 'operationId'>;
230
+ paths: string[];
231
+ } | null {
232
+ const operation = asRecord(rawOperation);
233
+ const identity = asRecord(operation?.['operation']);
234
+ const createdAt = operation?.['createdAt'];
235
+ const operationName = identity?.['name'];
236
+ const outputs = operation?.['outputs'];
237
+ if (typeof createdAt !== 'string' || typeof operationName !== 'string') return null;
238
+ if (!Array.isArray(outputs)) return null;
239
+ const paths = outputs.flatMap((rawOutput) => {
240
+ const path = asRecord(rawOutput)?.['path'];
241
+ return typeof path === 'string' ? [normalizeRefValue(path)] : [];
242
+ });
243
+ return { stamp: { operationName, createdAt }, paths };
244
+ }
245
+
246
+ /** Index newest provenance operation per output path (storage path). */
247
+ export function indexProvenanceOutputs(provenance: unknown): Map<string, AssetProvenanceStamp> {
248
+ const byPath = new Map<string, AssetProvenanceStamp>();
249
+ const operations = asRecord(asRecord(provenance)?.['operations']);
250
+ if (!operations) return byPath;
251
+ for (const [operationId, rawOperation] of Object.entries(operations)) {
252
+ const parsed = operationOutputPaths(rawOperation);
253
+ if (!parsed) continue;
254
+ for (const path of parsed.paths) {
255
+ const prior = byPath.get(path);
256
+ if (!prior || parsed.stamp.createdAt.localeCompare(prior.createdAt) > 0) {
257
+ byPath.set(path, { operationId, ...parsed.stamp });
258
+ }
259
+ }
260
+ }
261
+ return byPath;
262
+ }
263
+
264
+ // --- Engine-vendored runtime infrastructure ---------------------------------
265
+
266
+ /**
267
+ * Three.js `examples/jsm` runtime infrastructure vendored into every project's
268
+ * `public/jsm/` and served at `/jsm/…` — the DRACO decoder (`draco_decoder.wasm`
269
+ * / `.js` / `draco_wasm_wrapper.js`), loaders, decoders. It is loaded by the
270
+ * ENGINE at runtime (`loader.ts`: `dracoLoader.setDecoderPath('/jsm/libs/draco/
271
+ * gltf/')`), never referenced by project code or the manifest — so
272
+ * the reference scan finds nothing for it in EVERY storage mode, not just the
273
+ * server-mode code blind spot. Marked (never dropped from the report) so its
274
+ * real footprint stays visible, and kept out of the deletable "unused" set.
275
+ * Matches both the serving-rooted (`jsm/…`) and project-rooted (`public/jsm/…`)
276
+ * spellings.
277
+ */
278
+ export function isEngineVendoredRuntimePath(path: string): boolean {
279
+ return /(?:^|\/)jsm\//i.test(path);
280
+ }
281
+
282
+ // --- Reference scanning ------------------------------------------------------
283
+
284
+ const CODE_OWNER_PATTERN = /\.(?:tsx?|jsx?|mjs|cjs)$/i;
285
+ const MANIFEST_NAME = 'vgai.project.json';
286
+
287
+ /**
288
+ * The manifest is the ONLY JSON document that owns asset references. An
289
+ * `.inputmap.json` was scanned as one too, and could not be. `collectStringPaths`
290
+ * below yields string VALUES only (action names are record KEYS — never
291
+ * candidates), and every string value `InputMapFileSchema` admits is a binding
292
+ * `type` literal, a `KeyboardEvent.code`, a `direction`/`valueType` enum, or an
293
+ * injected/touch `sourceId`; button/axis indexes are numbers. None of them is a
294
+ * path, so the only value that could ever have resolved to an asset was a
295
+ * path-SHAPED `sourceId` — a fabricated reference, which is exactly what hides a
296
+ * genuinely unused asset from the Unused list. Do not re-add a document to this
297
+ * scan without a field that actually holds an asset path.
298
+ */
299
+ function isJsonReferenceOwner(path: string): boolean {
300
+ return (path.split('/').pop() ?? path) === MANIFEST_NAME;
301
+ }
302
+
303
+ function collectStringPaths(value: unknown, path = '$'): { path: string; value: string }[] {
304
+ if (typeof value === 'string') return [{ path, value }];
305
+ if (Array.isArray(value)) {
306
+ return value.flatMap((entry, index) => collectStringPaths(entry, `${path}[${index}]`));
307
+ }
308
+ const record = asRecord(value);
309
+ if (!record) return [];
310
+ return Object.entries(record).flatMap(([key, entry]) =>
311
+ collectStringPaths(entry, `${path}.${key}`),
312
+ );
313
+ }
314
+
315
+ /** Strip query/hash and leading `/` / `./` — the serving-vs-storage seam. */
316
+ function normalizeRefValue(value: string): string {
317
+ return value.split(/[?#]/, 1)[0]!.replace(/^\.\//, '').replace(/^\/+/, '');
318
+ }
319
+
320
+ /** Reference key (storage OR serving spelling) → entry path, + accumulator. */
321
+ class ReferenceIndex {
322
+ readonly keyToPath = new Map<string, string>();
323
+ private readonly referencesByPath = new Map<string, AssetReference[]>();
324
+
325
+ constructor(entryFiles: readonly AssetBudgetFile[]) {
326
+ for (const file of entryFiles) {
327
+ this.keyToPath.set(file.path, file.path);
328
+ if (file.path.startsWith('public/')) {
329
+ this.keyToPath.set(file.path.slice('public/'.length), file.path);
330
+ }
331
+ }
332
+ }
333
+
334
+ resolve(rawValue: string): string | undefined {
335
+ return this.keyToPath.get(normalizeRefValue(rawValue));
336
+ }
337
+
338
+ add(entryPath: string, reference: AssetReference): void {
339
+ const list = this.referencesByPath.get(entryPath) ?? [];
340
+ // Dedupe identical owner/jsonPath pairs (a scene often repeats a src).
341
+ if (
342
+ !list.some(
343
+ (existing) =>
344
+ existing.ownerPath === reference.ownerPath && existing.jsonPath === reference.jsonPath,
345
+ )
346
+ ) {
347
+ list.push(reference);
348
+ this.referencesByPath.set(entryPath, list);
349
+ }
350
+ }
351
+
352
+ referencesOf(entryPath: string): AssetReference[] {
353
+ return this.referencesByPath.get(entryPath) ?? [];
354
+ }
355
+ }
356
+
357
+ async function scanOneJsonOwner(
358
+ source: AssetBudgetSource,
359
+ owner: AssetBudgetFile,
360
+ index: ReferenceIndex,
361
+ ): Promise<void> {
362
+ if (!isJsonReferenceOwner(owner.path)) return;
363
+ let parsed: unknown;
364
+ try {
365
+ parsed = JSON.parse(await source.readText(owner.path));
366
+ } catch {
367
+ return; // invalid JSON is asset-health's finding, not the budget's
368
+ }
369
+ for (const candidate of collectStringPaths(parsed)) {
370
+ const target = index.resolve(candidate.value);
371
+ if (target && target !== owner.path) {
372
+ index.add(target, { ownerPath: owner.path, kind: 'manifest', jsonPath: candidate.path });
373
+ }
374
+ }
375
+ }
376
+
377
+ /** The manifest: JSON string values resolving to a known asset path are
378
+ * references. Owners span the walked snapshot AND any out-of-snapshot owner
379
+ * documents (server-mode manifest). */
380
+ async function scanJsonOwnerReferences(
381
+ source: AssetBudgetSource,
382
+ index: ReferenceIndex,
383
+ ): Promise<void> {
384
+ for (const owner of [...source.files, ...(source.ownerFiles ?? [])]) {
385
+ await scanOneJsonOwner(source, owner, index);
386
+ }
387
+ }
388
+
389
+ /** Any script outside public/ mentioning an asset path references it. */
390
+ async function scanCodeOwnerReferences(
391
+ source: AssetBudgetSource,
392
+ index: ReferenceIndex,
393
+ ): Promise<void> {
394
+ const owners = source.files.filter(
395
+ (file) => !file.path.startsWith('public/') && CODE_OWNER_PATTERN.test(file.path),
396
+ );
397
+ for (const owner of owners) {
398
+ let text: string;
399
+ try {
400
+ text = await source.readText(owner.path);
401
+ } catch {
402
+ continue;
403
+ }
404
+ for (const [key, entryPath] of index.keyToPath) {
405
+ // Length guard avoids substring noise; asset keys always carry a
406
+ // directory or extension, so >4 chars is safe in practice.
407
+ if (key.length > 4 && text.includes(key)) {
408
+ index.add(entryPath, { ownerPath: owner.path, kind: 'code' });
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ interface GltfExternalRefs {
415
+ buffers?: { uri?: string }[];
416
+ images?: { uri?: string }[];
417
+ }
418
+
419
+ function gltfExternalUris(json: GltfExternalRefs, gltfPath: string): string[] {
420
+ const base = gltfPath.includes('/') ? gltfPath.slice(0, gltfPath.lastIndexOf('/') + 1) : '';
421
+ return [...(json.buffers ?? []), ...(json.images ?? [])].flatMap(({ uri }) =>
422
+ uri && !uri.startsWith('data:') ? [`${base}${decodeURIComponent(uri)}`] : [],
423
+ );
424
+ }
425
+
426
+ /** A .gltf's external buffers/images are referenced BY that .gltf. */
427
+ async function scanGltfDependencyReferences(
428
+ source: AssetBudgetSource,
429
+ entryFiles: readonly AssetBudgetFile[],
430
+ index: ReferenceIndex,
431
+ ): Promise<void> {
432
+ for (const file of entryFiles) {
433
+ if (!/\.gltf$/i.test(file.path)) continue;
434
+ try {
435
+ const json = JSON.parse(await source.readText(file.path)) as GltfExternalRefs;
436
+ for (const uri of gltfExternalUris(json, file.path)) {
437
+ const target = index.resolve(uri);
438
+ if (target) index.add(target, { ownerPath: file.path, kind: 'asset' });
439
+ }
440
+ } catch {
441
+ /* undecodable .gltf JSON surfaces as inspectError on its own row */
442
+ }
443
+ }
444
+ }
445
+
446
+ // --- Project walk over the storage seam --------------------------------------
447
+
448
+ const IGNORED_DIRS = new Set([
449
+ 'node_modules',
450
+ '.git',
451
+ 'dist',
452
+ 'build',
453
+ '.vgai',
454
+ '.ci-scaffold',
455
+ 'coverage',
456
+ 'test-results',
457
+ 'playwright-report',
458
+ ]);
459
+
460
+ /** Snapshot the project through the storage seam (recursive list). */
461
+ export async function collectProjectBudgetSource(
462
+ backend: StorageBackend,
463
+ ): Promise<AssetBudgetSource> {
464
+ const files: AssetBudgetFile[] = [];
465
+ const walk = async (dir: string): Promise<void> => {
466
+ const entries = await backend.list(dir);
467
+ for (const entry of entries) {
468
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
469
+ if (entry.type === 'dir') {
470
+ if (!IGNORED_DIRS.has(entry.name)) await walk(path);
471
+ } else {
472
+ files.push({ path, size: entry.size ?? 0 });
473
+ }
474
+ }
475
+ };
476
+ await walk('');
477
+
478
+ // Server mode: `HttpStorage` is hard-rooted at the project's `public/`
479
+ // folder (its doc comment; the T3.3 "two-`.vgai`-dirs" finding), so BOTH
480
+ // project-root documents the budget needs live outside the seam. Use the
481
+ // dev server's own idioms for each: the raw manifest served at
482
+ // `/vgai.project.json` (`manifest-project.ts`) and the `.vgai/`-scoped
483
+ // `/__editor/vgai-file` route (`project-provenance.ts`). Anything
484
+ // unavailable stays honestly absent — no fabricated owners or stamps.
485
+ if (backend.id === 'http') {
486
+ const ownerFiles: AssetBudgetFile[] = [];
487
+ const virtualTexts = new Map<string, string>();
488
+ try {
489
+ const manifestRes = await fetch('/vgai.project.json');
490
+ if (manifestRes.ok) {
491
+ const text = await manifestRes.text();
492
+ virtualTexts.set('vgai.project.json', text);
493
+ ownerFiles.push({ path: 'vgai.project.json', size: text.length });
494
+ }
495
+ } catch {
496
+ // No reachable manifest → no manifest-owned references.
497
+ }
498
+ let provenance: unknown = null;
499
+ try {
500
+ // NOT read through `editor-server-response.ts`: this route's GET half
501
+ // answers `text/plain` raw file bytes, which that JSON-only reader would
502
+ // reject as a non-answer. See `optimize-apply.ts`'s `readLedgerText`.
503
+ const provenanceRes = await fetch(
504
+ `/__editor/vgai-file?${new URLSearchParams({ path: '.vgai/provenance.json' })}`,
505
+ );
506
+ if (provenanceRes.ok) provenance = JSON.parse(await provenanceRes.text());
507
+ } catch {
508
+ provenance = null; // unreadable provenance = no stamps, never a crash
509
+ }
510
+ return {
511
+ files,
512
+ ownerFiles,
513
+ servingRooted: true,
514
+ // HttpStorage is rooted at `public/`; the project's `src/**` is outside
515
+ // the seam and cannot be walked, so code references are NOT scanned here.
516
+ codeReferencesScannable: false,
517
+ provenance,
518
+ readBytes: (path) => backend.readBytes(path),
519
+ readText: (path) => {
520
+ const virtual = virtualTexts.get(normalizeRefValue(path));
521
+ return virtual !== undefined ? Promise.resolve(virtual) : backend.read(path);
522
+ },
523
+ };
524
+ }
525
+
526
+ let provenance: unknown = null;
527
+ try {
528
+ if (await backend.exists('.vgai/provenance.json')) {
529
+ provenance = JSON.parse(await backend.read('.vgai/provenance.json'));
530
+ }
531
+ } catch {
532
+ provenance = null; // unreadable provenance = no stamps, never a crash
533
+ }
534
+ return {
535
+ files,
536
+ // Project-rooted walk: `src/**` is in the snapshot, so code references are
537
+ // scanned (see `scanCodeOwnerReferences`).
538
+ codeReferencesScannable: true,
539
+ provenance,
540
+ readBytes: (path) => backend.readBytes(path),
541
+ readText: (path) => backend.read(path),
542
+ };
543
+ }
544
+
545
+ // --- glTF inspection ---------------------------------------------------------
546
+
547
+ async function readModelDocument(
548
+ path: string,
549
+ bytes: Uint8Array,
550
+ source: AssetBudgetSource,
551
+ ): Promise<Document> {
552
+ const { io } = await getGltfIO();
553
+ if (/\.glb$/i.test(path)) return io.readBinary(new Uint8Array(bytes));
554
+ // .gltf: resolve external buffers/images through the same snapshot. Copies
555
+ // are fresh ArrayBuffer-backed views (gltf-transform 4.x's resource type).
556
+ const json = JSON.parse(new TextDecoder().decode(bytes)) as GltfExternalRefs;
557
+ const resources: Record<string, Uint8Array<ArrayBuffer>> = {};
558
+ const base = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : '';
559
+ for (const { uri } of [...(json.buffers ?? []), ...(json.images ?? [])]) {
560
+ if (uri && !uri.startsWith('data:')) {
561
+ resources[uri] = new Uint8Array(await source.readBytes(`${base}${decodeURIComponent(uri)}`));
562
+ }
563
+ }
564
+ return io.readJSON({ json: json as never, resources });
565
+ }
566
+
567
+ async function inspectModel(
568
+ path: string,
569
+ bytes: Uint8Array,
570
+ source: AssetBudgetSource,
571
+ ): Promise<{ model?: AssetModelStats; inspectError?: string }> {
572
+ try {
573
+ const document = await readModelDocument(path, bytes, source);
574
+ const report = inspect(document);
575
+ const meshes = report.meshes.properties;
576
+ const textures = report.textures.properties;
577
+ const geometryBytes = meshes.reduce((sum, mesh) => sum + mesh.size, 0);
578
+ const textureGpuBytes = textures.reduce((sum, texture) => sum + (texture.gpuSize ?? 0), 0);
579
+ const meshNames = new Set<string>();
580
+ for (const node of document.getRoot().listNodes()) {
581
+ if (node.getName() && node.getMesh() && !node.getSkin()) meshNames.add(node.getName());
582
+ }
583
+ return {
584
+ model: {
585
+ meshCount: meshes.length,
586
+ materialCount: report.materials.properties.length,
587
+ textureCount: textures.length,
588
+ animationCount: report.animations.properties.length,
589
+ glPrimitives: meshes.reduce((sum, mesh) => sum + mesh.glPrimitives, 0),
590
+ vertices: meshes.reduce((sum, mesh) => sum + mesh.vertices, 0),
591
+ geometryBytes,
592
+ textureGpuBytes,
593
+ unknownTextureCount: textures.filter((texture) => texture.gpuSize === null).length,
594
+ vramBytes: geometryBytes + textureGpuBytes,
595
+ textures: textures.map((texture) => ({
596
+ name: texture.name || texture.uri,
597
+ mimeType: texture.mimeType,
598
+ resolution: texture.resolution,
599
+ slots: texture.slots,
600
+ bytes: texture.size,
601
+ gpuBytes: texture.gpuSize,
602
+ })),
603
+ meshNames: [...meshNames],
604
+ },
605
+ };
606
+ } catch (error) {
607
+ return { inspectError: error instanceof Error ? error.message : String(error) };
608
+ }
609
+ }
610
+
611
+ // --- The analysis ------------------------------------------------------------
612
+
613
+ /**
614
+ * Is this file a budget ROW (vs merely a reference owner)?
615
+ *
616
+ * Path spellings differ per backend root: a project-rooted snapshot prefixes
617
+ * assets with `public/`, while the live backend serves assets at its root
618
+ * (`HttpStorage` is public/-rooted). A serving-rooted snapshot makes every walked
619
+ * file a row; elsewhere accept the `public/` spelling, authored document
620
+ * formats anywhere, and the unambiguous binary asset categories at bare
621
+ * serving-style paths — never project code/config.
622
+ */
623
+ function isBudgetEntry(path: string, servingRooted: boolean): boolean {
624
+ if (servingRooted) return true;
625
+ if (path.startsWith('public/')) return true;
626
+ if (/\.inputmap\.json$/i.test(path)) return true;
627
+ if (path.startsWith('src/') || path.startsWith('server/') || !path.includes('/')) return false;
628
+ const category = classifyAssetCategory(path);
629
+ return category === 'model' || category === 'image' || category === 'audio';
630
+ }
631
+
632
+ const UNUSED_ELIGIBLE = new Set<AssetBudgetCategory>(['model', 'image', 'audio', 'data', 'other']);
633
+
634
+ /** Categories that are unambiguously assets at BARE (un-prefixed) paths. */
635
+ const BARE_ASSET_CATEGORIES = new Set<AssetBudgetCategory>(['model', 'image', 'audio']);
636
+
637
+ function servingPathOf(path: string, servingRooted: boolean): string | null {
638
+ if (servingRooted) return `/${path}`;
639
+ return path.startsWith('public/') ? `/${path.slice('public/'.length)}` : null;
640
+ }
641
+
642
+ /**
643
+ * Provenance stamp for an entry, tolerant of the ledger's project-rooted
644
+ * spelling (`public/models/x.glb` — the pipeline idiom `project-provenance.ts`
645
+ * also forces) against a serving-rooted entry path (`models/x.glb`), and the
646
+ * inverse.
647
+ */
648
+ function provenanceForEntry(
649
+ provenanceByPath: Map<string, AssetProvenanceStamp>,
650
+ path: string,
651
+ ): AssetProvenanceStamp | undefined {
652
+ const normalized = normalizeRefValue(path);
653
+ const alternate = normalized.startsWith('public/')
654
+ ? normalized.slice('public/'.length)
655
+ : `public/${normalized}`;
656
+ return provenanceByPath.get(normalized) ?? provenanceByPath.get(alternate);
657
+ }
658
+
659
+ async function buildEntry(
660
+ file: AssetBudgetFile,
661
+ source: AssetBudgetSource,
662
+ index: ReferenceIndex,
663
+ provenanceByPath: Map<string, AssetProvenanceStamp>,
664
+ ): Promise<AssetBudgetEntry> {
665
+ const category = classifyAssetCategory(file.path);
666
+ const servingRooted = source.servingRooted === true;
667
+ const engineVendored = isEngineVendoredRuntimePath(file.path);
668
+ let bytes: Uint8Array;
669
+ try {
670
+ bytes = await source.readBytes(file.path);
671
+ } catch (error) {
672
+ return {
673
+ path: file.path,
674
+ servingPath: servingPathOf(file.path, servingRooted),
675
+ category,
676
+ bytes: file.size,
677
+ sha256: '',
678
+ inspectError: `unreadable: ${error instanceof Error ? error.message : String(error)}`,
679
+ references: index.referencesOf(file.path),
680
+ ...(engineVendored ? { engineVendored } : {}),
681
+ };
682
+ }
683
+ let model: AssetModelStats | undefined;
684
+ let image: AssetImageStats | undefined;
685
+ let inspectError: string | undefined;
686
+ if (category === 'model') {
687
+ ({ model, inspectError } = await inspectModel(file.path, bytes, source));
688
+ } else if (category === 'image') {
689
+ const dims = parseImageDims(bytes);
690
+ if (dims) image = { ...dims, gpuBytes: estimateImageGpuBytes(dims) };
691
+ }
692
+ const provenance = provenanceForEntry(provenanceByPath, file.path);
693
+ return {
694
+ path: file.path,
695
+ servingPath: servingPathOf(file.path, servingRooted),
696
+ category,
697
+ bytes: bytes.byteLength,
698
+ sha256: await sha256Hex(bytes),
699
+ ...(model ? { model } : {}),
700
+ ...(image ? { image } : {}),
701
+ ...(inspectError ? { inspectError } : {}),
702
+ references: index.referencesOf(file.path),
703
+ ...(provenance ? { provenance } : {}),
704
+ ...(engineVendored ? { engineVendored } : {}),
705
+ };
706
+ }
707
+
708
+ function entryVramBytes(entry: AssetBudgetEntry): number {
709
+ return entry.model?.vramBytes ?? entry.image?.gpuBytes ?? 0;
710
+ }
711
+
712
+ function rollupCategories(entries: readonly AssetBudgetEntry[]): AssetBudgetCategoryTotal[] {
713
+ const totals = new Map<AssetBudgetCategory, { count: number; bytes: number; vram: number }>();
714
+ for (const entry of entries) {
715
+ const total = totals.get(entry.category) ?? { count: 0, bytes: 0, vram: 0 };
716
+ total.count += 1;
717
+ total.bytes += entry.bytes;
718
+ total.vram += entryVramBytes(entry);
719
+ totals.set(entry.category, total);
720
+ }
721
+ return [...totals.entries()]
722
+ .map(([category, total]) => ({
723
+ category,
724
+ count: total.count,
725
+ bytes: total.bytes,
726
+ vramBytes: total.vram,
727
+ }))
728
+ .sort((left, right) => right.bytes - left.bytes);
729
+ }
730
+
731
+ function collectDuplicates(entries: readonly AssetBudgetEntry[]): AssetBudgetDuplicateGroup[] {
732
+ const byHash = new Map<string, AssetBudgetEntry[]>();
733
+ for (const entry of entries) {
734
+ if (!entry.sha256) continue;
735
+ const group = byHash.get(entry.sha256) ?? [];
736
+ group.push(entry);
737
+ byHash.set(entry.sha256, group);
738
+ }
739
+ return [...byHash.values()]
740
+ .filter((group) => group.length > 1)
741
+ .map((group) => ({
742
+ sha256: group[0]!.sha256,
743
+ bytes: group[0]!.bytes,
744
+ paths: group.map((entry) => entry.path).sort(),
745
+ wastedBytes: group[0]!.bytes * (group.length - 1),
746
+ }))
747
+ .sort((left, right) => right.wastedBytes - left.wastedBytes);
748
+ }
749
+
750
+ export async function analyzeAssetBudget(source: AssetBudgetSource): Promise<AssetBudgetReport> {
751
+ const servingRooted = source.servingRooted === true;
752
+ const entryFiles = source.files.filter((file) => isBudgetEntry(file.path, servingRooted));
753
+ const provenanceByPath = indexProvenanceOutputs(source.provenance);
754
+
755
+ // Reference passes run BEFORE entry construction so every row sees the
756
+ // complete graph regardless of file order.
757
+ const index = new ReferenceIndex(entryFiles);
758
+ await scanJsonOwnerReferences(source, index);
759
+ // Code references are only discoverable when `src/**` is in the snapshot
760
+ // (project-rooted). In server mode the seam is `public/`-rooted, so the scan
761
+ // is skipped and the report says so (`codeReferencesScanned`).
762
+ const codeReferencesScanned = source.codeReferencesScannable !== false;
763
+ if (codeReferencesScanned) await scanCodeOwnerReferences(source, index);
764
+ await scanGltfDependencyReferences(source, entryFiles, index);
765
+
766
+ const entries: AssetBudgetEntry[] = [];
767
+ for (const file of entryFiles) {
768
+ entries.push(await buildEntry(file, source, index, provenanceByPath));
769
+ }
770
+ entries.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
771
+
772
+ // Unused: unreferenced eligible entries. At bare (serving-style) paths in a
773
+ // NON-serving-rooted snapshot, only unambiguous asset categories qualify —
774
+ // a data/other file there may be project config, and claiming it unused
775
+ // would be a fabricated finding.
776
+ const unused = entries
777
+ .filter(
778
+ (entry) =>
779
+ // Engine-vendored runtime infra (`/jsm/…`) is loaded by engine code, not
780
+ // project content; it is never a deletable "unused" asset.
781
+ !entry.engineVendored &&
782
+ UNUSED_ELIGIBLE.has(entry.category) &&
783
+ entry.references.length === 0 &&
784
+ (servingRooted ||
785
+ entry.path.startsWith('public/') ||
786
+ BARE_ASSET_CATEGORIES.has(entry.category)),
787
+ )
788
+ .map((entry) => entry.path);
789
+
790
+ return {
791
+ entries,
792
+ totalBytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
793
+ totalVramBytes: entries.reduce((sum, entry) => sum + entryVramBytes(entry), 0),
794
+ categories: rollupCategories(entries),
795
+ unused,
796
+ duplicates: collectDuplicates(entries),
797
+ codeReferencesScanned,
798
+ };
799
+ }