@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,717 @@
1
+ /**
2
+ * Live gameplay recording over the SAME clean game compositor used by
3
+ * `vgai screenshot`: world canvases + DOM HUD, no editor chrome.
4
+ *
5
+ * This is intentionally a browser primitive, not a CLI workflow. The page
6
+ * owns `MediaRecorder` and the live pixels/audio, streams bounded chunks to
7
+ * the project server, and `@volter/editor-live` merely starts/stops it through the
8
+ * existing session wire.
9
+ *
10
+ * KNOWN, MEASURED LIMIT — THE HUD LAYER IS INTERMITTENT, AND THIS FILE CANNOT
11
+ * TELL YOU WHEN. Measured on a hidden tab at ~29 fps (third-person, 8412
12
+ * frames): the world canvas is in every frame, and the DOM HUD is missing from
13
+ * a substantial minority of them, sometimes partially (header bar but no
14
+ * panels). The overlay IS built every frame — `drawPlayCompositeFrame` reports
15
+ * a non-zero `domOverlays` on the frames whose pixels have no HUD — so the loss
16
+ * is Chromium rasterizing the foreignObject blank, which nothing here can
17
+ * observe without reading back pixels every frame. `capturePlayComposite`, the
18
+ * STILL path, buys reliability with a compositor settle window of several
19
+ * hundred milliseconds per frame; a real-time recorder cannot spend that.
20
+ *
21
+ * A counter for it was written, shipped and REMOVED in the same afternoon: it
22
+ * keyed on `domOverlays === 0` and therefore reported a clean zero over clips
23
+ * visibly missing their HUD. A measurement that can only ever say "fine" is
24
+ * worse than none, so the limit is stated — here, and in the ack `vgai stop`
25
+ * prints — rather than counted. Read the world layer as complete evidence and
26
+ * the HUD as present-when-present.
27
+ */
28
+
29
+ import type {
30
+ ToolContributionRecordingFrame,
31
+ ToolContributionRecordingSnapshot,
32
+ } from '@volter/editor-sdk/contributions';
33
+ import type { AudioRecordingHandle } from '@volter/editor-project/adapter';
34
+ import type {
35
+ CaptureOptions,
36
+ CompositeFrame,
37
+ ImageSnapshotCache,
38
+ OverlayFrameCache,
39
+ } from '@volter/editor-core/composite-screenshot';
40
+ import {
41
+ createImageSnapshotCache,
42
+ createOverlayFrameCache,
43
+ drawPlayCompositeFrame,
44
+ isRootCanvas,
45
+ } from '@volter/editor-core/composite-screenshot';
46
+ import {
47
+ abortGameplayRecordingSink,
48
+ appendGameplayRecordingChunk,
49
+ beginGameplayRecordingSink,
50
+ finishGameplayRecordingSink,
51
+ type GameplayRecordingSink,
52
+ } from '@volter/editor-core/editor-api';
53
+ import { type GameplayDomRecording, startGameplayDomRecording } from '@volter/editor-core/gameplay-dom-recording';
54
+ import { publishToolContributionRecording } from '@volter/editor-core/gameplay-sessions';
55
+ import { createRecordingPreviewEncoder } from './recording-preview';
56
+
57
+ export interface GameplayRecordingStartOptions {
58
+ readonly fps?: number | undefined;
59
+ /** Preserve one composite WebM, or capture the world canvas directly and
60
+ * stream a synchronized DOM replay sidecar. */
61
+ readonly format?: 'composite-webm' | 'canvas-dom' | undefined;
62
+ readonly canvasFrame?: CaptureOptions['canvasFrame'];
63
+ readonly audio?: AudioRecordingHandle | null | undefined;
64
+ /**
65
+ * The file this recording lands in, under the project's `.vgai/recordings/`.
66
+ * During Play, absent is named after the Gameplay Session; outside one it
67
+ * falls back to `play-latest.webm`. A name is the caller's own keepsake. The server owns the literals — see
68
+ * `editor-server.ts`'s `/__editor/recording/start`.
69
+ */
70
+ readonly name?: string | null | undefined;
71
+ /**
72
+ * Called immediately before each composite draw, to make the pixels this
73
+ * frame is about to read CURRENT.
74
+ *
75
+ * A hidden tab gets no rAF, so a game's own render loop is stopped and its
76
+ * canvas holds whatever it last drew — the recorder would encode one frozen
77
+ * frame for the whole run. The caller supplies the recovery it already owns
78
+ * for the still-capture path (`handleBridgeScreenshot`'s
79
+ * `runtime.runTicks(1, { render: 'last' })` when `loop.liveness` reports
80
+ * `'loop-starved'`); this is the same seam on the video path. Throwing is
81
+ * the caller's business — a throw here is counted as a frame error and the
82
+ * stream keeps running on the preceding good frame.
83
+ */
84
+ readonly refreshFrame?: (() => void) | undefined;
85
+ }
86
+
87
+ export interface GameplayRecordingStarted {
88
+ readonly format: 'composite-webm' | 'canvas-dom';
89
+ readonly startedAt: string;
90
+ readonly mimeType: string;
91
+ readonly width: number;
92
+ readonly height: number;
93
+ readonly fps: number;
94
+ readonly audio: boolean;
95
+ readonly layers: CompositeFrame;
96
+ /**
97
+ * Where the bytes are landing, known AT START rather than at stop.
98
+ *
99
+ * That is the point of reporting it here: a caller that only learns the path
100
+ * when the file is finished cannot tell anyone where the evidence for a run
101
+ * still in progress will be, which is exactly the question asked while it is
102
+ * running.
103
+ */
104
+ readonly path: string;
105
+ readonly replayPath: string | null;
106
+ /** True only for the out-of-session rotating fallback. */
107
+ readonly rotates: boolean;
108
+ /** The `logs/play-*.jsonl` this run is writing (`null` when nothing is). */
109
+ readonly logFile: string | null;
110
+ }
111
+
112
+ export interface GameplayRecordingCapture extends GameplayRecordingStarted {
113
+ readonly durationMs: number;
114
+ /** Composite updates missed relative to the requested cadence. The encoded
115
+ * stream may repeat the preceding frame during those intervals, which is
116
+ * exactly the real-time pacing evidence a recorder must preserve. */
117
+ readonly droppedFrames: number;
118
+ readonly frameErrors: number;
119
+ /**
120
+ * The cadence this recording ACHIEVED — composited frames over its wall
121
+ * duration — as distinct from `fps`, which is the cadence it asked for.
122
+ *
123
+ * Reported because the two genuinely diverge and the reader has to know by
124
+ * how much: a browser is free to throttle a hidden tab's clock, and the
125
+ * answer to that is to record anyway and say what was captured, never to
126
+ * refuse. Read this before reasoning about a clip's timing.
127
+ */
128
+ readonly effectiveFps: number;
129
+ /** True when the tab was hidden for any part of the recording — the case
130
+ * `effectiveFps` is worth reading for. */
131
+ readonly hidden: boolean;
132
+ }
133
+
134
+ export interface GameplayRecordingTimeline {
135
+ readonly startedAt: string;
136
+ readonly elapsedMs: number;
137
+ }
138
+
139
+ interface ActiveRecording {
140
+ readonly started: GameplayRecordingStarted;
141
+ readonly recorder: MediaRecorder;
142
+ readonly output: HTMLCanvasElement;
143
+ /** Async compositing never clears the canvas MediaRecorder is sampling. */
144
+ readonly staging: HTMLCanvasElement | null;
145
+ readonly stream: MediaStream;
146
+ readonly audio: AudioRecordingHandle | null;
147
+ readonly sink: GameplayRecordingSink;
148
+ readonly startedWallMs: number;
149
+ readonly intervalMs: number;
150
+ readonly container: HTMLElement;
151
+ readonly options: Pick<GameplayRecordingStartOptions, 'canvasFrame' | 'refreshFrame'>;
152
+ clock: FrameClock | null;
153
+ frameTask: Promise<void> | null;
154
+ drawing: boolean;
155
+ /** Earliest time the next frame may draw — see the duty-cycle note in
156
+ * {@link paintFrame}. */
157
+ nextFrameAt: number;
158
+ /** Static-pixel reuse across this recording's frames — see
159
+ * {@link ImageSnapshotCache}. */
160
+ readonly snapshots: ImageSnapshotCache;
161
+ /** The DOM overlay raster, rebuilt on mutation instead of per frame — see
162
+ * {@link OverlayFrameCache}. Disposed (observer disconnected) on stop. */
163
+ readonly overlayCache: OverlayFrameCache | null;
164
+ readonly domRecording: GameplayDomRecording | null;
165
+ /**
166
+ * RESOURCE OWNERSHIP: this recording owns the thumbnail canvas, every Blob
167
+ * URL in `previewFrames`, and their one teardown path. Contributions only
168
+ * borrow those URLs; eviction and `stopGameplayRecording` revoke them.
169
+ */
170
+ readonly previewEncoder: ReturnType<typeof createRecordingPreviewEncoder>;
171
+ readonly previewFrames: ToolContributionRecordingFrame[];
172
+ previewSnapshot: ToolContributionRecordingSnapshot;
173
+ previewTask: Promise<void> | null;
174
+ previewEncoding: boolean;
175
+ previewError: string | null;
176
+ nextPreviewAt: number;
177
+ nextPreviewSequence: number;
178
+ frames: number;
179
+ frameErrors: number;
180
+ hidden: boolean;
181
+ recorderError: Error | null;
182
+ uploadError: Error | null;
183
+ uploads: Promise<void>;
184
+ nextSequence: number;
185
+ stopping: boolean;
186
+ }
187
+
188
+ let active: ActiveRecording | null = null;
189
+
190
+ /** Page-lifetime observers; an ActiveRecording owns pixels, never listeners. */
191
+
192
+ const MIN_FPS = 1;
193
+ const MAX_FPS = 60;
194
+ const DEFAULT_FPS = 30;
195
+ const PREVIEW_SAMPLE_FPS = 2;
196
+ const PREVIEW_INTERVAL_MS = 1000 / PREVIEW_SAMPLE_FPS;
197
+ const PREVIEW_RETENTION_MS = 120_000;
198
+ const PREVIEW_MAX_FRAMES = (PREVIEW_RETENTION_MS / 1000) * PREVIEW_SAMPLE_FPS;
199
+ const PREVIEW_WIDTH = 256;
200
+ const PREVIEW_MIME_TYPE = 'image/webp';
201
+ const PREVIEW_QUALITY = 0.72;
202
+
203
+ interface FrameClock {
204
+ stop(): void;
205
+ }
206
+
207
+ /**
208
+ * The recorder's frame clock — a dedicated Worker's interval, with
209
+ * `setInterval` as the fallback.
210
+ *
211
+ * `setInterval` on the page is the obvious pacer and it is the wrong one for
212
+ * the case this recorder exists to serve. Chrome clamps a background tab's
213
+ * timers to 1 Hz, and to roughly 1/min once the tab has been hidden for five
214
+ * minutes — and an agent's `vgai edit` tab is hidden BY DESIGN (worktree
215
+ * sessions open behind the human's window; see `after-paint.ts` for the same
216
+ * fact stated for rAF). A 30 fps request would encode as a handful of frames.
217
+ *
218
+ * A worker's timer is not clamped on the same schedule, and worker MESSAGE
219
+ * delivery is not throttled at all — the property `server/tab-heartbeat.ts`
220
+ * already relies on to keep a hidden tab present. That file also states the
221
+ * honest limit: Chrome "may throttle workers", so the cadence is NOT assumed
222
+ * to hold. Nothing here asserts a rate; `effectiveFps` on the capture reports
223
+ * what was actually achieved.
224
+ *
225
+ * The worker source is a Blob rather than a served script because this must
226
+ * work on the hosted editor build too, which serves no `/__editor/*` file.
227
+ * Every failure — no `Worker`, no `Blob`/`createObjectURL`, a CSP that
228
+ * forbids `blob:` workers — falls back to the page timer, which still
229
+ * records, at whatever cadence the browser grants.
230
+ */
231
+ function startFrameClock(intervalMs: number, tick: () => void): FrameClock {
232
+ const source =
233
+ "'use strict';var t=null;onmessage=function(e){clearInterval(t);t=null;" +
234
+ 'if(e.data&&e.data.ms>0){t=setInterval(function(){postMessage(0)},e.data.ms)}};';
235
+ try {
236
+ const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
237
+ const worker = new Worker(url);
238
+ URL.revokeObjectURL(url);
239
+ // A worker that cannot start is not an error to report — the timer below
240
+ // is the answer, and it is installed the moment the worker says so.
241
+ let fallback: number | null = null;
242
+ worker.addEventListener('error', (event) => {
243
+ event.preventDefault();
244
+ if (fallback === null) fallback = window.setInterval(tick, intervalMs);
245
+ });
246
+ worker.addEventListener('message', () => tick());
247
+ worker.postMessage({ ms: intervalMs });
248
+ return {
249
+ stop() {
250
+ worker.postMessage({ ms: 0 });
251
+ worker.terminate();
252
+ if (fallback !== null) window.clearInterval(fallback);
253
+ },
254
+ };
255
+ } catch {
256
+ const timer = window.setInterval(tick, intervalMs);
257
+ return {
258
+ stop() {
259
+ window.clearInterval(timer);
260
+ },
261
+ };
262
+ }
263
+ }
264
+
265
+ function requestedFps(value: number | undefined): number {
266
+ const fps = value ?? DEFAULT_FPS;
267
+ if (!Number.isFinite(fps) || !Number.isInteger(fps) || fps < MIN_FPS || fps > MAX_FPS) {
268
+ throw new Error(`gameplay recording fps must be an integer from ${MIN_FPS} to ${MAX_FPS}`);
269
+ }
270
+ return fps;
271
+ }
272
+
273
+ function recordingMimeType(hasAudio: boolean): string {
274
+ const candidates = hasAudio
275
+ ? ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']
276
+ : ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
277
+ const supported = candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate));
278
+ if (!supported) throw new Error('this browser exposes MediaRecorder but no WebM video codec');
279
+ return supported;
280
+ }
281
+
282
+ function notifyPreviewListeners(): void {
283
+ publishToolContributionRecording(active?.previewSnapshot ?? null);
284
+ }
285
+
286
+ function publishPreviewSnapshot(recording: ActiveRecording, liveEdgeMs: number): void {
287
+ recording.previewSnapshot = {
288
+ startedAt: recording.started.startedAt,
289
+ width: recording.started.width,
290
+ height: recording.started.height,
291
+ liveEdgeMs,
292
+ sampleFps: PREVIEW_SAMPLE_FPS,
293
+ retentionMs: PREVIEW_RETENTION_MS,
294
+ frames: [...recording.previewFrames],
295
+ previewError: recording.previewError,
296
+ };
297
+ if (active === recording) notifyPreviewListeners();
298
+ }
299
+
300
+ function releasePreviewFrames(recording: ActiveRecording): void {
301
+ for (const frame of recording.previewFrames) URL.revokeObjectURL(frame.src);
302
+ recording.previewFrames.length = 0;
303
+ }
304
+
305
+ /**
306
+ * Copy an already-composited video frame into the bounded live filmstrip.
307
+ * Encoding is never queued: if the preceding thumbnail is still encoding,
308
+ * this sample is skipped just like a recorder tick that cannot meet cadence.
309
+ */
310
+ function samplePreviewFrame(recording: ActiveRecording, mediaTimeMs: number): void {
311
+ if (
312
+ recording.stopping ||
313
+ recording.previewEncoding ||
314
+ recording.previewError ||
315
+ mediaTimeMs < recording.nextPreviewAt
316
+ ) {
317
+ return;
318
+ }
319
+ recording.nextPreviewAt = mediaTimeMs + PREVIEW_INTERVAL_MS;
320
+
321
+ const scale = Math.min(1, PREVIEW_WIDTH / Math.max(1, recording.output.width));
322
+ const width = Math.max(1, Math.round(recording.output.width * scale));
323
+ const height = Math.max(1, Math.round(recording.output.height * scale));
324
+ recording.previewEncoding = true;
325
+ const capturedAt = new Date().toISOString();
326
+ const sequence = recording.nextPreviewSequence;
327
+ recording.nextPreviewSequence += 1;
328
+ const task = recording.previewEncoder
329
+ .encode(recording.output, width, height, PREVIEW_MIME_TYPE, PREVIEW_QUALITY)
330
+ .then((blob) => {
331
+ if (recording.stopping) return;
332
+ if (!blob) {
333
+ recording.previewError = 'The browser could not encode recording previews.';
334
+ publishPreviewSnapshot(recording, mediaTimeMs);
335
+ return;
336
+ }
337
+ const frame: ToolContributionRecordingFrame = {
338
+ sequence,
339
+ mediaTimeMs: Math.max(0, Math.round(mediaTimeMs)),
340
+ capturedAt,
341
+ src: URL.createObjectURL(blob),
342
+ width,
343
+ height,
344
+ };
345
+ recording.previewFrames.push(frame);
346
+ while (
347
+ recording.previewFrames.length > PREVIEW_MAX_FRAMES ||
348
+ (recording.previewFrames[0]?.mediaTimeMs ?? frame.mediaTimeMs) <
349
+ frame.mediaTimeMs - PREVIEW_RETENTION_MS
350
+ ) {
351
+ const evicted = recording.previewFrames.shift();
352
+ if (evicted) URL.revokeObjectURL(evicted.src);
353
+ }
354
+ publishPreviewSnapshot(recording, frame.mediaTimeMs);
355
+ })
356
+ .catch((error) => {
357
+ if (recording.stopping) return;
358
+ recording.previewError =
359
+ error instanceof Error ? error.message : 'The browser could not encode recording previews.';
360
+ publishPreviewSnapshot(recording, mediaTimeMs);
361
+ })
362
+ .finally(() => {
363
+ recording.previewEncoding = false;
364
+ });
365
+ recording.previewTask = task;
366
+ }
367
+
368
+ /**
369
+ * A HIDDEN TAB RECORDS. There is deliberately no visibility gate here.
370
+ *
371
+ * The refusal this replaced ("show the editor tab, then start recording")
372
+ * exempted exactly the sessions the recorder exists for: an agent's
373
+ * `vgai edit` tab opens behind the human's window and stays hidden for the
374
+ * whole run, so every unattended playthrough — the ones with no human to
375
+ * describe what happened — would have produced no evidence at all. What a
376
+ * hidden tab actually costs is two things, and both are handled rather than
377
+ * refused: rAF stops, so the game's canvas goes stale (the caller's
378
+ * `refreshFrame` drives one deterministic tick per frame), and the page's
379
+ * timers are clamped (the frame clock is a worker, and `effectiveFps` reports
380
+ * whatever cadence was really achieved).
381
+ */
382
+ function assertCanStartRecording(): void {
383
+ if (active)
384
+ throw new Error('a gameplay recording is already active; stop it before starting one');
385
+ if (typeof MediaRecorder === 'undefined') {
386
+ throw new Error('this browser does not support MediaRecorder gameplay capture');
387
+ }
388
+ }
389
+
390
+ /**
391
+ * One frame, driven by the clock tick.
392
+ *
393
+ * A tick that lands while the previous draw is still in flight is DROPPED
394
+ * rather than queued: a queue would let a slow composite build an unbounded
395
+ * backlog that then encodes as fast-forward. The dropped tick shows up in
396
+ * `droppedFrames`, which is derived from the achieved count against the
397
+ * requested cadence.
398
+ */
399
+ function paintFrame(recording: ActiveRecording): void {
400
+ if (recording.stopping || recording.drawing) return;
401
+ if (performance.now() < recording.nextFrameAt) return;
402
+ if (typeof document !== 'undefined' && document.hidden) recording.hidden = true;
403
+ recording.drawing = true;
404
+ recording.frameTask = (async () => {
405
+ const drawStart = performance.now();
406
+ try {
407
+ // Make the pixels current BEFORE reading them. On a hidden tab this is
408
+ // the whole difference between a real recording and one frozen frame.
409
+ recording.options.refreshFrame?.();
410
+ if (recording.started.format === 'composite-webm') {
411
+ const staging = recording.staging;
412
+ const overlayCache = recording.overlayCache;
413
+ if (!staging || !overlayCache) {
414
+ throw new Error('gameplay recording: composite buffers unavailable');
415
+ }
416
+ await drawPlayCompositeFrame(recording.container, staging, {
417
+ canvasFrame: recording.options.canvasFrame,
418
+ snapshots: recording.snapshots,
419
+ overlayCache,
420
+ });
421
+ // Publish one complete frame synchronously. Awaiting canvas recovery or
422
+ // HUD decoding on the captured canvas exposes blank/partial frames.
423
+ const { output } = recording;
424
+ const context = output.getContext('2d');
425
+ if (!context) throw new Error('gameplay recording: output context unavailable');
426
+ if (output.width !== staging.width) output.width = staging.width;
427
+ if (output.height !== staging.height) output.height = staging.height;
428
+ context.globalCompositeOperation = 'copy';
429
+ context.drawImage(staging, 0, 0);
430
+ context.globalCompositeOperation = 'source-over';
431
+ samplePreviewFrame(recording, performance.now() - recording.startedWallMs);
432
+ }
433
+ // In canvas-dom mode MediaRecorder samples the game's canvas directly.
434
+ // This tick only keeps a hidden-tab game rendering and measures cadence.
435
+ recording.frames += 1;
436
+ } catch {
437
+ // Keep the real-time stream alive on the preceding good frame. The stop
438
+ // result reports this count, so a visually incomplete recording is never
439
+ // silently presented as clean evidence.
440
+ recording.frameErrors += 1;
441
+ } finally {
442
+ recording.drawing = false;
443
+ // THE RECORDER MAY NEVER OWN THE MAIN THREAD. Dropping ticks while a
444
+ // draw is in flight bounds the QUEUE, not the utilization: a draw that
445
+ // costs more than the interval makes the very next tick eligible, so an
446
+ // expensive composite ran back-to-back and recording consumed ~90% of
447
+ // the main thread (measured: the game's own frame at 0%, a trivial
448
+ // relay reply queued 4.5s, every capture/stop 504). A draw that cost D
449
+ // is therefore not eligible again until 2·D after it STARTED — an idle
450
+ // gap of at least D, capping recording at half the main thread — while
451
+ // a cheap draw keeps the requested cadence (the floor is HALF the
452
+ // interval, not the interval: the clock's own ticks already pace the
453
+ // cadence, and a full-interval floor raced their jitter and dropped
454
+ // every other healthy tick). The shortfall is reported, not hidden
455
+ // (`droppedFrames`/`effectiveFps` on the capture).
456
+ const drawCost = performance.now() - drawStart;
457
+ recording.nextFrameAt = drawStart + Math.max(recording.intervalMs / 2, 2 * drawCost);
458
+ }
459
+ })();
460
+ }
461
+
462
+ /** Start one recording. A second start refuses rather than replace evidence. */
463
+ export async function startGameplayRecording(
464
+ container: HTMLElement,
465
+ options: GameplayRecordingStartOptions = {},
466
+ ): Promise<GameplayRecordingStarted> {
467
+ assertCanStartRecording();
468
+
469
+ const fps = requestedFps(options.fps);
470
+ const format = options.format ?? 'composite-webm';
471
+ const compositeOutput = container.ownerDocument.createElement('canvas');
472
+ const audio = options.audio ?? null;
473
+ let stream: MediaStream | null = null;
474
+ let sink: GameplayRecordingSink | null = null;
475
+ let domRecording: GameplayDomRecording | null = null;
476
+ const snapshots = createImageSnapshotCache();
477
+ const overlayCache = createOverlayFrameCache();
478
+ try {
479
+ // The hybrid still takes one composite at startup to validate the mounted
480
+ // surface and report an honest layer inventory. It never enters this DOM
481
+ // raster path again during the live clip.
482
+ const layers = await drawPlayCompositeFrame(container, compositeOutput, {
483
+ canvasFrame: options.canvasFrame,
484
+ snapshots,
485
+ overlayCache,
486
+ });
487
+ if (layers.canvases === 0 && layers.domOverlays === 0) {
488
+ throw new Error('gameplay recording: no game layers are mounted');
489
+ }
490
+ const rootCanvases = Array.from(container.querySelectorAll('canvas')).filter(isRootCanvas);
491
+ if (format === 'canvas-dom' && rootCanvases.length !== 1) {
492
+ throw new Error(
493
+ `canvas-dom recording requires exactly one root canvas; this game has ${rootCanvases.length}`,
494
+ );
495
+ }
496
+ const output = format === 'canvas-dom' ? rootCanvases[0]! : compositeOutput;
497
+ if (typeof output.captureStream !== 'function') {
498
+ throw new Error('this browser cannot capture a canvas as a media stream');
499
+ }
500
+ stream = output.captureStream(fps);
501
+ if (audio) {
502
+ for (const track of audio.stream.getAudioTracks()) stream.addTrack(track);
503
+ }
504
+ const mimeType = recordingMimeType(stream.getAudioTracks().length > 0);
505
+ const recorder = new MediaRecorder(stream, { mimeType });
506
+ const startedAt = new Date().toISOString();
507
+ sink = await beginGameplayRecordingSink({
508
+ startedAt,
509
+ mimeType,
510
+ name: options.name ?? null,
511
+ format,
512
+ });
513
+ if (format === 'canvas-dom') {
514
+ domRecording = startGameplayDomRecording(container, output, sink);
515
+ overlayCache.dispose();
516
+ }
517
+ const started: GameplayRecordingStarted = {
518
+ format,
519
+ startedAt,
520
+ mimeType,
521
+ width: output.width,
522
+ height: output.height,
523
+ fps,
524
+ audio: stream.getAudioTracks().length > 0,
525
+ layers,
526
+ path: sink.path,
527
+ replayPath: sink.replayPath,
528
+ rotates: sink.rotates,
529
+ logFile: sink.logFile,
530
+ };
531
+ const recording: ActiveRecording = {
532
+ started,
533
+ recorder,
534
+ output,
535
+ staging: format === 'composite-webm' ? container.ownerDocument.createElement('canvas') : null,
536
+ stream,
537
+ audio,
538
+ sink,
539
+ startedWallMs: performance.now(),
540
+ intervalMs: 1000 / fps,
541
+ container,
542
+ options: {
543
+ canvasFrame: options.canvasFrame,
544
+ refreshFrame: options.refreshFrame,
545
+ },
546
+ clock: null,
547
+ frameTask: null,
548
+ drawing: false,
549
+ nextFrameAt: 0,
550
+ snapshots,
551
+ overlayCache: format === 'composite-webm' ? overlayCache : null,
552
+ domRecording,
553
+ previewEncoder: createRecordingPreviewEncoder(),
554
+ previewFrames: [],
555
+ previewSnapshot: {
556
+ startedAt,
557
+ width: output.width,
558
+ height: output.height,
559
+ liveEdgeMs: 0,
560
+ sampleFps: PREVIEW_SAMPLE_FPS,
561
+ retentionMs: PREVIEW_RETENTION_MS,
562
+ frames: [],
563
+ previewError: null,
564
+ },
565
+ previewTask: null,
566
+ previewEncoding: false,
567
+ previewError: null,
568
+ nextPreviewAt: 0,
569
+ nextPreviewSequence: 0,
570
+ frames: 1,
571
+ frameErrors: 0,
572
+ hidden: typeof document !== 'undefined' && document.hidden,
573
+ recorderError: null,
574
+ uploadError: null,
575
+ uploads: Promise.resolve(),
576
+ nextSequence: 0,
577
+ stopping: false,
578
+ };
579
+ recorder.addEventListener('dataavailable', (event) => {
580
+ if (event.data.size === 0 || recording.uploadError) return;
581
+ const sequence = recording.nextSequence;
582
+ recording.nextSequence += 1;
583
+ recording.uploads = recording.uploads.then(async () => {
584
+ if (recording.uploadError) return;
585
+ try {
586
+ await appendGameplayRecordingChunk(recording.sink, sequence, event.data);
587
+ } catch (error) {
588
+ recording.uploadError = error instanceof Error ? error : new Error(String(error));
589
+ }
590
+ });
591
+ });
592
+ recorder.addEventListener('error', (event) => {
593
+ recording.recorderError =
594
+ (event as ErrorEvent).error ?? new Error('gameplay recording failed');
595
+ });
596
+ recorder.addEventListener('start', () => {
597
+ recording.domRecording?.markVideoStarted(Date.now());
598
+ });
599
+ recorder.start(1000);
600
+ active = recording;
601
+ notifyPreviewListeners();
602
+ if (format === 'composite-webm') samplePreviewFrame(recording, 0);
603
+ recording.clock = startFrameClock(recording.intervalMs, () => paintFrame(recording));
604
+ return started;
605
+ } catch (error) {
606
+ await domRecording?.abort();
607
+ overlayCache.dispose();
608
+ if (sink) await abortGameplayRecordingSink(sink);
609
+ for (const track of stream?.getTracks() ?? []) track.stop();
610
+ audio?.dispose();
611
+ throw error;
612
+ }
613
+ }
614
+
615
+ /** Stop the active recorder and return its standard WebM bytes. `reason` is
616
+ * what ended it, journalled beside the finalized file. */
617
+ export async function stopGameplayRecording(
618
+ reason?: string | null,
619
+ ): Promise<GameplayRecordingCapture> {
620
+ const recording = active;
621
+ if (!recording) throw new Error('no gameplay recording is active');
622
+ recording.stopping = true;
623
+ recording.clock?.stop();
624
+ recording.clock = null;
625
+ await recording.frameTask;
626
+ recording.previewEncoder.dispose();
627
+ await recording.previewTask;
628
+
629
+ let finalized = false;
630
+ try {
631
+ if (recording.recorder.state !== 'inactive') {
632
+ const stopped = new Promise<void>((resolve) => {
633
+ recording.recorder.addEventListener('stop', () => resolve(), { once: true });
634
+ });
635
+ recording.recorder.stop();
636
+ await stopped;
637
+ }
638
+ if (recording.recorderError) throw recording.recorderError;
639
+ await recording.uploads;
640
+ if (recording.uploadError) throw recording.uploadError;
641
+ const durationMs = Math.max(0, Math.round(performance.now() - recording.startedWallMs));
642
+ const expectedFrames = Math.round((durationMs / 1000) * recording.started.fps);
643
+ const capture: GameplayRecordingCapture = {
644
+ ...recording.started,
645
+ durationMs,
646
+ droppedFrames: Math.max(0, expectedFrames - recording.frames),
647
+ frameErrors: recording.frameErrors,
648
+ effectiveFps:
649
+ durationMs > 0 ? Math.round((recording.frames / (durationMs / 1000)) * 10) / 10 : 0,
650
+ hidden: recording.hidden,
651
+ };
652
+ const videoFile = recording.sink.path.split(/[\\/]/).at(-1) ?? 'video.webm';
653
+ const replay = await recording.domRecording?.stop(videoFile);
654
+ await finishGameplayRecordingSink(
655
+ recording.sink,
656
+ reason ?? null,
657
+ undefined,
658
+ replay
659
+ ? {
660
+ ...replay,
661
+ capture: {
662
+ durationMs: capture.durationMs,
663
+ droppedFrames: capture.droppedFrames,
664
+ frameErrors: capture.frameErrors,
665
+ effectiveFps: capture.effectiveFps,
666
+ hidden: capture.hidden,
667
+ fps: capture.fps,
668
+ width: capture.width,
669
+ height: capture.height,
670
+ mimeType: capture.mimeType,
671
+ audio: capture.audio,
672
+ },
673
+ }
674
+ : null,
675
+ );
676
+ finalized = true;
677
+ return capture;
678
+ } finally {
679
+ if (!finalized) {
680
+ await recording.domRecording?.abort();
681
+ await abortGameplayRecordingSink(recording.sink);
682
+ }
683
+ active = null;
684
+ notifyPreviewListeners();
685
+ releasePreviewFrames(recording);
686
+ recording.overlayCache?.dispose();
687
+ for (const track of recording.stream.getTracks()) track.stop();
688
+ recording.audio?.dispose();
689
+ }
690
+ }
691
+
692
+ export function gameplayRecordingActive(): boolean {
693
+ return active !== null;
694
+ }
695
+
696
+ /** The live recording's start facts, or `null` when nothing is recording. */
697
+ export function activeGameplayRecording(): GameplayRecordingStarted | null {
698
+ return active?.started ?? null;
699
+ }
700
+
701
+ export function recordingTimelineElapsedMs(startedWallMs: number, nowMs: number): number {
702
+ return Math.max(0, Math.floor(nowMs - startedWallMs));
703
+ }
704
+
705
+ /** The live recorder's own monotonic timeline. Finalized `durationMs` is
706
+ * calculated from the same `startedWallMs`; wall-clock timestamps deliberately
707
+ * do not participate because sink setup separates the ISO start stamp from the
708
+ * moment media capture begins. Flooring guarantees any observed position is
709
+ * no later than the subsequently rounded finalized duration. */
710
+ export function activeGameplayRecordingTimeline(): GameplayRecordingTimeline | null {
711
+ const recording = active;
712
+ if (recording === null) return null;
713
+ return {
714
+ startedAt: recording.started.startedAt,
715
+ elapsedMs: recordingTimelineElapsedMs(recording.startedWallMs, performance.now()),
716
+ };
717
+ }