@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,579 @@
1
+ /**
2
+ * Pure data-path logic for the Frame debugger tab and the Profiler's
3
+ * Flamegraph/Memory views (W4b, F11) — no React/DOM, so the whole consumer
4
+ * side of the `RenderDebugAdapter` capabilities plus the flamegraph geometry
5
+ * is headless-testable (`frame-debugger-model.test.ts`). The rendering
6
+ * components (`FrameDebuggerPanel.tsx`, `PerformancePanel.tsx`) own only JSX;
7
+ * capability detection, the bounded capture ring, the draw-call grouping, and
8
+ * every bit of layout math live here (the W3b `network-inspector-model.ts`
9
+ * split, applied to render debugging).
10
+ *
11
+ * HONESTY (adapters never fabricate): a `null` capability map (no adapter)
12
+ * stays `null` — the panel renders the register-an-adapter notice rather than
13
+ * a fabricated all-false map; `readJsHeap` feature-detects the Chromium-only
14
+ * `performance.memory` and returns the REASON string when it is absent, never
15
+ * a zeroed reading. Editor code speaks only the `RenderDebugAdapter` interface
16
+ * and the plain capture/frame shapes — never the WebGL2 instrument itself.
17
+ */
18
+
19
+ import type { PerformanceFrame } from '@volter/game-runtime/dev/performance-profiler';
20
+ import type {
21
+ FrameCapture,
22
+ FrameCaptureDrawCall,
23
+ } from '@volter/game-runtime/dev/webgl-frame-capture';
24
+ import type { RenderDebugAdapter } from '@volter/editor-project/adapter';
25
+
26
+ /** Which optional render-debug capabilities the active adapter provides — the
27
+ * degradation ladder's per-section verdict. `null` input (no adapter = no
28
+ * running session, or a headless mount that registered none) stays `null`:
29
+ * the panel renders the register-an-adapter notice instead of a fabricated
30
+ * all-false map. `captureFrame` is a REQUIRED member, so `capture` is `true`
31
+ * whenever an adapter is present; `memorySnapshot` is the optional-within-
32
+ * optional capability (a mount without a real `renderer.info` omits it). */
33
+ export interface RenderDebugCapabilityMap {
34
+ capture: boolean;
35
+ memory: boolean;
36
+ }
37
+
38
+ export function deriveRenderDebugCapabilities(
39
+ adapter: RenderDebugAdapter | null | undefined,
40
+ ): RenderDebugCapabilityMap | null {
41
+ if (!adapter) return null;
42
+ return {
43
+ capture: typeof adapter.captureFrame === 'function',
44
+ memory: typeof adapter.memorySnapshot === 'function',
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Bounded ring of the last N frame captures (default 10) with a selection.
50
+ * This is a plain LIST of independent single-frame captures — not a replay
51
+ * timeline — so `add` appends and evicts the oldest, and selecting a capture
52
+ * that has been evicted is a no-op (the selection stays on whatever is still
53
+ * held). A fresh capture becomes the selection.
54
+ */
55
+ export class CaptureHistoryModel {
56
+ private captures: FrameCapture[] = [];
57
+ private selectedId: number | null = null;
58
+
59
+ constructor(private readonly capacity = 10) {}
60
+
61
+ add(capture: FrameCapture): void {
62
+ this.captures.push(capture);
63
+ if (this.captures.length > this.capacity) {
64
+ this.captures.splice(0, this.captures.length - this.capacity);
65
+ }
66
+ this.selectedId = capture.id;
67
+ }
68
+
69
+ get all(): readonly FrameCapture[] {
70
+ return this.captures;
71
+ }
72
+
73
+ /** Select an existing capture by id. No-op for an unknown/evicted id (the
74
+ * prior selection is kept — never cleared to a fabricated empty view). */
75
+ select(id: number): void {
76
+ if (this.captures.some((c) => c.id === id)) this.selectedId = id;
77
+ }
78
+
79
+ get selectedIdOrNull(): number | null {
80
+ return this.selectedId;
81
+ }
82
+
83
+ get selected(): FrameCapture | null {
84
+ if (this.selectedId === null) return null;
85
+ return this.captures.find((c) => c.id === this.selectedId) ?? null;
86
+ }
87
+
88
+ clear(): void {
89
+ this.captures = [];
90
+ this.selectedId = null;
91
+ }
92
+ }
93
+
94
+ /** One target's draws in a capture, for the grouped list view. */
95
+ export interface DrawCallGroup {
96
+ /** `'canvas'` for the default framebuffer, else the capture-local
97
+ * framebuffer identity label (`framebuffer#N`). */
98
+ readonly target: string;
99
+ readonly draws: readonly FrameCaptureDrawCall[];
100
+ }
101
+
102
+ /** Group a capture's draws by render target, preserving first-seen order
103
+ * (draws render in list order; a target header appears where its first draw
104
+ * did). */
105
+ export function groupDrawCallsByTarget(capture: FrameCapture): DrawCallGroup[] {
106
+ const groups = new Map<string, FrameCaptureDrawCall[]>();
107
+ const order: string[] = [];
108
+ for (const draw of capture.drawCalls) {
109
+ const key = draw.target.kind === 'canvas' ? 'canvas' : (draw.target.label ?? 'framebuffer');
110
+ let bucket = groups.get(key);
111
+ if (!bucket) {
112
+ bucket = [];
113
+ groups.set(key, bucket);
114
+ order.push(key);
115
+ }
116
+ bucket.push(draw);
117
+ }
118
+ return order.map((target) => ({ target, draws: groups.get(target) ?? [] }));
119
+ }
120
+
121
+ /** One-line rollup of a capture for the history strip. */
122
+ export interface CaptureSummary {
123
+ readonly id: number;
124
+ readonly drawCalls: number;
125
+ readonly unattributed: number;
126
+ readonly truncated: number;
127
+ readonly targets: number;
128
+ }
129
+
130
+ export function summarizeCapture(capture: FrameCapture): CaptureSummary {
131
+ const targets = new Set<string>();
132
+ for (const draw of capture.drawCalls) {
133
+ targets.add(draw.target.kind === 'canvas' ? 'canvas' : (draw.target.label ?? 'framebuffer'));
134
+ }
135
+ return {
136
+ id: capture.id,
137
+ drawCalls: capture.totals.drawCalls,
138
+ unattributed: capture.totals.unattributed,
139
+ truncated: capture.totals.truncated,
140
+ targets: targets.size,
141
+ };
142
+ }
143
+
144
+ // --- Flamegraph geometry ----------------------------------------------------
145
+
146
+ /** One rectangle in the flamegraph: `depth` is the nesting row (0 = frame,
147
+ * 1 = phase, 2 = system, 3 = component), `x`/`w` in pixels within `width`. */
148
+ export interface FlameRect {
149
+ readonly x: number;
150
+ readonly w: number;
151
+ readonly depth: number;
152
+ readonly label: string;
153
+ readonly ms: number;
154
+ }
155
+
156
+ /**
157
+ * Absolutely-positioned rectangles for a frame's flamegraph — pure layout math
158
+ * from the profiler's MEASURED per-span `startMs`/`ms` (never synthesized).
159
+ * Rows by kind: the frame root (depth 0) spans its `cpuMs`; phases (depth 1),
160
+ * systems (depth 2), and — while recording — components (depth 3) sit at their
161
+ * measured start offsets. GPU is never a row: only measured CPU spans exist
162
+ * (the profiler's `gpuMs` is a whole-frame aggregate, drawn as an n/a header,
163
+ * not a fabricated lane). Scaled so the whole frame fits `width`; a system that
164
+ * ran within its phase stays within that phase's x-span by construction.
165
+ */
166
+ export function flamegraphRows(frame: PerformanceFrame, width: number): FlameRect[] {
167
+ let maxEnd = Math.max(0, frame.cpuMs);
168
+ for (const p of frame.phases) maxEnd = Math.max(maxEnd, (p.startMs ?? 0) + p.ms);
169
+ for (const s of frame.systems) maxEnd = Math.max(maxEnd, s.startMs + s.ms);
170
+ for (const c of frame.components) maxEnd = Math.max(maxEnd, c.startMs + c.ms);
171
+ const total = maxEnd > 0 ? maxEnd : 1;
172
+ const scale = width / total;
173
+
174
+ const rect = (startMs: number, ms: number, depth: number, label: string): FlameRect => ({
175
+ x: Math.max(0, startMs * scale),
176
+ w: Math.max(0, ms * scale),
177
+ depth,
178
+ label,
179
+ ms,
180
+ });
181
+
182
+ const rows: FlameRect[] = [rect(0, frame.cpuMs, 0, `frame ${frame.id}`)];
183
+ for (const p of frame.phases) rows.push(rect(p.startMs ?? 0, p.ms, 1, p.name));
184
+ for (const s of frame.systems) rows.push(rect(s.startMs, s.ms, 2, s.name));
185
+ for (const c of frame.components) rows.push(rect(c.startMs, c.ms, 3, c.name));
186
+ return rows;
187
+ }
188
+
189
+ /** One bar in the frame-history selector strip. */
190
+ export interface FrameBar {
191
+ readonly index: number;
192
+ readonly frameId: number;
193
+ readonly x: number;
194
+ readonly w: number;
195
+ readonly h: number;
196
+ readonly cpuMs: number;
197
+ }
198
+
199
+ /**
200
+ * Bars for the frame-selector strip — the `sparklinePoints` idiom as discrete
201
+ * bars: one per recorded frame, height scaled to the tallest `cpuMs` (min 1 so
202
+ * an all-zero series draws flat, not NaN). Empty input → `[]` (nothing yet).
203
+ */
204
+ export function frameHistoryBars(
205
+ frames: readonly PerformanceFrame[],
206
+ width: number,
207
+ height: number,
208
+ ): FrameBar[] {
209
+ if (frames.length === 0) return [];
210
+ const max = Math.max(1, ...frames.map((f) => f.cpuMs));
211
+ const barW = width / frames.length;
212
+ return frames.map((frame, index) => ({
213
+ index,
214
+ frameId: frame.id,
215
+ x: index * barW,
216
+ w: barW,
217
+ h: Math.max(0, (frame.cpuMs / max) * height),
218
+ cpuMs: frame.cpuMs,
219
+ }));
220
+ }
221
+
222
+ // --- JS heap ----------------------------------------------------------------
223
+
224
+ /** Human-readable byte count (`1.50 MB`), for the memory tables/tiles. */
225
+ export function formatBytes(bytes: number): string {
226
+ if (!Number.isFinite(bytes) || bytes < 0) return '—';
227
+ if (bytes < 1024) return `${bytes} B`;
228
+ const units = ['KB', 'MB', 'GB', 'TB'];
229
+ let value = bytes / 1024;
230
+ let unit = 0;
231
+ while (value >= 1024 && unit < units.length - 1) {
232
+ value /= 1024;
233
+ unit++;
234
+ }
235
+ return `${value < 10 ? value.toFixed(2) : value.toFixed(1)} ${units[unit]}`;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Off-loop accounting (the "14fps but the loop measures 1.3ms" story)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * The per-source profiler measures only what runs INSIDE the registered
244
+ * render loop's rAF callback (its `editor`/`render` phases). Everything else
245
+ * that spends the frame budget — compositor/GPU work (post-effects, glass
246
+ * chrome, pixel ratio), React commits, other timers, GC — is invisible to it,
247
+ * so a GPU-bound editor reads as "1ms of CPU at 14fps" with no explanation.
248
+ * These helpers give the Overview an honest account of that gap. Found the
249
+ * hard way (2026-07-31): a full-window glass chain on the center documents
250
+ * group cost ~20ms/frame of pure compositor time that no phase could see.
251
+ */
252
+ export interface OffLoopAccount {
253
+ /** Representative frame interval (p95), ms. */
254
+ readonly frameMs: number;
255
+ /** frameMs minus the loop's measured CPU — time the profiler cannot see. */
256
+ readonly gapMs: number;
257
+ /**
258
+ * True when the frame rate is actually low AND the measured loop explains
259
+ * less than half of it — the exact situation where the phase table is
260
+ * misleading without a callout. Never true at healthy frame rates: at
261
+ * 60fps the "gap" is just vsync idle, not a problem to report.
262
+ */
263
+ readonly dominated: boolean;
264
+ }
265
+
266
+ export function offLoopAccount(p95FrameMs: number, cpuMs: number): OffLoopAccount {
267
+ const frameMs = Math.max(0, p95FrameMs);
268
+ const gapMs = Math.max(0, frameMs - Math.max(0, cpuMs));
269
+ return { frameMs, gapMs, dominated: frameMs > 20 && gapMs > frameMs / 2 };
270
+ }
271
+
272
+ /** Structural shapes for `long-animation-frame` entries (not yet in TS lib). */
273
+ export interface LongAnimationFrameScriptLike {
274
+ readonly duration: number;
275
+ readonly invoker?: string;
276
+ readonly sourceURL?: string;
277
+ readonly sourceFunctionName?: string;
278
+ }
279
+ export interface LongAnimationFrameEntryLike {
280
+ readonly startTime?: number;
281
+ readonly duration: number;
282
+ readonly scripts?: readonly LongAnimationFrameScriptLike[];
283
+ }
284
+
285
+ export interface LoafScriptRow {
286
+ /** `function @ tail-of-source-url` — stable, human-readable aggregation key. */
287
+ readonly key: string;
288
+ readonly totalMs: number;
289
+ readonly count: number;
290
+ }
291
+
292
+ export interface LoafSummary {
293
+ readonly longFrameCount: number;
294
+ readonly totalLongMs: number;
295
+ /** Long-frame time no script accounts for: style/layout, GC, engine work. */
296
+ readonly unattributedMs: number;
297
+ /** Script rows, largest total first. */
298
+ readonly scripts: readonly LoafScriptRow[];
299
+ }
300
+
301
+ /**
302
+ * Aggregate the browser's own long-animation-frame report (frames >50ms of
303
+ * main-thread work, WITH script attribution) into top offenders. This is the
304
+ * half of the off-loop gap the main thread can name; a large gap with an
305
+ * EMPTY summary is the other half — GPU/compositor — and the panel says so
306
+ * rather than showing nothing.
307
+ */
308
+ export function aggregateLongAnimationFrames(
309
+ entries: readonly LongAnimationFrameEntryLike[],
310
+ ): LoafSummary {
311
+ const rows = new Map<string, { totalMs: number; count: number }>();
312
+ let totalLongMs = 0;
313
+ let attributedMs = 0;
314
+ for (const entry of entries) {
315
+ totalLongMs += entry.duration;
316
+ for (const script of entry.scripts ?? []) {
317
+ attributedMs += script.duration;
318
+ const source = script.sourceURL ? script.sourceURL.split('/').slice(-2).join('/') : '';
319
+ const name = script.sourceFunctionName || script.invoker || '(anonymous)';
320
+ const key = source ? `${name} @ ${source}` : name;
321
+ const row = rows.get(key) ?? { totalMs: 0, count: 0 };
322
+ row.totalMs += script.duration;
323
+ row.count += 1;
324
+ rows.set(key, row);
325
+ }
326
+ }
327
+ return {
328
+ longFrameCount: entries.length,
329
+ totalLongMs: Math.round(totalLongMs),
330
+ unattributedMs: Math.round(Math.max(0, totalLongMs - attributedMs)),
331
+ scripts: [...rows.entries()]
332
+ .map(([key, row]) => ({ key, totalMs: Math.round(row.totalMs), count: row.count }))
333
+ .sort((a, b) => b.totalMs - a.totalMs)
334
+ .slice(0, 8),
335
+ };
336
+ }
337
+
338
+ /**
339
+ * The full per-frame accounting the Overview renders as "Where the frame
340
+ * goes" — the identity is `frameMs = loopMs + otherMainMs + pipelineMs`, so
341
+ * the breakdown always ADDS UP to the observed frame interval instead of
342
+ * leaving a mystery between "1ms of phases" and "14fps".
343
+ *
344
+ * - `loopMs` — measured: the profiled loop's own rAF callback CPU.
345
+ * - `otherMainMs` — measured (busy sampler, `main-thread-busy.ts`) minus the
346
+ * loop's share; `null` when the sampler has not covered the window, never
347
+ * a fabricated 0.
348
+ * - `pipelineMs` — the arithmetic remainder: GPU + compositor (post-effects,
349
+ * glass chrome) + waiting for vsync. Not directly measurable from JS —
350
+ * `gpuDrawMs` (timer queries, when the context supports them) names the
351
+ * loop's own share of it.
352
+ */
353
+ export interface FrameBudget {
354
+ readonly frames: number;
355
+ readonly fps: number;
356
+ readonly frameMs: number;
357
+ readonly loopMs: number;
358
+ readonly otherMainMs: number | null;
359
+ readonly pipelineMs: number;
360
+ /** Mean resolved GPU time of the loop's own draw; null when unmeasured. */
361
+ readonly gpuDrawMs: number | null;
362
+ }
363
+
364
+ /** Below this many frames (or busy samples) the window is noise, not data. */
365
+ const BUDGET_MIN_FRAMES = 5;
366
+ const BUDGET_MIN_BUSY_SAMPLES = 20;
367
+
368
+ export function frameBudget(
369
+ frames: readonly PerformanceFrame[],
370
+ windowStartMs: number,
371
+ busy: { busyMs: number; sampleCount: number } | null,
372
+ ): FrameBudget | null {
373
+ const windowed = frames.filter(
374
+ (frame) => frame.timestamp >= windowStartMs && frame.intervalMs > 0,
375
+ );
376
+ if (windowed.length < BUDGET_MIN_FRAMES) return null;
377
+ let sumInterval = 0;
378
+ let sumCpu = 0;
379
+ let sumGpu = 0;
380
+ let gpuCount = 0;
381
+ for (const frame of windowed) {
382
+ sumInterval += frame.intervalMs;
383
+ sumCpu += frame.cpuMs;
384
+ if (frame.render.gpuMs !== null) {
385
+ sumGpu += frame.render.gpuMs;
386
+ gpuCount++;
387
+ }
388
+ }
389
+ const count = windowed.length;
390
+ const frameMs = sumInterval / count;
391
+ const loopMs = sumCpu / count;
392
+ const otherMainMs =
393
+ busy !== null && busy.sampleCount >= BUDGET_MIN_BUSY_SAMPLES
394
+ ? Math.max(0, busy.busyMs - sumCpu) / count
395
+ : null;
396
+ return {
397
+ frames: count,
398
+ fps: frameMs > 0 ? 1000 / frameMs : 0,
399
+ frameMs,
400
+ loopMs,
401
+ otherMainMs,
402
+ pipelineMs: Math.max(0, frameMs - loopMs - (otherMainMs ?? 0)),
403
+ gpuDrawMs: gpuCount > 0 ? sumGpu / gpuCount : null,
404
+ };
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // The hierarchical "time spent" tree (owner direction, 2026-07-31)
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /**
412
+ * One row of the Overview's structural time-spent breakdown. Every row is a
413
+ * MEAN MS PER FRAME over the same window, so children sum to their parent
414
+ * and depth-1 rows sum to the frame interval — one tree, one denominator,
415
+ * replacing the flat budget table + latest-frame phase table + standalone
416
+ * systems table that each answered with a different unit.
417
+ *
418
+ * `parallel` marks an overlapping measure (the GPU draw executes while the
419
+ * CPU rows run) — rendered as an annotation, excluded from sum checking.
420
+ * `ms: null` is honest absence (e.g. the busy sampler has no coverage yet).
421
+ */
422
+ export interface TimeSpentRow {
423
+ readonly label: string;
424
+ readonly depth: number;
425
+ readonly ms: number | null;
426
+ readonly note?: string;
427
+ readonly parallel?: boolean;
428
+ }
429
+
430
+ /** Remainder rows below this are noise, not information. */
431
+ const TIME_SPENT_EPSILON_MS = 0.05;
432
+ const MAX_SYSTEMS_PER_PHASE = 3;
433
+ const MAX_SCRIPT_ROWS = 4;
434
+
435
+ function windowedFrames(
436
+ frames: readonly PerformanceFrame[],
437
+ windowStartMs: number,
438
+ ): PerformanceFrame[] {
439
+ return frames.filter((frame) => frame.timestamp >= windowStartMs && frame.intervalMs > 0);
440
+ }
441
+
442
+ function meanByKey(
443
+ entries: readonly (readonly [string, number])[],
444
+ count: number,
445
+ ): Map<string, number> {
446
+ const sums = new Map<string, number>();
447
+ for (const [key, ms] of entries) sums.set(key, (sums.get(key) ?? 0) + ms);
448
+ const means = new Map<string, number>();
449
+ for (const [key, sum] of sums) means.set(key, sum / count);
450
+ return means;
451
+ }
452
+
453
+ function phaseRows(windowed: readonly PerformanceFrame[], loopMs: number): TimeSpentRow[] {
454
+ const count = windowed.length;
455
+ const phases = meanByKey(
456
+ windowed.flatMap((frame) => frame.phases.map((p) => [p.name, p.ms] as const)),
457
+ count,
458
+ );
459
+ const systems = meanByKey(
460
+ windowed.flatMap((frame) => frame.systems.map((s) => [`${s.phase} ${s.name}`, s.ms] as const)),
461
+ count,
462
+ );
463
+ const rows: TimeSpentRow[] = [];
464
+ let phaseSum = 0;
465
+ for (const [name, ms] of [...phases.entries()].sort((a, b) => b[1] - a[1])) {
466
+ phaseSum += ms;
467
+ rows.push({ label: name, depth: 2, ms });
468
+ const inPhase = [...systems.entries()]
469
+ .filter(([key]) => key.startsWith(`${name} `))
470
+ .map(([key, sysMs]) => {
471
+ // Span names are `scope / phase / name` — the tree already says the
472
+ // phase, so show the tail.
473
+ const spanName = key.slice(name.length + 1);
474
+ return [spanName.split(' / ').at(-1) ?? spanName, sysMs] as const;
475
+ })
476
+ .sort((a, b) => b[1] - a[1]);
477
+ for (const [sysName, sysMs] of inPhase.slice(0, MAX_SYSTEMS_PER_PHASE)) {
478
+ rows.push({ label: sysName, depth: 3, ms: sysMs });
479
+ }
480
+ const rest = inPhase.slice(MAX_SYSTEMS_PER_PHASE);
481
+ if (rest.length > 0) {
482
+ rows.push({
483
+ label: `${rest.length} more system${rest.length === 1 ? '' : 's'}`,
484
+ depth: 3,
485
+ ms: rest.reduce((sum, [, sysMs]) => sum + sysMs, 0),
486
+ });
487
+ }
488
+ }
489
+ const unphased = loopMs - phaseSum;
490
+ if (unphased > TIME_SPENT_EPSILON_MS) {
491
+ rows.push({ label: 'outside the phases', depth: 2, ms: unphased });
492
+ }
493
+ return rows;
494
+ }
495
+
496
+ function otherMainRows(
497
+ otherMainMs: number | null,
498
+ loafEntries: readonly LongAnimationFrameEntryLike[],
499
+ windowStartMs: number,
500
+ frameCount: number,
501
+ ): TimeSpentRow[] {
502
+ if (otherMainMs === null) return [];
503
+ const loaf = aggregateLongAnimationFrames(
504
+ loafEntries.filter((entry) => (entry.startTime ?? 0) >= windowStartMs),
505
+ );
506
+ const rows: TimeSpentRow[] = [];
507
+ let attributed = 0;
508
+ for (const script of loaf.scripts.slice(0, MAX_SCRIPT_ROWS)) {
509
+ const ms = Math.min(script.totalMs / frameCount, otherMainMs - attributed);
510
+ if (ms <= TIME_SPENT_EPSILON_MS) continue;
511
+ attributed += ms;
512
+ rows.push({ label: script.key, depth: 2, ms, note: 'named by the browser (long frames)' });
513
+ }
514
+ const unattributed = otherMainMs - attributed;
515
+ if (unattributed > TIME_SPENT_EPSILON_MS || rows.length === 0) {
516
+ rows.push({
517
+ label: 'unattributed',
518
+ depth: 2,
519
+ ms: Math.max(0, unattributed),
520
+ note: "below the browser's 50ms attribution threshold",
521
+ });
522
+ }
523
+ return rows;
524
+ }
525
+
526
+ /**
527
+ * Build the whole tree, or null while the window lacks data. Invariants
528
+ * (unit-tested): depth-1 non-parallel rows sum to the frame interval; each
529
+ * level's non-parallel children sum to their parent (within epsilon).
530
+ */
531
+ export function timeSpentRows(
532
+ frames: readonly PerformanceFrame[],
533
+ windowStartMs: number,
534
+ busy: { busyMs: number; sampleCount: number } | null,
535
+ loafEntries: readonly LongAnimationFrameEntryLike[],
536
+ ): { rows: TimeSpentRow[]; budget: FrameBudget } | null {
537
+ const budget = frameBudget(frames, windowStartMs, busy);
538
+ if (budget === null) return null;
539
+ const windowed = windowedFrames(frames, windowStartMs);
540
+ const rows: TimeSpentRow[] = [
541
+ {
542
+ label: 'Frame interval',
543
+ depth: 0,
544
+ ms: budget.frameMs,
545
+ note: `${budget.frames} frames at ${budget.fps.toFixed(0)} fps`,
546
+ },
547
+ { label: 'Scene loop (CPU)', depth: 1, ms: budget.loopMs },
548
+ ...phaseRows(windowed, budget.loopMs),
549
+ {
550
+ label: 'Other main thread',
551
+ depth: 1,
552
+ ms: budget.otherMainMs,
553
+ note: budget.otherMainMs === null ? 'not sampled yet' : 'React, timers, observers',
554
+ },
555
+ ...otherMainRows(budget.otherMainMs, loafEntries, windowStartMs, budget.frames),
556
+ {
557
+ label: 'Display pipeline',
558
+ depth: 1,
559
+ ms: budget.pipelineMs,
560
+ note: 'everything after the main thread — the remainder',
561
+ },
562
+ ];
563
+ if (budget.gpuDrawMs !== null) {
564
+ rows.push({
565
+ label: 'viewport GPU draw',
566
+ depth: 2,
567
+ ms: budget.gpuDrawMs,
568
+ note: 'measured; runs in parallel with the CPU rows',
569
+ parallel: true,
570
+ });
571
+ rows.push({
572
+ label: 'compositor + vsync wait',
573
+ depth: 2,
574
+ ms: Math.max(0, budget.pipelineMs - budget.gpuDrawMs),
575
+ note: 'post-effects, glass chrome, waiting for the next frame slot',
576
+ });
577
+ }
578
+ return { rows, budget };
579
+ }
@@ -0,0 +1,74 @@
1
+ import { meterPercent } from '@volter/game-runtime/adapter/audio-meter';
2
+ import type { PerformanceFrame } from '@volter/game-runtime/dev/performance-profiler';
3
+
4
+ export const AUDIO_METER_SEGMENTS = 8;
5
+ export const PERFORMANCE_SPARKLINE_SAMPLES = 24;
6
+ export const FRAME_BUDGET_60_FPS_MS = 1000 / 60;
7
+
8
+ export type PerformanceTelemetryTone = 'normal' | 'warning' | 'danger';
9
+
10
+ /** Discrete VU fill for the header: instantaneous magnitude, not history. */
11
+ export function audioMeterSegmentCount(level: number, segments = AUDIO_METER_SEGMENTS): number {
12
+ if (!(level > 0) || segments <= 0) return 0;
13
+ return Math.min(segments, Math.max(1, Math.round((meterPercent(level) / 100) * segments)));
14
+ }
15
+
16
+ export interface FrameIntervalSample {
17
+ /** Newest frame consumed, even when no valid interval was available. */
18
+ readonly frameId: number;
19
+ /** P95 interval across the fresh frames, preserving spikes without plotting
20
+ * every noisy render tick. Null means the batch had no measured interval. */
21
+ readonly intervalMs: number | null;
22
+ }
23
+
24
+ /** Collapse every frame after `afterFrameId` into one stable history point.
25
+ * The header calls this on a slow cadence; the full profiler retains raw
26
+ * per-frame history for detailed inspection. */
27
+ export function sampleFrameInterval(
28
+ frames: readonly PerformanceFrame[],
29
+ afterFrameId: number,
30
+ ): FrameIntervalSample {
31
+ const fresh = frames.filter((frame) => frame.id > afterFrameId);
32
+ const frameId = fresh.at(-1)?.id ?? afterFrameId;
33
+ const intervals = fresh
34
+ .map((frame) => frame.intervalMs)
35
+ .filter((interval) => Number.isFinite(interval) && interval > 0)
36
+ .sort((a, b) => a - b);
37
+ if (intervals.length === 0) return { frameId, intervalMs: null };
38
+ const index = Math.floor((intervals.length - 1) * 0.95);
39
+ return { frameId, intervalMs: intervals[index] ?? null };
40
+ }
41
+
42
+ /** Alert only on a sustained trend. A single hitch remains visible as a spike
43
+ * but does not flash the whole instrument; three consecutive samples over the
44
+ * 60 FPS budget warn, and two severe half-second samples mark danger. */
45
+ export function performanceTelemetryTone(intervals: readonly number[]): PerformanceTelemetryTone {
46
+ const recent = intervals.slice(-3);
47
+ if (recent.slice(-2).length === 2 && recent.slice(-2).every((value) => value >= 50)) {
48
+ return 'danger';
49
+ }
50
+ if (recent.length === 3 && recent.every((value) => value > FRAME_BUDGET_60_FPS_MS * 1.05)) {
51
+ return 'warning';
52
+ }
53
+ return 'normal';
54
+ }
55
+
56
+ /** SVG point string for a time-series sparkline. Unlike the segmented audio
57
+ * meter this preserves ordering: slow-frame spikes move across the strip. */
58
+ export function frameSparklinePoints(
59
+ intervals: readonly number[],
60
+ width: number,
61
+ height: number,
62
+ ceilingMs = 50,
63
+ ): string {
64
+ if (intervals.length === 0 || width <= 0 || height <= 0) return '';
65
+ const denominator = Math.max(1, intervals.length - 1);
66
+ return intervals
67
+ .map((interval, index) => {
68
+ const x = (index / denominator) * width;
69
+ const normalized = Math.min(1, Math.max(0, interval / ceilingMs));
70
+ const y = height - normalized * height;
71
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
72
+ })
73
+ .join(' ');
74
+ }