@volter/editor-game 0.5.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,288 @@
1
+ import { pcmStats } from '@volter/game-runtime/audio/wav-encode';
2
+ import type { OfflineAudioRenderer } from '@volter/editor-project/adapter';
3
+ import {
4
+ AudioBufferSource,
5
+ BufferTarget,
6
+ CanvasSource,
7
+ canEncodeAudio,
8
+ canEncodeVideo,
9
+ Output,
10
+ Quality,
11
+ WebMOutputFormat,
12
+ } from 'mediabunny';
13
+ import type { CaptureOptions } from '@volter/editor-core/composite-screenshot';
14
+ import { createImageSnapshotCache, drawPlayCompositeFrame } from '@volter/editor-core/composite-screenshot';
15
+
16
+ /**
17
+ * Opus in the SAME WebM `Output` the video track already uses — the whole
18
+ * audio decision of this module, written down because the alternative is what
19
+ * goes wrong.
20
+ *
21
+ * The export's container is produced by mediabunny (`Output` +
22
+ * `WebMOutputFormat` + `CanvasSource`). Audio therefore goes in as a second
23
+ * TRACK on that same `Output`, via mediabunny's own `AudioBufferSource`.
24
+ * There is no ffmpeg step, no second muxer, and no WAV file on the way: a
25
+ * second container pipeline beside the first is how one export's timing,
26
+ * metadata and codec support drift away from the other's.
27
+ *
28
+ * Consequently `audio/wav-encode.ts` is NOT used to make the track.
29
+ * `encodeWav16` exists for the one place that genuinely needs a file on a
30
+ * wire — `runtime/render-audio-control.ts` base64s a complete WAV across
31
+ * `page.evaluate` to a Node capture driver. Here the `AudioBuffer` and the
32
+ * muxer are in the same JS heap, so encoding to 16-bit WAV and handing the
33
+ * bytes back to an encoder would quantize the samples for no reason at all.
34
+ * `pcmStats` from that module IS used, for the non-silence evidence in the
35
+ * result.
36
+ *
37
+ * Opus rather than a PCM track (Matroska can carry `A_PCM/INT/LIT`) because
38
+ * WebM's normal audio codec is what every player and every `ffprobe` build
39
+ * handles without argument, and because a PCM track would dominate the file
40
+ * size of a five-minute clip. libopus is deterministic for identical input
41
+ * and configuration, which is what the fixed-step promise below needs.
42
+ */
43
+ const AUDIO_CODEC = 'opus' as const;
44
+ const AUDIO_BITRATE = 128_000;
45
+
46
+ export interface GameplayExportOptions {
47
+ /** Number of output frames, including the current paused frame. */
48
+ frames: number;
49
+ /** Must divide the engine's 60 Hz stepping cadence. */
50
+ fps?: number;
51
+ }
52
+
53
+ export function validateGameplayExport(options: GameplayExportOptions): number {
54
+ const fps = options.fps ?? 30;
55
+ if (!Number.isInteger(fps) || fps < 1 || fps > 60 || 60 % fps !== 0) {
56
+ throw new Error('Video export FPS must be a positive divisor of 60.');
57
+ }
58
+ if (!Number.isSafeInteger(options.frames) || options.frames < 1) {
59
+ throw new Error('Video export frames must be a positive safe integer.');
60
+ }
61
+ if (options.frames / fps > 300)
62
+ throw new Error('Video export is limited to five minutes per file.');
63
+ return fps;
64
+ }
65
+
66
+ export async function assertGameplayVideoSupport(container: HTMLElement): Promise<void> {
67
+ if (
68
+ !(await canEncodeVideo('vp9', {
69
+ width: container.clientWidth,
70
+ height: container.clientHeight,
71
+ }))
72
+ ) {
73
+ throw new Error('This browser cannot encode VP9 video at the game resolution.');
74
+ }
75
+ }
76
+
77
+ /** What the exported file's audio track actually is, when there is one.
78
+ * `rms`/`peak` are measured over the raw float PCM before encoding, so a
79
+ * caller can tell "a real score" from "a muxed-in silent track" without
80
+ * decoding the WebM it just received. */
81
+ export interface GameplayExportAudio {
82
+ readonly codec: typeof AUDIO_CODEC;
83
+ readonly sampleRate: number;
84
+ readonly channels: number;
85
+ readonly durationSeconds: number;
86
+ readonly rms: number;
87
+ readonly peak: number;
88
+ }
89
+
90
+ export interface GameplayExportResult {
91
+ blob: Blob;
92
+ frames: number;
93
+ fps: number;
94
+ durationMs: number;
95
+ wallMs: number;
96
+ width: number;
97
+ height: number;
98
+ /** `false` when the world supplied no offline audio renderer — the file is
99
+ * genuinely video-only. This is a RUNTIME value, never a literal: the type
100
+ * has to be able to say "there is a track" for the runs where there is
101
+ * one. */
102
+ audio: GameplayExportAudio | false;
103
+ }
104
+
105
+ /** How the export gets sound: the world's own audio for the ABSOLUTE sim
106
+ * window the frame walk covers, rendered offline.
107
+ *
108
+ * THE OFFSET TRAP, restated at the call site because it is the correctness
109
+ * argument of this whole path (`runtime/render-audio-control.ts` states it
110
+ * first): an offline render of `[10, 10.72)` schedules its first event at
111
+ * LOCAL time 0, not absolute 10. `startSeconds` is where the paused run
112
+ * stands on its own canonical clock when frame 0 is captured; `render` is
113
+ * asked for `[startSeconds, startSeconds + frames / fps)` and must subtract
114
+ * `startSeconds` itself, because the buffer it returns is placed at
115
+ * timestamp 0 of the clip — the same instant frame 0 shows.
116
+ *
117
+ * Real-time capture (`AudioAdapter.acquireRecordingStream`) is deliberately
118
+ * NOT an option here. A paused run being stepped emits no real-time audio,
119
+ * and a wall-clock tap would make two exports of the same window differ. */
120
+ export interface GameplayExportAudioSource {
121
+ readonly startSeconds: number;
122
+ readonly render: OfflineAudioRenderer;
123
+ }
124
+
125
+ /** Render the export window's audio and describe the track it will become.
126
+ * Split out of `exportGameplayVideo` so the frame walk stays readable; it
127
+ * runs before any mediabunny object exists. */
128
+ async function renderExportAudio(
129
+ audio: GameplayExportAudioSource,
130
+ durationSeconds: number,
131
+ fps: number,
132
+ ): Promise<{ buffer: AudioBuffer; track: GameplayExportAudio }> {
133
+ const { buffer } = await audio.render(audio.startSeconds, audio.startSeconds + durationSeconds);
134
+ // The muxed length is the BUFFER's, not the renderer's self-report, so that
135
+ // is what gets checked against the video's. A track that drifts past one
136
+ // frame is a broken export, not a tolerance to absorb.
137
+ const bufferSeconds = buffer.length / buffer.sampleRate;
138
+ if (Math.abs(bufferSeconds - durationSeconds) > 1 / fps) {
139
+ throw new Error(
140
+ `Offline audio render returned ${bufferSeconds.toFixed(4)}s for a ` +
141
+ `${durationSeconds.toFixed(4)}s export window (tolerance one frame, ` +
142
+ `${(1 / fps).toFixed(4)}s).`,
143
+ );
144
+ }
145
+ if (
146
+ !(await canEncodeAudio(AUDIO_CODEC, {
147
+ numberOfChannels: buffer.numberOfChannels,
148
+ sampleRate: buffer.sampleRate,
149
+ }))
150
+ ) {
151
+ throw new Error(
152
+ `This browser cannot encode ${AUDIO_CODEC} at ${buffer.sampleRate} Hz, ` +
153
+ `${buffer.numberOfChannels} channel(s).`,
154
+ );
155
+ }
156
+ const channelData: Float32Array[] = [];
157
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) channelData.push(buffer.getChannelData(ch));
158
+ const stats = pcmStats(channelData);
159
+ return {
160
+ buffer,
161
+ track: {
162
+ codec: AUDIO_CODEC,
163
+ sampleRate: buffer.sampleRate,
164
+ channels: buffer.numberOfChannels,
165
+ durationSeconds: bufferSeconds,
166
+ rms: stats.rms,
167
+ peak: stats.peak,
168
+ },
169
+ };
170
+ }
171
+
172
+ /** Browser-only export. The caller owns pause, stepping and saving the
173
+ * returned file. Encoding backpressure changes wall time, never media time —
174
+ * and that holds for the audio too, which is rendered from the SIM clock, not
175
+ * captured from the speakers. */
176
+ export async function exportGameplayVideo(
177
+ container: HTMLElement,
178
+ options: GameplayExportOptions,
179
+ control: {
180
+ step(): void;
181
+ assertActive(): void;
182
+ canvasFrame?: CaptureOptions['canvasFrame'];
183
+ signal?: AbortSignal;
184
+ audio?: GameplayExportAudioSource;
185
+ },
186
+ ): Promise<GameplayExportResult> {
187
+ const fps = validateGameplayExport(options);
188
+ const started = performance.now();
189
+ const canvas = container.ownerDocument.createElement('canvas');
190
+ canvas.width = container.clientWidth;
191
+ canvas.height = container.clientHeight;
192
+ if (canvas.width < 1 || canvas.height < 1)
193
+ throw new Error('Game surface has no exportable size.');
194
+ const width = canvas.width;
195
+ const height = canvas.height;
196
+ const durationSeconds = options.frames / fps;
197
+
198
+ // Audio is rendered BEFORE the output exists: mediabunny takes every track
199
+ // before `start()`, and the offline render needs no output at all. It also
200
+ // must happen before the frame walk, because the walk advances the paused
201
+ // run past `startSeconds`.
202
+ const prepared = control.audio
203
+ ? await renderExportAudio(control.audio, durationSeconds, fps)
204
+ : null;
205
+ control.signal?.throwIfAborted();
206
+ control.assertActive();
207
+ let audioSource: AudioBufferSource | null = null;
208
+
209
+ const target = new BufferTarget();
210
+ const output = new Output({ target, format: new WebMOutputFormat() });
211
+ const source = new CanvasSource(canvas, {
212
+ codec: 'vp9',
213
+ quality: new Quality({ bitrate: 3_000_000 }),
214
+ });
215
+ output.addVideoTrack(source, { frameRate: fps });
216
+ if (prepared) {
217
+ // `startTimestamp` defaults to 0 and is left there deliberately: the
218
+ // buffer is the sim window starting at the instant frame 0 shows, so
219
+ // local 0 is exactly where it belongs (see GameplayExportAudioSource).
220
+ audioSource = new AudioBufferSource({
221
+ codec: AUDIO_CODEC,
222
+ quality: new Quality({ bitrate: AUDIO_BITRATE }),
223
+ });
224
+ output.addAudioTrack(audioSource);
225
+ }
226
+ const snapshots = createImageSnapshotCache();
227
+ let cancellation: Promise<void> | null = null;
228
+ const abort = () => {
229
+ cancellation ??= output.cancel();
230
+ };
231
+ control.signal?.addEventListener('abort', abort, { once: true });
232
+ try {
233
+ control.signal?.throwIfAborted();
234
+ await output.start();
235
+ if (audioSource && prepared) {
236
+ // The whole window in one `add` — it is already fully rendered, and
237
+ // audio is small beside VP9, so writing it first lets the muxer
238
+ // interleave against a track it never has to wait on.
239
+ await audioSource.add(prepared.buffer);
240
+ audioSource.close();
241
+ control.signal?.throwIfAborted();
242
+ }
243
+ for (let frame = 0; frame < options.frames; frame++) {
244
+ control.signal?.throwIfAborted();
245
+ control.assertActive();
246
+ if (!container.isConnected) throw new Error('Game surface was removed during video export.');
247
+ if (container.clientWidth !== width || container.clientHeight !== height) {
248
+ throw new Error('Game surface resized during video export.');
249
+ }
250
+ if (frame > 0) for (let tick = 0; tick < 60 / fps; tick++) control.step();
251
+ // No wall-time overlay cache: every exported frame needs its own current
252
+ // HUD. The caller's step commits React before this snapshot is taken.
253
+ await drawPlayCompositeFrame(container, canvas, {
254
+ size: { width, height },
255
+ snapshots,
256
+ canvasFrame: control.canvasFrame,
257
+ });
258
+ control.signal?.throwIfAborted();
259
+ control.assertActive();
260
+ if (container.clientWidth !== width || container.clientHeight !== height) {
261
+ throw new Error('Game surface resized during video export.');
262
+ }
263
+ await source.add(frame / fps, 1 / fps, { keyFrame: frame % (fps * 2) === 0 });
264
+ }
265
+ control.assertActive();
266
+ control.signal?.throwIfAborted();
267
+ await output.finalize();
268
+ control.signal?.throwIfAborted();
269
+ control.assertActive();
270
+ if (!target.buffer) throw new Error('Video encoder produced no output.');
271
+ return {
272
+ blob: new Blob([target.buffer], { type: 'video/webm' }),
273
+ frames: options.frames,
274
+ fps,
275
+ durationMs: (options.frames * 1000) / fps,
276
+ wallMs: performance.now() - started,
277
+ width,
278
+ height,
279
+ audio: prepared?.track ?? false,
280
+ };
281
+ } catch (error) {
282
+ if (cancellation) await cancellation;
283
+ else if (output.state !== 'finalized') await output.cancel();
284
+ throw error;
285
+ } finally {
286
+ control.signal?.removeEventListener('abort', abort);
287
+ }
288
+ }