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