@volter/editor-game 0.5.66 → 0.5.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared, headless facts about a CANVAS story: whether a mounted story holds
3
+ * Pixi content. The exact counterpart of `three-story-model.ts`'s
4
+ * `mountedStoryHasThreeContent`, kept pure and off the React tree.
5
+ *
6
+ * Membership is DECLARED (`story-declared-medium.ts`). This module does not
7
+ * classify a story by mounting it.
8
+ */
9
+
10
+ import type { Container } from 'pixi.js';
11
+
12
+ /**
13
+ * Whether a mounted story's stage actually holds Pixi content.
14
+ *
15
+ * A story can mount a `<Application>` successfully and render nothing into it —
16
+ * a gate whose condition never became true, a component that returns `null`.
17
+ * That is a successful mount with nothing in it, and a surface shows a story
18
+ * that rendered SOMETHING; "the mount succeeded" alone is not the test. Exactly
19
+ * the rule (and exactly the expression) `mountedStoryHasThreeContent` states
20
+ * for the three surface.
21
+ */
22
+ export function mountedStoryHasPixiContent(stage: Container): boolean {
23
+ if (stage.children.length === 0) return false;
24
+ // An empty wrapper (`<pixiContainer>` around a still-loading piece) is
25
+ // a child and not content. Isolation and IsolatedPiece both land pixels
26
+ // after an async asset load; a 0×0 stage is the same "gate never true"
27
+ // case the header names.
28
+ const bounds = stage.getBounds();
29
+ return bounds.width > 1 && bounds.height > 1;
30
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The captures the kit's canvas medium provides: story thumbnails
3
+ * (`@volter/editor-sdk/kit/story-thumbnails`) for a canvas story in an
4
+ * offscreen Pixi application (the three.js leg is `three-story-captures.ts`), and
5
+ * Pixi's same-frame canvas pixels (`@volter/editor-sdk/kit/canvas-frames`), for
6
+ * the canvases its live surfaces present and the ones a photographed mount
7
+ * creates. Each loads its renderer on first use.
8
+ */
9
+ import {
10
+ type CanvasFrame,
11
+ registerCanvasFrameSource,
12
+ registerCanvasMountObserver,
13
+ } from '@volter/editor-sdk/kit/canvas-frames';
14
+ import { registerStoryThumbnailCapture } from '@volter/editor-sdk/kit/story-thumbnails';
15
+
16
+ export function registerStoryMediaCaptures(): () => void {
17
+ const stopCanvas = registerStoryThumbnailCapture('canvas', async (component, options) => {
18
+ const { capturePixiStoryThumbnail } = await import('./story-pixi-preview');
19
+ return capturePixiStoryThumbnail(component as Parameters<typeof capturePixiStoryThumbnail>[0], options);
20
+ });
21
+ const stopFrames = registerCanvasFrameSource(async (canvas) =>
22
+ (await import('../canvas-preview-frames')).presentedPixiFrame(canvas),
23
+ );
24
+ // Every `Application` a photographed mount creates is collected, so the two
25
+ // things a Pixi canvas's capture needs — a settled first frame, and a readback
26
+ // the compositor cannot clear — are keyed on the MOUNT
27
+ // (`canvas-preview-frames.ts` owns both, with the measurement).
28
+ const stopObserver = registerCanvasMountObserver({
29
+ observe: async (task) => {
30
+ const { pixiCanvasFrame, withApplicationCollector } = await import('../canvas-preview-frames');
31
+ const { awaitPixiStoryFrame } = await import('./story-pixi-preview');
32
+ let apps: Parameters<typeof awaitPixiStoryFrame>[0] = [];
33
+ const result = await withApplicationCollector(async (created) => {
34
+ apps = created;
35
+ return task();
36
+ });
37
+ const frame: CanvasFrame = pixiCanvasFrame(apps);
38
+ return { result, settled: () => awaitPixiStoryFrame(apps), frame };
39
+ },
40
+ });
41
+ return () => {
42
+ stopCanvas();
43
+ stopFrames();
44
+ stopObserver();
45
+ };
46
+ }
@@ -33,14 +33,14 @@
33
33
  * pass publishes.
34
34
  */
35
35
 
36
- import { getProjectStoryRegions } from '@volter/editor-core/stories/project-story-regions';
36
+ import { getProjectStoryRegions } from '@volter/editor-sdk/kit/stories/project-story-regions';
37
37
  import {
38
38
  __resetUndeclaredStoryMediumReportsForTest,
39
39
  declaredStoryMedium,
40
40
  reportUndeclaredStoryMedium,
41
41
  type StoryMedium,
42
- } from '@volter/editor-core/stories/story-declared-medium';
43
- import { getProjectStoryModules, subscribeProjectStoryModules } from '@volter/editor-core/stories/story-registry';
42
+ } from '@volter/editor-sdk/kit/stories/story-declared-medium';
43
+ import { getProjectStoryModules, subscribeProjectStoryModules } from '@volter/editor-sdk/kit/stories/story-registry';
44
44
 
45
45
  /** Which component boards the project's stories call for. */
46
46
  export interface StoryMediaPresence {
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Off-screen CANVAS/Pixi story preview — mount a composed CSF story that
3
+ * produces Pixi content and hand back the `PIXI.Container` it rendered into,
4
+ * plus the `Application` that owns its clock.
5
+ *
6
+ * The canvas-surface sibling of `story-three-preview.ts`, and it exists for the
7
+ * same reason: portable CSF is a prefab's explicit "here is how this renders",
8
+ * and a surface that wants to SHOW a prefab (the canvas board, and the
9
+ * qualification both boards key membership on) needs that render as an object,
10
+ * not as a picture.
11
+ *
12
+ * ## Why the mount goes through react-dom, not through a Pixi root
13
+ *
14
+ * A 2D prefab story mounts its OWN `@pixi/react` `<Application>` (see any 2D prefab's colocated
15
+ * story under an example project's `prefabs` directory): unlike the three lane, where the
16
+ * board supplies the scene and the story renders bare JSX into it, nothing
17
+ * supplies a canvas-surface stage, so the story carries one and renders
18
+ * anywhere react-dom renders. That is the story's own declaration and this
19
+ * module does not second-guess it — it mounts the story exactly as written, in
20
+ * a detached DOM host, and then ADOPTS what came out.
21
+ *
22
+ * ## What comes out, and what the caller may do with it
23
+ *
24
+ * `host` is the detached element the story rendered into and `stage` is the
25
+ * Application's own root container — the REAL objects, with the story's real
26
+ * textures on them. A caller that wants to DISPLAY the story reparents the
27
+ * host into its own DOM (the `2D` board does exactly this, one host per
28
+ * frame); nothing here rasterizes, copies, or re-creates anything, which is
29
+ * what keeps the composed story render the sole visual authority (the
30
+ * anti-shim rule) and what makes a `scaleMode: 'nearest'` sheet magnify
31
+ * nearest wherever it ends up — the filtering belongs to the texture's own
32
+ * source, and adopting the real render carries it along.
33
+ *
34
+ * The mount arrives STOPPED: `Application.init` defaults to `autoStart: true`,
35
+ * so the story's own `requestAnimationFrame` loop would run on wall time in a
36
+ * detached host nobody is looking at. {@link MountedStoryPixi.ticker} is that
37
+ * clock, handed to the caller so a surface that wants the cycle to PLAY drives
38
+ * it from its own frame instead — one clock per surface, none on wall time.
39
+ *
40
+ * ## Mounts are SERIAL, and one bad story never takes the rest down
41
+ *
42
+ * Same physics as the three lane, same queue (`story-mount-turn.ts`): a story's
43
+ * loaders touch process-global state (Pixi's `Assets` cache and the
44
+ * `@pixi/react` component catalogue are both module-global), so overlapping two
45
+ * mounts interleaves their setup. A story that crashes while rendering is
46
+ * caught by {@link CrashNullBoundary} and rejects in milliseconds with its own
47
+ * error rather than burning the ceiling below — the exact measurement the three
48
+ * lane records, and the reason a board over a project of non-canvas stories
49
+ * finishes at all.
50
+ */
51
+
52
+ import type { Application, Container } from 'pixi.js';
53
+ import { createElement } from 'react';
54
+ import type { Root } from 'react-dom/client';
55
+ import { withApplicationCollector } from '../canvas-preview-frames';
56
+ import { CrashNullBoundary } from '@volter/editor-sdk/kit/crash-null-boundary';
57
+ import { mountedStoryHasPixiContent } from './pixi-story-model';
58
+ import { resolveStoryDomRuntime } from '@volter/editor-sdk/kit/stories/story-dom-runtime';
59
+ import { runInStoryMountTurn } from '@volter/editor-sdk/kit/stories/story-mount-turn';
60
+ import type { StoryPreviewComponent } from '@volter/editor-sdk/kit/stories/story-preview-component';
61
+
62
+ /**
63
+ * How long the mount waits for the story's `Application` to finish initializing
64
+ * AND for its stage to hold content. Both are legitimately slow: `init()`
65
+ * creates a real renderer, and a prefab story commonly gates its children on
66
+ * its atlas (`SpritesReady`), so the content arrives when the sheet does.
67
+ * Matches the three lane's own first-commit ceiling.
68
+ */
69
+ const PIXI_MOUNT_TIMEOUT_MS = 10_000;
70
+
71
+ /**
72
+ * Event-loop turns the mount waits for the story's FIRST DOM commit, and for a
73
+ * `<canvas>` to appear in it. This is the cheap NO: a story that renders DOM
74
+ * and no canvas is not a canvas story, and it must answer in a few turns rather
75
+ * than at the ceiling above — otherwise every board build over a UI-only
76
+ * project would pay ten seconds per story.
77
+ */
78
+ const COMMIT_TURNS = 12;
79
+
80
+ /** Where the detached host sits: far off-screen, contained, inert. `contain:
81
+ * strict` makes the host the containing block for `position: fixed`
82
+ * descendants, so a story that pins itself cannot flash across the editor —
83
+ * the same containment `story-capture.ts` documents for its own host. */
84
+ const HOST_STYLE =
85
+ 'position:fixed;left:-20000px;top:0;width:512px;height:512px;' +
86
+ 'overflow:hidden;contain:strict;pointer-events:none;visibility:hidden;';
87
+
88
+ /** One mounted canvas story. */
89
+ export interface MountedStoryPixi {
90
+ /**
91
+ * The detached element the story rendered into, with its `<canvas>` inside.
92
+ *
93
+ * A DOM surface that wants to SHOW this story reparents THIS element — never
94
+ * the canvas alone. React owns the host's children: moving a react-rendered
95
+ * node out from under its own root leaves `root.unmount()` removing a child
96
+ * of a parent it no longer has, which throws. The host itself is ours, so it
97
+ * travels freely and {@link dispose} still removes it from wherever it ended
98
+ * up. Restyle it on adoption; {@link HOST_STYLE} only parks it off-screen.
99
+ */
100
+ readonly host: HTMLElement;
101
+ /** The Application's own root container — the story's real display tree.
102
+ * Reparent it; never copy it. */
103
+ readonly stage: Container;
104
+ /** The story's clock, already STOPPED. A surface that wants the story's
105
+ * cycle to play calls `ticker.update(elapsedMs)` from its own frame. */
106
+ readonly ticker: Application['ticker'];
107
+ /**
108
+ * The Application the story built — its own renderer included.
109
+ *
110
+ * A displaying surface keeps that renderer (it is what draws the adopted
111
+ * host's canvas) and therefore keeps its WebGL context. That is the real
112
+ * ceiling on how many stories one surface may show at once: a browser allows
113
+ * on the order of sixteen contexts per page and drops the OLDEST when that
114
+ * runs out. `app.screen` is the mounting canvas the story's own
115
+ * `<Application>` declared; presentation surfaces may crop it to stage
116
+ * content unless the story explicitly requests a fullscreen layout.
117
+ */
118
+ readonly app: Application;
119
+ /** Tear the isolated react root down and destroy the story's display tree and
120
+ * clock. Runs the story's own effect cleanups. Idempotent. */
121
+ dispose(): void;
122
+ }
123
+
124
+ /** A mount function valid inside an already-acquired story turn — the canvas
125
+ * sibling of `story-three-preview.ts`'s `StoryMountInTurn`. */
126
+ export type PixiStoryMountInTurn = (
127
+ Component: StoryPreviewComponent,
128
+ props?: Record<string, unknown>,
129
+ ) => Promise<MountedStoryPixi>;
130
+
131
+ /** Run one compound canvas-story operation (a whole board build) with no other
132
+ * story mount, of any medium, interleaving it. */
133
+ export function withPixiStoryMountTurn<T>(
134
+ task: (mount: PixiStoryMountInTurn) => Promise<T>,
135
+ ): Promise<T> {
136
+ return runInStoryMountTurn(() => task(mountStoryPixiInTurn));
137
+ }
138
+
139
+ /** Mount one canvas story off-screen, taking a turn of its own. */
140
+ export function mountStoryPixi(
141
+ Component: StoryPreviewComponent,
142
+ props: Record<string, unknown> = {},
143
+ ): Promise<MountedStoryPixi> {
144
+ return withPixiStoryMountTurn((mount) => mount(Component, props));
145
+ }
146
+
147
+ /** Dispose a standalone mount in the same shared turn domain. Compound callers
148
+ * already holding a turn dispose their own mounts inside it. */
149
+ export function disposeStoryPixi(mounted: MountedStoryPixi): Promise<void> {
150
+ return runInStoryMountTurn(async () => mounted.dispose());
151
+ }
152
+
153
+ export interface PixiStoryThumbnailOptions {
154
+ readonly width?: number;
155
+ readonly height?: number;
156
+ readonly props?: Record<string, unknown>;
157
+ /** Storybook's standard layout policy. Only `fullscreen` makes the
158
+ * mounting Application's declared canvas part of the preview. */
159
+ readonly layout?: 'centered' | 'padded' | 'fullscreen';
160
+ }
161
+
162
+ /**
163
+ * Photograph a canvas prefab from its portable CSF story.
164
+ *
165
+ * This is the Pixi sibling of `captureStoryComponentThumbnail`: the story is
166
+ * mounted through its own real `<Application>`, its paused stage is rendered
167
+ * once without advancing the ticker, and Pixi's extraction API reads the
168
+ * result back. The requested rectangle is presentation only; it never becomes
169
+ * a second authored size or a component-specific thumbnail asset.
170
+ */
171
+ export async function capturePixiStoryThumbnail(
172
+ Component: StoryPreviewComponent,
173
+ options: PixiStoryThumbnailOptions = {},
174
+ ): Promise<string> {
175
+ const mounted = await mountStoryPixi(Component, options.props ?? {});
176
+ try {
177
+ const width = options.width ?? 128;
178
+ const height = options.height ?? 96;
179
+ mounted.app.renderer.render({ container: mounted.stage });
180
+
181
+ const bounds = mounted.stage.getBounds();
182
+ const fullscreen = options.layout === 'fullscreen';
183
+ const padding = 8;
184
+ // Both frames are the STORY's own `Rectangle` — `screen.clone()` is the
185
+ // app's class, whatever graph the story mounted in. A story's
186
+ // `<Application>` comes from its own module graph (the project's under the
187
+ // packaged runtime), and this rectangle is handed straight back to that
188
+ // app's extractor; constructing the shell's class here was the one value
189
+ // in this path that crossed.
190
+ const frame = mounted.app.screen.clone();
191
+ if (!fullscreen) {
192
+ frame.x = bounds.x - padding;
193
+ frame.y = bounds.y - padding;
194
+ frame.width = Math.max(1, bounds.width + padding * 2);
195
+ frame.height = Math.max(1, bounds.height + padding * 2);
196
+ }
197
+ // Match the target aspect by adding transparent breathing room. Never
198
+ // stretch a sprite to make a rectangular thumbnail.
199
+ const targetAspect = width / height;
200
+ if (!fullscreen && frame.width / frame.height < targetAspect) {
201
+ const nextWidth = frame.height * targetAspect;
202
+ frame.x -= (nextWidth - frame.width) / 2;
203
+ frame.width = nextWidth;
204
+ } else if (!fullscreen && frame.width / frame.height > targetAspect) {
205
+ const nextHeight = frame.width / targetAspect;
206
+ frame.y -= (nextHeight - frame.height) / 2;
207
+ frame.height = nextHeight;
208
+ }
209
+ const background = mounted.app.renderer.background;
210
+ const source = (await mounted.app.renderer.extract.canvas({
211
+ target: mounted.stage,
212
+ frame,
213
+ ...(fullscreen && background.alpha > 0 ? { clearColor: background.colorRgba } : {}),
214
+ })) as HTMLCanvasElement;
215
+ const output = document.createElement('canvas');
216
+ output.width = width;
217
+ output.height = height;
218
+ const context = output.getContext('2d');
219
+ if (!context) throw new Error('Canvas story preview could not create a 2D readback context.');
220
+
221
+ const scale = Math.min(width / source.width, height / source.height);
222
+ const drawWidth = source.width * scale;
223
+ const drawHeight = source.height * scale;
224
+ context.drawImage(
225
+ source,
226
+ (width - drawWidth) / 2,
227
+ (height - drawHeight) / 2,
228
+ drawWidth,
229
+ drawHeight,
230
+ );
231
+ return output.toDataURL('image/png');
232
+ } finally {
233
+ await disposeStoryPixi(mounted);
234
+ }
235
+ }
236
+
237
+ const nextTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
238
+
239
+ /** The mount body for callers that already own the story turn. */
240
+ async function mountStoryPixiInTurn(
241
+ StoryComponent: StoryPreviewComponent,
242
+ props: Record<string, unknown> = {},
243
+ ): Promise<MountedStoryPixi> {
244
+ // Loaders first (Storybook 9's portable-story contract): a story's `loaded`
245
+ // data is empty until `.load()` has run, and a canvas prefab's loader is
246
+ // where it fetches its atlas.
247
+ if (typeof StoryComponent.load === 'function') await StoryComponent.load();
248
+
249
+ // In the packaged editor the CSF component and @pixi/react both belong to
250
+ // the project's Vite graph. Mount them with that graph's react-dom too;
251
+ // using the prebuilt editor bundle's createRoot crosses React dispatchers.
252
+ const storyRuntime = await resolveStoryDomRuntime();
253
+
254
+ const host = document.createElement('div');
255
+ host.setAttribute('style', HOST_STYLE);
256
+ document.body.appendChild(host);
257
+
258
+ let root: Root | null = null;
259
+ const teardown = (): void => {
260
+ root?.unmount();
261
+ root = null;
262
+ host.remove();
263
+ };
264
+
265
+ try {
266
+ return await withApplicationCollector(async (created) => {
267
+ let renderError: unknown = null;
268
+ root = storyRuntime.createRoot(host);
269
+ root.render(
270
+ createElement(
271
+ CrashNullBoundary,
272
+ {
273
+ onCaught: (error: unknown) => {
274
+ renderError ??= error;
275
+ },
276
+ },
277
+ createElement(StoryComponent, props),
278
+ ),
279
+ );
280
+
281
+ // The cheap NO: a committed story with no canvas in it is not a canvas
282
+ // story, and says so in a few turns.
283
+ for (let turn = 0; turn < COMMIT_TURNS; turn++) {
284
+ if (renderError) break;
285
+ if (host.querySelector('canvas')) break;
286
+ await nextTurn();
287
+ }
288
+ if (renderError) throw describeMountFailure(renderError);
289
+ if (!host.querySelector('canvas')) {
290
+ throw new Error('The story rendered no canvas — it does not mount Pixi content.');
291
+ }
292
+
293
+ // A canvas is present, so an `Application.init()` is in flight (or has
294
+ // already landed). From here the ceiling is the real one: init creates a
295
+ // renderer, and the story's own content may be gated on its atlas.
296
+ const deadline = Date.now() + PIXI_MOUNT_TIMEOUT_MS;
297
+ let app: Application | undefined;
298
+ while (Date.now() < deadline) {
299
+ if (renderError) throw describeMountFailure(renderError);
300
+ app ??= created[0];
301
+ if (app && mountedStoryHasPixiContent(app.stage)) break;
302
+ await nextTurn();
303
+ }
304
+ if (!app) {
305
+ throw new Error(
306
+ 'The story rendered a canvas but never initialized a Pixi Application within ' +
307
+ `${PIXI_MOUNT_TIMEOUT_MS / 1000}s — it is not a canvas story, or its renderer ` +
308
+ 'could not be created here.',
309
+ );
310
+ }
311
+
312
+ // Content time does not run on wall time at design time. The story's own
313
+ // rAF loop stops here and the clock is handed to whoever presents it.
314
+ app.stop();
315
+ app.ticker.lastTime = 0;
316
+
317
+ let disposed = false;
318
+ return {
319
+ host,
320
+ stage: app.stage,
321
+ ticker: app.ticker,
322
+ app,
323
+ dispose(): void {
324
+ if (disposed) return;
325
+ disposed = true;
326
+ // React, and react ONLY. Unmounting the story's `<Application>` is
327
+ // what tears the Application down: `@pixi/react`'s own unmount path
328
+ // (`helpers/unmountRoot`) renders `null` into the stage — running
329
+ // every `useTick` cleanup in order — and only then destroys the app
330
+ // and forgets its canvas. Destroying anything here as well is a
331
+ // double free of a ticker its cleanups are still using.
332
+ teardown();
333
+ },
334
+ } satisfies MountedStoryPixi;
335
+ });
336
+ } catch (error) {
337
+ teardown();
338
+ throw error;
339
+ }
340
+ }
341
+
342
+ // ------------------------------------------------- the capture lane's frame
343
+
344
+ /** How long {@link awaitPixiStoryFrame} waits. Same ceiling, same reasons, as
345
+ * the mount above: a real renderer is created and a prefab's content commonly
346
+ * waits on its atlas. */
347
+ const FIRST_FRAME_TIMEOUT_MS = PIXI_MOUNT_TIMEOUT_MS;
348
+
349
+ /**
350
+ * Wait until every collected `Application` has PRESENTED a frame with content
351
+ * in it — the readiness test a capture of a canvas story owes itself.
352
+ *
353
+ * MEASURED DEFECT (this is why the function exists): `vgai screenshot
354
+ * <file>.stories.tsx` photographed a `@pixi/react` story blank. The capture
355
+ * lane's own readiness test is "the container has committed child nodes", and
356
+ * `<Application>` commits its `<canvas>` on the FIRST commit — before
357
+ * `Application.init()` has even created a renderer, and long before an atlas
358
+ * the story gates its children on has resolved. So the sheet was rasterized
359
+ * from an empty canvas and looked like a perfectly plausible blank frame, which
360
+ * is the worst failure a look verb has.
361
+ *
362
+ * Two conditions, because either alone still lies: a renderer that has drawn an
363
+ * empty stage has presented a frame, and a stage with children whose renderer
364
+ * has not run yet has no pixels.
365
+ *
366
+ * Resolves (rather than throwing) at the ceiling: a story that genuinely never
367
+ * draws must still be photographed, and an honestly blank cell is the truth
368
+ * about it.
369
+ */
370
+ export async function awaitPixiStoryFrame(
371
+ apps: readonly Application[],
372
+ now: () => number = Date.now,
373
+ ): Promise<void> {
374
+ if (apps.length === 0) return;
375
+ const presented = new Set<Application>();
376
+ const subscriptions = apps.map((app) => {
377
+ const observer = {
378
+ postrender: () => {
379
+ presented.add(app);
380
+ },
381
+ };
382
+ app.renderer?.runners.postrender.add(observer);
383
+ return { app, observer };
384
+ });
385
+ try {
386
+ const deadline = now() + FIRST_FRAME_TIMEOUT_MS;
387
+ while (now() < deadline) {
388
+ const ready = apps.every(
389
+ (app) =>
390
+ app.stage !== undefined && mountedStoryHasPixiContent(app.stage) && presented.has(app),
391
+ );
392
+ if (ready) return;
393
+ await nextTurn();
394
+ }
395
+ } finally {
396
+ for (const { app, observer } of subscriptions) {
397
+ app.renderer?.runners.postrender.remove(observer);
398
+ }
399
+ }
400
+ }
401
+
402
+ function describeMountFailure(error: unknown): Error {
403
+ return new Error(
404
+ 'mountStoryPixi: the story threw while rendering — it does not mount Pixi content, or its ' +
405
+ `component crashed. Original error: ${error instanceof Error ? error.message : String(error)}`,
406
+ { cause: error },
407
+ );
408
+ }