@volter/editor-core 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,1641 @@
1
+ /**
2
+ * #146 — full-stack play screenshot: the game canvas(es) PLUS the DOM UI
3
+ * layers stacked over/beside them (React adapter roots,
4
+ * world layers), composited into one PNG. This is "what the game looks
5
+ * like" for agent eyes — a canvas-only capture of a game whose score/health
6
+ * lives in the HUD reads as a different (often emptier) game than the one
7
+ * the human is watching.
8
+ *
9
+ * How: each `<canvas>` child of the play container is drawn onto an
10
+ * offscreen 2D canvas at its on-screen rect, then the NON-canvas children are serialized into an
11
+ * SVG `<foreignObject>` and drawn on top. The foreignObject leg is faithful
12
+ * by construction FOR FIRST-PARTY DOM: the HUD rule is inline-styles-only
13
+ * (CLAUDE.md), and react-world layers are engine-mounted with inline
14
+ * positioning — so a detached serialization loses no styling. A FOREIGN
15
+ * game's DOM is the exception, and it is handled explicitly: its layout lives
16
+ * in a page stylesheet, which the host serves `@scope`d and this leg
17
+ * re-inlines into the clone (`buildOverlaySvg`) — without that the capture
18
+ * would report a styled screen as unstyled. The SVG rides a `data:` URL with
19
+ * no external references, which keeps the output canvas untainted (the same
20
+ * mechanism html-to-image libraries rely on).
21
+ *
22
+ * A canvas is only readable this late when its WebGL context was created with
23
+ * `preserveDrawingBuffer: true`. Every canvas the vgai RUNTIME mounts sets it
24
+ * (`@vgai/game-runtime/runtime/create-runtime`), so first-party play reads directly. A
25
+ * canvas an INGESTED game created is its own — racing-game's `<Canvas>` passes
26
+ * no `gl` prop, so fiber's `false` default applies and this read returns black.
27
+ * `CaptureOptions.canvasFrame` is the seam for that case: the caller hands back
28
+ * pixels copied inside the game's own render pass. This module deliberately
29
+ * knows nothing about how (see `ingest/ingest-frame-snapshot.ts`).
30
+ *
31
+ * Active EDITOR documents use the same compositor with
32
+ * `includeDocumentStyles`: their document container and accessible CSSOM ride
33
+ * the detached clone, so design-system chrome photographs as it appears. Game
34
+ * capture leaves that option off and therefore never imports editor styling.
35
+ */
36
+
37
+ import { GAME_CSS_SCOPE_ATTRIBUTE } from '@volter/editor-sdk/session/game-css-scope';
38
+ import { scopedGameStylesCssText } from './scoped-game-css';
39
+
40
+ export interface OverlaySvg {
41
+ svg: string;
42
+ /** How many non-canvas layers were serialized (0 ⇒ callers skip the leg). */
43
+ overlayCount: number;
44
+ }
45
+
46
+ const ROOT_SURFACE_SELECTOR = '[data-vgai-root-surface="true"]';
47
+
48
+ // A DOM root is also marked as a surface. Its descendant canvases are UI
49
+ // content (for example a 3D die), not additional host surfaces. Keep their
50
+ // pixels in the overlay so their own backgrounds and layout survive capture.
51
+ export function isRootCanvas(canvas: HTMLCanvasElement): boolean {
52
+ return (
53
+ canvas.matches(ROOT_SURFACE_SELECTOR) ||
54
+ canvas.closest('[data-vgai-canvas-scene="true"]') !== null
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Reusable data-URL snapshots of STATIC pixels across a sequence of frames.
60
+ *
61
+ * The overlay clone inlines every nested `<img>`'s pixels through a canvas
62
+ * `toDataURL` — a synchronous PNG encode. For a one-shot screenshot that cost
63
+ * is invisible; for the gameplay recorder it ran per `<img>` per frame, and one
64
+ * HUD image measured at 68% of the entire main thread during play (the game's
65
+ * own frame rounded to 0%, and relay commands queued seconds deep behind the
66
+ * encodes). An `<img>`'s pixels only change when its `src` does, so the
67
+ * recorder passes one of these per recording and the encode runs once per
68
+ * source, not once per frame. `null` results are cached too — a tainted image
69
+ * stays refused without re-attempting the readback every frame.
70
+ */
71
+ export interface ImageSnapshotCache {
72
+ readonly imgs: WeakMap<HTMLImageElement, { src: string; url: string | null }>;
73
+ }
74
+
75
+ export function createImageSnapshotCache(): ImageSnapshotCache {
76
+ return { imgs: new WeakMap() };
77
+ }
78
+
79
+ /**
80
+ * How stale a cached overlay raster may get before a rebuild is forced even
81
+ * with no mutation observed. This bounds the ONE blind spot of
82
+ * mutation-driven invalidation: motion no mutation reports — a CSS animation
83
+ * mid-flight, a `<video>` element, an `<img>` finishing its load, a nested
84
+ * non-root canvas repainting. Half a second of HUD staleness is invisible in
85
+ * evidence video; rebuilding twice a second is invisible in the profile.
86
+ */
87
+ export const OVERLAY_CACHE_MAX_AGE_MS = 500;
88
+
89
+ /**
90
+ * The floor of the rebuild throttle window, and the cost multiplier that
91
+ * stretches it.
92
+ *
93
+ * Mutation-driven invalidation has a degenerate regime: a HUD that mutates
94
+ * every frame (an ammo counter during fire, a per-frame debug readout) makes
95
+ * every window dirty, and the recorder is back to a full clone + serialize +
96
+ * decode per frame — measured at ~40% of the main thread with 50–70ms relay
97
+ * latency under a forced 16ms-mutation bench. So a DIRTY raster is still
98
+ * served until `max(floor, multiplier × last build's wall cost)` has passed
99
+ * since the last rebuild: rebuild work is bounded at 1/multiplier (~12.5%) of
100
+ * the thread however hostile the HUD, and a cheap HUD still updates in the
101
+ * video at ~1000/floor (~10) fps — comfortably above the "10 frames in a 5s
102
+ * span" bar the evidence doors ask reviewers to hold recordings to. The
103
+ * staleness this trades is bounded by the window itself and only exists
104
+ * while the HUD is actively churning — the first take past the window
105
+ * rebuilds from the live DOM.
106
+ */
107
+ export const OVERLAY_REBUILD_FLOOR_MS = 100;
108
+ export const OVERLAY_REBUILD_COST_MULTIPLIER = 8;
109
+
110
+ /** What {@link OverlayFrameCache} stores: the decoded, `drawImage`-ready
111
+ * overlay raster — `image: null` is the cached form of "this container has
112
+ * no DOM overlay layers", so a canvas-only game skips the whole leg without
113
+ * re-asking the DOM every frame. */
114
+ export interface CachedOverlay {
115
+ readonly image: CanvasImageSource | null;
116
+ readonly overlayCount: number;
117
+ /** Backdrop membership shares this overlay's stacking snapshot. */
118
+ readonly backdrops?: readonly HTMLElement[];
119
+ }
120
+
121
+ /**
122
+ * The DOM overlay, rasterized once per CHANGE instead of once per frame.
123
+ *
124
+ * The gameplay recorder calls {@link drawPlayCompositeFrame} at up to 30fps.
125
+ * Rebuilding the overlay every frame — clone the HUD subtree, re-inline
126
+ * styles, serialize to a foreignObject SVG, decode — measured at ~20% of the
127
+ * main thread even after {@link ImageSnapshotCache} removed the per-frame PNG
128
+ * encodes. A HUD mutates orders of magnitude less often than 30 times a
129
+ * second, so a MutationObserver decides when the raster is rebuilt and every
130
+ * other frame pays two `drawImage` calls.
131
+ *
132
+ * `take` is the whole protocol: it returns the reusable overlay, or returns
133
+ * `null` and ARMS the cache for the rebuild the caller does next. Arming
134
+ * clears the dirty flag at the moment the caller is about to read the DOM
135
+ * (the read is synchronous in the same task, so nothing can interleave);
136
+ * a mutation landing during the rebuild's async decode re-dirties the entry
137
+ * through the observer, so the frame `store`d after it is already invalid —
138
+ * one extra rebuild, never a stale cache.
139
+ */
140
+ export interface OverlayFrameCache {
141
+ take(container: HTMLElement, width: number, height: number): CachedOverlay | null;
142
+ /** `buildCostMs` is the rebuild's WALL cost (clone through decode) — it
143
+ * sizes the throttle window that bounds how much of the main thread a
144
+ * churning HUD can spend on rebuilds. */
145
+ store(
146
+ container: HTMLElement,
147
+ width: number,
148
+ height: number,
149
+ overlay: CachedOverlay,
150
+ buildCostMs: number,
151
+ ): void;
152
+ /** Disconnect the observer. The recording that owns this cache ended. */
153
+ dispose(): void;
154
+ }
155
+
156
+ export function createOverlayFrameCache(
157
+ maxAgeMs = OVERLAY_CACHE_MAX_AGE_MS,
158
+ rebuildFloorMs = OVERLAY_REBUILD_FLOOR_MS,
159
+ ): OverlayFrameCache {
160
+ let observed: HTMLElement | null = null;
161
+ let observer: MutationObserver | null = null;
162
+ let dirty = false;
163
+ let frame:
164
+ | (CachedOverlay & { width: number; height: number; builtAt: number; notBefore: number })
165
+ | null = null;
166
+
167
+ const observe = (container: HTMLElement): void => {
168
+ observer?.disconnect();
169
+ observed = container;
170
+ frame = null;
171
+ dirty = false;
172
+ observer =
173
+ typeof MutationObserver === 'undefined'
174
+ ? null
175
+ : new MutationObserver(() => {
176
+ dirty = true;
177
+ });
178
+ observer?.observe(container, {
179
+ subtree: true,
180
+ childList: true,
181
+ attributes: true,
182
+ characterData: true,
183
+ });
184
+ };
185
+
186
+ return {
187
+ take(container, width, height) {
188
+ if (observed !== container) {
189
+ // First frame, or the recorder retargeted: watch THIS container.
190
+ observe(container);
191
+ return null;
192
+ }
193
+ // Flush records the observer callback has not delivered yet — `take`
194
+ // must never vouch for a CLEAN frame the DOM has already moved under.
195
+ if (observer && observer.takeRecords().length > 0) dirty = true;
196
+ const now = performance.now();
197
+ if (frame && frame.width === width && frame.height === height) {
198
+ if (!dirty && now - frame.builtAt <= maxAgeMs) return frame;
199
+ // Dirty, but the last rebuild is still inside its throttle window:
200
+ // serve the raster anyway (bounded staleness, stated in the window
201
+ // constants above). `dirty` stays set, so the first take PAST the
202
+ // window rebuilds from the live DOM.
203
+ if (dirty && now < frame.notBefore) return frame;
204
+ }
205
+ frame = null;
206
+ dirty = false; // armed: the caller rebuilds from the DOM as it is NOW
207
+ return null;
208
+ },
209
+ store(container, width, height, overlay, buildCostMs) {
210
+ if (observed !== container) return; // a retarget raced the rebuild
211
+ // `dirty` is deliberately left alone: if a mutation landed during the
212
+ // rebuild's decode await, this frame is already out of date and the
213
+ // next `take` past the throttle window correctly rebuilds it.
214
+ const now = performance.now();
215
+ frame = {
216
+ ...overlay,
217
+ width,
218
+ height,
219
+ builtAt: now,
220
+ notBefore: now + Math.max(rebuildFloorMs, OVERLAY_REBUILD_COST_MULTIPLIER * buildCostMs),
221
+ };
222
+ },
223
+ dispose() {
224
+ observer?.disconnect();
225
+ observer = null;
226
+ observed = null;
227
+ frame = null;
228
+ },
229
+ };
230
+ }
231
+
232
+ export interface CaptureOptions {
233
+ /** Authoring compositions may deliberately paint nothing or retain alpha. */
234
+ readonly allowTransparent?: boolean;
235
+ /** Output dimensions independent of the editor preview's CSS scale. Canvases
236
+ * and backdrops are painted at that scale; the DOM leg keeps its CSS-pixel
237
+ * layout and rasterizes through a viewBox at this size, so a frame asked for
238
+ * at device resolution comes back with device-resolution text. */
239
+ readonly size?: { readonly width: number; readonly height: number } | undefined;
240
+ /**
241
+ * Same-frame pixels for a canvas this process cannot read back late.
242
+ *
243
+ * A WebGL canvas is only `drawImage`-able after its frame if its context was
244
+ * created with `preserveDrawingBuffer: true`. Every canvas the vgai runtime
245
+ * mounts sets it; a canvas an INGESTED game created does not, so reading it
246
+ * here — several paint boundaries after its frame — yields black. The caller
247
+ * supplies this when it has a seam that can copy the buffer inside the
248
+ * game's own render pass (`ingest/ingest-frame-snapshot.ts`). Returning
249
+ * `null` means "read the canvas directly", which is the unchanged path for
250
+ * every first-party capture.
251
+ */
252
+ readonly canvasFrame?:
253
+ | ((canvas: HTMLCanvasElement) => Promise<CanvasImageSource | null>)
254
+ | undefined;
255
+ /**
256
+ * Preserve stylesheet-driven chrome when the subject is an editor document.
257
+ *
258
+ * The ordinary game path intentionally uses a neutral wrapper: editor
259
+ * classes are not part of the game. An editor-owned document is the opposite
260
+ * case — its classes and the editor's one static stylesheet ARE its visual
261
+ * language, so omitting them turns a styled panel into browser-default HTML.
262
+ */
263
+ readonly includeDocumentStyles?: boolean | undefined;
264
+ /**
265
+ * Crop the photograph to the union of painted pixels plus padding, on a
266
+ * backdrop of the container's own background.
267
+ *
268
+ * The Storybook framing model, split in two: the MOUNT box supplies the
269
+ * screen the subject's anchors resolve against and is a semantic input,
270
+ * while the PHOTOGRAPH frames the subject — measured, exactly as the 3D
271
+ * story leg frames its camera on the object's bounds. A lone HUD widget
272
+ * crops to the widget; a full screen's painted union spans the frame and
273
+ * stays effectively full-frame, so no screen-vs-widget classifier exists.
274
+ * DOM-only subjects only: a canvas-backed frame is already the full picture.
275
+ */
276
+ readonly cropToContent?: boolean | undefined;
277
+ /**
278
+ * Does this subject present on a CANVAS? — the declared answer, supplied by
279
+ * the caller.
280
+ *
281
+ * This module used to answer it by counting `<canvas>` children, which is a
282
+ * guess at a fact the adapter's region table already states: a `three`/
283
+ * `canvas` region is handed a canvas, a `dom` region is handed a container
284
+ * (ARCHITECTURE-CORE §The editor protocol, zero inference). The count and the
285
+ * declaration agree for every healthy game — and disagree exactly in the case
286
+ * worth catching, a canvas game photographed before its canvas mounted, which
287
+ * the count silently graded as "DOM-only".
288
+ *
289
+ * Passed IN rather than read here on purpose: this file is pure DOM (jsdom-
290
+ * testable, imports no editor state), and `presentation-surface.ts` is the one
291
+ * door that knows. `undefined` = nobody declared, and the measured canvas
292
+ * count stands — the unchanged path for any caller that has no adapter table.
293
+ */
294
+ readonly presentsOnCanvas?: boolean | undefined;
295
+ /** See {@link ImageSnapshotCache}. Absent = snapshot every frame (the
296
+ * one-shot screenshot path, where there is only one frame). */
297
+ readonly snapshots?: ImageSnapshotCache | undefined;
298
+ /** See {@link OverlayFrameCache}. Absent = rasterize the DOM overlay every
299
+ * frame (correct for a one-shot still; ruinous at 30fps). */
300
+ readonly overlayCache?: OverlayFrameCache | undefined;
301
+ }
302
+
303
+ /**
304
+ * Serialize the play container's NON-canvas children into a standalone SVG
305
+ * sized `width`×`height` (CSS pixels). Game capture clones children into a
306
+ * neutral relatively-positioned wrapper. `includeDocumentStyles` instead
307
+ * shallow-clones the editor document container and carries its CSSOM/theme,
308
+ * because those classes are part of that subject. Returns null when there is
309
+ * nothing but canvases to show.
310
+ * Pure DOM (no rasterizing), so it's unit-testable under jsdom.
311
+ */
312
+ /** Same-frame pixels, keyed by the canvas they came from — see the nested-canvas
313
+ * note in {@link buildOverlaySvg}'s clone loop. */
314
+ export type CanvasPixels = ReadonlyMap<HTMLCanvasElement, CanvasImageSource>;
315
+
316
+ /** One canvas's pixels as a data URL the foreignObject clone can carry. An SVG
317
+ * rasterized from a data: URL may not fetch EXTERNAL resources, but an inline
318
+ * data image is not external — the same mechanism html-to-image relies on.
319
+ * `null` when the source cannot be read back (a tainted canvas), which puts
320
+ * the caller back on the honest strip-it path. */
321
+ function canvasDataUrl(document: Document, pixels: CanvasImageSource): string | null {
322
+ try {
323
+ const copy = document.createElement('canvas');
324
+ const image = pixels as HTMLImageElement;
325
+ // A VIDEO's frame size is `videoWidth/Height`: it has no `naturalWidth`,
326
+ // and its `width` attribute is 0 unless someone set one — which sized the
327
+ // copy 1×1 and threw the frame away.
328
+ const video = pixels instanceof HTMLVideoElement ? pixels : null;
329
+ copy.width = Math.max(
330
+ 1,
331
+ Math.round(
332
+ Number(video?.videoWidth || image.naturalWidth || (pixels as HTMLCanvasElement).width) || 0,
333
+ ),
334
+ );
335
+ copy.height = Math.max(
336
+ 1,
337
+ Math.round(
338
+ Number(video?.videoHeight || image.naturalHeight || (pixels as HTMLCanvasElement).height) ||
339
+ 0,
340
+ ),
341
+ );
342
+ const ctx = copy.getContext('2d');
343
+ if (!ctx) return null;
344
+ ctx.drawImage(pixels, 0, 0);
345
+ return copy.toDataURL('image/png');
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ /** Flatten every readable sheet in document cascade order. CSSOM access is
352
+ * the browser-native answer here: Vite dev styles, the production bundle, and
353
+ * dynamically installed scoped game CSS all present the same interface.
354
+ * Cross-origin sheets refuse `cssRules`; skipping those preserves the normal
355
+ * browser security boundary instead of making capture itself fail. */
356
+ function documentStylesCssText(document: Document): string {
357
+ const seen = new Set<CSSStyleSheet>();
358
+ const read = (sheet: CSSStyleSheet): string => {
359
+ if (seen.has(sheet)) return '';
360
+ seen.add(sheet);
361
+ try {
362
+ return Array.from(sheet.cssRules)
363
+ .map((rule) => {
364
+ const imported = (rule as CSSImportRule).styleSheet;
365
+ return imported ? read(imported) : rule.cssText;
366
+ })
367
+ .join('\n');
368
+ } catch {
369
+ return '';
370
+ }
371
+ };
372
+ return Array.from(document.styleSheets, read).filter(Boolean).join('\n');
373
+ }
374
+
375
+ /** Carry the effective theme across the detached-foreignObject boundary.
376
+ * Theme tokens live as custom properties on an editor-shell ancestor, which
377
+ * the active document intentionally does not clone. Reading them from the
378
+ * subject's computed style preserves adaptive panel ink as well as the base
379
+ * palette, without guessing which ancestor established each value. */
380
+ function copyComputedDocumentContext(source: HTMLElement, target: HTMLElement): void {
381
+ const computed = source.ownerDocument.defaultView?.getComputedStyle(source);
382
+ if (!computed) return;
383
+ for (const property of Array.from(computed)) {
384
+ if (!property.startsWith('--')) continue;
385
+ const value = computed.getPropertyValue(property);
386
+ if (value) target.style.setProperty(property, value);
387
+ }
388
+ for (const property of [
389
+ 'color',
390
+ 'color-scheme',
391
+ 'font-family',
392
+ 'font-size',
393
+ 'font-weight',
394
+ 'line-height',
395
+ 'text-shadow',
396
+ ]) {
397
+ const value = computed.getPropertyValue(property);
398
+ if (value) target.style.setProperty(property, value);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * The element's own painted background colour, or `null` when it is fully
404
+ * transparent (nothing to paint) or unreadable. Computed style, not the inline
405
+ * attribute, so a class-styled document container answers too.
406
+ */
407
+ export function opaqueBackgroundColor(element: HTMLElement): string | null {
408
+ const computed = element.ownerDocument.defaultView?.getComputedStyle(element);
409
+ const color = computed?.backgroundColor;
410
+ if (!color || color === 'transparent') return null;
411
+ // `rgba(r, g, b, 0)` is the other spelling of transparent, and the one
412
+ // browsers actually report for an unset background.
413
+ const alpha = /^rgba?\([^)]*,\s*([\d.]+)\s*\)$/.exec(color)?.[1];
414
+ if (alpha !== undefined && Number(alpha) === 0) return null;
415
+ return color;
416
+ }
417
+
418
+ /**
419
+ * THE ELEMENTS WHOSE BACKGROUND PAINTS *BEHIND* A ROOT-SURFACE CANVAS — the
420
+ * canvas's own ancestor chain, outermost first, `container` included.
421
+ *
422
+ * Why this list has to exist. The compositor's model is "every canvas first,
423
+ * then the whole DOM over the top with those canvases taken out of the clone"
424
+ * (see the nested-canvas note in {@link buildOverlaySvg}: a root-surface canvas
425
+ * is STRIPPED there because the canvas leg already drew it at its exact
426
+ * on-screen rect). That model is only faithful while nothing between the canvas
427
+ * and the composite root paints anything — and an editor document's Scene
428
+ * container paints its ground. CSS puts an ancestor's background BELOW every
429
+ * descendant; the overlay leg puts it ABOVE the canvas leg. So the ground wins
430
+ * and the world vanishes.
431
+ *
432
+ * MEASURED (dodge-the-creeps, imported through `gd-analyze`; the same frame the
433
+ * match-3 import produces): the Edit Scene photographed as ~99% flat `#131416`
434
+ * over a canvas leg that had just drawn 69,366 pixels of the game's authored
435
+ * `#385f61` ColorRect. Nothing about the world, the mount, the Pixi
436
+ * `Application` or the render loop was wrong — only this ordering was, and it
437
+ * was invisible on screen, where the browser composites correctly.
438
+ *
439
+ * Both legs consume this list: {@link drawPlayCompositeFrame} paints these
440
+ * backgrounds UNDER the canvases (where the screen has them), and
441
+ * {@link buildOverlaySvg} clears them from the clone so they cannot paint
442
+ * twice. A document with no root-surface canvas yields an empty list and is
443
+ * composited exactly as before.
444
+ *
445
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
446
+ */
447
+ export function rootSurfaceBackdrops(container: HTMLElement): HTMLElement[] {
448
+ const chain: HTMLElement[] = [];
449
+ const seen = new Set<Element>();
450
+ for (const canvas of Array.from(container.querySelectorAll('canvas'))) {
451
+ if (!isRootCanvas(canvas)) continue;
452
+ const ancestors: HTMLElement[] = [];
453
+ for (
454
+ let node = canvas.parentElement;
455
+ node && (node === container || container.contains(node));
456
+ node = node.parentElement
457
+ ) {
458
+ ancestors.unshift(node);
459
+ if (node === container) break;
460
+ }
461
+ // Outermost first, which is paint order: a nearer ancestor paints over a
462
+ // further one. `unshift` above already produced that order, and the shared
463
+ // outer ancestors of a second root surface are seen (and kept) first.
464
+ for (const element of [...ancestors, ...stackedUnder(container, canvas)]) {
465
+ if (seen.has(element)) continue;
466
+ seen.add(element);
467
+ chain.push(element);
468
+ }
469
+ }
470
+ return chain;
471
+ }
472
+
473
+ /**
474
+ * The elements that paint UNDER a root-surface canvas WITHOUT being its
475
+ * ancestors — the second way a backdrop reaches the screen. A dock lays its
476
+ * panel content in a render overlay positioned over the group boxes, so the
477
+ * group's own surface (`--dv-group-view-background-color`) is a SIBLING
478
+ * subtree beneath the canvas in stacking order, not an ancestor above it in
479
+ * the tree. The ancestor walk cannot see it; the overlay leg clones it opaque
480
+ * and paints it over the canvas the canvas leg just drew.
481
+ *
482
+ * MEASURED (`vgai screenshot editor` of the Game document in play, the
483
+ * starter cube and daylight sky on screen): the whole game region came back
484
+ * flat `rgb(36,36,36)` with every ancestor already cleared — the overlay leg
485
+ * rasterized alone read that grey at the canvas centre and the SVG carried no
486
+ * such literal, so it was a stylesheet-painted box that no ancestor owned.
487
+ * The 3D tool documents never showed it because their canvases are not root
488
+ * surfaces: they ride the clone inline, in document order, above that box.
489
+ *
490
+ * Found the way the screen resolves it: `elementsFromPoint` at a few points
491
+ * inside the canvas's rect, everything listed BELOW the canvas that is inside
492
+ * `container` and paints an opaque background. Bottom-most first, which is
493
+ * paint order for the canvas leg. Points the canvas does not top (an overlay
494
+ * above it) still list the canvas, so the cut is at the canvas itself.
495
+ */
496
+ function stackedUnder(container: HTMLElement, canvas: HTMLCanvasElement): HTMLElement[] {
497
+ const doc = container.ownerDocument;
498
+ const rect = canvas.getBoundingClientRect();
499
+ if (rect.width <= 0 || rect.height <= 0 || typeof doc.elementsFromPoint !== 'function') {
500
+ return [];
501
+ }
502
+ const under = new Set<HTMLElement>();
503
+ const points: [number, number][] = [
504
+ [rect.left + rect.width / 2, rect.top + rect.height / 2],
505
+ [rect.left + rect.width * 0.1, rect.top + rect.height * 0.1],
506
+ [rect.left + rect.width * 0.9, rect.top + rect.height * 0.1],
507
+ [rect.left + rect.width * 0.1, rect.top + rect.height * 0.9],
508
+ [rect.left + rect.width * 0.9, rect.top + rect.height * 0.9],
509
+ ];
510
+ for (const [x, y] of points) {
511
+ const stack = doc.elementsFromPoint(x, y);
512
+ const at = stack.indexOf(canvas);
513
+ if (at < 0) continue;
514
+ for (const element of stack.slice(at + 1)) {
515
+ if (paintsOpaqueWithin(element, container)) under.add(element);
516
+ }
517
+ }
518
+ // `elementsFromPoint` lists top-most first; the canvas leg paints
519
+ // bottom-most first.
520
+ return Array.from(under).reverse();
521
+ }
522
+
523
+ function paintsOpaqueWithin(element: Element, container: HTMLElement): element is HTMLElement {
524
+ return (
525
+ element instanceof HTMLElement &&
526
+ (element === container || container.contains(element)) &&
527
+ opaqueBackgroundColor(element) !== null
528
+ );
529
+ }
530
+
531
+ /**
532
+ * THE BACKDROP AN EDITOR DOCUMENT DOES NOT PAINT ITSELF — the subject's own
533
+ * ancestor chain, outermost first, `container` EXCLUDED (the overlay leg
534
+ * clones the container and keeps whatever it paints).
535
+ *
536
+ * The compositor's model is "clone the subject, detached, and rasterize it".
537
+ * A detached clone has no ancestors, so anything an ancestor paints is simply
538
+ * gone — and in this editor the panel fill is DELIBERATELY an ancestor's:
539
+ * `components/workspace-surfaces.css` says in as many words that interior
540
+ * wrappers (`.vgai-dock-document-content`, the element every document capture
541
+ * and the document probe resolve as "the document") paint NOTHING, because
542
+ * the surface AROUND them carries the fill for every theme.
543
+ *
544
+ * MEASURED (2026-08-20, `project-tools` — the editor's OWN built-in document —
545
+ * through `editor.captureActiveDocument` on the Vite dev server AND on the
546
+ * built `dist/` served by the packaged/prod editor servers): the composite came
547
+ * back with legible text over ZERO alpha everywhere else, which every PNG
548
+ * reader renders black and which `measureFlatness` (alpha-blind, by design)
549
+ * scores as "~96-98% one flat surface (#000000)". `ok: true`, a warning nobody
550
+ * can act on, and a black photograph offered as evidence of a document that was
551
+ * on screen and perfectly legible. It is NOT a packaged-only defect: dev
552
+ * measured 0.976 and the packaged bundle 0.955 — one mechanism, both paths.
553
+ *
554
+ * `story-capture.ts` had already bought this lesson from the other side — it
555
+ * paints an opaque `STORY_BACKDROP` behind every story precisely because a
556
+ * canvas-less transparent composite reads as a torn frame.
557
+ *
558
+ * Only the EDITOR-DOCUMENT subject takes this leg (`includeDocumentStyles`): a
559
+ * game's picture must never inherit editor chrome, which is the whole reason
560
+ * the game path composites onto a neutral transparent wrapper.
561
+ */
562
+ export function ancestorBackdrops(container: HTMLElement): HTMLElement[] {
563
+ const chain: HTMLElement[] = [];
564
+ for (let node = container.parentElement; node; node = node.parentElement) {
565
+ chain.unshift(node);
566
+ }
567
+ return chain;
568
+ }
569
+
570
+ /**
571
+ * Take the backgrounds {@link rootSurfaceBackdrops} named out of one overlay
572
+ * layer's clone: the canvas leg has already painted them, UNDER the canvas they
573
+ * belong under, so a second copy here would paint over the world.
574
+ *
575
+ * Called before any other clone surgery, while `source` and `clone` still walk
576
+ * in the same order — the same index-parallel assumption the image and nested
577
+ * canvas passes make.
578
+ */
579
+ /**
580
+ * CARRY WHAT A CLONE DOES NOT: the form state that lives in an IDL property
581
+ * rather than in an attribute.
582
+ *
583
+ * `cloneNode(true)` copies ATTRIBUTES. A `<select>`'s selectedness is not one
584
+ * — it is `HTMLOptionElement.selected`, and React never writes the `selected`
585
+ * attribute for a controlled `<select>` — so the serialized clone reaches the
586
+ * foreignObject with every option unselected and the browser paints THE FIRST
587
+ * ONE. That is a camera that lies about the screen, and it lied loudly enough
588
+ * to be written down as a product defect: WALK 5's beat 11b recorded "the rail
589
+ * draws `Quaternion (WXYZ)`" for an object whose Rotation Mode was XYZ and
590
+ * then ZYX, and a follow-up was raised against the enum WIDGET. Measured here
591
+ * through the chrome door on 2026-09-21: the live `<select>`'s `value` is
592
+ * `"XYZ"`, its options carry no `selected` attribute, and the widget is
593
+ * correct. The instrument was the whole symptom.
594
+ *
595
+ * A checkbox has the same split (`checked` the property vs `checked` the
596
+ * attribute), so a ticked box photographs empty; a `<textarea>`'s value is its
597
+ * child text. A text/number `<input>` needs nothing — React keeps the `value`
598
+ * attribute in sync for controlled inputs, which is why the Properties rail's
599
+ * numbers always photographed correctly and only its one dropdown did not. It
600
+ * is written anyway, because "which inputs React syncs" is not a fact this
601
+ * module should have to depend on.
602
+ *
603
+ * Called while source and clone are still INDEX-PARALLEL — before the img,
604
+ * video and canvas legs replace nodes.
605
+ */
606
+ function carryFormStateInClone(source: Element, clone: Element): void {
607
+ const FIELDS = 'select, input, textarea';
608
+ const fieldsIn = (root: Element): Element[] => [
609
+ ...(root.matches(FIELDS) ? [root] : []),
610
+ ...Array.from(root.querySelectorAll(FIELDS)),
611
+ ];
612
+ const sources = fieldsIn(source);
613
+ const clones = fieldsIn(clone);
614
+ sources.forEach((field, index) => {
615
+ const copy = clones[index];
616
+ if (!copy) return;
617
+ if (field instanceof HTMLSelectElement && copy instanceof HTMLSelectElement) {
618
+ const options = Array.from(field.options);
619
+ Array.from(copy.options).forEach((option, at) => {
620
+ if (options[at]?.selected) option.setAttribute('selected', '');
621
+ else option.removeAttribute('selected');
622
+ });
623
+ return;
624
+ }
625
+ if (field instanceof HTMLInputElement && copy instanceof HTMLInputElement) {
626
+ if (field.type === 'checkbox' || field.type === 'radio') {
627
+ if (field.checked) copy.setAttribute('checked', '');
628
+ else copy.removeAttribute('checked');
629
+ } else {
630
+ copy.setAttribute('value', field.value);
631
+ }
632
+ return;
633
+ }
634
+ if (field instanceof HTMLTextAreaElement && copy instanceof HTMLTextAreaElement) {
635
+ copy.textContent = field.value;
636
+ }
637
+ });
638
+ }
639
+
640
+ /**
641
+ * CARRY THE SCROLL. A clone's `scrollTop` is zero, and no markup can say
642
+ * otherwise — so every scrolled panel in the editor photographed AT THE TOP.
643
+ *
644
+ * This is {@link carryFormStateInClone}'s twin and it cost more: B11 handed on
645
+ * "an agent-added torus never appears in the Outliner, even after a restart"
646
+ * as a panel-height finding, read off a frame from this camera. Measured here
647
+ * 2026-09-21, through the chrome door, on eight objects in a five-row panel:
648
+ * the live tree IS scrolled to the new object (first row at y −53 against a
649
+ * body starting at 46 — 99 px of scroll, the active `Sphere.005` at 127..147,
650
+ * the last fully visible row), and the SAME frame photographs the tree at row
651
+ * zero. The panel was doing its job; the camera was not.
652
+ *
653
+ * WHY THE FIRST CHILD'S MARGIN and not a wrapper: a scroll IS the content
654
+ * drawn `scrollTop` higher inside the same clipped box, and a negative margin
655
+ * on the first in-flow child produces exactly that while leaving the
656
+ * scroller's own layout mode untouched. A wrapper div would collapse a flex
657
+ * column's items into one item and re-lay-out everything under it. The
658
+ * element's own margin is READ off the source and subtracted from, so a
659
+ * scroller whose first child already has one is not flattened.
660
+ *
661
+ * `overflow: hidden` on the clone because the offset content must be CLIPPED:
662
+ * a foreignObject gets no scrollbars and an `auto` box would simply grow.
663
+ */
664
+ function carryScrollInClone(source: Element, clone: Element): void {
665
+ const sourceNodes = [source, ...Array.from(source.querySelectorAll('*'))];
666
+ const cloneNodes = [clone, ...Array.from(clone.querySelectorAll('*'))];
667
+ sourceNodes.forEach((node, index) => {
668
+ const top = node.scrollTop;
669
+ const left = node.scrollLeft;
670
+ if (top === 0 && left === 0) return;
671
+ const target = cloneNodes[index];
672
+ if (!(target instanceof HTMLElement)) return;
673
+ const first = target.firstElementChild;
674
+ if (!(first instanceof HTMLElement)) return;
675
+ target.style.overflow = 'hidden';
676
+ const computed = getComputedStyle(node.firstElementChild ?? node);
677
+ const marginTop = Number.parseFloat(computed.marginTop) || 0;
678
+ const marginLeft = Number.parseFloat(computed.marginLeft) || 0;
679
+ if (top !== 0) first.style.marginTop = `${marginTop - top}px`;
680
+ if (left !== 0) first.style.marginLeft = `${marginLeft - left}px`;
681
+ });
682
+ }
683
+
684
+ function clearBackdropsInClone(
685
+ source: Element,
686
+ clone: Element,
687
+ backdrops: ReadonlySet<Element> | undefined,
688
+ ): void {
689
+ if (!backdrops?.size) return;
690
+ const sourceNodes = [source, ...Array.from(source.querySelectorAll('*'))];
691
+ const cloneNodes = [clone, ...Array.from(clone.querySelectorAll('*'))];
692
+ sourceNodes.forEach((node, index) => {
693
+ if (!backdrops.has(node)) return;
694
+ const target = cloneNodes[index];
695
+ if (target instanceof HTMLElement) target.style.background = 'transparent';
696
+ });
697
+ }
698
+
699
+ export function buildOverlaySvg(
700
+ container: HTMLElement,
701
+ width: number,
702
+ height: number,
703
+ options?: CaptureOptions & {
704
+ readonly canvasPixels?: CanvasPixels | undefined;
705
+ /** {@link rootSurfaceBackdrops} — cleared in the clone because the canvas
706
+ * leg painted them under the canvas they belong under. */
707
+ readonly transparentBackdrops?: ReadonlySet<Element> | undefined;
708
+ /**
709
+ * Rasterize at THIS pixel size while laying the clone out at `width` ×
710
+ * `height`. The two are one number apart and they are not the same
711
+ * question: the clone is DOM, so it must be laid out in the CSS pixels its
712
+ * styles are written in (a 14px label is 14px, a 1px border is 1px),
713
+ * while the frame may be wanted at device resolution or above it.
714
+ * Stretching the wrapper's box instead would keep every font size and
715
+ * border width at 1x inside a larger box — the layout would change, not
716
+ * the resolution. A `viewBox` is the one mechanism that scales the
717
+ * rendering: the browser rasterizes the `foreignObject` through it, so
718
+ * text and borders come out at the output scale, vector-crisp.
719
+ */
720
+ readonly rasterSize?: { readonly width: number; readonly height: number } | undefined;
721
+ },
722
+ ): OverlaySvg | null {
723
+ const overlays = Array.from(container.children).filter(
724
+ (el) => el.tagName.toLowerCase() !== 'canvas',
725
+ );
726
+ if (overlays.length === 0) return null;
727
+
728
+ const includeDocumentStyles = options?.includeDocumentStyles === true;
729
+ const wrapper = includeDocumentStyles
730
+ ? (container.cloneNode(false) as HTMLElement)
731
+ : container.ownerDocument.createElement('div');
732
+ wrapper.style.position = 'relative';
733
+ wrapper.style.inset = 'auto';
734
+ wrapper.style.width = `${width}px`;
735
+ wrapper.style.height = `${height}px`;
736
+ wrapper.style.margin = '0';
737
+ wrapper.style.transform = 'none';
738
+ // Transparent is load-bearing for GAME capture, not tidiness — see the
739
+ // scope marker below. An editor document keeps its real container backdrop,
740
+ // unless that backdrop paints behind a root-surface canvas the canvas leg
741
+ // already drew (see {@link rootSurfaceBackdrops}).
742
+ if (includeDocumentStyles) copyComputedDocumentContext(container, wrapper);
743
+ else wrapper.style.background = 'transparent';
744
+ const backdrops = options?.transparentBackdrops;
745
+ if (backdrops?.has(container)) wrapper.style.background = 'transparent';
746
+ // THE CONTAINER MAY BE THE GAME'S CSS SCOPE ROOT. The neutral game path does
747
+ // not clone that container (see this function's doc comment), so it would
748
+ // otherwise lose the root and, with it, every `@scope`d rule that positions
749
+ // the HUD. The ingest surface is exactly this shape: `#container` IS the game's
750
+ // page in-realm and IS the marked scope root, and its HUD elements are its
751
+ // CHILDREN. The wrapper already stands in for the container's box; it stands
752
+ // in for its scope identity here too.
753
+ //
754
+ // Its own background stays transparent because that identity brings one
755
+ // declaration that does not belong on this layer: the page backdrop
756
+ // (`body { background }` → `:scope`). On screen that sits BEHIND the canvas,
757
+ // and the canvas leg has already drawn it; repainting it here would hide the
758
+ // game under its own background. A scope root that is INSIDE the container
759
+ // (a story card's content) is cloned normally and keeps its backdrop.
760
+ if (container.closest(`[${GAME_CSS_SCOPE_ATTRIBUTE}]`)) {
761
+ wrapper.setAttribute(GAME_CSS_SCOPE_ATTRIBUTE, '');
762
+ }
763
+ if (includeDocumentStyles) {
764
+ const css = documentStylesCssText(container.ownerDocument);
765
+ if (css) {
766
+ const documentStyles = container.ownerDocument.createElement('style');
767
+ documentStyles.textContent = css;
768
+ wrapper.appendChild(documentStyles);
769
+ }
770
+ }
771
+ // Snapshot a stable DOM frame. CSS animations/transitions inside an SVG
772
+ // foreignObject can be rasterized while Chrome is between compositor
773
+ // layers, producing large opaque-black rectangles even though the live DOM
774
+ // is fine (reproduced by the 2048 dogfood run during a tile-pop frame).
775
+ // Disabling motion in the detached clone leaves the live game untouched
776
+ // and makes the captured evidence atomic.
777
+ const freezeMotion = container.ownerDocument.createElement('style');
778
+ freezeMotion.textContent =
779
+ '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;' +
780
+ 'backdrop-filter:none!important;-webkit-backdrop-filter:none!important;}';
781
+ wrapper.appendChild(freezeMotion);
782
+ // The GAME's own page stylesheet, re-inlined for the same structural reason
783
+ // the freeze above is: this clone is DETACHED, and a detached fragment
784
+ // carries no document stylesheet. The sheet is already `@scope`d to the
785
+ // containers the clone contains (`scoped-game-css.ts`), so re-inlining it
786
+ // reaches exactly the game DOM and nothing else — and without it a styled
787
+ // HUD photographs as unstyled, i.e. the look verb reports the very
788
+ // wreckage the scoped stylesheet exists to remove.
789
+ const gameCss = scopedGameStylesCssText();
790
+ if (gameCss) {
791
+ const gameStyles = container.ownerDocument.createElement('style');
792
+ gameStyles.textContent = gameCss;
793
+ wrapper.appendChild(gameStyles);
794
+ }
795
+ for (const el of overlays) {
796
+ const clone = el.cloneNode(true) as Element;
797
+ // Clear the backgrounds that paint BEHIND a root-surface canvas before any
798
+ // other clone surgery, while source and clone are still index-parallel.
799
+ clearBackdropsInClone(el, clone, backdrops);
800
+ // …and carry the form state and the scroll offsets, for the same
801
+ // index-parallel reason: the legs below REPLACE nodes.
802
+ carryFormStateInClone(el, clone);
803
+ carryScrollInClone(el, clone);
804
+ // An SVG loaded from a `data:` URL cannot fetch an ordinary `/public/...`
805
+ // image referenced by a nested `<img>`. The live DOM therefore looked
806
+ // correct while both story screenshots and Doctor's UI-board evidence
807
+ // silently omitted every authored bitmap. Snapshot each already-loaded
808
+ // image through canvas and carry the pixels in the clone, exactly like the
809
+ // nested-canvas path below. A cross-origin/tainted or not-yet-loaded image
810
+ // keeps its original URL: capture remains best-effort for foreign content,
811
+ // while same-origin project assets become self-contained.
812
+ const sourceImages = [
813
+ ...(el instanceof HTMLImageElement ? [el] : []),
814
+ ...Array.from(el.querySelectorAll('img')),
815
+ ];
816
+ const clonedImages = [
817
+ ...(clone instanceof HTMLImageElement ? [clone] : []),
818
+ ...Array.from(clone.querySelectorAll('img')),
819
+ ];
820
+ clonedImages.forEach((image, index) => {
821
+ const source = sourceImages[index];
822
+ if (!source?.complete || source.naturalWidth <= 0 || source.naturalHeight <= 0) return;
823
+ // `currentSrc` first: a `srcset` image's pixels follow the RESOLVED
824
+ // candidate, and re-snapshotting on a `src` that never changed is the
825
+ // exact per-frame cost the cache exists to remove.
826
+ const src = source.currentSrc || source.src;
827
+ const cached = options?.snapshots?.imgs.get(source);
828
+ const url =
829
+ cached && cached.src === src ? cached.url : canvasDataUrl(container.ownerDocument, source);
830
+ if (!cached || cached.src !== src) options?.snapshots?.imgs.set(source, { src, url });
831
+ if (url) image.setAttribute('src', url);
832
+ });
833
+ // A `<video>` is the `<img>` case one step further along: a detached clone
834
+ // has no media pipeline, so a clip whose frame is decoded and on screen
835
+ // photographs as a BLACK BOX — measured on a reference clip's Content tile
836
+ // and its Asset Lab document, both of which a person could see perfectly
837
+ // well. The element's CURRENT frame is `drawImage`-able exactly like an
838
+ // image, so snapshot it and carry the pixels, keeping the element's own box
839
+ // (an `<img>` in its place inherits the same style and layout). A video
840
+ // with no decoded frame yet keeps its empty element rather than gaining a
841
+ // fabricated one.
842
+ const sourceVideos = [
843
+ ...(el instanceof HTMLVideoElement ? [el] : []),
844
+ ...Array.from(el.querySelectorAll('video')),
845
+ ];
846
+ const clonedVideos = [
847
+ ...(clone instanceof HTMLVideoElement ? [clone] : []),
848
+ ...Array.from(clone.querySelectorAll('video')),
849
+ ];
850
+ clonedVideos.forEach((video, index) => {
851
+ const source = sourceVideos[index];
852
+ if (!source || source.readyState < 2 || source.videoWidth <= 0) return;
853
+ const url = canvasDataUrl(container.ownerDocument, source);
854
+ if (!url) return;
855
+ const image = container.ownerDocument.createElement('img');
856
+ image.setAttribute('src', url);
857
+ image.setAttribute('style', video.getAttribute('style') ?? '');
858
+ video.replaceWith(image);
859
+ });
860
+ // A canvas nested INSIDE an overlay layer serializes as an empty box. Its
861
+ // pixels are drawn by the canvas leg, but UNDER this whole overlay — so
862
+ // anything the layer paints between them (the `2D` board's frames paint an
863
+ // alpha checkerboard behind every exhibit) covers it, and the exhibit
864
+ // photographs blank while looking perfectly fine on screen. When the caller
865
+ // has the pixels, they are inlined HERE, in the nested canvas's own place,
866
+ // so document order — the real z-order — decides what covers what. With no
867
+ // pixels the old answer stands: strip it rather than ship a lying blank.
868
+ const sources = Array.from(el.querySelectorAll('canvas'));
869
+ Array.from(clone.querySelectorAll('canvas')).forEach((nested, index) => {
870
+ const source = sources[index];
871
+ // A host root-surface canvas was already painted by the canvas leg at
872
+ // its exact on-screen rect. Re-inlining it here paints the same pixels a
873
+ // second time inside a detached stacking context; Chromium then places
874
+ // that raster over higher-z sibling DOM roots (the measured failure was
875
+ // a Three game's HUD disappearing only from composite screenshots).
876
+ // Board/story canvases are not root surfaces and still need this inline
877
+ // path so their surrounding card backgrounds preserve document order.
878
+ if (source && isRootCanvas(source)) {
879
+ nested.remove();
880
+ return;
881
+ }
882
+ const pixels = source ? options?.canvasPixels?.get(source) : undefined;
883
+ const url = pixels ? canvasDataUrl(container.ownerDocument, pixels) : null;
884
+ if (!url) {
885
+ nested.remove();
886
+ return;
887
+ }
888
+ const image = container.ownerDocument.createElement('img');
889
+ image.setAttribute('src', url);
890
+ image.setAttribute('style', `${nested.getAttribute('style') ?? ''}`);
891
+ for (const attribute of Array.from(nested.attributes)) {
892
+ image.setAttribute(attribute.name, attribute.value);
893
+ }
894
+ nested.replaceWith(image);
895
+ });
896
+ wrapper.appendChild(clone);
897
+ }
898
+
899
+ const serialized = new XMLSerializer().serializeToString(wrapper);
900
+ const rasterWidth = options?.rasterSize?.width ?? width;
901
+ const rasterHeight = options?.rasterSize?.height ?? height;
902
+ // The viewBox is emitted ONLY when the raster differs from the layout, so an
903
+ // unscaled capture serializes the exact string it always has.
904
+ const viewBox =
905
+ rasterWidth === width && rasterHeight === height ? '' : ` viewBox="0 0 ${width} ${height}"`;
906
+ const svg =
907
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${rasterWidth}" height="${rasterHeight}"${viewBox}>` +
908
+ `<foreignObject width="100%" height="100%">${serialized}</foreignObject></svg>`;
909
+ return { svg, overlayCount: overlays.length };
910
+ }
911
+
912
+ export interface CompositeCapture {
913
+ base64: string;
914
+ mimeType: 'image/png';
915
+ /** Honest capture provenance: how many canvases were drawn, and how many
916
+ * DOM layers rode the foreignObject leg. */
917
+ layers: { canvases: number; domOverlays: number };
918
+ /** How much of the frame is one flat surface — see {@link measureFlatness}.
919
+ * Absent only when the readback itself was unavailable (tainted canvas). */
920
+ flatness?: CaptureFlatness;
921
+ }
922
+
923
+ export interface CompositeFrame {
924
+ /** Number of native canvas surfaces painted into this frame. */
925
+ canvases: number;
926
+ /** Number of DOM overlay roots painted above those canvases. */
927
+ domOverlays: number;
928
+ }
929
+
930
+ /** The three independently fallible legs of a full game-frame capture. */
931
+ export type CaptureLayer = 'canvas' | 'dom-overlay' | 'composite-output';
932
+
933
+ const CAPTURE_LAYER_LABEL: Record<CaptureLayer, string> = {
934
+ canvas: 'canvas layer',
935
+ 'dom-overlay': 'DOM overlay layer',
936
+ 'composite-output': 'composite output',
937
+ };
938
+
939
+ /**
940
+ * A capture failure whose message names the layer that failed. This crosses
941
+ * the active-document and relay doors unchanged, so neither caller has to
942
+ * infer a layer from a browser exception such as "The operation is insecure".
943
+ */
944
+ export class CaptureLayerError extends Error {
945
+ readonly layer: CaptureLayer;
946
+
947
+ constructor(layer: CaptureLayer, error: unknown) {
948
+ // Browser exceptions can come from another realm (an ingested page or
949
+ // jsdom's DOM realm), where `instanceof Error` is false despite a real
950
+ // `.message`. Read the platform shape rather than losing it to
951
+ // `String(error)`'s generic "Error: …" prefix.
952
+ const detail =
953
+ typeof error === 'object' &&
954
+ error !== null &&
955
+ 'message' in error &&
956
+ typeof error.message === 'string'
957
+ ? error.message
958
+ : String(error);
959
+ super(`${CAPTURE_LAYER_LABEL[layer]} capture failed — ${detail}`);
960
+ this.name = 'CaptureLayerError';
961
+ this.layer = layer;
962
+ }
963
+ }
964
+
965
+ function layerFailure(layer: CaptureLayer, error: unknown): CaptureLayerError {
966
+ return error instanceof CaptureLayerError ? error : new CaptureLayerError(layer, error);
967
+ }
968
+
969
+ /**
970
+ * Is this capture worth anything as evidence?
971
+ *
972
+ * Measured failure (two blind probes, 2026-08): both filed near-blank
973
+ * screenshots — "essentially blank: a black band over a flat beige plane",
974
+ * "camera buried in geometry" — under "Tested". A PNG that is one flat colour
975
+ * proves nothing, and nothing in the pipeline said so, so the reader had to
976
+ * open the file and notice. This is the cheap heuristic that says it at
977
+ * capture time.
978
+ *
979
+ * `warning` is the ONE place the sentence is spelled. Every surface that
980
+ * shows this (the `vgai screenshot` verb, the `/__vgai/screenshot` poke, the
981
+ * relay transport behind `game.screenshot()`) lives in a different package,
982
+ * and three copies of a sentence is three sentences that drift — so the layer
983
+ * holding the pixels writes the words and the rest print them verbatim.
984
+ */
985
+ export interface CaptureFlatness {
986
+ /** Fraction of sampled cells inside the single largest near-uniform region. */
987
+ dominantFraction: number;
988
+ /** That region's mean colour, `#rrggbb` — names WHICH flat surface it is. */
989
+ dominantColor: string;
990
+ /** How many coarse colour regions cover >=1% of the frame. Reported for
991
+ * context only; nothing branches on it (see the threshold note below). */
992
+ distinctRegions: number;
993
+ degenerate: boolean;
994
+ /** Present iff `degenerate`. */
995
+ warning?: string;
996
+ }
997
+
998
+ /**
999
+ * Grid the measure samples at. 32x32 = 1024 cells: fine enough that a HUD
1000
+ * strip, a character, or a horizon occupies several cells, coarse enough that
1001
+ * the whole measurement is one GPU-side downsample plus 4 KB of readback no
1002
+ * matter how large the capture is.
1003
+ */
1004
+ export const FLATNESS_GRID = 32;
1005
+
1006
+ /** Per-channel tolerance for "still the same flat surface". A wall or sky
1007
+ * under one light still shades by a few levels across the frame; 24/255 keeps
1008
+ * those together without merging two genuinely different surfaces. */
1009
+ const FLAT_TOLERANCE = 24;
1010
+
1011
+ /**
1012
+ * Warn at 90%: nine tenths of the frame is one surface, i.e. less than a tenth
1013
+ * of the picture shows anything at all. Deliberately high — this warns, it
1014
+ * never refuses, and a warning that fires on ordinary frames is a warning
1015
+ * people learn to skip. Measured live against the starter template: the
1016
+ * default stage (sky + construction grid + focal cube) reads 0.745 — high,
1017
+ * because a ground plane legitimately owns three quarters of that frame — and
1018
+ * a camera sealed inside geometry reads 1.000. Anything under 0.9 is a picture
1019
+ * a reader can still learn something from; the interesting failures pile up at
1020
+ * the very top of the range.
1021
+ */
1022
+ export const FLATNESS_WARN_AT = 0.9;
1023
+
1024
+ /**
1025
+ * The pure half: given RGBA for `cellCount` sampled cells, how dominated is
1026
+ * the frame by one near-uniform region?
1027
+ *
1028
+ * Two passes, no dependencies. First bucket every cell coarsely (32-wide per
1029
+ * channel) to find the busiest region; then re-count every cell within
1030
+ * `FLAT_TOLERANCE` of that region's MEAN, which is what stops a smooth surface
1031
+ * that happens to straddle a bucket boundary from reading as two regions.
1032
+ */
1033
+ type Rgb = [number, number, number];
1034
+
1035
+ /** One sampled cell's colour. The `?? 0` keeps a short/ragged buffer from
1036
+ * producing NaN arithmetic downstream. */
1037
+ function cellColor(rgba: Uint8ClampedArray | number[], cell: number): Rgb {
1038
+ return [rgba[cell * 4] ?? 0, rgba[cell * 4 + 1] ?? 0, rgba[cell * 4 + 2] ?? 0];
1039
+ }
1040
+
1041
+ /** Pass 1: coarse buckets (32 levels per channel) to find the busiest region
1042
+ * and count how many regions cover >=1% of the frame. */
1043
+ function dominantRegion(
1044
+ rgba: Uint8ClampedArray | number[],
1045
+ cellCount: number,
1046
+ ): { mean: Rgb; distinctRegions: number } {
1047
+ const buckets = new Map<number, { n: number; r: number; g: number; b: number }>();
1048
+ for (let cell = 0; cell < cellCount; cell++) {
1049
+ const [r, g, b] = cellColor(rgba, cell);
1050
+ const key = ((r >> 5) << 10) | ((g >> 5) << 5) | (b >> 5);
1051
+ const bucket = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
1052
+ bucket.n += 1;
1053
+ bucket.r += r;
1054
+ bucket.g += g;
1055
+ bucket.b += b;
1056
+ buckets.set(key, bucket);
1057
+ }
1058
+ let top = { n: 1, r: 0, g: 0, b: 0 };
1059
+ let distinctRegions = 0;
1060
+ for (const bucket of buckets.values()) {
1061
+ if (bucket.n / cellCount >= 0.01) distinctRegions += 1;
1062
+ if (bucket.n > top.n) top = bucket;
1063
+ }
1064
+ return {
1065
+ mean: [Math.round(top.r / top.n), Math.round(top.g / top.n), Math.round(top.b / top.n)],
1066
+ distinctRegions,
1067
+ };
1068
+ }
1069
+
1070
+ /** Pass 2: every cell within `FLAT_TOLERANCE` of that mean, which is what
1071
+ * stops a smooth surface straddling a bucket boundary from reading as two. */
1072
+ function countWithinTolerance(
1073
+ rgba: Uint8ClampedArray | number[],
1074
+ cellCount: number,
1075
+ mean: Rgb,
1076
+ ): number {
1077
+ let within = 0;
1078
+ for (let cell = 0; cell < cellCount; cell++) {
1079
+ const color = cellColor(rgba, cell);
1080
+ const delta = Math.max(
1081
+ Math.abs(color[0] - mean[0]),
1082
+ Math.abs(color[1] - mean[1]),
1083
+ Math.abs(color[2] - mean[2]),
1084
+ );
1085
+ if (delta <= FLAT_TOLERANCE) within += 1;
1086
+ }
1087
+ return within;
1088
+ }
1089
+
1090
+ export function measureFlatness(
1091
+ rgba: Uint8ClampedArray | number[],
1092
+ cellCount: number,
1093
+ threshold = FLATNESS_WARN_AT,
1094
+ ): CaptureFlatness {
1095
+ if (cellCount <= 0) {
1096
+ return { dominantFraction: 0, dominantColor: '#000000', distinctRegions: 0, degenerate: false };
1097
+ }
1098
+ const { mean, distinctRegions } = dominantRegion(rgba, cellCount);
1099
+ const dominantFraction =
1100
+ Math.round((countWithinTolerance(rgba, cellCount, mean) / cellCount) * 1000) / 1000;
1101
+ const dominantColor = `#${mean.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
1102
+ const degenerate = dominantFraction >= threshold;
1103
+ const warning =
1104
+ `this capture is ~${Math.round(dominantFraction * 100)}% one flat surface ` +
1105
+ `(${dominantColor}) — likely a wall, an empty view, or a buried camera; ` +
1106
+ 'it is weak evidence.';
1107
+ return {
1108
+ dominantFraction,
1109
+ dominantColor,
1110
+ distinctRegions,
1111
+ degenerate,
1112
+ ...(degenerate ? { warning } : {}),
1113
+ };
1114
+ }
1115
+
1116
+ /**
1117
+ * The DOM half: downsample any drawable source to {@link FLATNESS_GRID} and
1118
+ * measure it. The downsample is `drawImage` into a tiny canvas — the
1119
+ * compositor does the averaging, so cost is independent of capture size and
1120
+ * the readback is 4 KB. Returns null when readback is unavailable (a tainted
1121
+ * canvas), because a missing measurement must never read as a clean one.
1122
+ */
1123
+ export function sampleFlatness(
1124
+ source: CanvasImageSource,
1125
+ doc: Document,
1126
+ threshold = FLATNESS_WARN_AT,
1127
+ ): CaptureFlatness | null {
1128
+ try {
1129
+ const grid = doc.createElement('canvas');
1130
+ grid.width = FLATNESS_GRID;
1131
+ grid.height = FLATNESS_GRID;
1132
+ const ctx = grid.getContext('2d');
1133
+ if (!ctx) return null;
1134
+ ctx.drawImage(source, 0, 0, FLATNESS_GRID, FLATNESS_GRID);
1135
+ const pixels = ctx.getImageData(0, 0, FLATNESS_GRID, FLATNESS_GRID);
1136
+ return measureFlatness(pixels.data, FLATNESS_GRID * FLATNESS_GRID, threshold);
1137
+ } catch {
1138
+ return null;
1139
+ }
1140
+ }
1141
+
1142
+ /** Wait briefly for finite CSS/Web Animations to settle before serializing a
1143
+ * DOM frame. Chromium can rasterize an actively animated foreignObject as
1144
+ * opaque black compositor tiles; a settled frame is both more legible and
1145
+ * closer to what a human sees after the interaction. Infinite ambience never
1146
+ * blocks evidence capture, and the timeout keeps a broken animation from
1147
+ * wedging the relay. */
1148
+ export async function waitForFiniteMotion(container: HTMLElement, maxWaitMs = 350): Promise<void> {
1149
+ if (typeof container.getAnimations !== 'function') return;
1150
+ const animations = container.getAnimations({ subtree: true }).filter((animation) => {
1151
+ const endTime = animation.effect?.getComputedTiming().endTime;
1152
+ return typeof endTime === 'number' && Number.isFinite(endTime);
1153
+ });
1154
+ if (animations.length === 0) return;
1155
+ await Promise.race([
1156
+ Promise.allSettled(animations.map((animation) => animation.finished)),
1157
+ new Promise<void>((resolve) => setTimeout(resolve, maxWaitMs)),
1158
+ ]);
1159
+ }
1160
+
1161
+ /** Cross two paint boundaries when the tab is rendering, but never depend on
1162
+ * rAF firing: Chromium suspends it for hidden/background editor tabs, and a
1163
+ * screenshot is specifically expected to work there. The timer is the
1164
+ * bounded fallback, not an extra delay after a successful frame. */
1165
+ export async function waitForPaint(container: HTMLElement, maxWaitMs = 75): Promise<void> {
1166
+ const requestFrame = container.ownerDocument.defaultView?.requestAnimationFrame.bind(
1167
+ container.ownerDocument.defaultView,
1168
+ );
1169
+ if (!requestFrame) return;
1170
+ await new Promise<void>((resolve) => {
1171
+ let settled = false;
1172
+ const finish = () => {
1173
+ if (settled) return;
1174
+ settled = true;
1175
+ clearTimeout(timeout);
1176
+ resolve();
1177
+ };
1178
+ const timeout = setTimeout(finish, maxWaitMs);
1179
+ requestFrame(() => requestFrame(finish));
1180
+ });
1181
+ }
1182
+
1183
+ /**
1184
+ * Rasterize the full play container (canvases + DOM UI layers) to a PNG.
1185
+ * Output is sized to the container's CSS rect — the same geometry the human
1186
+ * sees. Throws on any rasterization failure; the caller
1187
+ * (`handleBridgeScreenshot`) degrades to the canvas-only capture and marks
1188
+ * the result honestly rather than failing the op.
1189
+ */
1190
+ export async function capturePlayComposite(
1191
+ container: HTMLElement,
1192
+ options?: CaptureOptions,
1193
+ ): Promise<CompositeCapture> {
1194
+ // Debug state can become observable just before React commits the matching
1195
+ // DOM. Cross two paint boundaries first, then let newly-mounted finite
1196
+ // transitions finish, then cross one more stable paint before cloning.
1197
+ await waitForPaint(container);
1198
+ // Chrome's foreignObject renderer can retain opaque-black compositor tiles
1199
+ // for several frames after a React-only tree replaces an overlay (observed
1200
+ // on an immediate game-over -> restart capture even after its 180ms tile
1201
+ // animation reported finished). Give DOM-only roots one short compositor
1202
+ // settle window; canvas-backed games do not use this fragile leg as their
1203
+ // sole image and keep the faster path.
1204
+ //
1205
+ // WHICH ROOTS ARE DOM-ONLY is a DECLARATION when the caller has one
1206
+ // ({@link CaptureOptions.presentsOnCanvas}); the canvas count is the measured
1207
+ // fallback, and it is spelled second so the fallback reads as one.
1208
+ //
1209
+ // The window itself STAYS, for both: it does not answer "has the game
1210
+ // painted" (declared readiness answers that, upstream, before capture is even
1211
+ // called) — it answers "has Chromium finished compositing the tiles it will
1212
+ // rasterize from", which no game can declare. Its measured companion is the
1213
+ // transparent/near-black retry below, which grades the actual pixels.
1214
+ const presentsOnCanvas =
1215
+ options?.presentsOnCanvas ?? container.querySelectorAll('canvas').length > 0;
1216
+ if (!presentsOnCanvas) {
1217
+ await new Promise<void>((resolve) => setTimeout(resolve, 650));
1218
+ }
1219
+ await waitForFiniteMotion(container);
1220
+ await waitForPaint(container);
1221
+ return capturePlayCompositeAttempt(container, 2, options ?? {});
1222
+ }
1223
+
1224
+ /** How much fully-transparent area still reads as a torn/blank frame rather
1225
+ * than as rounded corners (which stay far below it). One constant, because the
1226
+ * retry and the refusal below must agree about what "photographed nothing"
1227
+ * means. */
1228
+ export const TRANSPARENT_PIXEL_LIMIT = 0.03;
1229
+
1230
+ /** Fraction of the frame at alpha 0 — the measure both the bounded retry and
1231
+ * the refusal read. */
1232
+ export function transparentPixelFraction(rgba: Uint8ClampedArray, pixelCount: number): number {
1233
+ if (pixelCount <= 0) return 0;
1234
+ let transparent = 0;
1235
+ for (let alpha = 3; alpha < rgba.length; alpha += 4) {
1236
+ if (rgba[alpha] === 0) transparent += 1;
1237
+ }
1238
+ return transparent / pixelCount;
1239
+ }
1240
+
1241
+ export function hasExcessTransparentPixels(
1242
+ rgba: Uint8ClampedArray,
1243
+ pixelCount: number,
1244
+ limit = TRANSPARENT_PIXEL_LIMIT,
1245
+ ): boolean {
1246
+ if (pixelCount <= 0) return false;
1247
+ return transparentPixelFraction(rgba, pixelCount) > limit;
1248
+ }
1249
+
1250
+ /** Chromium's foreignObject compositor sometimes fills a torn tile with
1251
+ * fully-opaque black rather than transparency. Catch only a LARGE region of
1252
+ * near-pure black; a legitimately dark game merely takes the bounded retry
1253
+ * path and is still returned unchanged when it remains dark. */
1254
+ export function hasExcessNearBlackPixels(
1255
+ rgba: Uint8ClampedArray,
1256
+ pixelCount: number,
1257
+ limit = 0.08,
1258
+ ): boolean {
1259
+ if (pixelCount <= 0) return false;
1260
+ let black = 0;
1261
+ for (let offset = 0; offset < rgba.length; offset += 4) {
1262
+ if (
1263
+ rgba[offset]! <= 8 &&
1264
+ rgba[offset + 1]! <= 8 &&
1265
+ rgba[offset + 2]! <= 8 &&
1266
+ rgba[offset + 3]! >= 250
1267
+ ) {
1268
+ black += 1;
1269
+ }
1270
+ }
1271
+ return black / pixelCount > limit;
1272
+ }
1273
+
1274
+ type DomOnlyVerdict =
1275
+ | { kind: 'ok' }
1276
+ | { kind: 'retry' }
1277
+ | { kind: 'blank'; transparentFraction: number };
1278
+
1279
+ /**
1280
+ * Grade a DOM-ONLY composite (no canvas underneath it) on its own pixels.
1281
+ *
1282
+ * Two failures wear the same face here, and both end as black PNGs:
1283
+ * - A Chromium foreignObject can decode while React is between compositor
1284
+ * frames, leaving large transparent rectangles in an otherwise opaque
1285
+ * frame — that one is transient, so it earns a repaint and a retry.
1286
+ * Rounded-corner edge pixels stay far below {@link TRANSPARENT_PIXEL_LIMIT}.
1287
+ * - Nothing painted a backdrop at all. Out of retries and still mostly zero
1288
+ * alpha, the frame is REFUSED rather than returned: `measureFlatness` never
1289
+ * sees alpha, so it scores an empty frame as an ordinary flat surface and
1290
+ * the caller is handed a black photograph plus a warning about a buried
1291
+ * camera. That is the fabrication this module exists to not commit.
1292
+ *
1293
+ * A canvas-backed capture has its canvas underneath and is never graded here.
1294
+ */
1295
+ function judgeDomOnlyFrame(
1296
+ ctx2d: CanvasRenderingContext2D,
1297
+ width: number,
1298
+ height: number,
1299
+ retriesRemaining: number,
1300
+ ): DomOnlyVerdict {
1301
+ let pixels: ImageData;
1302
+ try {
1303
+ pixels = ctx2d.getImageData(0, 0, width, height);
1304
+ } catch {
1305
+ // Readback unavailable (a tainted canvas): preserve the existing honest
1306
+ // capture, or let `toDataURL` surface the taint downstream.
1307
+ return { kind: 'ok' };
1308
+ }
1309
+ const pixelCount = width * height;
1310
+ const transparentFraction = transparentPixelFraction(pixels.data, pixelCount);
1311
+ const excessTransparent = transparentFraction > TRANSPARENT_PIXEL_LIMIT;
1312
+ if (
1313
+ retriesRemaining > 0 &&
1314
+ (excessTransparent || hasExcessNearBlackPixels(pixels.data, pixelCount))
1315
+ ) {
1316
+ return { kind: 'retry' };
1317
+ }
1318
+ return excessTransparent ? { kind: 'blank', transparentFraction } : { kind: 'ok' };
1319
+ }
1320
+
1321
+ /**
1322
+ * The bounding box of every pixel with any alpha, or `null` for a frame that
1323
+ * painted nothing. Runs on the PRE-backdrop composite (the crop path skips the
1324
+ * container's own background exactly so this scan sees only the subject), so
1325
+ * painted alpha IS the content — the measured half of "mount in the truth,
1326
+ * frame the subject".
1327
+ */
1328
+ function contentAlphaBounds(
1329
+ ctx2d: CanvasRenderingContext2D,
1330
+ width: number,
1331
+ height: number,
1332
+ ): { left: number; top: number; right: number; bottom: number } | null {
1333
+ let pixels: ImageData;
1334
+ try {
1335
+ pixels = ctx2d.getImageData(0, 0, width, height);
1336
+ } catch {
1337
+ // Readback unavailable (a tainted canvas): no measurement, so degrade to
1338
+ // the full frame — an uncropped capture, never a false blank refusal.
1339
+ return { left: 0, top: 0, right: width, bottom: height };
1340
+ }
1341
+ let left = width;
1342
+ let top = height;
1343
+ let right = -1;
1344
+ let bottom = -1;
1345
+ const data = pixels.data;
1346
+ for (let y = 0; y < height; y += 1) {
1347
+ const row = y * width * 4;
1348
+ for (let x = 0; x < width; x += 1) {
1349
+ if (data[row + x * 4 + 3] === 0) continue;
1350
+ if (x < left) left = x;
1351
+ if (x > right) right = x;
1352
+ if (y < top) top = y;
1353
+ if (y > bottom) bottom = y;
1354
+ }
1355
+ }
1356
+ return right < 0 ? null : { left, top, right: right + 1, bottom: bottom + 1 };
1357
+ }
1358
+
1359
+ async function capturePlayCompositeAttempt(
1360
+ container: HTMLElement,
1361
+ retriesRemaining: number,
1362
+ options: CaptureOptions,
1363
+ ): Promise<CompositeCapture> {
1364
+ const out = container.ownerDocument.createElement('canvas');
1365
+ const layers = await drawPlayCompositeFrame(container, out, options);
1366
+ const width = out.width;
1367
+ const height = out.height;
1368
+ const ctx2d = out.getContext('2d');
1369
+ if (!ctx2d) {
1370
+ throw new CaptureLayerError('composite-output', 'no 2d canvas context');
1371
+ }
1372
+
1373
+ if (layers.canvases === 0 && layers.domOverlays > 0 && options?.cropToContent === true) {
1374
+ // The crop path's own emptiness test: the frame deliberately has no
1375
+ // backdrop yet, so painted alpha IS the content. Nothing painted after
1376
+ // the retries is the same blank refusal as below; content crops to its
1377
+ // measured union plus padding, on the container's own backdrop.
1378
+ const bounds = contentAlphaBounds(ctx2d, width, height);
1379
+ if (bounds === null) {
1380
+ if (options.allowTransparent)
1381
+ return { base64: out.toDataURL('image/png').split(',')[1]!, mimeType: 'image/png', layers };
1382
+ if (retriesRemaining > 0) {
1383
+ await new Promise<void>((resolve) => setTimeout(resolve, 300));
1384
+ await waitForPaint(container);
1385
+ return capturePlayCompositeAttempt(container, retriesRemaining - 1, options);
1386
+ }
1387
+ throw new CaptureLayerError(
1388
+ 'dom-overlay',
1389
+ 'the composite is ~100% fully transparent after two repaints — nothing in it painted ' +
1390
+ "a backdrop, so the PNG would read as a flat black frame. This subject's background " +
1391
+ 'is painted by an element OUTSIDE what is being photographed (a detached clone ' +
1392
+ 'carries no ancestors), or its own DOM never mounted.',
1393
+ );
1394
+ }
1395
+ const CROP_PAD = 16;
1396
+ const x0 = Math.max(0, bounds.left - CROP_PAD);
1397
+ const y0 = Math.max(0, bounds.top - CROP_PAD);
1398
+ const x1 = Math.min(width, bounds.right + CROP_PAD);
1399
+ const y1 = Math.min(height, bounds.bottom + CROP_PAD);
1400
+ const cropped = container.ownerDocument.createElement('canvas');
1401
+ cropped.width = Math.max(1, x1 - x0);
1402
+ cropped.height = Math.max(1, y1 - y0);
1403
+ const croppedCtx = cropped.getContext('2d');
1404
+ if (!croppedCtx) throw new CaptureLayerError('composite-output', 'no 2d canvas context');
1405
+ const ownBackground = getComputedStyle(container).backgroundColor;
1406
+ if (ownBackground && ownBackground !== 'transparent' && ownBackground !== 'rgba(0, 0, 0, 0)') {
1407
+ croppedCtx.fillStyle = ownBackground;
1408
+ croppedCtx.fillRect(0, 0, cropped.width, cropped.height);
1409
+ }
1410
+ croppedCtx.drawImage(
1411
+ out,
1412
+ x0,
1413
+ y0,
1414
+ cropped.width,
1415
+ cropped.height,
1416
+ 0,
1417
+ 0,
1418
+ cropped.width,
1419
+ cropped.height,
1420
+ );
1421
+ const flatness = sampleFlatness(cropped, container.ownerDocument);
1422
+ let croppedUrl: string;
1423
+ try {
1424
+ croppedUrl = cropped.toDataURL('image/png');
1425
+ } catch (error) {
1426
+ throw layerFailure('composite-output', error);
1427
+ }
1428
+ const croppedComma = croppedUrl.indexOf(',');
1429
+ return {
1430
+ base64: croppedComma >= 0 ? croppedUrl.slice(croppedComma + 1) : croppedUrl,
1431
+ mimeType: 'image/png',
1432
+ layers,
1433
+ ...(flatness ? { flatness } : {}),
1434
+ };
1435
+ }
1436
+ if (layers.canvases === 0 && layers.domOverlays > 0 && !options.allowTransparent) {
1437
+ const verdict = judgeDomOnlyFrame(ctx2d, width, height, retriesRemaining);
1438
+ if (verdict.kind === 'retry') {
1439
+ await new Promise<void>((resolve) => setTimeout(resolve, 300));
1440
+ await waitForPaint(container);
1441
+ return capturePlayCompositeAttempt(container, retriesRemaining - 1, options);
1442
+ }
1443
+ if (verdict.kind === 'blank') {
1444
+ throw new CaptureLayerError(
1445
+ 'dom-overlay',
1446
+ `the composite is ~${Math.round(verdict.transparentFraction * 100)}% fully transparent ` +
1447
+ 'after two repaints — nothing in it painted a backdrop, so the PNG would read as a flat ' +
1448
+ "black frame. This subject's background is painted by an element OUTSIDE what is being " +
1449
+ 'photographed (a detached clone carries no ancestors), or its own DOM never mounted.',
1450
+ );
1451
+ }
1452
+ }
1453
+
1454
+ // Measure BEFORE encoding, off the composite we just drew: the pixels are
1455
+ // already here, so honesty costs one downsample and no PNG decode anywhere
1456
+ // downstream. (The alternative — measuring in Node from the CLI — would need
1457
+ // a PNG decoder this repo does not ship, in a process that never has the
1458
+ // frame in memory in the first place.)
1459
+ const flatness = sampleFlatness(out, container.ownerDocument);
1460
+
1461
+ let dataUrl: string;
1462
+ try {
1463
+ dataUrl = out.toDataURL('image/png');
1464
+ } catch (error) {
1465
+ throw layerFailure('composite-output', error);
1466
+ }
1467
+ const comma = dataUrl.indexOf(',');
1468
+ return {
1469
+ base64: comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl,
1470
+ mimeType: 'image/png',
1471
+ layers,
1472
+ ...(flatness ? { flatness } : {}),
1473
+ };
1474
+ }
1475
+
1476
+ /** Fill each element's own painted background at its own on-screen box,
1477
+ * outermost first — the order and the places the browser has them, so a
1478
+ * translucent ("glass") surface composites the way it does on screen. */
1479
+ function paintBackdrops(
1480
+ ctx2d: CanvasRenderingContext2D,
1481
+ elements: readonly HTMLElement[],
1482
+ rect: DOMRect,
1483
+ scaleX = 1,
1484
+ scaleY = 1,
1485
+ ): void {
1486
+ for (const element of elements) {
1487
+ const color = opaqueBackgroundColor(element);
1488
+ if (!color) continue;
1489
+ const box = element.getBoundingClientRect();
1490
+ ctx2d.fillStyle = color;
1491
+ ctx2d.fillRect(
1492
+ (box.left - rect.left) * scaleX,
1493
+ (box.top - rect.top) * scaleY,
1494
+ box.width * scaleX,
1495
+ box.height * scaleY,
1496
+ );
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * Paint one current game frame into a caller-owned canvas.
1502
+ *
1503
+ * Screenshots call this once and encode the result. Gameplay recording calls
1504
+ * it repeatedly while a native `MediaRecorder` consumes `output.captureStream()`.
1505
+ * Keeping the draw primitive here is what guarantees that a still and a video
1506
+ * see the same game stack: every world canvas plus DOM HUD. This function
1507
+ * performs no settling,
1508
+ * retry, quality judgment, or encoding; those are policies of its callers.
1509
+ */
1510
+ export async function drawPlayCompositeFrame(
1511
+ container: HTMLElement,
1512
+ output: HTMLCanvasElement,
1513
+ options?: CaptureOptions,
1514
+ ): Promise<CompositeFrame> {
1515
+ const rect = container.getBoundingClientRect();
1516
+ const width = Math.max(1, Math.round(options?.size?.width ?? rect.width));
1517
+ const height = Math.max(1, Math.round(options?.size?.height ?? rect.height));
1518
+ const scaleX = options?.size ? width / rect.width : 1;
1519
+ const scaleY = options?.size ? height / rect.height : 1;
1520
+ if (output.width !== width) output.width = width;
1521
+ if (output.height !== height) output.height = height;
1522
+
1523
+ const ctx2d = output.getContext('2d');
1524
+ if (!ctx2d) {
1525
+ throw new CaptureLayerError('composite-output', 'no 2d canvas context');
1526
+ }
1527
+ ctx2d.clearRect(0, 0, width, height);
1528
+
1529
+ // The container's OWN background, painted first. Both backdrop walks below
1530
+ // start from canvases, so a canvas-less DOM subject (a story capture host,
1531
+ // whose inline backdrop exists precisely to show through wherever the story
1532
+ // paints nothing) composited as pure overlay — and a sparse HUD story came
1533
+ // back ~99% transparent and was refused as blank. A crop-to-content capture
1534
+ // skips it HERE so the content-bounds scan sees only what the subject
1535
+ // painted; the cropped output re-paints it underneath.
1536
+ if (options?.cropToContent !== true) {
1537
+ const ownBackground = getComputedStyle(container).backgroundColor;
1538
+ if (ownBackground && ownBackground !== 'transparent' && ownBackground !== 'rgba(0, 0, 0, 0)') {
1539
+ ctx2d.fillStyle = ownBackground;
1540
+ ctx2d.fillRect(0, 0, width, height);
1541
+ }
1542
+ }
1543
+
1544
+ // The backdrop the SUBJECT ITSELF does not paint — an editor document's panel
1545
+ // fill lives on an ancestor, which the detached clone cannot carry. See {@link ancestorBackdrops} for the measurement that put this here.
1546
+ if (options?.includeDocumentStyles) {
1547
+ paintBackdrops(ctx2d, ancestorBackdrops(container), rect, scaleX, scaleY);
1548
+ }
1549
+
1550
+ // The backgrounds that paint BEHIND a root-surface canvas, painted before the
1551
+ // canvases themselves. Without this leg they ride the overlay ABOVE the
1552
+ // canvases and erase the world. See {@link rootSurfaceBackdrops} for the
1553
+ // measurement that put this here.
1554
+ // Canvas pixels can change without a DOM mutation. A nested UI canvas must
1555
+ // be photographed this frame, not frozen into the recorder's HUD cache.
1556
+ const nestedCanvas = Array.from(container.querySelectorAll('canvas')).some(
1557
+ (canvas) => !isRootCanvas(canvas),
1558
+ );
1559
+ const cached = nestedCanvas ? null : options?.overlayCache?.take(container, width, height);
1560
+ const backdrops = cached?.backdrops ?? rootSurfaceBackdrops(container);
1561
+ paintBackdrops(ctx2d, backdrops, rect, scaleX, scaleY);
1562
+
1563
+ // Hidden/restored documents can retain a canvas whose backing store or
1564
+ // layout box is zero-sized. The browser paints no pixels for it, and
1565
+ // CanvasRenderingContext2D.drawImage throws instead of expressing that
1566
+ // no-op. Keep the capture's canvas count tied to surfaces that could
1567
+ // actually contribute pixels to the frame.
1568
+ const canvases = Array.from(container.querySelectorAll('canvas')).filter((canvas) => {
1569
+ const canvasRect = canvas.getBoundingClientRect();
1570
+ return canvas.width > 0 && canvas.height > 0 && canvasRect.width > 0 && canvasRect.height > 0;
1571
+ });
1572
+ const canvasPixels = new Map<HTMLCanvasElement, CanvasImageSource>();
1573
+ for (const canvas of canvases) {
1574
+ try {
1575
+ const canvasRect = canvas.getBoundingClientRect();
1576
+ // `?? canvas` is the unchanged path: no seam offered, or the seam had no
1577
+ // frame for THIS canvas, means read the canvas itself.
1578
+ const pixels = (await options?.canvasFrame?.(canvas)) ?? canvas;
1579
+ canvasPixels.set(canvas, pixels);
1580
+ ctx2d.drawImage(
1581
+ pixels,
1582
+ (canvasRect.left - rect.left) * scaleX,
1583
+ (canvasRect.top - rect.top) * scaleY,
1584
+ canvasRect.width * scaleX,
1585
+ canvasRect.height * scaleY,
1586
+ );
1587
+ } catch (error) {
1588
+ throw layerFailure('canvas', error);
1589
+ }
1590
+ }
1591
+
1592
+ let domOverlays = 0;
1593
+ // The same pixels ride the overlay leg, so a canvas nested inside an overlay
1594
+ // layer keeps its z-order instead of being painted over by its own layer.
1595
+ try {
1596
+ if (cached) {
1597
+ if (cached.image) ctx2d.drawImage(cached.image, 0, 0, width, height);
1598
+ domOverlays = cached.overlayCount;
1599
+ } else {
1600
+ const buildStart = performance.now();
1601
+ // LAYOUT in CSS pixels, RASTER at the output size. `size` scales the
1602
+ // frame, never the DOM's own geometry: the clone is laid out in the
1603
+ // pixels its stylesheet is written in and the SVG's viewBox does the
1604
+ // scaling, so a 2x frame has 2x-resolution text rather than the same
1605
+ // text in a doubled box. See {@link buildOverlaySvg}'s `rasterSize`.
1606
+ const overlay = buildOverlaySvg(container, width / scaleX, height / scaleY, {
1607
+ includeDocumentStyles: options?.includeDocumentStyles,
1608
+ canvasPixels,
1609
+ transparentBackdrops: new Set<Element>(backdrops),
1610
+ snapshots: options?.snapshots,
1611
+ rasterSize: { width, height },
1612
+ });
1613
+ if (overlay) {
1614
+ const image = new Image();
1615
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(overlay.svg)}`;
1616
+ await image.decode();
1617
+ ctx2d.drawImage(image, 0, 0, width, height);
1618
+ domOverlays = overlay.overlayCount;
1619
+ options?.overlayCache?.store(
1620
+ container,
1621
+ width,
1622
+ height,
1623
+ { image, overlayCount: overlay.overlayCount, backdrops },
1624
+ performance.now() - buildStart,
1625
+ );
1626
+ } else {
1627
+ options?.overlayCache?.store(
1628
+ container,
1629
+ width,
1630
+ height,
1631
+ { image: null, overlayCount: 0, backdrops },
1632
+ performance.now() - buildStart,
1633
+ );
1634
+ }
1635
+ }
1636
+ } catch (error) {
1637
+ throw layerFailure('dom-overlay', error);
1638
+ }
1639
+
1640
+ return { canvases: canvases.length, domOverlays };
1641
+ }