@volter/sdk 0.0.0-stage → 0.5.203

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 (523) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +20 -0
  3. package/README.md +38 -3
  4. package/package.json +510 -4
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +88 -0
  7. package/src/client.ts +1646 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +619 -0
  10. package/src/css-numeric-style.ts +97 -0
  11. package/src/document-probe.ts +282 -0
  12. package/src/editor-view.ts +225 -0
  13. package/src/extension.ts +40 -0
  14. package/src/generations.ts +178 -0
  15. package/src/host.ts +1157 -0
  16. package/src/http-transport.browser.ts +14 -0
  17. package/src/http-transport.node.ts +19 -0
  18. package/src/index.ts +131 -0
  19. package/src/kit/CapabilityCoverageSection.tsx +185 -0
  20. package/src/kit/account-client.ts +333 -0
  21. package/src/kit/action-registry.ts +317 -0
  22. package/src/kit/active-product.ts +76 -0
  23. package/src/kit/active-project.ts +155 -0
  24. package/src/kit/adapter-editor-config.ts +25 -0
  25. package/src/kit/adapter-module.ts +7 -0
  26. package/src/kit/adapter-observation.ts +49 -0
  27. package/src/kit/animation/animation-clock.ts +479 -0
  28. package/src/kit/animation/stage-transport.ts +385 -0
  29. package/src/kit/api/assets.ts +365 -0
  30. package/src/kit/api/project-open.ts +355 -0
  31. package/src/kit/api/project-source.ts +180 -0
  32. package/src/kit/api/project-state.ts +110 -0
  33. package/src/kit/api/relay.ts +270 -0
  34. package/src/kit/api/themes.ts +45 -0
  35. package/src/kit/api-asset-library-wire.ts +45 -0
  36. package/src/kit/api-base.ts +10 -0
  37. package/src/kit/api-build.ts +99 -0
  38. package/src/kit/api-git-wire.ts +56 -0
  39. package/src/kit/api-logs.ts +92 -0
  40. package/src/kit/api-project-identity.ts +74 -0
  41. package/src/kit/api-settings.ts +36 -0
  42. package/src/kit/api-worktrees.ts +205 -0
  43. package/src/kit/asset-capabilities.ts +344 -0
  44. package/src/kit/asset-compare-core.ts +171 -0
  45. package/src/kit/asset-editor-context.tsx +101 -0
  46. package/src/kit/asset-events.ts +96 -0
  47. package/src/kit/asset-inspector-actions.ts +87 -0
  48. package/src/kit/asset-selection-viewer-registry.ts +113 -0
  49. package/src/kit/asset-selection.ts +146 -0
  50. package/src/kit/asset-thumbnails.ts +25 -0
  51. package/src/kit/asset-viewers.ts +115 -0
  52. package/src/kit/asset-workflow/asset-import-jobs.ts +106 -0
  53. package/src/kit/asset-workflow/asset-ledger-backend.ts +126 -0
  54. package/src/kit/asset-workflow/asset-ledger.ts +156 -0
  55. package/src/kit/asset-workflow/asset-materialization-report.ts +140 -0
  56. package/src/kit/asset-workflow/asset-pack-manifest.ts +320 -0
  57. package/src/kit/asset-workflow/asset-types.ts +142 -0
  58. package/src/kit/asset-workflow/audio-preview-player.ts +193 -0
  59. package/src/kit/asset-workflow/audio-waveform.ts +22 -0
  60. package/src/kit/asset-workflow/cloud-asset-client.ts +263 -0
  61. package/src/kit/asset-workflow/hosted-asset-materialization.ts +236 -0
  62. package/src/kit/asset-workflow/image-view-scale.ts +31 -0
  63. package/src/kit/asset-workflow/import-contract.ts +124 -0
  64. package/src/kit/asset-workflow/ledger-write-lock.ts +244 -0
  65. package/src/kit/asset-workflow/pixi-spritesheet.ts +197 -0
  66. package/src/kit/asset-workflow/preview-resource-lifetime.ts +44 -0
  67. package/src/kit/asset-workflow/project-asset-commands.ts +20 -0
  68. package/src/kit/asset-workflow/project-content.ts +288 -0
  69. package/src/kit/asset-workflow/project-source-index.ts +545 -0
  70. package/src/kit/asset-workflow/thumbnail-system.ts +256 -0
  71. package/src/kit/authoring/active-adapter.ts +200 -0
  72. package/src/kit/authoring/active-systems.ts +422 -0
  73. package/src/kit/authoring/adapter-key.ts +18 -0
  74. package/src/kit/authoring/authoring-asset-url.ts +27 -0
  75. package/src/kit/authoring/bootstrap-state.ts +49 -0
  76. package/src/kit/authoring/boundary-authoring-adapter.ts +189 -0
  77. package/src/kit/authoring/canvas-scene-guides.ts +84 -0
  78. package/src/kit/authoring/composite-authoring-adapter.ts +2109 -0
  79. package/src/kit/authoring/consumer-actions.ts +531 -0
  80. package/src/kit/authoring/design-time-layers.ts +852 -0
  81. package/src/kit/authoring/design-time-mount-registry.ts +244 -0
  82. package/src/kit/authoring/edit-mode-authoring.ts +637 -0
  83. package/src/kit/authoring/empty-project-authoring.ts +22 -0
  84. package/src/kit/authoring/instance-source-menu.ts +135 -0
  85. package/src/kit/authoring/layered-pick.ts +185 -0
  86. package/src/kit/authoring/mounted-root-subjects.ts +146 -0
  87. package/src/kit/authoring/no-authoring-adapter.ts +59 -0
  88. package/src/kit/authoring/object3d-document-persistence.ts +122 -0
  89. package/src/kit/authoring/panel-authoring.ts +121 -0
  90. package/src/kit/authoring/project-authoring-session.ts +105 -0
  91. package/src/kit/authoring/provenance.ts +99 -0
  92. package/src/kit/authoring/react-canvas-navigation.ts +259 -0
  93. package/src/kit/authoring/react-design-canvas-style.ts +20 -0
  94. package/src/kit/authoring/react-story-board.ts +917 -0
  95. package/src/kit/authoring/selection-scope.ts +195 -0
  96. package/src/kit/authoring/shell-document-ops.ts +169 -0
  97. package/src/kit/authoring/story-board-chrome-fit.ts +107 -0
  98. package/src/kit/authoring/story-board-presentation.ts +111 -0
  99. package/src/kit/authoring/three-root.ts +67 -0
  100. package/src/kit/authoring/viewport-tool-context.ts +73 -0
  101. package/src/kit/authoring/viewport-tool-owner.ts +38 -0
  102. package/src/kit/authoring/world-session-state.ts +101 -0
  103. package/src/kit/authoring-seam-evidence.ts +300 -0
  104. package/src/kit/availability-tick.ts +66 -0
  105. package/src/kit/bitmap-label.ts +120 -0
  106. package/src/kit/boot-routing.ts +392 -0
  107. package/src/kit/breakpoint-state.ts +43 -0
  108. package/src/kit/build-identity.ts +16 -0
  109. package/src/kit/bytes-codec.ts +62 -0
  110. package/src/kit/cancellation-reason.ts +58 -0
  111. package/src/kit/canvas-frames.ts +88 -0
  112. package/src/kit/capture-camera-pose.ts +77 -0
  113. package/src/kit/capture-size.ts +88 -0
  114. package/src/kit/chrome-registry.ts +159 -0
  115. package/src/kit/chrome-slot-registry.ts +91 -0
  116. package/src/kit/collaboration-client.ts +264 -0
  117. package/src/kit/collaboration-presence.ts +41 -0
  118. package/src/kit/command-dispatch.ts +19 -0
  119. package/src/kit/command-listener.ts +2182 -0
  120. package/src/kit/command-registry.ts +71 -0
  121. package/src/kit/component-board-registry.ts +205 -0
  122. package/src/kit/component-states-registry.ts +199 -0
  123. package/src/kit/components/AlignToolbar.tsx +204 -0
  124. package/src/kit/components/ApplicationMenus.tsx +372 -0
  125. package/src/kit/components/AssetEditorShell.tsx +216 -0
  126. package/src/kit/components/AssetInspectorToolSection.tsx +124 -0
  127. package/src/kit/components/BoardRulers.tsx +354 -0
  128. package/src/kit/components/CanvasAddNodeDialogs.tsx +529 -0
  129. package/src/kit/components/CanvasSceneViewport.tsx +1195 -0
  130. package/src/kit/components/ChromeSlot.tsx +20 -0
  131. package/src/kit/components/CodeView.tsx +470 -0
  132. package/src/kit/components/CompactInspectorShell.tsx +39 -0
  133. package/src/kit/components/ConsolePanel.tsx +273 -0
  134. package/src/kit/components/GameplaySessionTimeline.tsx +295 -0
  135. package/src/kit/components/InspectionProjection.tsx +932 -0
  136. package/src/kit/components/Inspector.tsx +270 -0
  137. package/src/kit/components/InspectorCanvasPreview.tsx +35 -0
  138. package/src/kit/components/InspectorFieldsSection.tsx +290 -0
  139. package/src/kit/components/InspectorStoriesSection.tsx +92 -0
  140. package/src/kit/components/InspectorToolSection.tsx +96 -0
  141. package/src/kit/components/InspectorTransformSection.tsx +245 -0
  142. package/src/kit/components/LightExplorerPanel.tsx +433 -0
  143. package/src/kit/components/MediaProperties.tsx +145 -0
  144. package/src/kit/components/ProjectHeader.tsx +328 -0
  145. package/src/kit/components/ReactCanvasControls.tsx +358 -0
  146. package/src/kit/components/RootSelectionOverlay.tsx +3688 -0
  147. package/src/kit/components/RootTextEditor.tsx +79 -0
  148. package/src/kit/components/SaveStatus.tsx +70 -0
  149. package/src/kit/components/SurfaceCrashBoundary.tsx +105 -0
  150. package/src/kit/components/SurfaceStateOverlay.tsx +24 -0
  151. package/src/kit/components/ToolContributionSurfaces.tsx +49 -0
  152. package/src/kit/components/ToolHost.tsx +380 -0
  153. package/src/kit/components/Toolbar.tsx +811 -0
  154. package/src/kit/components/TransientHint.tsx +44 -0
  155. package/src/kit/components/VersionControlSection.tsx +470 -0
  156. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  157. package/src/kit/components/VolterLogo.tsx +18 -0
  158. package/src/kit/components/WorktreeSwitcher.tsx +712 -0
  159. package/src/kit/components/account-documents.tsx +1162 -0
  160. package/src/kit/components/asset-documents.tsx +794 -0
  161. package/src/kit/components/asset-editor-persistence.ts +216 -0
  162. package/src/kit/components/asset-selection-section.tsx +545 -0
  163. package/src/kit/components/asset-thumbnails.tsx +307 -0
  164. package/src/kit/components/asset-viewers/AudioViewer.tsx +201 -0
  165. package/src/kit/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  166. package/src/kit/components/asset-viewers/ImageViewer.tsx +300 -0
  167. package/src/kit/components/asset-viewers/JsonAssetDocument.tsx +98 -0
  168. package/src/kit/components/asset-viewers/OnlineAssetDetail.tsx +426 -0
  169. package/src/kit/components/asset-viewers/SourceAssetViewer.tsx +356 -0
  170. package/src/kit/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  171. package/src/kit/components/asset-viewers/VideoViewer.tsx +101 -0
  172. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  173. package/src/kit/components/board-guides.ts +150 -0
  174. package/src/kit/components/canvas-scene-hotkeys.ts +37 -0
  175. package/src/kit/components/canvas-temporary-pivot.ts +34 -0
  176. package/src/kit/components/core-utilities.tsx +94 -0
  177. package/src/kit/components/inspector-preview-section.tsx +223 -0
  178. package/src/kit/components/inspector-revert-label.ts +20 -0
  179. package/src/kit/components/inspector-selection.ts +42 -0
  180. package/src/kit/components/inspector-stories-gating.ts +171 -0
  181. package/src/kit/components/inspector-transform-subject.ts +11 -0
  182. package/src/kit/components/inspector-transform.ts +88 -0
  183. package/src/kit/components/kind-documents.tsx +544 -0
  184. package/src/kit/components/primitives/DraftColorInput.tsx +74 -0
  185. package/src/kit/components/project-tool-documents.tsx +402 -0
  186. package/src/kit/components/scene-documents.tsx +221 -0
  187. package/src/kit/components/status-contributions.tsx +407 -0
  188. package/src/kit/components/tool-documents.tsx +302 -0
  189. package/src/kit/components/tool-schema-form.tsx +262 -0
  190. package/src/kit/components/use-after-paint.ts +41 -0
  191. package/src/kit/components/use-project-image-assets.ts +86 -0
  192. package/src/kit/components/workspace-history.ts +32 -0
  193. package/src/kit/components/world-documents.tsx +570 -0
  194. package/src/kit/components/world-overlay-gestures.ts +1939 -0
  195. package/src/kit/composite-screenshot.ts +2238 -0
  196. package/src/kit/content-entry-source-registry.ts +184 -0
  197. package/src/kit/contribution-surfaces.ts +48 -0
  198. package/src/kit/coverage/canvas-reveal.ts +192 -0
  199. package/src/kit/coverage/design-time-surfaces.ts +101 -0
  200. package/src/kit/coverage/ontology-invariants.ts +466 -0
  201. package/src/kit/coverage/session-vitals.ts +503 -0
  202. package/src/kit/crash-null-boundary.ts +36 -0
  203. package/src/kit/creation-site-edit.ts +1491 -0
  204. package/src/kit/creation-site-registry.ts +160 -0
  205. package/src/kit/delegate-harness-registry.ts +134 -0
  206. package/src/kit/document-areas.ts +70 -0
  207. package/src/kit/document-context-registry.ts +193 -0
  208. package/src/kit/document-open-registry.ts +200 -0
  209. package/src/kit/document-play-extension.ts +221 -0
  210. package/src/kit/document-preview-source.ts +20 -0
  211. package/src/kit/document-renderer-session.ts +138 -0
  212. package/src/kit/document-stage-sessions.ts +26 -0
  213. package/src/kit/document-viewports.ts +120 -0
  214. package/src/kit/editor-api.ts +46 -0
  215. package/src/kit/editor-chrome-capture.ts +136 -0
  216. package/src/kit/editor-commands.ts +176 -0
  217. package/src/kit/editor-console.ts +580 -0
  218. package/src/kit/editor-current-view.ts +56 -0
  219. package/src/kit/editor-document-probe.ts +1166 -0
  220. package/src/kit/editor-git-client.ts +115 -0
  221. package/src/kit/editor-hotkeys.ts +728 -0
  222. package/src/kit/editor-lease-view.ts +39 -0
  223. package/src/kit/editor-lease.ts +415 -0
  224. package/src/kit/editor-mode.ts +19 -0
  225. package/src/kit/editor-notifications.ts +140 -0
  226. package/src/kit/editor-presence.ts +563 -0
  227. package/src/kit/editor-presentation-activity.ts +58 -0
  228. package/src/kit/editor-presentation-notice.ts +42 -0
  229. package/src/kit/editor-runtime.tsx +147 -0
  230. package/src/kit/editor-server-response.ts +86 -0
  231. package/src/kit/editor-session-attribution.ts +85 -0
  232. package/src/kit/editor-session-mode.ts +54 -0
  233. package/src/kit/editor-state-facets.ts +74 -0
  234. package/src/kit/editor-view-presentation.ts +777 -0
  235. package/src/kit/environment-images.ts +58 -0
  236. package/src/kit/eyedropper-session.ts +60 -0
  237. package/src/kit/files/file-provider.ts +62 -0
  238. package/src/kit/files/project-files.ts +270 -0
  239. package/src/kit/finders/index.ts +137 -0
  240. package/src/kit/finders/scenes-from-entrypoint-selection.ts +387 -0
  241. package/src/kit/frame/frame-parts.ts +30 -0
  242. package/src/kit/framed-document-capture.ts +34 -0
  243. package/src/kit/game-globals-prelude.ts +143 -0
  244. package/src/kit/game-surface-defaults.ts +33 -0
  245. package/src/kit/gameplay-dom-recording.ts +318 -0
  246. package/src/kit/gameplay-export-state.ts +14 -0
  247. package/src/kit/gameplay-replay.ts +417 -0
  248. package/src/kit/gameplay-session-time.ts +9 -0
  249. package/src/kit/gameplay-sessions.ts +204 -0
  250. package/src/kit/hierarchy-component-marks.ts +298 -0
  251. package/src/kit/hierarchy-internals.ts +197 -0
  252. package/src/kit/hierarchy-kind-icon.ts +217 -0
  253. package/src/kit/hierarchy-menu-registry.ts +67 -0
  254. package/src/kit/hierarchy-node-rows.ts +307 -0
  255. package/src/kit/hierarchy-panel-view.ts +280 -0
  256. package/src/kit/hierarchy-projection.ts +76 -0
  257. package/src/kit/hierarchy-row-media.ts +45 -0
  258. package/src/kit/hierarchy-row-model.ts +308 -0
  259. package/src/kit/hierarchy-rows.ts +11 -0
  260. package/src/kit/hierarchy-walk.ts +86 -0
  261. package/src/kit/history/editor-session.ts +25 -0
  262. package/src/kit/history/history-commands.ts +147 -0
  263. package/src/kit/history/history-delegate.ts +187 -0
  264. package/src/kit/history/history-limit-notices.ts +43 -0
  265. package/src/kit/history/history-service.ts +1189 -0
  266. package/src/kit/history/persistence-coordinator.ts +35 -0
  267. package/src/kit/history/project-file-history.ts +386 -0
  268. package/src/kit/history/project-root-history-backends.ts +139 -0
  269. package/src/kit/history/resource-registry.ts +209 -0
  270. package/src/kit/history/snapshot-store.ts +103 -0
  271. package/src/kit/history/source-history-backend.ts +546 -0
  272. package/src/kit/history-types.ts +124 -0
  273. package/src/kit/hmr-registration-group.ts +67 -0
  274. package/src/kit/hmr-stable-react-context.ts +23 -0
  275. package/src/kit/hotkeys.ts +190 -0
  276. package/src/kit/inference-diagnostics.ts +69 -0
  277. package/src/kit/initial-project.ts +80 -0
  278. package/src/kit/inspection/active-subject.ts +571 -0
  279. package/src/kit/inspection/active-surface.ts +142 -0
  280. package/src/kit/inspection/compose-subject.ts +1055 -0
  281. package/src/kit/inspection/compose.ts +7 -0
  282. package/src/kit/inspection/display.ts +171 -0
  283. package/src/kit/inspection/document-subject.ts +109 -0
  284. package/src/kit/inspection/game-subject.ts +85 -0
  285. package/src/kit/inspection/null-subject.ts +119 -0
  286. package/src/kit/inspection/serialize.ts +357 -0
  287. package/src/kit/inspection/use-active-inspection.ts +180 -0
  288. package/src/kit/inspection-model.ts +542 -0
  289. package/src/kit/inspection-node-media.ts +58 -0
  290. package/src/kit/inspector-presentation.ts +203 -0
  291. package/src/kit/inspector-property-grouping.ts +64 -0
  292. package/src/kit/inspector-section-registry.ts +221 -0
  293. package/src/kit/instance-source-actions.ts +163 -0
  294. package/src/kit/js-heap.ts +71 -0
  295. package/src/kit/key-actions.ts +91 -0
  296. package/src/kit/keymap-presets.ts +428 -0
  297. package/src/kit/layout-policy.ts +31 -0
  298. package/src/kit/light-explorer-model.ts +134 -0
  299. package/src/kit/live-canvas-frame.ts +55 -0
  300. package/src/kit/live-document.ts +296 -0
  301. package/src/kit/live-gesture-lock.ts +50 -0
  302. package/src/kit/live-seam-evidence.ts +11 -0
  303. package/src/kit/live-session-registry.ts +220 -0
  304. package/src/kit/live-transition.ts +391 -0
  305. package/src/kit/manifest-project.ts +107 -0
  306. package/src/kit/module-fetch-diagnosis.ts +192 -0
  307. package/src/kit/mount-failure-report.ts +154 -0
  308. package/src/kit/native-selection-style.ts +497 -0
  309. package/src/kit/object3d-document-write-policy.ts +137 -0
  310. package/src/kit/packaged-runtime.ts +108 -0
  311. package/src/kit/palettes/maya.palette.json +57 -0
  312. package/src/kit/palettes/substance.palette.json +57 -0
  313. package/src/kit/performance-profiler.ts +367 -0
  314. package/src/kit/performance-sources.ts +69 -0
  315. package/src/kit/photograph-notice.ts +141 -0
  316. package/src/kit/play-boot-phase.ts +166 -0
  317. package/src/kit/play-camera-flight.ts +35 -0
  318. package/src/kit/png-encode.worker.ts +26 -0
  319. package/src/kit/presentation-surface.ts +248 -0
  320. package/src/kit/product-command.ts +90 -0
  321. package/src/kit/project-adapter.ts +1140 -0
  322. package/src/kit/project-asset-refresh.ts +23 -0
  323. package/src/kit/project-asset-roots.ts +68 -0
  324. package/src/kit/project-local-state.ts +151 -0
  325. package/src/kit/project-manager.ts +243 -0
  326. package/src/kit/project-module-changes.ts +201 -0
  327. package/src/kit/project-module-split.ts +270 -0
  328. package/src/kit/project-play-layers.ts +25 -0
  329. package/src/kit/project-provenance.ts +115 -0
  330. package/src/kit/project-ready.ts +42 -0
  331. package/src/kit/project-shape.ts +68 -0
  332. package/src/kit/project-tools.ts +107 -0
  333. package/src/kit/projection-types.ts +44 -0
  334. package/src/kit/readiness.ts +113 -0
  335. package/src/kit/renderer-resource-counts.ts +27 -0
  336. package/src/kit/reported-play-state.ts +90 -0
  337. package/src/kit/resolve-contributed-command.ts +14 -0
  338. package/src/kit/resolve-relative-specifier.ts +33 -0
  339. package/src/kit/retained-document-states.ts +91 -0
  340. package/src/kit/scene-document-plan.ts +320 -0
  341. package/src/kit/scene-live-open.ts +210 -0
  342. package/src/kit/scoped-game-css.ts +152 -0
  343. package/src/kit/served-url.ts +5 -0
  344. package/src/kit/session-close.ts +17 -0
  345. package/src/kit/session-tombstone.ts +127 -0
  346. package/src/kit/settings/settings-provider.ts +82 -0
  347. package/src/kit/settings-store.ts +348 -0
  348. package/src/kit/shell-document-state.ts +27 -0
  349. package/src/kit/shell-store-door.ts +45 -0
  350. package/src/kit/shell-store.ts +722 -0
  351. package/src/kit/source-conflict.ts +122 -0
  352. package/src/kit/stage-context.ts +377 -0
  353. package/src/kit/stage-invalidation.ts +25 -0
  354. package/src/kit/stage-store-registry.ts +69 -0
  355. package/src/kit/startup-failure.ts +80 -0
  356. package/src/kit/state-report-deferral.ts +73 -0
  357. package/src/kit/storage/host-files-storage.ts +97 -0
  358. package/src/kit/storage/http-storage.ts +174 -0
  359. package/src/kit/storage/index.ts +75 -0
  360. package/src/kit/storage/mem-storage.ts +158 -0
  361. package/src/kit/storage/path-lock.ts +44 -0
  362. package/src/kit/storage/paths.ts +26 -0
  363. package/src/kit/storage-types.ts +127 -0
  364. package/src/kit/stories/StoryPreviewMount.tsx +306 -0
  365. package/src/kit/stories/compose-project-stories.ts +255 -0
  366. package/src/kit/stories/prefabs-finder.ts +54 -0
  367. package/src/kit/stories/prefabs-from-stories.ts +182 -0
  368. package/src/kit/stories/project-story-regions.ts +24 -0
  369. package/src/kit/stories/story-capture.ts +579 -0
  370. package/src/kit/stories/story-declared-medium.ts +126 -0
  371. package/src/kit/stories/story-discovery.ts +176 -0
  372. package/src/kit/stories/story-dom-runtime.ts +78 -0
  373. package/src/kit/stories/story-grouping.ts +111 -0
  374. package/src/kit/stories/story-mount-turn.ts +27 -0
  375. package/src/kit/stories/story-presentation.ts +215 -0
  376. package/src/kit/stories/story-preview-component.ts +7 -0
  377. package/src/kit/stories/story-registry.ts +530 -0
  378. package/src/kit/stories-scope.ts +35 -0
  379. package/src/kit/story-document-openers.ts +36 -0
  380. package/src/kit/story-thumbnails.ts +47 -0
  381. package/src/kit/surface-keyboard.ts +101 -0
  382. package/src/kit/surface-state.ts +135 -0
  383. package/src/kit/system-seam-evidence.ts +72 -0
  384. package/src/kit/tab-census.ts +202 -0
  385. package/src/kit/tab-lifecycle-client.ts +227 -0
  386. package/src/kit/theme-library.ts +897 -0
  387. package/src/kit/theme-preference.ts +429 -0
  388. package/src/kit/three-viewport-presentation.ts +23 -0
  389. package/src/kit/tool-contribution-play.ts +74 -0
  390. package/src/kit/tool-loader.ts +1918 -0
  391. package/src/kit/transform-mode-request.ts +66 -0
  392. package/src/kit/transient-hint.ts +78 -0
  393. package/src/kit/transport-strip.tsx +174 -0
  394. package/src/kit/ui-source/adapter-region-includes.ts +238 -0
  395. package/src/kit/ui-source/file-region-resolver.ts +302 -0
  396. package/src/kit/ui-source/inspect.ts +775 -0
  397. package/src/kit/ui-source/source-write-backend.ts +605 -0
  398. package/src/kit/ui-source/tier-source-write-backend.ts +279 -0
  399. package/src/kit/user-local-state.ts +105 -0
  400. package/src/kit/viewport-activation-timings.ts +840 -0
  401. package/src/kit/viewport-editor-controls.ts +22 -0
  402. package/src/kit/viewport-presentation.ts +668 -0
  403. package/src/kit/viewport-surface-status.tsx +55 -0
  404. package/src/kit/wait-until.ts +37 -0
  405. package/src/kit/worker-call-metrics.ts +166 -0
  406. package/src/kit/workspace-areas.ts +191 -0
  407. package/src/kit/workspace-aux-commands.ts +11 -0
  408. package/src/kit/workspace-available-documents.ts +142 -0
  409. package/src/kit/workspace-core-utilities.ts +31 -0
  410. package/src/kit/workspace-document-ids.ts +59 -0
  411. package/src/kit/workspace-document-registry.ts +624 -0
  412. package/src/kit/workspace-document-restore.ts +146 -0
  413. package/src/kit/workspace-host-commands.ts +141 -0
  414. package/src/kit/workspace-persistence-gate.ts +40 -0
  415. package/src/kit/workspace-play-utilities.ts +44 -0
  416. package/src/kit/workspace-presets.ts +446 -0
  417. package/src/kit/workspace-regions.ts +276 -0
  418. package/src/kit/workspace-static-panels.ts +73 -0
  419. package/src/kit/workspace-status-registry.ts +121 -0
  420. package/src/kit/workspace-storage.ts +35 -0
  421. package/src/kit/workspace-style.ts +226 -0
  422. package/src/kit/workspace-utility-commands.ts +74 -0
  423. package/src/kit/workspace-utility-registry.ts +263 -0
  424. package/src/kit/world-adoption-event.ts +23 -0
  425. package/src/kit/world-adoption.ts +115 -0
  426. package/src/kit/world-canvas-viewport-state.ts +35 -0
  427. package/src/kit/world-document-routing.ts +104 -0
  428. package/src/kit/world-pan-state.ts +198 -0
  429. package/src/kit/write-pipe.ts +173 -0
  430. package/src/layout-arrangements.ts +5 -0
  431. package/src/layouts.tsx +108 -0
  432. package/src/looks.ts +16 -0
  433. package/src/project/output-roots.ts +73 -0
  434. package/src/project/tab-census.ts +155 -0
  435. package/src/project-tool-catalog.ts +104 -0
  436. package/src/selection.tsx +107 -0
  437. package/src/services.ts +18 -0
  438. package/src/session/build-report.ts +22 -0
  439. package/src/session/collaboration-types.ts +262 -0
  440. package/src/session/command-table.ts +327 -0
  441. package/src/session/discovery.ts +100 -0
  442. package/src/session/editor-brand.ts +48 -0
  443. package/src/session/editor-compatibility.ts +317 -0
  444. package/src/session/editor-control-lifecycle.ts +68 -0
  445. package/src/session/editor-control-protocol.ts +5 -0
  446. package/src/session/entrypoint-selection-readers.ts +66 -0
  447. package/src/session/entrypoint-selection-source.ts +120 -0
  448. package/src/session/game-css-scope.ts +30 -0
  449. package/src/session/hosted-attachment.ts +225 -0
  450. package/src/session/limited-view.ts +82 -0
  451. package/src/session/product-create.ts +24 -0
  452. package/src/session/product-locator.ts +478 -0
  453. package/src/session/project-module-url.ts +242 -0
  454. package/src/session/project-serving.ts +164 -0
  455. package/src/session/project-upgrade.ts +403 -0
  456. package/src/session/registry-format.ts +210 -0
  457. package/src/session/relative-path-guard.ts +56 -0
  458. package/src/session/scoped-game-css.ts +461 -0
  459. package/src/session/source-glob.ts +15 -0
  460. package/src/session/tool-contribution-convention.ts +123 -0
  461. package/src/session/workbench-locator.ts +712 -0
  462. package/src/session.ts +41 -0
  463. package/src/share.ts +160 -0
  464. package/src/source-analysis.ts +28 -0
  465. package/src/source-authoring.ts +439 -0
  466. package/src/tools/errors.ts +91 -0
  467. package/src/tools/provider-execution.ts +70 -0
  468. package/src/tools/registry.ts +341 -0
  469. package/src/tools/types.ts +159 -0
  470. package/src/transport.ts +100 -0
  471. package/src/types.ts +1693 -0
  472. package/src/views.ts +164 -0
  473. package/src/widgets/design-system.ts +93 -0
  474. package/src/widgets/editor-appearance.ts +151 -0
  475. package/src/widgets/editor-material.ts +83 -0
  476. package/src/widgets/icon-set-registry.ts +105 -0
  477. package/src/widgets/index.ts +71 -0
  478. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  479. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  480. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  481. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  482. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  483. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  484. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  485. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  486. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  487. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  488. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  489. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  490. package/src/widgets/inspector-widgets/index.ts +25 -0
  491. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  492. package/src/widgets/interactive-edit-scope.ts +33 -0
  493. package/src/widgets/patterns/Dialog.tsx +140 -0
  494. package/src/widgets/patterns/Fields.tsx +44 -0
  495. package/src/widgets/patterns/List.tsx +25 -0
  496. package/src/widgets/patterns/StateSurface.tsx +40 -0
  497. package/src/widgets/patterns/Surfaces.tsx +122 -0
  498. package/src/widgets/patterns/Tabs.tsx +80 -0
  499. package/src/widgets/patterns/Toolbar.tsx +72 -0
  500. package/src/widgets/patterns/Tree.tsx +72 -0
  501. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  502. package/src/widgets/primitives/Button.tsx +62 -0
  503. package/src/widgets/primitives/ColorInput.tsx +78 -0
  504. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  505. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  506. package/src/widgets/primitives/FormControls.tsx +88 -0
  507. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  508. package/src/widgets/primitives/JsonInput.tsx +113 -0
  509. package/src/widgets/primitives/Layout.tsx +100 -0
  510. package/src/widgets/primitives/Menu.tsx +161 -0
  511. package/src/widgets/primitives/NumberInput.tsx +169 -0
  512. package/src/widgets/primitives/Panel.tsx +80 -0
  513. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  514. package/src/widgets/primitives/Text.tsx +54 -0
  515. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  516. package/src/widgets/primitives/Tooltip.tsx +204 -0
  517. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  518. package/src/widgets/primitives/banner-tones.ts +32 -0
  519. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  520. package/src/widgets/primitives/editor-icons.ts +254 -0
  521. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  522. package/src/widgets/theme.ts +2841 -0
  523. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,2238 @@
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 volter RUNTIME mounts sets it
24
+ * (`@volter/editor-game/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 the accessible CSSOM
33
+ * rules that can style it ride the detached clone (`subjectStylesCssText`), so
34
+ * design-system chrome photographs as it appears. Game
35
+ * capture leaves that option off and therefore never imports editor styling.
36
+ */
37
+
38
+ import { GAME_CSS_SCOPE_ATTRIBUTE } from '@volter/sdk/session/game-css-scope';
39
+ import { scopedGameStylesCssText } from './scoped-game-css';
40
+
41
+ export interface OverlaySvg {
42
+ svg: string;
43
+ /** How many non-canvas layers were serialized (0 ⇒ callers skip the leg). */
44
+ overlayCount: number;
45
+ snapshotMs?: number;
46
+ }
47
+
48
+ const ROOT_SURFACE_SELECTOR = '[data-volter-root-surface="true"]';
49
+
50
+ // A DOM root is also marked as a surface. Its descendant canvases are UI
51
+ // content (for example a 3D die), not additional host surfaces. Keep their
52
+ // pixels in the overlay so their own backgrounds and layout survive capture.
53
+ export function isRootCanvas(canvas: HTMLCanvasElement): boolean {
54
+ return (
55
+ canvas.matches(ROOT_SURFACE_SELECTOR) ||
56
+ canvas.closest('[data-volter-canvas-scene="true"]') !== null
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Reusable data-URL snapshots of STATIC pixels across a sequence of frames.
62
+ *
63
+ * The overlay clone inlines every nested `<img>`'s pixels through a canvas
64
+ * `toDataURL` — a synchronous PNG encode. For a one-shot screenshot that cost
65
+ * is invisible; for the gameplay recorder it ran per `<img>` per frame, and one
66
+ * HUD image measured at 68% of the entire main thread during play (the game's
67
+ * own frame rounded to 0%, and relay commands queued seconds deep behind the
68
+ * encodes). An `<img>`'s pixels only change when its `src` does, so the
69
+ * recorder passes one of these per recording and the encode runs once per
70
+ * source, not once per frame. `null` results are cached too — a tainted image
71
+ * stays refused without re-attempting the readback every frame.
72
+ */
73
+ export interface ImageSnapshotCache {
74
+ readonly imgs: WeakMap<HTMLImageElement, { src: string; url: string | null }>;
75
+ }
76
+
77
+ export function createImageSnapshotCache(): ImageSnapshotCache {
78
+ return { imgs: new WeakMap() };
79
+ }
80
+
81
+ /**
82
+ * How stale a cached overlay raster may get before a rebuild is forced even
83
+ * with no mutation observed. This bounds the ONE blind spot of
84
+ * mutation-driven invalidation: motion no mutation reports — a CSS animation
85
+ * mid-flight, a `<video>` element, an `<img>` finishing its load, a nested
86
+ * non-root canvas repainting. Half a second of HUD staleness is invisible in
87
+ * evidence video; rebuilding twice a second is invisible in the profile.
88
+ */
89
+ export const OVERLAY_CACHE_MAX_AGE_MS = 500;
90
+
91
+ /**
92
+ * The floor of the rebuild throttle window, and the cost multiplier that
93
+ * stretches it.
94
+ *
95
+ * Mutation-driven invalidation has a degenerate regime: a HUD that mutates
96
+ * every frame (an ammo counter during fire, a per-frame debug readout) makes
97
+ * every window dirty, and the recorder is back to a full clone + serialize +
98
+ * decode per frame — measured at ~40% of the main thread with 50–70ms relay
99
+ * latency under a forced 16ms-mutation bench. So a DIRTY raster is still
100
+ * served until `max(floor, multiplier × last build's wall cost)` has passed
101
+ * since the last rebuild: rebuild work is bounded at 1/multiplier (~12.5%) of
102
+ * the thread however hostile the HUD, and a cheap HUD still updates in the
103
+ * video at ~1000/floor (~10) fps — comfortably above the "10 frames in a 5s
104
+ * span" bar the evidence doors ask reviewers to hold recordings to. The
105
+ * staleness this trades is bounded by the window itself and only exists
106
+ * while the HUD is actively churning — the first take past the window
107
+ * rebuilds from the live DOM.
108
+ */
109
+ export const OVERLAY_REBUILD_FLOOR_MS = 100;
110
+ export const OVERLAY_REBUILD_COST_MULTIPLIER = 8;
111
+
112
+ /** What {@link OverlayFrameCache} stores: the decoded, `drawImage`-ready
113
+ * overlay raster — `image: null` is the cached form of "this container has
114
+ * no DOM overlay layers", so a canvas-only game skips the whole leg without
115
+ * re-asking the DOM every frame. */
116
+ export interface CachedOverlay {
117
+ readonly image: CanvasImageSource | null;
118
+ readonly overlayCount: number;
119
+ /** Backdrop membership shares this overlay's stacking snapshot. */
120
+ readonly backdrops?: readonly HTMLElement[];
121
+ }
122
+
123
+ /**
124
+ * The DOM overlay, rasterized once per CHANGE instead of once per frame.
125
+ *
126
+ * The gameplay recorder calls {@link drawPlayCompositeFrame} at up to 30fps.
127
+ * Rebuilding the overlay every frame — clone the HUD subtree, re-inline
128
+ * styles, serialize to a foreignObject SVG, decode — measured at ~20% of the
129
+ * main thread even after {@link ImageSnapshotCache} removed the per-frame PNG
130
+ * encodes. A HUD mutates orders of magnitude less often than 30 times a
131
+ * second, so a MutationObserver decides when the raster is rebuilt and every
132
+ * other frame pays two `drawImage` calls.
133
+ *
134
+ * `take` is the whole protocol: it returns the reusable overlay, or returns
135
+ * `null` and ARMS the cache for the rebuild the caller does next. Arming
136
+ * clears the dirty flag at the moment the caller is about to read the DOM
137
+ * (the read is synchronous in the same task, so nothing can interleave);
138
+ * a mutation landing during the rebuild's async decode re-dirties the entry
139
+ * through the observer, so the frame `store`d after it is already invalid —
140
+ * one extra rebuild, never a stale cache.
141
+ */
142
+ export interface OverlayFrameCache {
143
+ take(container: HTMLElement, width: number, height: number): CachedOverlay | null;
144
+ /** `buildCostMs` is the rebuild's WALL cost (clone through decode) — it
145
+ * sizes the throttle window that bounds how much of the main thread a
146
+ * churning HUD can spend on rebuilds. */
147
+ store(
148
+ container: HTMLElement,
149
+ width: number,
150
+ height: number,
151
+ overlay: CachedOverlay,
152
+ buildCostMs: number,
153
+ ): void;
154
+ /** Disconnect the observer. The recording that owns this cache ended. */
155
+ dispose(): void;
156
+ }
157
+
158
+ export function createOverlayFrameCache(
159
+ maxAgeMs = OVERLAY_CACHE_MAX_AGE_MS,
160
+ rebuildFloorMs = OVERLAY_REBUILD_FLOOR_MS,
161
+ ): OverlayFrameCache {
162
+ let observed: HTMLElement | null = null;
163
+ let observer: MutationObserver | null = null;
164
+ let dirty = false;
165
+ let frame:
166
+ | (CachedOverlay & { width: number; height: number; builtAt: number; notBefore: number })
167
+ | null = null;
168
+
169
+ const observe = (container: HTMLElement): void => {
170
+ observer?.disconnect();
171
+ observed = container;
172
+ frame = null;
173
+ dirty = false;
174
+ observer =
175
+ typeof MutationObserver === 'undefined'
176
+ ? null
177
+ : new MutationObserver(() => {
178
+ dirty = true;
179
+ });
180
+ observer?.observe(container, {
181
+ subtree: true,
182
+ childList: true,
183
+ attributes: true,
184
+ characterData: true,
185
+ });
186
+ };
187
+
188
+ return {
189
+ take(container, width, height) {
190
+ if (observed !== container) {
191
+ // First frame, or the recorder retargeted: watch THIS container.
192
+ observe(container);
193
+ return null;
194
+ }
195
+ // Flush records the observer callback has not delivered yet — `take`
196
+ // must never vouch for a CLEAN frame the DOM has already moved under.
197
+ if (observer && observer.takeRecords().length > 0) dirty = true;
198
+ const now = performance.now();
199
+ if (frame && frame.width === width && frame.height === height) {
200
+ if (!dirty && now - frame.builtAt <= maxAgeMs) return frame;
201
+ // Dirty, but the last rebuild is still inside its throttle window:
202
+ // serve the raster anyway (bounded staleness, stated in the window
203
+ // constants above). `dirty` stays set, so the first take PAST the
204
+ // window rebuilds from the live DOM.
205
+ if (dirty && now < frame.notBefore) return frame;
206
+ }
207
+ frame = null;
208
+ dirty = false; // armed: the caller rebuilds from the DOM as it is NOW
209
+ return null;
210
+ },
211
+ store(container, width, height, overlay, buildCostMs) {
212
+ if (observed !== container) return; // a retarget raced the rebuild
213
+ // `dirty` is deliberately left alone: if a mutation landed during the
214
+ // rebuild's decode await, this frame is already out of date and the
215
+ // next `take` past the throttle window correctly rebuilds it.
216
+ const now = performance.now();
217
+ frame = {
218
+ ...overlay,
219
+ width,
220
+ height,
221
+ builtAt: now,
222
+ notBefore: now + Math.max(rebuildFloorMs, OVERLAY_REBUILD_COST_MULTIPLIER * buildCostMs),
223
+ };
224
+ },
225
+ dispose() {
226
+ observer?.disconnect();
227
+ observer = null;
228
+ observed = null;
229
+ frame = null;
230
+ },
231
+ };
232
+ }
233
+
234
+ export interface CaptureOptions {
235
+ /** Authoring compositions may deliberately paint nothing or retain alpha. */
236
+ readonly allowTransparent?: boolean;
237
+ /** Output dimensions independent of the editor preview's CSS scale. Canvases
238
+ * and backdrops are painted at that scale; the DOM leg keeps its CSS-pixel
239
+ * layout and rasterizes through a viewBox at this size, so a frame asked for
240
+ * at device resolution comes back with device-resolution text. */
241
+ readonly size?: { readonly width: number; readonly height: number } | undefined;
242
+ /**
243
+ * Same-frame pixels for a canvas this process cannot read back late.
244
+ *
245
+ * A WebGL canvas is only `drawImage`-able after its frame if its context was
246
+ * created with `preserveDrawingBuffer: true`. Every canvas the Volter runtime
247
+ * mounts sets it; a canvas an INGESTED game created does not, so reading it
248
+ * here — several paint boundaries after its frame — yields black. The caller
249
+ * supplies this when it has a seam that can copy the buffer inside the
250
+ * game's own render pass (`ingest/ingest-frame-snapshot.ts`). Returning
251
+ * `null` means "read the canvas directly", which is the unchanged path for
252
+ * every first-party capture.
253
+ */
254
+ readonly canvasFrame?:
255
+ | ((canvas: HTMLCanvasElement) => Promise<CanvasImageSource | null>)
256
+ | undefined;
257
+ /**
258
+ * Preserve stylesheet-driven chrome when the subject is an editor document.
259
+ *
260
+ * The ordinary game path intentionally uses a neutral wrapper: editor
261
+ * classes are not part of the game. An editor-owned document is the opposite
262
+ * case — its classes and the editor's one static stylesheet ARE its visual
263
+ * language, so omitting them turns a styled panel into browser-default HTML.
264
+ */
265
+ readonly includeDocumentStyles?: boolean | undefined;
266
+ /**
267
+ * The open project's root. A native game styles its DOM with CSS its modules
268
+ * import, and Vite serves each of those as a `<style>` in the EDITOR document,
269
+ * outside the container the clone is taken from; the game path re-inlines the
270
+ * ones whose source file is under this root. Absent, every served stylesheet
271
+ * outside `node_modules` counts (in the packaged editor that is only project
272
+ * CSS; a source checkout also serves its own).
273
+ */
274
+ readonly projectRoot?: string | undefined;
275
+ /**
276
+ * Crop the photograph to the union of painted pixels plus padding, on a
277
+ * backdrop of the container's own background.
278
+ *
279
+ * The Storybook framing model, split in two: the MOUNT box supplies the
280
+ * screen the subject's anchors resolve against and is a semantic input,
281
+ * while the PHOTOGRAPH frames the subject — measured, exactly as the 3D
282
+ * story leg frames its camera on the object's bounds. A lone HUD widget
283
+ * crops to the widget; a full screen's painted union spans the frame and
284
+ * stays effectively full-frame, so no screen-vs-widget classifier exists.
285
+ * DOM-only subjects only: a canvas-backed frame is already the full picture.
286
+ */
287
+ readonly cropToContent?: boolean | undefined;
288
+ /**
289
+ * Does this subject present on a CANVAS? — the declared answer, supplied by
290
+ * the caller.
291
+ *
292
+ * This module used to answer it by counting `<canvas>` children, which is a
293
+ * guess at a fact the adapter's region table already states: a `three`/
294
+ * `canvas` region is handed a canvas, a `dom` region is handed a container
295
+ * (ARCHITECTURE-CORE §The editor protocol, zero inference). The count and the
296
+ * declaration agree for every healthy game — and disagree exactly in the case
297
+ * worth catching, a canvas game photographed before its canvas mounted, which
298
+ * the count silently graded as "DOM-only".
299
+ *
300
+ * Passed IN rather than read here on purpose: this file is pure DOM (jsdom-
301
+ * testable, imports no editor state), and `presentation-surface.ts` is the one
302
+ * door that knows. `undefined` = nobody declared, and the measured canvas
303
+ * count stands — the unchanged path for any caller that has no adapter table.
304
+ */
305
+ readonly presentsOnCanvas?: boolean | undefined;
306
+ /** See {@link ImageSnapshotCache}. Absent = snapshot every frame (the
307
+ * one-shot screenshot path, where there is only one frame). */
308
+ readonly snapshots?: ImageSnapshotCache | undefined;
309
+ /** See {@link OverlayFrameCache}. Absent = rasterize the DOM overlay every
310
+ * frame (correct for a one-shot still; ruinous at 30fps). */
311
+ readonly overlayCache?: OverlayFrameCache | undefined;
312
+ }
313
+
314
+ /**
315
+ * Serialize the play container's NON-canvas children into a standalone SVG
316
+ * sized `width`×`height` (CSS pixels). Game capture clones children into a
317
+ * neutral relatively-positioned wrapper. `includeDocumentStyles` instead
318
+ * shallow-clones the editor document container and carries its CSSOM/theme,
319
+ * because those classes are part of that subject. Returns null when there is
320
+ * nothing but canvases to show.
321
+ * Pure DOM (no rasterizing), so it's unit-testable under jsdom.
322
+ */
323
+ /** Same-frame pixels, keyed by the canvas they came from — see the nested-canvas
324
+ * note in {@link buildOverlaySvg}'s clone loop. */
325
+ export type CanvasPixels = ReadonlyMap<HTMLCanvasElement, CanvasImageSource>;
326
+
327
+ /** One canvas's pixels as a data URL the foreignObject clone can carry. An SVG
328
+ * rasterized from a data: URL may not fetch EXTERNAL resources, but an inline
329
+ * data image is not external — the same mechanism html-to-image relies on.
330
+ * `null` when the source cannot be read back (a tainted canvas), which puts
331
+ * the caller back on the honest strip-it path. */
332
+ function canvasDataUrl(document: Document, pixels: CanvasImageSource): string | null {
333
+ const copy = document.createElement('canvas');
334
+ try {
335
+ const image = pixels as HTMLImageElement;
336
+ // A VIDEO's frame size is `videoWidth/Height`: it has no `naturalWidth`,
337
+ // and its `width` attribute is 0 unless someone set one — which sized the
338
+ // copy 1×1 and threw the frame away.
339
+ const video = pixels instanceof HTMLVideoElement ? pixels : null;
340
+ copy.width = Math.max(
341
+ 1,
342
+ Math.round(
343
+ Number(video?.videoWidth || image.naturalWidth || (pixels as HTMLCanvasElement).width) || 0,
344
+ ),
345
+ );
346
+ copy.height = Math.max(
347
+ 1,
348
+ Math.round(
349
+ Number(video?.videoHeight || image.naturalHeight || (pixels as HTMLCanvasElement).height) ||
350
+ 0,
351
+ ),
352
+ );
353
+ const ctx = copy.getContext('2d');
354
+ if (!ctx) return null;
355
+ ctx.drawImage(pixels, 0, 0);
356
+ return copy.toDataURL('image/png');
357
+ } catch {
358
+ return null;
359
+ } finally {
360
+ copy.width = 0;
361
+ copy.height = 0;
362
+ }
363
+ }
364
+
365
+ /** Flatten every readable sheet in document cascade order. CSSOM access is
366
+ * the browser-native answer here: Vite dev styles, the production bundle, and
367
+ * dynamically installed scoped game CSS all present the same interface.
368
+ * Cross-origin sheets refuse `cssRules`; skipping those preserves the normal
369
+ * browser security boundary instead of making capture itself fail. */
370
+ function sheetsCssText(document: Document, sheets: Iterable<CSSStyleSheet>): string {
371
+ const seen = new Set<CSSStyleSheet>();
372
+ const read = (sheet: CSSStyleSheet): string => {
373
+ if (seen.has(sheet)) return '';
374
+ seen.add(sheet);
375
+ try {
376
+ return Array.from(sheet.cssRules)
377
+ .map((rule) => {
378
+ const imported = (rule as CSSImportRule).styleSheet;
379
+ if (imported) return read(imported);
380
+ // A detached SVG has no stylesheet URL against which to resolve fonts.
381
+ if (rule.type === CSSRule.FONT_FACE_RULE) {
382
+ return rule.cssText.replace(/url\((['"]?)(.*?)\1\)/g, (_match, _quote, url) =>
383
+ `url("${new URL(url, sheet.href ?? document.baseURI).href}")`,
384
+ );
385
+ }
386
+ return rule.cssText;
387
+ })
388
+ .join('\n');
389
+ } catch {
390
+ return '';
391
+ }
392
+ };
393
+ return Array.from(sheets, read).filter(Boolean).join('\n');
394
+ }
395
+
396
+ /** The project's own module stylesheets as Vite serves them: a sheet whose
397
+ * owner carries `data-vite-dev-id` naming a source file outside `node_modules`
398
+ * (and under `projectRoot`, when given). See {@link CaptureOptions.projectRoot}. */
399
+ function projectModuleStylesCssText(document: Document, projectRoot: string | undefined): string {
400
+ const root = projectRoot?.replace(/\\/g, '/').replace(/\/$/, '');
401
+ const sheets = Array.from(document.styleSheets).filter((sheet) => {
402
+ const owner = sheet.ownerNode;
403
+ const id = owner instanceof Element ? owner.getAttribute('data-vite-dev-id') : null;
404
+ if (!id) return false;
405
+ const file = (id.split('?')[0] ?? id).replace(/\\/g, '/');
406
+ if (file.includes('/node_modules/')) return false;
407
+ return root ? file.startsWith(`${root}/`) : true;
408
+ });
409
+ return sheets.length === 0 ? '' : sheetsCssText(document, sheets);
410
+ }
411
+
412
+ /*
413
+ * THE SUBJECT'S STYLESHEET, NOT THE DOCUMENT'S.
414
+ *
415
+ * An editor-document capture used to inline EVERY rule in the document — the
416
+ * whole Code-OSS workbench sheet among them. Measured 2026-10-07 on a live
417
+ * Cyclotron page: 23 sheets, 11,565 style rules, 2.2 MB of CSS, of which a few
418
+ * hundred rules can match anything inside `#editor-chrome-root`. The SVG
419
+ * image that carries the clone is parsed, styled, laid out and rasterized ON
420
+ * THE PAGE'S MAIN THREAD (`image.decode()` and the `drawImage` of an SVG are
421
+ * not off-thread work in Chromium), so every capture made the browser build a
422
+ * cascade of 11.5k rules and match it against the clone in one long task the
423
+ * running game's frame loop could not interrupt — and that task grows with
424
+ * both the rule count and the DOM the subject holds. Serializing the 2.2 MB
425
+ * from the CSSOM was another 30-70 ms on the same thread before anything was
426
+ * drawn.
427
+ *
428
+ * A rule whose selector matches nothing in the subject cannot style the clone
429
+ * — the clone IS that subtree, detached — so it is left out. The test is the
430
+ * browser's own selector matching against the LIVE subtree (`matches` /
431
+ * `querySelector`, which see the subject's real ancestors), with every
432
+ * judgement that could disagree with the clone resolved towards KEEPING a
433
+ * rule: a pseudo-element is tested on its originating element; form-state
434
+ * attributes the clone writes (`checked`, `selected`, `value`) are not
435
+ * required; a selector that answers for the document root keeps its rule; an
436
+ * `@scope` block is kept whole. Before that test a cheap index refuses a
437
+ * selector whose subject — or an ancestor compound leading to it — names a
438
+ * class, id or tag that neither the subtree nor its ancestors carry, which is
439
+ * the bulk of the workbench sheet, so the browser is asked about the rules
440
+ * that might apply rather than about all of them.
441
+ */
442
+
443
+ /** One selector of a style rule's list, prepared for the subject test. */
444
+ interface SelectorAlternative {
445
+ /** The selector with pseudo-elements and clone-written attributes removed —
446
+ * what `matches` / `querySelector` can answer for a live element. */
447
+ readonly test: string;
448
+ /** Tokens (`tag`, `.class`, `#id`) the selector's ancestor chain requires;
449
+ * `null` keeps the rule unconditionally. */
450
+ readonly requires: readonly string[] | null;
451
+ }
452
+
453
+ interface PreparedSelector {
454
+ readonly selectorText: string;
455
+ readonly alternatives: readonly SelectorAlternative[];
456
+ }
457
+
458
+ /** Prepared once per rule object; a rule whose selector changes re-prepares. */
459
+ const preparedSelectors = new WeakMap<CSSStyleRule, PreparedSelector>();
460
+
461
+ /** Attributes the clone writes from live element state (`carryFormStateInClone`),
462
+ * so a live element can lack an attribute its clone has. */
463
+ const CLONE_WRITTEN_ATTRIBUTES = new Set(['checked', 'selected', 'value']);
464
+
465
+ /** CSS2 pseudo-elements still accepted with a single colon. */
466
+ const LEGACY_PSEUDO_ELEMENTS = new Set(['before', 'after', 'first-line', 'first-letter']);
467
+
468
+ const HEX_DIGIT = /[0-9a-fA-F]/;
469
+ const IDENTIFIER_CHAR = /[\w\-\u00a0-\uffff]/;
470
+ const IDENTIFIER_START = /^[a-zA-Z\\\u00a0-\uffff]/;
471
+
472
+ /** Index just past the escape that starts at `at` (a backslash). */
473
+ function escapeEnd(text: string, at: number): number {
474
+ let index = at + 1;
475
+ if (index < text.length && HEX_DIGIT.test(text[index]!)) {
476
+ const limit = Math.min(text.length, index + 6);
477
+ while (index < limit && HEX_DIGIT.test(text[index]!)) index += 1;
478
+ if (index < text.length && /\s/.test(text[index]!)) index += 1;
479
+ return index;
480
+ }
481
+ return Math.min(text.length, index + 1);
482
+ }
483
+
484
+ /** Index just past the bracket group or string that opens at `at`. */
485
+ function groupEnd(text: string, at: number): number {
486
+ const opener = text[at];
487
+ if (opener === '"' || opener === "'") {
488
+ for (let index = at + 1; index < text.length; index += 1) {
489
+ if (text[index] === '\\') index = escapeEnd(text, index) - 1;
490
+ else if (text[index] === opener) return index + 1;
491
+ }
492
+ return text.length;
493
+ }
494
+ let depth = 0;
495
+ for (let index = at; index < text.length; index += 1) {
496
+ const char = text[index]!;
497
+ if (char === '\\') index = escapeEnd(text, index) - 1;
498
+ else if (char === '"' || char === "'") index = groupEnd(text, index) - 1;
499
+ else if (char === '(' || char === '[') depth += 1;
500
+ else if (char === ')' || char === ']') {
501
+ depth -= 1;
502
+ if (depth === 0) return index + 1;
503
+ }
504
+ }
505
+ return text.length;
506
+ }
507
+
508
+ /** An identifier starting at `at`, unescaped, and the index past it. */
509
+ function readIdentifier(text: string, at: number): { name: string; end: number } {
510
+ let name = '';
511
+ let index = at;
512
+ while (index < text.length) {
513
+ const char = text[index]!;
514
+ if (char === '\\') {
515
+ const end = escapeEnd(text, index);
516
+ const body = text.slice(index + 1, end).trim();
517
+ name += /^[0-9a-fA-F]+$/.test(body) ? String.fromCodePoint(Number.parseInt(body, 16) || 0xfffd) : body;
518
+ index = end;
519
+ } else if (IDENTIFIER_CHAR.test(char)) {
520
+ name += char;
521
+ index += 1;
522
+ } else break;
523
+ }
524
+ return { name, end: index };
525
+ }
526
+
527
+ /** Split a selector list at its top-level commas. */
528
+ function selectorList(text: string): string[] {
529
+ const parts: string[] = [];
530
+ let start = 0;
531
+ for (let index = 0; index < text.length; index += 1) {
532
+ const char = text[index]!;
533
+ if (char === '\\') index = escapeEnd(text, index) - 1;
534
+ else if (char === '(' || char === '[' || char === '"' || char === "'") index = groupEnd(text, index) - 1;
535
+ else if (char === ',') {
536
+ parts.push(text.slice(start, index));
537
+ start = index + 1;
538
+ }
539
+ }
540
+ parts.push(text.slice(start));
541
+ return parts.map((part) => part.trim()).filter(Boolean);
542
+ }
543
+
544
+ /** The tag, classes and id a compound selector names outside its functional
545
+ * pseudo-classes — what every element it matches must carry. */
546
+ function compoundTokens(compound: string): string[] {
547
+ const text = compound.trim();
548
+ const tokens: string[] = [];
549
+ let at = 0;
550
+ if (IDENTIFIER_START.test(text)) {
551
+ const tag = readIdentifier(text, 0);
552
+ if (text[tag.end] !== '|' && tag.name) tokens.push(tag.name.toLowerCase());
553
+ at = Math.max(tag.end, 1);
554
+ }
555
+ while (at < text.length) {
556
+ const char = text[at]!;
557
+ if (char === '\\') at = escapeEnd(text, at);
558
+ else if (char === '.' || char === '#') {
559
+ const identifier = readIdentifier(text, at + 1);
560
+ if (identifier.name) tokens.push(`${char}${identifier.name}`);
561
+ at = Math.max(identifier.end, at + 1);
562
+ } else if (char === '(' || char === '[' || char === '"' || char === "'") at = groupEnd(text, at);
563
+ else if (char === ':') at = Math.max(readIdentifier(text, at + 1).end, at + 1);
564
+ else at += 1;
565
+ }
566
+ return tokens;
567
+ }
568
+
569
+ /** One selector, rewritten for a live-element test, with its chain's tokens. */
570
+ function prepareAlternative(selector: string): SelectorAlternative {
571
+ let test = '';
572
+ const compounds: { text: string; before: 'ancestor' | 'sibling' | null }[] = [];
573
+ let compoundStart = 0;
574
+ let combinatorBefore: 'ancestor' | 'sibling' | null = null;
575
+ let index = 0;
576
+ while (index < selector.length) {
577
+ const char = selector[index]!;
578
+ if (char === '\\') {
579
+ const end = escapeEnd(selector, index);
580
+ test += selector.slice(index, end);
581
+ index = end;
582
+ } else if (char === '[') {
583
+ const end = groupEnd(selector, index);
584
+ const attribute = readIdentifier(selector, index + 1 + (/^\s*/.exec(selector.slice(index + 1))?.[0].length ?? 0));
585
+ if (!CLONE_WRITTEN_ATTRIBUTES.has(attribute.name.toLowerCase())) test += selector.slice(index, end);
586
+ index = end;
587
+ } else if (char === '(' || char === '"' || char === "'") {
588
+ const end = groupEnd(selector, index);
589
+ test += selector.slice(index, end);
590
+ index = end;
591
+ } else if (char === ':') {
592
+ const doubled = selector[index + 1] === ':';
593
+ const name = readIdentifier(selector, index + (doubled ? 2 : 1));
594
+ let end = Math.max(name.end, index + 1);
595
+ if (selector[end] === '(') end = groupEnd(selector, end);
596
+ // A pseudo-element styles its ORIGINATING element: test that element.
597
+ if (!doubled && !LEGACY_PSEUDO_ELEMENTS.has(name.name.toLowerCase())) test += selector.slice(index, end);
598
+ index = end;
599
+ } else if (/[\s>+~]/.test(char)) {
600
+ let end = index;
601
+ while (end < selector.length && /[\s>+~]/.test(selector[end]!)) end += 1;
602
+ compounds.push({ text: test.slice(compoundStart), before: combinatorBefore });
603
+ combinatorBefore = /[+~]/.test(selector.slice(index, end)) ? 'sibling' : 'ancestor';
604
+ test += selector.slice(index, end);
605
+ compoundStart = test.length;
606
+ index = end;
607
+ } else {
608
+ test += char;
609
+ index += 1;
610
+ }
611
+ }
612
+ compounds.push({ text: test.slice(compoundStart), before: combinatorBefore });
613
+ test = test.trim();
614
+ // An empty remainder (`::selection`) styles everything; a document-root
615
+ // selector answers in the SVG image for its own root, not for the subject.
616
+ if (!test || /:(root|scope|host)\b/i.test(test)) return { test, requires: null };
617
+ // The subject compound and every compound reached from it through
618
+ // descendant/child combinators name elements in the subject or among its
619
+ // ancestors. Left of a sibling combinator an element may be neither.
620
+ const requires: string[] = [];
621
+ for (let at = compounds.length - 1; at >= 0; at -= 1) {
622
+ const compound = compounds[at]!;
623
+ requires.push(...compoundTokens(compound.text));
624
+ if (compound.before !== 'ancestor') break;
625
+ }
626
+ return { test, requires };
627
+ }
628
+
629
+ /**
630
+ * A nested rule's selector, resolved against its parent's: `&` stands for the
631
+ * parent, and a selector without one is relative to it (a descendant, or the
632
+ * combinator it starts with). `:is()` only asks whether the parent can match.
633
+ */
634
+ function resolvedNestedSelector(parent: string, selector: string): string {
635
+ return selectorList(selector)
636
+ .map((alternative) =>
637
+ alternative.includes('&') ? alternative.replaceAll('&', `:is(${parent})`) : `:is(${parent}) ${alternative}`,
638
+ )
639
+ .join(', ');
640
+ }
641
+
642
+ function preparedSelector(rule: CSSStyleRule, selectorText: string): PreparedSelector {
643
+ const cached = preparedSelectors.get(rule);
644
+ if (cached && cached.selectorText === selectorText) return cached;
645
+ const prepared = { selectorText, alternatives: selectorList(selectorText).map(prepareAlternative) };
646
+ preparedSelectors.set(rule, prepared);
647
+ return prepared;
648
+ }
649
+
650
+ interface SubjectContext {
651
+ readonly container: Element;
652
+ /** Tags, classes and ids of the subject, its descendants and its ancestors. */
653
+ readonly tokens: ReadonlySet<string>;
654
+ readonly seen: Set<CSSStyleSheet>;
655
+ }
656
+
657
+ /** Every tag, class and id present in the subject, its descendants and its
658
+ * ancestors (a selector's leading compounds may name those). A canvas or
659
+ * video the clone replaces with an `<img>` also stands for `img`. */
660
+ function subjectTokens(container: Element): ReadonlySet<string> {
661
+ const tokens = new Set<string>();
662
+ const add = (element: Element): void => {
663
+ tokens.add(element.localName.toLowerCase());
664
+ if (element.id) tokens.add(`#${element.id}`);
665
+ for (const name of Array.from(element.classList)) tokens.add(`.${name}`);
666
+ };
667
+ for (const element of Array.from(container.querySelectorAll('*'))) add(element);
668
+ for (let element: Element | null = container; element; element = element.parentElement) add(element);
669
+ if (tokens.has('canvas') || tokens.has('video')) tokens.add('img');
670
+ return tokens;
671
+ }
672
+
673
+ function selectorReachesSubject(prepared: PreparedSelector, context: SubjectContext): boolean {
674
+ return prepared.alternatives.some(({ test, requires }) => {
675
+ if (requires === null) return true;
676
+ if (!requires.every((token) => context.tokens.has(token))) return false;
677
+ try {
678
+ return context.container.matches(test) || context.container.querySelector(test) !== null;
679
+ } catch {
680
+ // A selector this test cannot phrase is kept, never guessed away.
681
+ return true;
682
+ }
683
+ });
684
+ }
685
+
686
+ /** Whether a style rule — or any rule nested inside it — can match in the subject. */
687
+ function styleRuleReachesSubject(rule: CSSStyleRule, selectorText: string, context: SubjectContext): boolean {
688
+ if (selectorReachesSubject(preparedSelector(rule, selectorText), context)) return true;
689
+ const nested = (rule as CSSStyleRule & { readonly cssRules?: CSSRuleList }).cssRules;
690
+ return nested !== undefined && nestedRulesReachSubject(nested, selectorText, context);
691
+ }
692
+
693
+ function nestedRulesReachSubject(rules: CSSRuleList, parent: string, context: SubjectContext): boolean {
694
+ for (const rule of Array.from(rules)) {
695
+ if (rule.type === CSSRule.STYLE_RULE) {
696
+ const style = rule as CSSStyleRule;
697
+ if (styleRuleReachesSubject(style, resolvedNestedSelector(parent, style.selectorText), context)) return true;
698
+ continue;
699
+ }
700
+ const inner = (rule as CSSRule & { readonly cssRules?: CSSRuleList }).cssRules;
701
+ if (inner && nestedRulesReachSubject(inner, parent, context)) return true;
702
+ }
703
+ return false;
704
+ }
705
+
706
+ /** The CSS of `rules` that can style the subject, in cascade order. A kept
707
+ * style rule is kept whole, nested rules and all. */
708
+ function subjectRulesCssText(rules: CSSRuleList, sheet: CSSStyleSheet, context: SubjectContext): string {
709
+ const kept: string[] = [];
710
+ for (const rule of Array.from(rules)) {
711
+ const imported = (rule as CSSImportRule).styleSheet;
712
+ if (imported) {
713
+ const css = subjectSheetCssText(imported, context);
714
+ if (css) kept.push(css);
715
+ continue;
716
+ }
717
+ if (rule.type === CSSRule.STYLE_RULE) {
718
+ const style = rule as CSSStyleRule;
719
+ if (styleRuleReachesSubject(style, style.selectorText, context)) kept.push(rule.cssText);
720
+ continue;
721
+ }
722
+ // A detached SVG has no stylesheet URL against which to resolve fonts.
723
+ if (rule.type === CSSRule.FONT_FACE_RULE) {
724
+ kept.push(
725
+ rule.cssText.replace(/url\((['"]?)(.*?)\1\)/g, (_match, _quote, url) =>
726
+ `url("${new URL(url, sheet.href ?? context.container.ownerDocument.baseURI).href}")`,
727
+ ),
728
+ );
729
+ continue;
730
+ }
731
+ // The clone freezes every animation (`buildOverlaySvg`'s freeze sheet).
732
+ if (rule.type === CSSRule.KEYFRAMES_RULE) continue;
733
+ const grouping = (rule as CSSRule & { readonly cssRules?: CSSRuleList }).cssRules;
734
+ const isScope = typeof CSSScopeRule !== 'undefined' && rule instanceof CSSScopeRule;
735
+ if (grouping && !isScope) {
736
+ // @media, @supports, @container, @layer and the like: the same test
737
+ // inside, under the rule's own prelude.
738
+ const inner = subjectRulesCssText(grouping, sheet, context);
739
+ const prelude = rule.cssText.slice(0, rule.cssText.indexOf('{')).trim();
740
+ // An empty @layer block still states its layer's place in the order.
741
+ if (inner || prelude.startsWith('@layer')) kept.push(`${prelude} {\n${inner}\n}`);
742
+ continue;
743
+ }
744
+ kept.push(rule.cssText);
745
+ }
746
+ return kept.join('\n');
747
+ }
748
+
749
+ function subjectSheetCssText(sheet: CSSStyleSheet, context: SubjectContext): string {
750
+ if (context.seen.has(sheet) || sheet.disabled) return '';
751
+ context.seen.add(sheet);
752
+ let rules: CSSRuleList;
753
+ try {
754
+ rules = sheet.cssRules;
755
+ } catch {
756
+ // Cross-origin sheets refuse `cssRules`; skipping those preserves the
757
+ // normal browser security boundary instead of making capture itself fail.
758
+ return '';
759
+ }
760
+ return subjectRulesCssText(rules, sheet, context);
761
+ }
762
+
763
+ /** Every readable rule in the document that can style `container` or anything
764
+ * inside it, in document cascade order. */
765
+ function subjectStylesCssText(container: Element): string {
766
+ const context: SubjectContext = { container, tokens: subjectTokens(container), seen: new Set() };
767
+ return Array.from(container.ownerDocument.styleSheets, (sheet) => subjectSheetCssText(sheet, context))
768
+ .filter(Boolean)
769
+ .join('\n');
770
+ }
771
+
772
+ /** SVG images cannot fetch external fonts, even ones already loaded by the page.
773
+ * Carry the bytes with the capture so provider and toolbar glyphs remain legible.
774
+ * Each font file is fetched and encoded ONCE per document — a page and a
775
+ * document capture share them — and a failed fetch is forgotten so the next
776
+ * capture asks again. */
777
+ const embeddedFontsByDocument = new WeakMap<Document, Map<string, Promise<string>>>();
778
+
779
+ async function embeddedSubjectStyles(container: Element): Promise<string> {
780
+ const document = container.ownerDocument;
781
+ await document.fonts.ready;
782
+ const css = subjectStylesCssText(container);
783
+ let fonts = embeddedFontsByDocument.get(document);
784
+ if (!fonts) {
785
+ fonts = new Map();
786
+ embeddedFontsByDocument.set(document, fonts);
787
+ }
788
+ const known = fonts;
789
+ const urls = new Set<string>();
790
+ for (const face of css.matchAll(/@font-face\s*\{[^}]*\}/g)) {
791
+ for (const match of face[0].matchAll(/url\("([^"]+)"\)/g)) {
792
+ if (!match[1]!.startsWith('data:')) urls.add(match[1]!);
793
+ }
794
+ }
795
+ const encoded = await Promise.all(Array.from(urls, async (url) => [url, await fontDataUrl(known, url)] as const));
796
+ let embedded = css;
797
+ for (const [url, dataUrl] of encoded) embedded = embedded.replaceAll(`url("${url}")`, `url("${dataUrl}")`);
798
+ return embedded;
799
+ }
800
+
801
+ function fontDataUrl(fonts: Map<string, Promise<string>>, url: string): Promise<string> {
802
+ const known = fonts.get(url);
803
+ if (known) return known;
804
+ const pending = (async () => {
805
+ const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
806
+ if (!response.ok) throw new Error(`Capture font could not be loaded (${response.status}): ${url}`);
807
+ const blob = await response.blob();
808
+ return new Promise<string>((resolve, reject) => {
809
+ const reader = new FileReader();
810
+ reader.onload = () => resolve(String(reader.result));
811
+ reader.onerror = () => reject(reader.error);
812
+ reader.readAsDataURL(blob);
813
+ });
814
+ })();
815
+ fonts.set(url, pending);
816
+ pending.catch(() => {
817
+ if (fonts.get(url) === pending) fonts.delete(url);
818
+ });
819
+ return pending;
820
+ }
821
+
822
+ /** Carry the effective theme across the detached-foreignObject boundary.
823
+ * Theme tokens live as custom properties on an editor-shell ancestor, which
824
+ * the active document intentionally does not clone. Reading them from the
825
+ * subject's computed style preserves adaptive panel ink as well as the base
826
+ * palette, without guessing which ancestor established each value. */
827
+ function copyComputedDocumentContext(source: HTMLElement, target: HTMLElement): void {
828
+ const computed = source.ownerDocument.defaultView?.getComputedStyle(source);
829
+ if (!computed) return;
830
+ for (const property of Array.from(computed)) {
831
+ if (!property.startsWith('--')) continue;
832
+ const value = computed.getPropertyValue(property);
833
+ if (value) target.style.setProperty(property, value);
834
+ }
835
+ for (const property of [
836
+ 'color',
837
+ 'color-scheme',
838
+ 'font-family',
839
+ 'font-size',
840
+ 'font-weight',
841
+ 'line-height',
842
+ 'text-shadow',
843
+ ]) {
844
+ const value = computed.getPropertyValue(property);
845
+ if (value) target.style.setProperty(property, value);
846
+ }
847
+ }
848
+
849
+ /**
850
+ * The element's own painted background colour, or `null` when it is fully
851
+ * transparent (nothing to paint) or unreadable. Computed style, not the inline
852
+ * attribute, so a class-styled document container answers too.
853
+ */
854
+ export function opaqueBackgroundColor(element: HTMLElement): string | null {
855
+ const computed = element.ownerDocument.defaultView?.getComputedStyle(element);
856
+ const color = computed?.backgroundColor;
857
+ if (!color || color === 'transparent') return null;
858
+ // `rgba(r, g, b, 0)` is the other spelling of transparent, and the one
859
+ // browsers actually report for an unset background.
860
+ const alpha = /^rgba?\([^)]*,\s*([\d.]+)\s*\)$/.exec(color)?.[1];
861
+ if (alpha !== undefined && Number(alpha) === 0) return null;
862
+ return color;
863
+ }
864
+
865
+ /**
866
+ * THE ELEMENTS WHOSE BACKGROUND PAINTS *BEHIND* A ROOT-SURFACE CANVAS — the
867
+ * canvas's own ancestor chain, outermost first, `container` included.
868
+ *
869
+ * Why this list has to exist. The compositor's model is "every canvas first,
870
+ * then the whole DOM over the top with those canvases taken out of the clone"
871
+ * (see the nested-canvas note in {@link buildOverlaySvg}: a root-surface canvas
872
+ * is STRIPPED there because the canvas leg already drew it at its exact
873
+ * on-screen rect). That model is only faithful while nothing between the canvas
874
+ * and the composite root paints anything — and an editor document's Scene
875
+ * container paints its ground. CSS puts an ancestor's background BELOW every
876
+ * descendant; the overlay leg puts it ABOVE the canvas leg. So the ground wins
877
+ * and the world vanishes.
878
+ *
879
+ * MEASURED (dodge-the-creeps, imported through `gd-analyze`; the same frame the
880
+ * match-3 import produces): the Edit Scene photographed as ~99% flat `#131416`
881
+ * over a canvas leg that had just drawn 69,366 pixels of the game's authored
882
+ * `#385f61` ColorRect. Nothing about the world, the mount, the Pixi
883
+ * `Application` or the render loop was wrong — only this ordering was, and it
884
+ * was invisible on screen, where the browser composites correctly.
885
+ *
886
+ * Both legs consume this list: {@link drawPlayCompositeFrame} paints these
887
+ * backgrounds UNDER the canvases (where the screen has them), and
888
+ * {@link buildOverlaySvg} clears them from the clone so they cannot paint
889
+ * twice. A document with no root-surface canvas yields an empty list and is
890
+ * composited exactly as before.
891
+ *
892
+ * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
893
+ */
894
+ export function rootSurfaceBackdrops(container: HTMLElement): HTMLElement[] {
895
+ const chain: HTMLElement[] = [];
896
+ const seen = new Set<Element>();
897
+ for (const canvas of Array.from(container.querySelectorAll('canvas'))) {
898
+ if (!isRootCanvas(canvas) || !canvas.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
899
+ const ancestors: HTMLElement[] = [];
900
+ for (
901
+ let node = canvas.parentElement;
902
+ node && (node === container || container.contains(node));
903
+ node = node.parentElement
904
+ ) {
905
+ ancestors.unshift(node);
906
+ if (node === container) break;
907
+ }
908
+ // Outermost first, which is paint order: a nearer ancestor paints over a
909
+ // further one. `unshift` above already produced that order, and the shared
910
+ // outer ancestors of a second root surface are seen (and kept) first.
911
+ for (const element of [...ancestors, ...stackedUnder(container, canvas)]) {
912
+ if (seen.has(element)) continue;
913
+ seen.add(element);
914
+ chain.push(element);
915
+ }
916
+ }
917
+ return chain;
918
+ }
919
+
920
+ /**
921
+ * The elements that paint UNDER a root-surface canvas WITHOUT being its
922
+ * ancestors — the second way a backdrop reaches the screen. A dock lays its
923
+ * panel content in a render overlay positioned over the group boxes, so the
924
+ * group's own surface (`--dv-group-view-background-color`) is a SIBLING
925
+ * subtree beneath the canvas in stacking order, not an ancestor above it in
926
+ * the tree. The ancestor walk cannot see it; the overlay leg clones it opaque
927
+ * and paints it over the canvas the canvas leg just drew.
928
+ *
929
+ * MEASURED (the editor's `screenshot editor` command of the Game document in play, the
930
+ * starter cube and daylight sky on screen): the whole game region came back
931
+ * flat `rgb(36,36,36)` with every ancestor already cleared — the overlay leg
932
+ * rasterized alone read that grey at the canvas centre and the SVG carried no
933
+ * such literal, so it was a stylesheet-painted box that no ancestor owned.
934
+ * The 3D tool documents never showed it because their canvases are not root
935
+ * surfaces: they ride the clone inline, in document order, above that box.
936
+ *
937
+ * Found the way the screen resolves it: `elementsFromPoint` at a few points
938
+ * inside the canvas's rect, everything listed BELOW the canvas that is inside
939
+ * `container` and paints an opaque background. Bottom-most first, which is
940
+ * paint order for the canvas leg. Points the canvas does not top (an overlay
941
+ * above it) still list the canvas, so the cut is at the canvas itself.
942
+ */
943
+ function stackedUnder(container: HTMLElement, canvas: HTMLCanvasElement): HTMLElement[] {
944
+ const doc = container.ownerDocument;
945
+ const rect = canvas.getBoundingClientRect();
946
+ if (rect.width <= 0 || rect.height <= 0 || typeof doc.elementsFromPoint !== 'function') {
947
+ return [];
948
+ }
949
+ const under = new Set<HTMLElement>();
950
+ const points: [number, number][] = [
951
+ [rect.left + rect.width / 2, rect.top + rect.height / 2],
952
+ [rect.left + rect.width * 0.1, rect.top + rect.height * 0.1],
953
+ [rect.left + rect.width * 0.9, rect.top + rect.height * 0.1],
954
+ [rect.left + rect.width * 0.1, rect.top + rect.height * 0.9],
955
+ [rect.left + rect.width * 0.9, rect.top + rect.height * 0.9],
956
+ ];
957
+ for (const [x, y] of points) {
958
+ const stack = doc.elementsFromPoint(x, y);
959
+ const at = stack.indexOf(canvas);
960
+ if (at < 0) continue;
961
+ for (const element of stack.slice(at + 1)) {
962
+ if (paintsOpaqueWithin(element, container)) under.add(element);
963
+ }
964
+ }
965
+ // `elementsFromPoint` lists top-most first; the canvas leg paints
966
+ // bottom-most first.
967
+ return Array.from(under).reverse();
968
+ }
969
+
970
+ function paintsOpaqueWithin(element: Element, container: HTMLElement): element is HTMLElement {
971
+ return (
972
+ element instanceof HTMLElement &&
973
+ (element === container || container.contains(element)) &&
974
+ opaqueBackgroundColor(element) !== null
975
+ );
976
+ }
977
+
978
+ /**
979
+ * THE BACKDROP AN EDITOR DOCUMENT DOES NOT PAINT ITSELF — the subject's own
980
+ * ancestor chain, outermost first, `container` EXCLUDED (the overlay leg
981
+ * clones the container and keeps whatever it paints).
982
+ *
983
+ * The compositor's model is "clone the subject, detached, and rasterize it".
984
+ * A detached clone has no ancestors, so anything an ancestor paints is simply
985
+ * gone — and in this editor the panel fill is DELIBERATELY an ancestor's:
986
+ * `components/workspace-surfaces.css` says in as many words that interior
987
+ * wrappers (`.volter-dock-document-content`, the element every document capture
988
+ * and the document probe resolve as "the document") paint NOTHING, because
989
+ * the surface AROUND them carries the fill for every theme.
990
+ *
991
+ * MEASURED (2026-08-20, `project-tools` — the editor's OWN built-in document —
992
+ * through `editor.captureActiveDocument` on the Vite dev server AND on the
993
+ * built `dist/` served by the packaged/prod editor servers): the composite came
994
+ * back with legible text over ZERO alpha everywhere else, which every PNG
995
+ * reader renders black and which `measureFlatness` (alpha-blind, by design)
996
+ * scores as "~96-98% one flat surface (#000000)". `ok: true`, a warning nobody
997
+ * can act on, and a black photograph offered as evidence of a document that was
998
+ * on screen and perfectly legible. It is NOT a packaged-only defect: dev
999
+ * measured 0.976 and the packaged bundle 0.955 — one mechanism, both paths.
1000
+ *
1001
+ * `story-capture.ts` had already bought this lesson from the other side — it
1002
+ * paints an opaque `STORY_BACKDROP` behind every story precisely because a
1003
+ * canvas-less transparent composite reads as a torn frame.
1004
+ *
1005
+ * Only the EDITOR-DOCUMENT subject takes this leg (`includeDocumentStyles`): a
1006
+ * game's picture must never inherit editor chrome, which is the whole reason
1007
+ * the game path composites onto a neutral transparent wrapper.
1008
+ */
1009
+ export function ancestorBackdrops(container: HTMLElement): HTMLElement[] {
1010
+ const chain: HTMLElement[] = [];
1011
+ for (let node = container.parentElement; node; node = node.parentElement) {
1012
+ chain.unshift(node);
1013
+ }
1014
+ return chain;
1015
+ }
1016
+
1017
+ /**
1018
+ * Take the backgrounds {@link rootSurfaceBackdrops} named out of one overlay
1019
+ * layer's clone: the canvas leg has already painted them, UNDER the canvas they
1020
+ * belong under, so a second copy here would paint over the world.
1021
+ *
1022
+ * Called before any other clone surgery, while `source` and `clone` still walk
1023
+ * in the same order — the same index-parallel assumption the image and nested
1024
+ * canvas passes make.
1025
+ */
1026
+ /**
1027
+ * CARRY WHAT A CLONE DOES NOT: the form state that lives in an IDL property
1028
+ * rather than in an attribute.
1029
+ *
1030
+ * `cloneNode(true)` copies ATTRIBUTES. A `<select>`'s selectedness is not one
1031
+ * — it is `HTMLOptionElement.selected`, and React never writes the `selected`
1032
+ * attribute for a controlled `<select>` — so the serialized clone reaches the
1033
+ * foreignObject with every option unselected and the browser paints THE FIRST
1034
+ * ONE. That is a camera that lies about the screen, and it lied loudly enough
1035
+ * to be written down as a product defect: WALK 5's beat 11b recorded "the rail
1036
+ * draws `Quaternion (WXYZ)`" for an object whose Rotation Mode was XYZ and
1037
+ * then ZYX, and a follow-up was raised against the enum WIDGET. Measured here
1038
+ * through the chrome door on 2026-09-21: the live `<select>`'s `value` is
1039
+ * `"XYZ"`, its options carry no `selected` attribute, and the widget is
1040
+ * correct. The instrument was the whole symptom.
1041
+ *
1042
+ * A checkbox has the same split (`checked` the property vs `checked` the
1043
+ * attribute), so a ticked box photographs empty; a `<textarea>`'s value is its
1044
+ * child text. A text/number `<input>` needs nothing — React keeps the `value`
1045
+ * attribute in sync for controlled inputs, which is why the Properties rail's
1046
+ * numbers always photographed correctly and only its one dropdown did not. It
1047
+ * is written anyway, because "which inputs React syncs" is not a fact this
1048
+ * module should have to depend on.
1049
+ *
1050
+ * Called while source and clone are still INDEX-PARALLEL — before the img,
1051
+ * video and canvas legs replace nodes.
1052
+ */
1053
+ function carryFormStateInClone(source: Element, clone: Element): void {
1054
+ const FIELDS = 'select, input, textarea';
1055
+ const fieldsIn = (root: Element): Element[] => [
1056
+ ...(root.matches(FIELDS) ? [root] : []),
1057
+ ...Array.from(root.querySelectorAll(FIELDS)),
1058
+ ];
1059
+ const sources = fieldsIn(source);
1060
+ const clones = fieldsIn(clone);
1061
+ sources.forEach((field, index) => {
1062
+ const copy = clones[index];
1063
+ if (!copy) return;
1064
+ if (field instanceof HTMLSelectElement && copy instanceof HTMLSelectElement) {
1065
+ const options = Array.from(field.options);
1066
+ Array.from(copy.options).forEach((option, at) => {
1067
+ if (options[at]?.selected) option.setAttribute('selected', '');
1068
+ else option.removeAttribute('selected');
1069
+ });
1070
+ return;
1071
+ }
1072
+ if (field instanceof HTMLInputElement && copy instanceof HTMLInputElement) {
1073
+ if (field.type === 'checkbox' || field.type === 'radio') {
1074
+ if (field.checked) copy.setAttribute('checked', '');
1075
+ else copy.removeAttribute('checked');
1076
+ } else {
1077
+ copy.setAttribute('value', field.value);
1078
+ }
1079
+ return;
1080
+ }
1081
+ if (field instanceof HTMLTextAreaElement && copy instanceof HTMLTextAreaElement) {
1082
+ copy.textContent = field.value;
1083
+ }
1084
+ });
1085
+ }
1086
+
1087
+ /**
1088
+ * CARRY THE SCROLL. A clone's `scrollTop` is zero, and no markup can say
1089
+ * otherwise — so every scrolled panel in the editor photographed AT THE TOP.
1090
+ *
1091
+ * This is {@link carryFormStateInClone}'s twin and it cost more: B11 handed on
1092
+ * "an agent-added torus never appears in the Outliner, even after a restart"
1093
+ * as a panel-height finding, read off a frame from this camera. Measured here
1094
+ * 2026-09-21, through the chrome door, on eight objects in a five-row panel:
1095
+ * the live tree IS scrolled to the new object (first row at y −53 against a
1096
+ * body starting at 46 — 99 px of scroll, the active `Sphere.005` at 127..147,
1097
+ * the last fully visible row), and the SAME frame photographs the tree at row
1098
+ * zero. The panel was doing its job; the camera was not.
1099
+ *
1100
+ * WHY THE FIRST CHILD'S MARGIN and not a wrapper: a scroll IS the content
1101
+ * drawn `scrollTop` higher inside the same clipped box, and a negative margin
1102
+ * on the first in-flow child produces exactly that while leaving the
1103
+ * scroller's own layout mode untouched. A wrapper div would collapse a flex
1104
+ * column's items into one item and re-lay-out everything under it. The
1105
+ * element's own margin is READ off the source and subtracted from, so a
1106
+ * scroller whose first child already has one is not flattened.
1107
+ *
1108
+ * `overflow: hidden` on the clone because the offset content must be CLIPPED:
1109
+ * a foreignObject gets no scrollbars and an `auto` box would simply grow.
1110
+ */
1111
+ function carryScrollInClone(source: Element, clone: Element): void {
1112
+ const sourceNodes = [source, ...Array.from(source.querySelectorAll('*'))];
1113
+ const cloneNodes = [clone, ...Array.from(clone.querySelectorAll('*'))];
1114
+ sourceNodes.forEach((node, index) => {
1115
+ const top = node.scrollTop;
1116
+ const left = node.scrollLeft;
1117
+ if (top === 0 && left === 0) return;
1118
+ const target = cloneNodes[index];
1119
+ if (!(target instanceof HTMLElement)) return;
1120
+ const first = target.firstElementChild;
1121
+ if (!(first instanceof HTMLElement)) return;
1122
+ target.style.overflow = 'hidden';
1123
+ const computed = getComputedStyle(node.firstElementChild ?? node);
1124
+ const marginTop = Number.parseFloat(computed.marginTop) || 0;
1125
+ const marginLeft = Number.parseFloat(computed.marginLeft) || 0;
1126
+ if (top !== 0) first.style.marginTop = `${marginTop - top}px`;
1127
+ if (left !== 0) first.style.marginLeft = `${marginLeft - left}px`;
1128
+ });
1129
+ }
1130
+
1131
+ function clearBackdropsInClone(
1132
+ source: Element,
1133
+ clone: Element,
1134
+ backdrops: ReadonlySet<Element> | undefined,
1135
+ ): void {
1136
+ if (!backdrops?.size) return;
1137
+ const sourceNodes = [source, ...Array.from(source.querySelectorAll('*'))];
1138
+ const cloneNodes = [clone, ...Array.from(clone.querySelectorAll('*'))];
1139
+ sourceNodes.forEach((node, index) => {
1140
+ if (!backdrops.has(node)) return;
1141
+ const target = cloneNodes[index];
1142
+ if (target instanceof HTMLElement) target.style.background = 'transparent';
1143
+ });
1144
+ }
1145
+
1146
+ export function buildOverlaySvg(
1147
+ container: HTMLElement,
1148
+ width: number,
1149
+ height: number,
1150
+ options?: CaptureOptions & {
1151
+ readonly documentCssText?: string | undefined;
1152
+ readonly canvasPixels?: CanvasPixels | undefined;
1153
+ /** {@link rootSurfaceBackdrops} — cleared in the clone because the canvas
1154
+ * leg painted them under the canvas they belong under. */
1155
+ readonly transparentBackdrops?: ReadonlySet<Element> | undefined;
1156
+ /**
1157
+ * Rasterize at THIS pixel size while laying the clone out at `width` ×
1158
+ * `height`. The two are one number apart and they are not the same
1159
+ * question: the clone is DOM, so it must be laid out in the CSS pixels its
1160
+ * styles are written in (a 14px label is 14px, a 1px border is 1px),
1161
+ * while the frame may be wanted at device resolution or above it.
1162
+ * Stretching the wrapper's box instead would keep every font size and
1163
+ * border width at 1x inside a larger box — the layout would change, not
1164
+ * the resolution. A `viewBox` is the one mechanism that scales the
1165
+ * rendering: the browser rasterizes the `foreignObject` through it, so
1166
+ * text and borders come out at the output scale, vector-crisp.
1167
+ */
1168
+ readonly rasterSize?: { readonly width: number; readonly height: number } | undefined;
1169
+ },
1170
+ ): OverlaySvg | null {
1171
+ const overlays = Array.from(container.children).filter(
1172
+ (el) => el.tagName.toLowerCase() !== 'canvas',
1173
+ );
1174
+ if (overlays.length === 0) return null;
1175
+
1176
+ const includeDocumentStyles = options?.includeDocumentStyles === true;
1177
+ let snapshotMs = 0;
1178
+ const snapshot = (pixels: CanvasImageSource): string | null => {
1179
+ const started = performance.now();
1180
+ try { return canvasDataUrl(container.ownerDocument, pixels); }
1181
+ finally { snapshotMs += performance.now() - started; }
1182
+ };
1183
+ const wrapper = includeDocumentStyles
1184
+ ? (container.cloneNode(false) as HTMLElement)
1185
+ : container.ownerDocument.createElement('div');
1186
+ wrapper.style.position = 'relative';
1187
+ wrapper.style.inset = 'auto';
1188
+ wrapper.style.width = `${width}px`;
1189
+ wrapper.style.height = `${height}px`;
1190
+ wrapper.style.margin = '0';
1191
+ wrapper.style.transform = 'none';
1192
+ // Transparent is load-bearing for GAME capture, not tidiness — see the
1193
+ // scope marker below. An editor document keeps its real container backdrop,
1194
+ // unless that backdrop paints behind a root-surface canvas the canvas leg
1195
+ // already drew (see {@link rootSurfaceBackdrops}).
1196
+ if (includeDocumentStyles) copyComputedDocumentContext(container, wrapper);
1197
+ else wrapper.style.background = 'transparent';
1198
+ const backdrops = options?.transparentBackdrops;
1199
+ if (backdrops?.has(container)) wrapper.style.background = 'transparent';
1200
+ // THE CONTAINER MAY BE THE GAME'S CSS SCOPE ROOT. The neutral game path does
1201
+ // not clone that container (see this function's doc comment), so it would
1202
+ // otherwise lose the root and, with it, every `@scope`d rule that positions
1203
+ // the HUD. The ingest surface is exactly this shape: `#container` IS the game's
1204
+ // page in-realm and IS the marked scope root, and its HUD elements are its
1205
+ // CHILDREN. The wrapper already stands in for the container's box; it stands
1206
+ // in for its scope identity here too.
1207
+ //
1208
+ // Its own background stays transparent because that identity brings one
1209
+ // declaration that does not belong on this layer: the page backdrop
1210
+ // (`body { background }` → `:scope`). On screen that sits BEHIND the canvas,
1211
+ // and the canvas leg has already drawn it; repainting it here would hide the
1212
+ // game under its own background. A scope root that is INSIDE the container
1213
+ // (a story card's content) is cloned normally and keeps its backdrop.
1214
+ if (container.closest(`[${GAME_CSS_SCOPE_ATTRIBUTE}]`)) {
1215
+ wrapper.setAttribute(GAME_CSS_SCOPE_ATTRIBUTE, '');
1216
+ }
1217
+ if (includeDocumentStyles) {
1218
+ const css = options?.documentCssText ?? subjectStylesCssText(container);
1219
+ if (css) {
1220
+ const documentStyles = container.ownerDocument.createElement('style');
1221
+ documentStyles.textContent = css;
1222
+ wrapper.appendChild(documentStyles);
1223
+ }
1224
+ }
1225
+ // Snapshot a stable DOM frame. CSS animations/transitions inside an SVG
1226
+ // foreignObject can be rasterized while Chrome is between compositor
1227
+ // layers, producing large opaque-black rectangles even though the live DOM
1228
+ // is fine (reproduced by the 2048 dogfood run during a tile-pop frame).
1229
+ // Disabling motion in the detached clone leaves the live game untouched
1230
+ // and makes the captured evidence atomic.
1231
+ const freezeMotion = container.ownerDocument.createElement('style');
1232
+ freezeMotion.textContent =
1233
+ '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;' +
1234
+ 'backdrop-filter:none!important;-webkit-backdrop-filter:none!important;}';
1235
+ wrapper.appendChild(freezeMotion);
1236
+ // The GAME's own page stylesheet, re-inlined for the same structural reason
1237
+ // the freeze above is: this clone is DETACHED, and a detached fragment
1238
+ // carries no document stylesheet. The sheet is already `@scope`d to the
1239
+ // containers the clone contains (`scoped-game-css.ts`), so re-inlining it
1240
+ // reaches exactly the game DOM and nothing else — and without it a styled
1241
+ // HUD photographs as unstyled, i.e. the look verb reports the very
1242
+ // wreckage the scoped stylesheet exists to remove.
1243
+ const gameCss = scopedGameStylesCssText();
1244
+ if (gameCss) {
1245
+ const gameStyles = container.ownerDocument.createElement('style');
1246
+ gameStyles.textContent = gameCss;
1247
+ wrapper.appendChild(gameStyles);
1248
+ }
1249
+ // …and the stylesheets the game's own MODULES imported, for the same reason:
1250
+ // Vite serves each one as a `<style>` in the editor document, so the detached
1251
+ // clone of a HUD styled by an imported `.css` file would photograph unstyled.
1252
+ if (!includeDocumentStyles) {
1253
+ const moduleCss = projectModuleStylesCssText(container.ownerDocument, options?.projectRoot);
1254
+ if (moduleCss) {
1255
+ const moduleStyles = container.ownerDocument.createElement('style');
1256
+ moduleStyles.textContent = moduleCss;
1257
+ wrapper.appendChild(moduleStyles);
1258
+ }
1259
+ }
1260
+ for (const el of overlays) {
1261
+ const clone = el.cloneNode(true) as Element;
1262
+ // Clear the backgrounds that paint BEHIND a root-surface canvas before any
1263
+ // other clone surgery, while source and clone are still index-parallel.
1264
+ clearBackdropsInClone(el, clone, backdrops);
1265
+ // …and carry the form state and the scroll offsets, for the same
1266
+ // index-parallel reason: the legs below REPLACE nodes.
1267
+ carryFormStateInClone(el, clone);
1268
+ carryScrollInClone(el, clone);
1269
+ // An SVG loaded from a `data:` URL cannot fetch an ordinary `/public/...`
1270
+ // image referenced by a nested `<img>`. The live DOM therefore looked
1271
+ // correct while both story screenshots and Doctor's UI-board evidence
1272
+ // silently omitted every authored bitmap. Snapshot each already-loaded
1273
+ // image through canvas and carry the pixels in the clone, exactly like the
1274
+ // nested-canvas path below. A cross-origin/tainted or not-yet-loaded image
1275
+ // keeps its original URL: capture remains best-effort for foreign content,
1276
+ // while same-origin project assets become self-contained.
1277
+ const sourceImages = [
1278
+ ...(el instanceof HTMLImageElement ? [el] : []),
1279
+ ...Array.from(el.querySelectorAll('img')),
1280
+ ];
1281
+ const clonedImages = [
1282
+ ...(clone instanceof HTMLImageElement ? [clone] : []),
1283
+ ...Array.from(clone.querySelectorAll('img')),
1284
+ ];
1285
+ clonedImages.forEach((image, index) => {
1286
+ const source = sourceImages[index];
1287
+ if (!source?.complete || source.naturalWidth <= 0 || source.naturalHeight <= 0) return;
1288
+ // `currentSrc` first: a `srcset` image's pixels follow the RESOLVED
1289
+ // candidate, and re-snapshotting on a `src` that never changed is the
1290
+ // exact per-frame cost the cache exists to remove.
1291
+ const src = source.currentSrc || source.src;
1292
+ const cached = options?.snapshots?.imgs.get(source);
1293
+ const url =
1294
+ cached && cached.src === src ? cached.url : snapshot(source);
1295
+ if (!cached || cached.src !== src) options?.snapshots?.imgs.set(source, { src, url });
1296
+ if (url) image.setAttribute('src', url);
1297
+ });
1298
+ // A `<video>` is the `<img>` case one step further along: a detached clone
1299
+ // has no media pipeline, so a clip whose frame is decoded and on screen
1300
+ // photographs as a BLACK BOX — measured on a reference clip's Content tile
1301
+ // and its Asset Lab document, both of which a person could see perfectly
1302
+ // well. The element's CURRENT frame is `drawImage`-able exactly like an
1303
+ // image, so snapshot it and carry the pixels, keeping the element's own box
1304
+ // (an `<img>` in its place inherits the same style and layout). A video
1305
+ // with no decoded frame yet keeps its empty element rather than gaining a
1306
+ // fabricated one.
1307
+ const sourceVideos = [
1308
+ ...(el instanceof HTMLVideoElement ? [el] : []),
1309
+ ...Array.from(el.querySelectorAll('video')),
1310
+ ];
1311
+ const clonedVideos = [
1312
+ ...(clone instanceof HTMLVideoElement ? [clone] : []),
1313
+ ...Array.from(clone.querySelectorAll('video')),
1314
+ ];
1315
+ clonedVideos.forEach((video, index) => {
1316
+ const source = sourceVideos[index];
1317
+ if (!source || source.readyState < 2 || source.videoWidth <= 0) return;
1318
+ const url = snapshot(source);
1319
+ if (!url) return;
1320
+ const image = container.ownerDocument.createElement('img');
1321
+ image.setAttribute('src', url);
1322
+ image.setAttribute('style', video.getAttribute('style') ?? '');
1323
+ video.replaceWith(image);
1324
+ });
1325
+ // A canvas nested INSIDE an overlay layer serializes as an empty box. Its
1326
+ // pixels are drawn by the canvas leg, but UNDER this whole overlay — so
1327
+ // anything the layer paints between them (the `2D` board's frames paint an
1328
+ // alpha checkerboard behind every exhibit) covers it, and the exhibit
1329
+ // photographs blank while looking perfectly fine on screen. When the caller
1330
+ // has the pixels, they are inlined HERE, in the nested canvas's own place,
1331
+ // so document order — the real z-order — decides what covers what. With no
1332
+ // pixels the old answer stands: strip it rather than ship a lying blank.
1333
+ const sources = Array.from(el.querySelectorAll('canvas'));
1334
+ Array.from(clone.querySelectorAll('canvas')).forEach((nested, index) => {
1335
+ const source = sources[index];
1336
+ // A host root-surface canvas was already painted by the canvas leg at
1337
+ // its exact on-screen rect. Re-inlining it here paints the same pixels a
1338
+ // second time inside a detached stacking context; Chromium then places
1339
+ // that raster over higher-z sibling DOM roots (the measured failure was
1340
+ // a Three game's HUD disappearing only from composite screenshots).
1341
+ // Board/story canvases are not root surfaces and still need this inline
1342
+ // path so their surrounding card backgrounds preserve document order.
1343
+ if (source && isRootCanvas(source)) {
1344
+ nested.remove();
1345
+ return;
1346
+ }
1347
+ const pixels = source ? options?.canvasPixels?.get(source) : undefined;
1348
+ const url = pixels ? snapshot(pixels) : null;
1349
+ if (!url) {
1350
+ nested.remove();
1351
+ return;
1352
+ }
1353
+ const image = container.ownerDocument.createElement('img');
1354
+ image.setAttribute('src', url);
1355
+ image.setAttribute('style', `${nested.getAttribute('style') ?? ''}`);
1356
+ for (const attribute of Array.from(nested.attributes)) {
1357
+ image.setAttribute(attribute.name, attribute.value);
1358
+ }
1359
+ nested.replaceWith(image);
1360
+ });
1361
+ wrapper.appendChild(clone);
1362
+ }
1363
+
1364
+ const serialized = new XMLSerializer().serializeToString(wrapper);
1365
+ const rasterWidth = options?.rasterSize?.width ?? width;
1366
+ const rasterHeight = options?.rasterSize?.height ?? height;
1367
+ // The viewBox is emitted ONLY when the raster differs from the layout, so an
1368
+ // unscaled capture serializes the exact string it always has.
1369
+ const viewBox =
1370
+ rasterWidth === width && rasterHeight === height ? '' : ` viewBox="0 0 ${width} ${height}"`;
1371
+ const svg =
1372
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${rasterWidth}" height="${rasterHeight}"${viewBox}>` +
1373
+ `<foreignObject width="100%" height="100%">${serialized}</foreignObject></svg>`;
1374
+ return { svg, overlayCount: overlays.length, snapshotMs };
1375
+ }
1376
+
1377
+ export interface CompositeCapture {
1378
+ base64: string;
1379
+ mimeType: 'image/png';
1380
+ /** Honest capture provenance: how many canvases were drawn, and how many
1381
+ * DOM layers rode the foreignObject leg. */
1382
+ layers: { canvases: number; domOverlays: number };
1383
+ /** How much of the frame is one flat surface — see {@link measureFlatness}.
1384
+ * Absent only when the readback itself was unavailable (tainted canvas). */
1385
+ flatness?: CaptureFlatness;
1386
+ /** Wall times include asynchronous scheduling; they are not CPU timings. */
1387
+ timings?: { compositeMs: number; pngEncodeMs: number } & CompositeFrameTimings;
1388
+ }
1389
+
1390
+ interface CompositeFrameTimings {
1391
+ canvasDrawMs?: number;
1392
+ documentStylesMs?: number;
1393
+ overlayBuildMs?: number;
1394
+ overlaySnapshotMs?: number;
1395
+ overlayDecodeMs?: number;
1396
+ overlayDrawMs?: number;
1397
+ }
1398
+
1399
+ export interface CompositeFrame {
1400
+ /** Number of native canvas surfaces painted into this frame. */
1401
+ canvases: number;
1402
+ /** Number of DOM overlay roots painted above those canvases. */
1403
+ domOverlays: number;
1404
+ /** Capture stage wall times, including asynchronous scheduling. */
1405
+ timings?: CompositeFrameTimings;
1406
+ }
1407
+
1408
+ /** The three independently fallible legs of a full game-frame capture. */
1409
+ export type CaptureLayer = 'canvas' | 'dom-overlay' | 'composite-output';
1410
+
1411
+ const CAPTURE_LAYER_LABEL: Record<CaptureLayer, string> = {
1412
+ canvas: 'canvas layer',
1413
+ 'dom-overlay': 'DOM overlay layer',
1414
+ 'composite-output': 'composite output',
1415
+ };
1416
+
1417
+ /**
1418
+ * A capture failure whose message names the layer that failed. This crosses
1419
+ * the active-document and relay doors unchanged, so neither caller has to
1420
+ * infer a layer from a browser exception such as "The operation is insecure".
1421
+ */
1422
+ export class CaptureLayerError extends Error {
1423
+ readonly layer: CaptureLayer;
1424
+
1425
+ constructor(layer: CaptureLayer, error: unknown) {
1426
+ // Browser exceptions can come from another realm (an ingested page or
1427
+ // jsdom's DOM realm), where `instanceof Error` is false despite a real
1428
+ // `.message`. Read the platform shape rather than losing it to
1429
+ // `String(error)`'s generic "Error: …" prefix.
1430
+ const detail =
1431
+ typeof error === 'object' &&
1432
+ error !== null &&
1433
+ 'message' in error &&
1434
+ typeof error.message === 'string'
1435
+ ? error.message
1436
+ : String(error);
1437
+ super(`${CAPTURE_LAYER_LABEL[layer]} capture failed — ${detail}`);
1438
+ this.name = 'CaptureLayerError';
1439
+ this.layer = layer;
1440
+ }
1441
+ }
1442
+
1443
+ function layerFailure(layer: CaptureLayer, error: unknown): CaptureLayerError {
1444
+ return error instanceof CaptureLayerError ? error : new CaptureLayerError(layer, error);
1445
+ }
1446
+
1447
+ /**
1448
+ * Is this capture worth anything as evidence?
1449
+ *
1450
+ * Measured failure (two blind probes, 2026-08): both filed near-blank
1451
+ * screenshots — "essentially blank: a black band over a flat beige plane",
1452
+ * "camera buried in geometry" — under "Tested". A PNG that is one flat colour
1453
+ * proves nothing, and nothing in the pipeline said so, so the reader had to
1454
+ * open the file and notice. This is the cheap heuristic that says it at
1455
+ * capture time.
1456
+ *
1457
+ * `warning` is the ONE place the sentence is spelled. Every surface that
1458
+ * shows this (the the editor's `screenshot` command verb, the `/__volter/screenshot` poke, the
1459
+ * relay transport behind `game.screenshot()`) lives in a different package,
1460
+ * and three copies of a sentence is three sentences that drift — so the layer
1461
+ * holding the pixels writes the words and the rest print them verbatim.
1462
+ */
1463
+ export interface CaptureFlatness {
1464
+ /** Fraction of sampled cells inside the single largest near-uniform region. */
1465
+ dominantFraction: number;
1466
+ /** That region's mean colour, `#rrggbb` — names WHICH flat surface it is. */
1467
+ dominantColor: string;
1468
+ /** How many coarse colour regions cover >=1% of the frame. Reported for
1469
+ * context only; nothing branches on it (see the threshold note below). */
1470
+ distinctRegions: number;
1471
+ degenerate: boolean;
1472
+ /** Present iff `degenerate`. */
1473
+ warning?: string;
1474
+ }
1475
+
1476
+ /**
1477
+ * Grid the measure samples at. 32x32 = 1024 cells: fine enough that a HUD
1478
+ * strip, a character, or a horizon occupies several cells, coarse enough that
1479
+ * the whole measurement is one GPU-side downsample plus 4 KB of readback no
1480
+ * matter how large the capture is.
1481
+ */
1482
+ export const FLATNESS_GRID = 32;
1483
+
1484
+ /** Per-channel tolerance for "still the same flat surface". A wall or sky
1485
+ * under one light still shades by a few levels across the frame; 24/255 keeps
1486
+ * those together without merging two genuinely different surfaces. */
1487
+ const FLAT_TOLERANCE = 24;
1488
+
1489
+ /**
1490
+ * Warn at 90%: nine tenths of the frame is one surface, i.e. less than a tenth
1491
+ * of the picture shows anything at all. Deliberately high — this warns, it
1492
+ * never refuses, and a warning that fires on ordinary frames is a warning
1493
+ * people learn to skip. Measured live against the starter template: the
1494
+ * default stage (sky + construction grid + focal cube) reads 0.745 — high,
1495
+ * because a ground plane legitimately owns three quarters of that frame — and
1496
+ * a camera sealed inside geometry reads 1.000. Anything under 0.9 is a picture
1497
+ * a reader can still learn something from; the interesting failures pile up at
1498
+ * the very top of the range.
1499
+ */
1500
+ export const FLATNESS_WARN_AT = 0.9;
1501
+
1502
+ /**
1503
+ * The pure half: given RGBA for `cellCount` sampled cells, how dominated is
1504
+ * the frame by one near-uniform region?
1505
+ *
1506
+ * Two passes, no dependencies. First bucket every cell coarsely (32-wide per
1507
+ * channel) to find the busiest region; then re-count every cell within
1508
+ * `FLAT_TOLERANCE` of that region's MEAN, which is what stops a smooth surface
1509
+ * that happens to straddle a bucket boundary from reading as two regions.
1510
+ */
1511
+ type Rgb = [number, number, number];
1512
+
1513
+ /** One sampled cell's colour. The `?? 0` keeps a short/ragged buffer from
1514
+ * producing NaN arithmetic downstream. */
1515
+ function cellColor(rgba: Uint8ClampedArray | number[], cell: number): Rgb {
1516
+ return [rgba[cell * 4] ?? 0, rgba[cell * 4 + 1] ?? 0, rgba[cell * 4 + 2] ?? 0];
1517
+ }
1518
+
1519
+ /** Pass 1: coarse buckets (32 levels per channel) to find the busiest region
1520
+ * and count how many regions cover >=1% of the frame. */
1521
+ function dominantRegion(
1522
+ rgba: Uint8ClampedArray | number[],
1523
+ cellCount: number,
1524
+ ): { mean: Rgb; distinctRegions: number } {
1525
+ const buckets = new Map<number, { n: number; r: number; g: number; b: number }>();
1526
+ for (let cell = 0; cell < cellCount; cell++) {
1527
+ const [r, g, b] = cellColor(rgba, cell);
1528
+ const key = ((r >> 5) << 10) | ((g >> 5) << 5) | (b >> 5);
1529
+ const bucket = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
1530
+ bucket.n += 1;
1531
+ bucket.r += r;
1532
+ bucket.g += g;
1533
+ bucket.b += b;
1534
+ buckets.set(key, bucket);
1535
+ }
1536
+ let top = { n: 1, r: 0, g: 0, b: 0 };
1537
+ let distinctRegions = 0;
1538
+ for (const bucket of buckets.values()) {
1539
+ if (bucket.n / cellCount >= 0.01) distinctRegions += 1;
1540
+ if (bucket.n > top.n) top = bucket;
1541
+ }
1542
+ return {
1543
+ mean: [Math.round(top.r / top.n), Math.round(top.g / top.n), Math.round(top.b / top.n)],
1544
+ distinctRegions,
1545
+ };
1546
+ }
1547
+
1548
+ /** Pass 2: every cell within `FLAT_TOLERANCE` of that mean, which is what
1549
+ * stops a smooth surface straddling a bucket boundary from reading as two. */
1550
+ function countWithinTolerance(
1551
+ rgba: Uint8ClampedArray | number[],
1552
+ cellCount: number,
1553
+ mean: Rgb,
1554
+ ): number {
1555
+ let within = 0;
1556
+ for (let cell = 0; cell < cellCount; cell++) {
1557
+ const color = cellColor(rgba, cell);
1558
+ const delta = Math.max(
1559
+ Math.abs(color[0] - mean[0]),
1560
+ Math.abs(color[1] - mean[1]),
1561
+ Math.abs(color[2] - mean[2]),
1562
+ );
1563
+ if (delta <= FLAT_TOLERANCE) within += 1;
1564
+ }
1565
+ return within;
1566
+ }
1567
+
1568
+ export function measureFlatness(
1569
+ rgba: Uint8ClampedArray | number[],
1570
+ cellCount: number,
1571
+ threshold = FLATNESS_WARN_AT,
1572
+ ): CaptureFlatness {
1573
+ if (cellCount <= 0) {
1574
+ return { dominantFraction: 0, dominantColor: '#000000', distinctRegions: 0, degenerate: false };
1575
+ }
1576
+ const { mean, distinctRegions } = dominantRegion(rgba, cellCount);
1577
+ const dominantFraction =
1578
+ Math.round((countWithinTolerance(rgba, cellCount, mean) / cellCount) * 1000) / 1000;
1579
+ const dominantColor = `#${mean.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
1580
+ const degenerate = dominantFraction >= threshold;
1581
+ const warning =
1582
+ `this capture is ~${Math.round(dominantFraction * 100)}% one flat surface ` +
1583
+ `(${dominantColor}) — likely a wall, an empty view, or a buried camera; ` +
1584
+ 'it is weak evidence.';
1585
+ return {
1586
+ dominantFraction,
1587
+ dominantColor,
1588
+ distinctRegions,
1589
+ degenerate,
1590
+ ...(degenerate ? { warning } : {}),
1591
+ };
1592
+ }
1593
+
1594
+ /**
1595
+ * The DOM half: downsample any drawable source to {@link FLATNESS_GRID} and
1596
+ * measure it. The downsample is `drawImage` into a tiny canvas — the
1597
+ * compositor does the averaging, so cost is independent of capture size and
1598
+ * the readback is 4 KB. Returns null when readback is unavailable (a tainted
1599
+ * canvas), because a missing measurement must never read as a clean one.
1600
+ */
1601
+ export function sampleFlatness(
1602
+ source: CanvasImageSource,
1603
+ doc: Document,
1604
+ threshold = FLATNESS_WARN_AT,
1605
+ ): CaptureFlatness | null {
1606
+ try {
1607
+ const grid = doc.createElement('canvas');
1608
+ grid.width = FLATNESS_GRID;
1609
+ grid.height = FLATNESS_GRID;
1610
+ const ctx = grid.getContext('2d');
1611
+ if (!ctx) return null;
1612
+ ctx.drawImage(source, 0, 0, FLATNESS_GRID, FLATNESS_GRID);
1613
+ const pixels = ctx.getImageData(0, 0, FLATNESS_GRID, FLATNESS_GRID);
1614
+ return measureFlatness(pixels.data, FLATNESS_GRID * FLATNESS_GRID, threshold);
1615
+ } catch {
1616
+ return null;
1617
+ }
1618
+ }
1619
+
1620
+ /** Wait briefly for finite CSS/Web Animations to settle before serializing a
1621
+ * DOM frame. Chromium can rasterize an actively animated foreignObject as
1622
+ * opaque black compositor tiles; a settled frame is both more legible and
1623
+ * closer to what a human sees after the interaction. Infinite ambience never
1624
+ * blocks evidence capture, and the timeout keeps a broken animation from
1625
+ * wedging the relay. */
1626
+ export async function waitForFiniteMotion(container: HTMLElement, maxWaitMs = 350): Promise<void> {
1627
+ if (typeof container.getAnimations !== 'function') return;
1628
+ const animations = container.getAnimations({ subtree: true }).filter((animation) => {
1629
+ const endTime = animation.effect?.getComputedTiming().endTime;
1630
+ return typeof endTime === 'number' && Number.isFinite(endTime);
1631
+ });
1632
+ if (animations.length === 0) return;
1633
+ await Promise.race([
1634
+ Promise.allSettled(animations.map((animation) => animation.finished)),
1635
+ new Promise<void>((resolve) => setTimeout(resolve, maxWaitMs)),
1636
+ ]);
1637
+ }
1638
+
1639
+ /** Cross two paint boundaries when the tab is rendering, but never depend on
1640
+ * rAF firing: Chromium suspends it for hidden/background editor tabs, and a
1641
+ * screenshot is specifically expected to work there. The timer is the
1642
+ * bounded fallback, not an extra delay after a successful frame. */
1643
+ export async function waitForPaint(container: HTMLElement, maxWaitMs = 75): Promise<void> {
1644
+ const requestFrame = container.ownerDocument.defaultView?.requestAnimationFrame.bind(
1645
+ container.ownerDocument.defaultView,
1646
+ );
1647
+ if (!requestFrame) return;
1648
+ await new Promise<void>((resolve) => {
1649
+ let settled = false;
1650
+ const finish = () => {
1651
+ if (settled) return;
1652
+ settled = true;
1653
+ clearTimeout(timeout);
1654
+ resolve();
1655
+ };
1656
+ const timeout = setTimeout(finish, maxWaitMs);
1657
+ requestFrame(() => requestFrame(finish));
1658
+ });
1659
+ }
1660
+
1661
+ /**
1662
+ * Rasterize the full play container (canvases + DOM UI layers) to a PNG.
1663
+ * Output is sized to the container's CSS rect — the same geometry the human
1664
+ * sees. Throws on any rasterization failure; the caller
1665
+ * (`handleBridgeScreenshot`) degrades to the canvas-only capture and marks
1666
+ * the result honestly rather than failing the op.
1667
+ */
1668
+ export async function capturePlayComposite(
1669
+ container: HTMLElement,
1670
+ options?: CaptureOptions,
1671
+ ): Promise<CompositeCapture> {
1672
+ // Debug state can become observable just before React commits the matching
1673
+ // DOM. Cross two paint boundaries first, then let newly-mounted finite
1674
+ // transitions finish, then cross one more stable paint before cloning.
1675
+ await waitForPaint(container);
1676
+ // Chrome's foreignObject renderer can retain opaque-black compositor tiles
1677
+ // for several frames after a React-only tree replaces an overlay (observed
1678
+ // on an immediate game-over -> restart capture even after its 180ms tile
1679
+ // animation reported finished). Give DOM-only roots one short compositor
1680
+ // settle window; canvas-backed games do not use this fragile leg as their
1681
+ // sole image and keep the faster path.
1682
+ //
1683
+ // WHICH ROOTS ARE DOM-ONLY is a DECLARATION when the caller has one
1684
+ // ({@link CaptureOptions.presentsOnCanvas}); the canvas count is the measured
1685
+ // fallback, and it is spelled second so the fallback reads as one.
1686
+ //
1687
+ // The window itself STAYS, for both: it does not answer "has the game
1688
+ // painted" (declared readiness answers that, upstream, before capture is even
1689
+ // called) — it answers "has Chromium finished compositing the tiles it will
1690
+ // rasterize from", which no game can declare. Its measured companion is the
1691
+ // transparent/near-black retry below, which grades the actual pixels.
1692
+ const presentsOnCanvas =
1693
+ options?.presentsOnCanvas ?? container.querySelectorAll('canvas').length > 0;
1694
+ if (!presentsOnCanvas) {
1695
+ await new Promise<void>((resolve) => setTimeout(resolve, 650));
1696
+ }
1697
+ await waitForFiniteMotion(container);
1698
+ await waitForPaint(container);
1699
+ return capturePlayCompositeAttempt(container, 2, options ?? {});
1700
+ }
1701
+
1702
+ /** How much fully-transparent area still reads as a torn/blank frame rather
1703
+ * than as rounded corners (which stay far below it). One constant, because the
1704
+ * retry and the refusal below must agree about what "photographed nothing"
1705
+ * means. */
1706
+ export const TRANSPARENT_PIXEL_LIMIT = 0.03;
1707
+
1708
+ /** Fraction of the frame at alpha 0 — the measure both the bounded retry and
1709
+ * the refusal read. */
1710
+ export function transparentPixelFraction(rgba: Uint8ClampedArray, pixelCount: number): number {
1711
+ if (pixelCount <= 0) return 0;
1712
+ let transparent = 0;
1713
+ for (let alpha = 3; alpha < rgba.length; alpha += 4) {
1714
+ if (rgba[alpha] === 0) transparent += 1;
1715
+ }
1716
+ return transparent / pixelCount;
1717
+ }
1718
+
1719
+ export function hasExcessTransparentPixels(
1720
+ rgba: Uint8ClampedArray,
1721
+ pixelCount: number,
1722
+ limit = TRANSPARENT_PIXEL_LIMIT,
1723
+ ): boolean {
1724
+ if (pixelCount <= 0) return false;
1725
+ return transparentPixelFraction(rgba, pixelCount) > limit;
1726
+ }
1727
+
1728
+ /** Chromium's foreignObject compositor sometimes fills a torn tile with
1729
+ * fully-opaque black rather than transparency. Catch only a LARGE region of
1730
+ * near-pure black; a legitimately dark game merely takes the bounded retry
1731
+ * path and is still returned unchanged when it remains dark. */
1732
+ export function hasExcessNearBlackPixels(
1733
+ rgba: Uint8ClampedArray,
1734
+ pixelCount: number,
1735
+ limit = 0.08,
1736
+ ): boolean {
1737
+ if (pixelCount <= 0) return false;
1738
+ let black = 0;
1739
+ for (let offset = 0; offset < rgba.length; offset += 4) {
1740
+ if (
1741
+ rgba[offset]! <= 8 &&
1742
+ rgba[offset + 1]! <= 8 &&
1743
+ rgba[offset + 2]! <= 8 &&
1744
+ rgba[offset + 3]! >= 250
1745
+ ) {
1746
+ black += 1;
1747
+ }
1748
+ }
1749
+ return black / pixelCount > limit;
1750
+ }
1751
+
1752
+ type DomOnlyVerdict =
1753
+ | { kind: 'ok' }
1754
+ | { kind: 'retry' }
1755
+ | { kind: 'blank'; transparentFraction: number };
1756
+
1757
+ /**
1758
+ * Grade a DOM-ONLY composite (no canvas underneath it) on its own pixels.
1759
+ *
1760
+ * Two failures wear the same face here, and both end as black PNGs:
1761
+ * - A Chromium foreignObject can decode while React is between compositor
1762
+ * frames, leaving large transparent rectangles in an otherwise opaque
1763
+ * frame — that one is transient, so it earns a repaint and a retry.
1764
+ * Rounded-corner edge pixels stay far below {@link TRANSPARENT_PIXEL_LIMIT}.
1765
+ * - Nothing painted a backdrop at all. Out of retries and still mostly zero
1766
+ * alpha, the frame is REFUSED rather than returned: `measureFlatness` never
1767
+ * sees alpha, so it scores an empty frame as an ordinary flat surface and
1768
+ * the caller is handed a black photograph plus a warning about a buried
1769
+ * camera. That is the fabrication this module exists to not commit.
1770
+ *
1771
+ * A canvas-backed capture has its canvas underneath and is never graded here.
1772
+ */
1773
+ function judgeDomOnlyFrame(
1774
+ ctx2d: CanvasRenderingContext2D,
1775
+ width: number,
1776
+ height: number,
1777
+ retriesRemaining: number,
1778
+ ): DomOnlyVerdict {
1779
+ let pixels: ImageData;
1780
+ try {
1781
+ pixels = ctx2d.getImageData(0, 0, width, height);
1782
+ } catch {
1783
+ // Readback unavailable (a tainted canvas): preserve the existing honest
1784
+ // capture, or let `toDataURL` surface the taint downstream.
1785
+ return { kind: 'ok' };
1786
+ }
1787
+ const pixelCount = width * height;
1788
+ const transparentFraction = transparentPixelFraction(pixels.data, pixelCount);
1789
+ const excessTransparent = transparentFraction > TRANSPARENT_PIXEL_LIMIT;
1790
+ if (
1791
+ retriesRemaining > 0 &&
1792
+ (excessTransparent || hasExcessNearBlackPixels(pixels.data, pixelCount))
1793
+ ) {
1794
+ return { kind: 'retry' };
1795
+ }
1796
+ return excessTransparent ? { kind: 'blank', transparentFraction } : { kind: 'ok' };
1797
+ }
1798
+
1799
+ /**
1800
+ * The bounding box of every pixel with any alpha, or `null` for a frame that
1801
+ * painted nothing. Runs on the PRE-backdrop composite (the crop path skips the
1802
+ * container's own background exactly so this scan sees only the subject), so
1803
+ * painted alpha IS the content — the measured half of "mount in the truth,
1804
+ * frame the subject".
1805
+ */
1806
+ function contentAlphaBounds(
1807
+ ctx2d: CanvasRenderingContext2D,
1808
+ width: number,
1809
+ height: number,
1810
+ ): { left: number; top: number; right: number; bottom: number } | null {
1811
+ let pixels: ImageData;
1812
+ try {
1813
+ pixels = ctx2d.getImageData(0, 0, width, height);
1814
+ } catch {
1815
+ // Readback unavailable (a tainted canvas): no measurement, so degrade to
1816
+ // the full frame — an uncropped capture, never a false blank refusal.
1817
+ return { left: 0, top: 0, right: width, bottom: height };
1818
+ }
1819
+ let left = width;
1820
+ let top = height;
1821
+ let right = -1;
1822
+ let bottom = -1;
1823
+ const data = pixels.data;
1824
+ for (let y = 0; y < height; y += 1) {
1825
+ const row = y * width * 4;
1826
+ for (let x = 0; x < width; x += 1) {
1827
+ if (data[row + x * 4 + 3] === 0) continue;
1828
+ if (x < left) left = x;
1829
+ if (x > right) right = x;
1830
+ if (y < top) top = y;
1831
+ if (y > bottom) bottom = y;
1832
+ }
1833
+ }
1834
+ return right < 0 ? null : { left, top, right: right + 1, bottom: bottom + 1 };
1835
+ }
1836
+
1837
+ /** Encode the owned output without synchronously compressing PNG on the UI thread. */
1838
+ async function canvasPngBase64(canvas: HTMLCanvasElement): Promise<string> {
1839
+ let blob: Blob;
1840
+ if (typeof Worker !== 'undefined' && typeof OffscreenCanvas !== 'undefined' && typeof createImageBitmap !== 'undefined') {
1841
+ const bitmap = await createImageBitmap(canvas);
1842
+ let encoder: Worker | undefined;
1843
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1844
+ try {
1845
+ encoder = new Worker(new URL('./png-encode.worker.ts', import.meta.url), { type: 'module' });
1846
+ blob = await new Promise<Blob>((resolve, reject) => {
1847
+ timeout = setTimeout(() => reject(new Error('PNG worker encoding timed out')), 30_000);
1848
+ encoder!.onerror = event => reject(new Error(event.message || 'PNG worker encoding failed'));
1849
+ encoder!.onmessageerror = () => reject(new Error('PNG worker returned an unreadable result'));
1850
+ encoder!.onmessage = ({ data }: MessageEvent<{ blob?: Blob; error?: string }>) => {
1851
+ if (data.blob instanceof Blob) resolve(data.blob);
1852
+ else reject(new Error(data.error ?? 'PNG worker returned no image'));
1853
+ };
1854
+ encoder!.postMessage(bitmap, [bitmap]);
1855
+ });
1856
+ } finally {
1857
+ if (timeout !== undefined) clearTimeout(timeout);
1858
+ encoder?.terminate();
1859
+ bitmap.close();
1860
+ }
1861
+ } else {
1862
+ blob = await new Promise<Blob>((resolve, reject) => {
1863
+ canvas.toBlob((value) => {
1864
+ if (value) resolve(value);
1865
+ else reject(new Error('PNG encoding returned no image'));
1866
+ }, 'image/png');
1867
+ });
1868
+ }
1869
+ return new Promise<string>((resolve, reject) => {
1870
+ const reader = new FileReader();
1871
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read encoded PNG'));
1872
+ reader.onload = () => {
1873
+ const value = reader.result;
1874
+ if (typeof value !== 'string' || !value.startsWith('data:image/png;base64,')) {
1875
+ reject(new Error('PNG encoder returned an unexpected data URL'));
1876
+ return;
1877
+ }
1878
+ resolve(value.slice(value.indexOf(',') + 1));
1879
+ };
1880
+ reader.readAsDataURL(blob);
1881
+ });
1882
+ }
1883
+
1884
+ async function capturePlayCompositeAttempt(
1885
+ container: HTMLElement,
1886
+ retriesRemaining: number,
1887
+ options: CaptureOptions,
1888
+ ): Promise<CompositeCapture> {
1889
+ const out = container.ownerDocument.createElement('canvas');
1890
+ try {
1891
+ const compositeStarted = performance.now();
1892
+ const layers = await drawPlayCompositeFrame(container, out, options);
1893
+ const compositeMs = performance.now() - compositeStarted;
1894
+ const width = out.width;
1895
+ const height = out.height;
1896
+ const ctx2d = out.getContext('2d');
1897
+ if (!ctx2d) {
1898
+ throw new CaptureLayerError('composite-output', 'no 2d canvas context');
1899
+ }
1900
+
1901
+ if (layers.canvases === 0 && layers.domOverlays > 0 && options?.cropToContent === true) {
1902
+ // The crop path's own emptiness test: the frame deliberately has no
1903
+ // backdrop yet, so painted alpha IS the content. Nothing painted after
1904
+ // the retries is the same blank refusal as below; content crops to its
1905
+ // measured union plus padding, on the container's own backdrop.
1906
+ const bounds = contentAlphaBounds(ctx2d, width, height);
1907
+ if (bounds === null) {
1908
+ if (options.allowTransparent)
1909
+ return { base64: await canvasPngBase64(out), mimeType: 'image/png', layers };
1910
+ if (retriesRemaining > 0) {
1911
+ await new Promise<void>((resolve) => setTimeout(resolve, 300));
1912
+ await waitForPaint(container);
1913
+ return capturePlayCompositeAttempt(container, retriesRemaining - 1, options);
1914
+ }
1915
+ throw new CaptureLayerError(
1916
+ 'dom-overlay',
1917
+ 'the composite is ~100% fully transparent after two repaints — nothing in it painted ' +
1918
+ "a backdrop, so the PNG would read as a flat black frame. This subject's background " +
1919
+ 'is painted by an element OUTSIDE what is being photographed (a detached clone ' +
1920
+ 'carries no ancestors), or its own DOM never mounted.',
1921
+ );
1922
+ }
1923
+ const CROP_PAD = 16;
1924
+ const x0 = Math.max(0, bounds.left - CROP_PAD);
1925
+ const y0 = Math.max(0, bounds.top - CROP_PAD);
1926
+ const x1 = Math.min(width, bounds.right + CROP_PAD);
1927
+ const y1 = Math.min(height, bounds.bottom + CROP_PAD);
1928
+ const cropped = container.ownerDocument.createElement('canvas');
1929
+ cropped.width = Math.max(1, x1 - x0);
1930
+ cropped.height = Math.max(1, y1 - y0);
1931
+ const croppedCtx = cropped.getContext('2d');
1932
+ if (!croppedCtx) throw new CaptureLayerError('composite-output', 'no 2d canvas context');
1933
+ const ownBackground = getComputedStyle(container).backgroundColor;
1934
+ if (ownBackground && ownBackground !== 'transparent' && ownBackground !== 'rgba(0, 0, 0, 0)') {
1935
+ croppedCtx.fillStyle = ownBackground;
1936
+ croppedCtx.fillRect(0, 0, cropped.width, cropped.height);
1937
+ }
1938
+ croppedCtx.drawImage(
1939
+ out,
1940
+ x0,
1941
+ y0,
1942
+ cropped.width,
1943
+ cropped.height,
1944
+ 0,
1945
+ 0,
1946
+ cropped.width,
1947
+ cropped.height,
1948
+ );
1949
+ const flatness = sampleFlatness(cropped, container.ownerDocument);
1950
+ let base64: string;
1951
+ try {
1952
+ base64 = await canvasPngBase64(cropped);
1953
+ } catch (error) {
1954
+ throw layerFailure('composite-output', error);
1955
+ } finally {
1956
+ cropped.width = 0;
1957
+ cropped.height = 0;
1958
+ }
1959
+ return {
1960
+ base64,
1961
+ mimeType: 'image/png',
1962
+ layers,
1963
+ ...(flatness ? { flatness } : {}),
1964
+ };
1965
+ }
1966
+ if (layers.canvases === 0 && layers.domOverlays > 0 && !options.allowTransparent) {
1967
+ const verdict = judgeDomOnlyFrame(ctx2d, width, height, retriesRemaining);
1968
+ if (verdict.kind === 'retry') {
1969
+ await new Promise<void>((resolve) => setTimeout(resolve, 300));
1970
+ await waitForPaint(container);
1971
+ return capturePlayCompositeAttempt(container, retriesRemaining - 1, options);
1972
+ }
1973
+ if (verdict.kind === 'blank') {
1974
+ throw new CaptureLayerError(
1975
+ 'dom-overlay',
1976
+ `the composite is ~${Math.round(verdict.transparentFraction * 100)}% fully transparent ` +
1977
+ 'after two repaints — nothing in it painted a backdrop, so the PNG would read as a flat ' +
1978
+ "black frame. This subject's background is painted by an element OUTSIDE what is being " +
1979
+ 'photographed (a detached clone carries no ancestors), or its own DOM never mounted.',
1980
+ );
1981
+ }
1982
+ }
1983
+
1984
+ // Measure BEFORE encoding, off the composite we just drew: the pixels are
1985
+ // already here, so honesty costs one downsample and no PNG decode anywhere
1986
+ // downstream. (The alternative — measuring in Node from the CLI — would need
1987
+ // a PNG decoder this repo does not ship, in a process that never has the
1988
+ // frame in memory in the first place.)
1989
+ const flatness = sampleFlatness(out, container.ownerDocument);
1990
+
1991
+ const pngStarted = performance.now();
1992
+ let base64: string;
1993
+ try {
1994
+ base64 = await canvasPngBase64(out);
1995
+ } catch (error) {
1996
+ throw layerFailure('composite-output', error);
1997
+ }
1998
+ return {
1999
+ base64,
2000
+ mimeType: 'image/png',
2001
+ layers,
2002
+ ...(flatness ? { flatness } : {}),
2003
+ timings: { compositeMs, ...layers.timings, pngEncodeMs: performance.now() - pngStarted },
2004
+ };
2005
+ } finally {
2006
+ // This one-shot capture owns its backing store. Release native pixels as
2007
+ // soon as its PNG is encoded, including retries and refused captures.
2008
+ out.width = 0;
2009
+ out.height = 0;
2010
+ }
2011
+ }
2012
+
2013
+ /** Fill each element's own painted background at its own on-screen box,
2014
+ * outermost first — the order and the places the browser has them, so a
2015
+ * translucent ("glass") surface composites the way it does on screen. */
2016
+ function paintBackdrops(
2017
+ ctx2d: CanvasRenderingContext2D,
2018
+ elements: readonly HTMLElement[],
2019
+ rect: DOMRect,
2020
+ scaleX = 1,
2021
+ scaleY = 1,
2022
+ ): void {
2023
+ for (const element of elements) {
2024
+ const color = opaqueBackgroundColor(element);
2025
+ if (!color) continue;
2026
+ const box = element.getBoundingClientRect();
2027
+ ctx2d.fillStyle = color;
2028
+ ctx2d.fillRect(
2029
+ (box.left - rect.left) * scaleX,
2030
+ (box.top - rect.top) * scaleY,
2031
+ box.width * scaleX,
2032
+ box.height * scaleY,
2033
+ );
2034
+ }
2035
+ }
2036
+
2037
+ /** Root surfaces still obey their live DOM's overflow clips. A dock viewport
2038
+ * can extend behind its toolbar while an ancestor hides that overflow. */
2039
+ function canvasPaintBounds(canvas: HTMLCanvasElement, container: HTMLElement): {
2040
+ left: number; top: number; right: number; bottom: number;
2041
+ } | null {
2042
+ const box = canvas.getBoundingClientRect();
2043
+ const root = container.getBoundingClientRect();
2044
+ let left = Math.max(box.left, root.left), top = Math.max(box.top, root.top);
2045
+ let right = Math.min(box.right, root.right), bottom = Math.min(box.bottom, root.bottom);
2046
+ for (let node = canvas.parentElement; node && container.contains(node); node = node.parentElement) {
2047
+ const style = getComputedStyle(node);
2048
+ const clipX = style.overflowX !== 'visible';
2049
+ const clipY = style.overflowY !== 'visible';
2050
+ if (clipX || clipY) {
2051
+ const rect = node.getBoundingClientRect();
2052
+ const scaleX = node.offsetWidth ? rect.width / node.offsetWidth : 1;
2053
+ const scaleY = node.offsetHeight ? rect.height / node.offsetHeight : 1;
2054
+ const x = rect.left + node.clientLeft * scaleX;
2055
+ const y = rect.top + node.clientTop * scaleY;
2056
+ if (clipX) { left = Math.max(left, x); right = Math.min(right, x + node.clientWidth * scaleX); }
2057
+ if (clipY) { top = Math.max(top, y); bottom = Math.min(bottom, y + node.clientHeight * scaleY); }
2058
+ }
2059
+ if (node === container) break;
2060
+ }
2061
+ return right > left && bottom > top ? { left, top, right, bottom } : null;
2062
+ }
2063
+
2064
+ /**
2065
+ * Paint one current game frame into a caller-owned canvas.
2066
+ *
2067
+ * Screenshots call this once and encode the result. Gameplay recording calls
2068
+ * it repeatedly while a native `MediaRecorder` consumes `output.captureStream()`.
2069
+ * Keeping the draw primitive here is what guarantees that a still and a video
2070
+ * see the same game stack: every world canvas plus DOM HUD. This function
2071
+ * performs no settling,
2072
+ * retry, quality judgment, or encoding; those are policies of its callers.
2073
+ */
2074
+ export async function drawPlayCompositeFrame(
2075
+ container: HTMLElement,
2076
+ output: HTMLCanvasElement,
2077
+ options?: CaptureOptions,
2078
+ ): Promise<CompositeFrame> {
2079
+ const rect = container.getBoundingClientRect();
2080
+ const width = Math.max(1, Math.round(options?.size?.width ?? rect.width));
2081
+ const height = Math.max(1, Math.round(options?.size?.height ?? rect.height));
2082
+ const scaleX = options?.size ? width / rect.width : 1;
2083
+ const scaleY = options?.size ? height / rect.height : 1;
2084
+ if (output.width !== width) output.width = width;
2085
+ if (output.height !== height) output.height = height;
2086
+
2087
+ const ctx2d = output.getContext('2d');
2088
+ if (!ctx2d) {
2089
+ throw new CaptureLayerError('composite-output', 'no 2d canvas context');
2090
+ }
2091
+ ctx2d.clearRect(0, 0, width, height);
2092
+
2093
+ // The container's OWN background, painted first. Both backdrop walks below
2094
+ // start from canvases, so a canvas-less DOM subject (a story capture host,
2095
+ // whose inline backdrop exists precisely to show through wherever the story
2096
+ // paints nothing) composited as pure overlay — and a sparse HUD story came
2097
+ // back ~99% transparent and was refused as blank. A crop-to-content capture
2098
+ // skips it HERE so the content-bounds scan sees only what the subject
2099
+ // painted; the cropped output re-paints it underneath.
2100
+ if (options?.cropToContent !== true) {
2101
+ const ownBackground = getComputedStyle(container).backgroundColor;
2102
+ if (ownBackground && ownBackground !== 'transparent' && ownBackground !== 'rgba(0, 0, 0, 0)') {
2103
+ ctx2d.fillStyle = ownBackground;
2104
+ ctx2d.fillRect(0, 0, width, height);
2105
+ }
2106
+ }
2107
+
2108
+ // The backdrop the SUBJECT ITSELF does not paint — an editor document's panel
2109
+ // fill lives on an ancestor, which the detached clone cannot carry. See {@link ancestorBackdrops} for the measurement that put this here.
2110
+ if (options?.includeDocumentStyles) {
2111
+ paintBackdrops(ctx2d, ancestorBackdrops(container), rect, scaleX, scaleY);
2112
+ }
2113
+
2114
+ // The backgrounds that paint BEHIND a root-surface canvas, painted before the
2115
+ // canvases themselves. Without this leg they ride the overlay ABOVE the
2116
+ // canvases and erase the world. See {@link rootSurfaceBackdrops} for the
2117
+ // measurement that put this here.
2118
+ // Canvas pixels can change without a DOM mutation. A nested UI canvas must
2119
+ // be photographed this frame, not frozen into the recorder's HUD cache.
2120
+ const nestedCanvas = Array.from(container.querySelectorAll('canvas')).some(
2121
+ (canvas) => !isRootCanvas(canvas),
2122
+ );
2123
+ const cached = nestedCanvas ? null : options?.overlayCache?.take(container, width, height);
2124
+ const backdrops = cached?.backdrops ?? rootSurfaceBackdrops(container);
2125
+ paintBackdrops(ctx2d, backdrops, rect, scaleX, scaleY);
2126
+
2127
+ // Hidden/restored documents can retain a canvas whose backing store or
2128
+ // layout box is zero-sized. The browser paints no pixels for it, and
2129
+ // CanvasRenderingContext2D.drawImage throws instead of expressing that
2130
+ // no-op. Keep the capture's canvas count tied to surfaces that could
2131
+ // actually contribute pixels to the frame.
2132
+ const canvases = Array.from(container.querySelectorAll('canvas')).filter((canvas) => {
2133
+ const canvasRect = canvas.getBoundingClientRect();
2134
+ return canvas.width > 0 && canvas.height > 0 && canvasRect.width > 0 && canvasRect.height > 0 &&
2135
+ canvas.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
2136
+ });
2137
+ const canvasPixels = new Map<HTMLCanvasElement, CanvasImageSource>();
2138
+ const canvasStarted = performance.now();
2139
+ for (const canvas of canvases) {
2140
+ try {
2141
+ const canvasRect = canvas.getBoundingClientRect();
2142
+ const clip = canvasPaintBounds(canvas, container);
2143
+ if (!clip) continue;
2144
+ // `?? canvas` is the unchanged path: no seam offered, or the seam had no
2145
+ // frame for THIS canvas, means read the canvas itself.
2146
+ const pixels = (await options?.canvasFrame?.(canvas)) ?? canvas;
2147
+ canvasPixels.set(canvas, pixels);
2148
+ ctx2d.save();
2149
+ try {
2150
+ ctx2d.beginPath();
2151
+ ctx2d.rect((clip.left - rect.left) * scaleX, (clip.top - rect.top) * scaleY,
2152
+ (clip.right - clip.left) * scaleX, (clip.bottom - clip.top) * scaleY);
2153
+ ctx2d.clip();
2154
+ ctx2d.drawImage(
2155
+ pixels,
2156
+ (canvasRect.left - rect.left) * scaleX,
2157
+ (canvasRect.top - rect.top) * scaleY,
2158
+ canvasRect.width * scaleX,
2159
+ canvasRect.height * scaleY,
2160
+ );
2161
+ } finally { ctx2d.restore(); }
2162
+ } catch (error) {
2163
+ throw layerFailure('canvas', error);
2164
+ }
2165
+ }
2166
+
2167
+ let domOverlays = 0;
2168
+ const timings: CompositeFrameTimings = { canvasDrawMs: performance.now() - canvasStarted };
2169
+ // The same pixels ride the overlay leg, so a canvas nested inside an overlay
2170
+ // layer keeps its z-order instead of being painted over by its own layer.
2171
+ try {
2172
+ if (cached) {
2173
+ if (cached.image) ctx2d.drawImage(cached.image, 0, 0, width, height);
2174
+ domOverlays = cached.overlayCount;
2175
+ } else {
2176
+ const buildStart = performance.now();
2177
+ const documentCssText = options?.includeDocumentStyles
2178
+ ? await embeddedSubjectStyles(container)
2179
+ : undefined;
2180
+ timings.documentStylesMs = performance.now() - buildStart;
2181
+ const overlayStarted = performance.now();
2182
+ // LAYOUT in CSS pixels, RASTER at the output size. `size` scales the
2183
+ // frame, never the DOM's own geometry: the clone is laid out in the
2184
+ // pixels its stylesheet is written in and the SVG's viewBox does the
2185
+ // scaling, so a 2x frame has 2x-resolution text rather than the same
2186
+ // text in a doubled box. See {@link buildOverlaySvg}'s `rasterSize`.
2187
+ const overlay = buildOverlaySvg(container, width / scaleX, height / scaleY, {
2188
+ includeDocumentStyles: options?.includeDocumentStyles,
2189
+ projectRoot: options?.projectRoot,
2190
+ documentCssText,
2191
+ canvasPixels,
2192
+ transparentBackdrops: new Set<Element>(backdrops),
2193
+ snapshots: options?.snapshots,
2194
+ rasterSize: { width, height },
2195
+ });
2196
+ timings.overlayBuildMs = performance.now() - overlayStarted;
2197
+ timings.overlaySnapshotMs = overlay?.snapshotMs ?? 0;
2198
+ if (overlay) {
2199
+ const image = new Image();
2200
+ let retained = false;
2201
+ try {
2202
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(overlay.svg)}`;
2203
+ const decodeStarted = performance.now();
2204
+ await image.decode();
2205
+ timings.overlayDecodeMs = performance.now() - decodeStarted;
2206
+ const drawStarted = performance.now();
2207
+ ctx2d.drawImage(image, 0, 0, width, height);
2208
+ timings.overlayDrawMs = performance.now() - drawStarted;
2209
+ domOverlays = overlay.overlayCount;
2210
+ options?.overlayCache?.store(
2211
+ container,
2212
+ width,
2213
+ height,
2214
+ { image, overlayCount: overlay.overlayCount, backdrops },
2215
+ performance.now() - buildStart,
2216
+ );
2217
+ retained = Boolean(options?.overlayCache);
2218
+ } finally {
2219
+ // A recorder cache owns its decoded raster; a one-shot screenshot
2220
+ // does not. Detach that SVG resource after drawing its pixels.
2221
+ if (!retained) image.removeAttribute('src');
2222
+ }
2223
+ } else {
2224
+ options?.overlayCache?.store(
2225
+ container,
2226
+ width,
2227
+ height,
2228
+ { image: null, overlayCount: 0, backdrops },
2229
+ performance.now() - buildStart,
2230
+ );
2231
+ }
2232
+ }
2233
+ } catch (error) {
2234
+ throw layerFailure('dom-overlay', error);
2235
+ }
2236
+
2237
+ return { canvases: canvasPixels.size, domOverlays, timings };
2238
+ }