@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.
- package/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- 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
|
+
}
|