@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,288 @@
|
|
|
1
|
+
import { pcmStats } from '@volter/game-runtime/audio/wav-encode';
|
|
2
|
+
import type { OfflineAudioRenderer } from '@volter/editor-project/adapter';
|
|
3
|
+
import {
|
|
4
|
+
AudioBufferSource,
|
|
5
|
+
BufferTarget,
|
|
6
|
+
CanvasSource,
|
|
7
|
+
canEncodeAudio,
|
|
8
|
+
canEncodeVideo,
|
|
9
|
+
Output,
|
|
10
|
+
Quality,
|
|
11
|
+
WebMOutputFormat,
|
|
12
|
+
} from 'mediabunny';
|
|
13
|
+
import type { CaptureOptions } from '@volter/editor-core/composite-screenshot';
|
|
14
|
+
import { createImageSnapshotCache, drawPlayCompositeFrame } from '@volter/editor-core/composite-screenshot';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opus in the SAME WebM `Output` the video track already uses — the whole
|
|
18
|
+
* audio decision of this module, written down because the alternative is what
|
|
19
|
+
* goes wrong.
|
|
20
|
+
*
|
|
21
|
+
* The export's container is produced by mediabunny (`Output` +
|
|
22
|
+
* `WebMOutputFormat` + `CanvasSource`). Audio therefore goes in as a second
|
|
23
|
+
* TRACK on that same `Output`, via mediabunny's own `AudioBufferSource`.
|
|
24
|
+
* There is no ffmpeg step, no second muxer, and no WAV file on the way: a
|
|
25
|
+
* second container pipeline beside the first is how one export's timing,
|
|
26
|
+
* metadata and codec support drift away from the other's.
|
|
27
|
+
*
|
|
28
|
+
* Consequently `audio/wav-encode.ts` is NOT used to make the track.
|
|
29
|
+
* `encodeWav16` exists for the one place that genuinely needs a file on a
|
|
30
|
+
* wire — `runtime/render-audio-control.ts` base64s a complete WAV across
|
|
31
|
+
* `page.evaluate` to a Node capture driver. Here the `AudioBuffer` and the
|
|
32
|
+
* muxer are in the same JS heap, so encoding to 16-bit WAV and handing the
|
|
33
|
+
* bytes back to an encoder would quantize the samples for no reason at all.
|
|
34
|
+
* `pcmStats` from that module IS used, for the non-silence evidence in the
|
|
35
|
+
* result.
|
|
36
|
+
*
|
|
37
|
+
* Opus rather than a PCM track (Matroska can carry `A_PCM/INT/LIT`) because
|
|
38
|
+
* WebM's normal audio codec is what every player and every `ffprobe` build
|
|
39
|
+
* handles without argument, and because a PCM track would dominate the file
|
|
40
|
+
* size of a five-minute clip. libopus is deterministic for identical input
|
|
41
|
+
* and configuration, which is what the fixed-step promise below needs.
|
|
42
|
+
*/
|
|
43
|
+
const AUDIO_CODEC = 'opus' as const;
|
|
44
|
+
const AUDIO_BITRATE = 128_000;
|
|
45
|
+
|
|
46
|
+
export interface GameplayExportOptions {
|
|
47
|
+
/** Number of output frames, including the current paused frame. */
|
|
48
|
+
frames: number;
|
|
49
|
+
/** Must divide the engine's 60 Hz stepping cadence. */
|
|
50
|
+
fps?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateGameplayExport(options: GameplayExportOptions): number {
|
|
54
|
+
const fps = options.fps ?? 30;
|
|
55
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 60 || 60 % fps !== 0) {
|
|
56
|
+
throw new Error('Video export FPS must be a positive divisor of 60.');
|
|
57
|
+
}
|
|
58
|
+
if (!Number.isSafeInteger(options.frames) || options.frames < 1) {
|
|
59
|
+
throw new Error('Video export frames must be a positive safe integer.');
|
|
60
|
+
}
|
|
61
|
+
if (options.frames / fps > 300)
|
|
62
|
+
throw new Error('Video export is limited to five minutes per file.');
|
|
63
|
+
return fps;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function assertGameplayVideoSupport(container: HTMLElement): Promise<void> {
|
|
67
|
+
if (
|
|
68
|
+
!(await canEncodeVideo('vp9', {
|
|
69
|
+
width: container.clientWidth,
|
|
70
|
+
height: container.clientHeight,
|
|
71
|
+
}))
|
|
72
|
+
) {
|
|
73
|
+
throw new Error('This browser cannot encode VP9 video at the game resolution.');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the exported file's audio track actually is, when there is one.
|
|
78
|
+
* `rms`/`peak` are measured over the raw float PCM before encoding, so a
|
|
79
|
+
* caller can tell "a real score" from "a muxed-in silent track" without
|
|
80
|
+
* decoding the WebM it just received. */
|
|
81
|
+
export interface GameplayExportAudio {
|
|
82
|
+
readonly codec: typeof AUDIO_CODEC;
|
|
83
|
+
readonly sampleRate: number;
|
|
84
|
+
readonly channels: number;
|
|
85
|
+
readonly durationSeconds: number;
|
|
86
|
+
readonly rms: number;
|
|
87
|
+
readonly peak: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface GameplayExportResult {
|
|
91
|
+
blob: Blob;
|
|
92
|
+
frames: number;
|
|
93
|
+
fps: number;
|
|
94
|
+
durationMs: number;
|
|
95
|
+
wallMs: number;
|
|
96
|
+
width: number;
|
|
97
|
+
height: number;
|
|
98
|
+
/** `false` when the world supplied no offline audio renderer — the file is
|
|
99
|
+
* genuinely video-only. This is a RUNTIME value, never a literal: the type
|
|
100
|
+
* has to be able to say "there is a track" for the runs where there is
|
|
101
|
+
* one. */
|
|
102
|
+
audio: GameplayExportAudio | false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How the export gets sound: the world's own audio for the ABSOLUTE sim
|
|
106
|
+
* window the frame walk covers, rendered offline.
|
|
107
|
+
*
|
|
108
|
+
* THE OFFSET TRAP, restated at the call site because it is the correctness
|
|
109
|
+
* argument of this whole path (`runtime/render-audio-control.ts` states it
|
|
110
|
+
* first): an offline render of `[10, 10.72)` schedules its first event at
|
|
111
|
+
* LOCAL time 0, not absolute 10. `startSeconds` is where the paused run
|
|
112
|
+
* stands on its own canonical clock when frame 0 is captured; `render` is
|
|
113
|
+
* asked for `[startSeconds, startSeconds + frames / fps)` and must subtract
|
|
114
|
+
* `startSeconds` itself, because the buffer it returns is placed at
|
|
115
|
+
* timestamp 0 of the clip — the same instant frame 0 shows.
|
|
116
|
+
*
|
|
117
|
+
* Real-time capture (`AudioAdapter.acquireRecordingStream`) is deliberately
|
|
118
|
+
* NOT an option here. A paused run being stepped emits no real-time audio,
|
|
119
|
+
* and a wall-clock tap would make two exports of the same window differ. */
|
|
120
|
+
export interface GameplayExportAudioSource {
|
|
121
|
+
readonly startSeconds: number;
|
|
122
|
+
readonly render: OfflineAudioRenderer;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Render the export window's audio and describe the track it will become.
|
|
126
|
+
* Split out of `exportGameplayVideo` so the frame walk stays readable; it
|
|
127
|
+
* runs before any mediabunny object exists. */
|
|
128
|
+
async function renderExportAudio(
|
|
129
|
+
audio: GameplayExportAudioSource,
|
|
130
|
+
durationSeconds: number,
|
|
131
|
+
fps: number,
|
|
132
|
+
): Promise<{ buffer: AudioBuffer; track: GameplayExportAudio }> {
|
|
133
|
+
const { buffer } = await audio.render(audio.startSeconds, audio.startSeconds + durationSeconds);
|
|
134
|
+
// The muxed length is the BUFFER's, not the renderer's self-report, so that
|
|
135
|
+
// is what gets checked against the video's. A track that drifts past one
|
|
136
|
+
// frame is a broken export, not a tolerance to absorb.
|
|
137
|
+
const bufferSeconds = buffer.length / buffer.sampleRate;
|
|
138
|
+
if (Math.abs(bufferSeconds - durationSeconds) > 1 / fps) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Offline audio render returned ${bufferSeconds.toFixed(4)}s for a ` +
|
|
141
|
+
`${durationSeconds.toFixed(4)}s export window (tolerance one frame, ` +
|
|
142
|
+
`${(1 / fps).toFixed(4)}s).`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
!(await canEncodeAudio(AUDIO_CODEC, {
|
|
147
|
+
numberOfChannels: buffer.numberOfChannels,
|
|
148
|
+
sampleRate: buffer.sampleRate,
|
|
149
|
+
}))
|
|
150
|
+
) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`This browser cannot encode ${AUDIO_CODEC} at ${buffer.sampleRate} Hz, ` +
|
|
153
|
+
`${buffer.numberOfChannels} channel(s).`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const channelData: Float32Array[] = [];
|
|
157
|
+
for (let ch = 0; ch < buffer.numberOfChannels; ch++) channelData.push(buffer.getChannelData(ch));
|
|
158
|
+
const stats = pcmStats(channelData);
|
|
159
|
+
return {
|
|
160
|
+
buffer,
|
|
161
|
+
track: {
|
|
162
|
+
codec: AUDIO_CODEC,
|
|
163
|
+
sampleRate: buffer.sampleRate,
|
|
164
|
+
channels: buffer.numberOfChannels,
|
|
165
|
+
durationSeconds: bufferSeconds,
|
|
166
|
+
rms: stats.rms,
|
|
167
|
+
peak: stats.peak,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Browser-only export. The caller owns pause, stepping and saving the
|
|
173
|
+
* returned file. Encoding backpressure changes wall time, never media time —
|
|
174
|
+
* and that holds for the audio too, which is rendered from the SIM clock, not
|
|
175
|
+
* captured from the speakers. */
|
|
176
|
+
export async function exportGameplayVideo(
|
|
177
|
+
container: HTMLElement,
|
|
178
|
+
options: GameplayExportOptions,
|
|
179
|
+
control: {
|
|
180
|
+
step(): void;
|
|
181
|
+
assertActive(): void;
|
|
182
|
+
canvasFrame?: CaptureOptions['canvasFrame'];
|
|
183
|
+
signal?: AbortSignal;
|
|
184
|
+
audio?: GameplayExportAudioSource;
|
|
185
|
+
},
|
|
186
|
+
): Promise<GameplayExportResult> {
|
|
187
|
+
const fps = validateGameplayExport(options);
|
|
188
|
+
const started = performance.now();
|
|
189
|
+
const canvas = container.ownerDocument.createElement('canvas');
|
|
190
|
+
canvas.width = container.clientWidth;
|
|
191
|
+
canvas.height = container.clientHeight;
|
|
192
|
+
if (canvas.width < 1 || canvas.height < 1)
|
|
193
|
+
throw new Error('Game surface has no exportable size.');
|
|
194
|
+
const width = canvas.width;
|
|
195
|
+
const height = canvas.height;
|
|
196
|
+
const durationSeconds = options.frames / fps;
|
|
197
|
+
|
|
198
|
+
// Audio is rendered BEFORE the output exists: mediabunny takes every track
|
|
199
|
+
// before `start()`, and the offline render needs no output at all. It also
|
|
200
|
+
// must happen before the frame walk, because the walk advances the paused
|
|
201
|
+
// run past `startSeconds`.
|
|
202
|
+
const prepared = control.audio
|
|
203
|
+
? await renderExportAudio(control.audio, durationSeconds, fps)
|
|
204
|
+
: null;
|
|
205
|
+
control.signal?.throwIfAborted();
|
|
206
|
+
control.assertActive();
|
|
207
|
+
let audioSource: AudioBufferSource | null = null;
|
|
208
|
+
|
|
209
|
+
const target = new BufferTarget();
|
|
210
|
+
const output = new Output({ target, format: new WebMOutputFormat() });
|
|
211
|
+
const source = new CanvasSource(canvas, {
|
|
212
|
+
codec: 'vp9',
|
|
213
|
+
quality: new Quality({ bitrate: 3_000_000 }),
|
|
214
|
+
});
|
|
215
|
+
output.addVideoTrack(source, { frameRate: fps });
|
|
216
|
+
if (prepared) {
|
|
217
|
+
// `startTimestamp` defaults to 0 and is left there deliberately: the
|
|
218
|
+
// buffer is the sim window starting at the instant frame 0 shows, so
|
|
219
|
+
// local 0 is exactly where it belongs (see GameplayExportAudioSource).
|
|
220
|
+
audioSource = new AudioBufferSource({
|
|
221
|
+
codec: AUDIO_CODEC,
|
|
222
|
+
quality: new Quality({ bitrate: AUDIO_BITRATE }),
|
|
223
|
+
});
|
|
224
|
+
output.addAudioTrack(audioSource);
|
|
225
|
+
}
|
|
226
|
+
const snapshots = createImageSnapshotCache();
|
|
227
|
+
let cancellation: Promise<void> | null = null;
|
|
228
|
+
const abort = () => {
|
|
229
|
+
cancellation ??= output.cancel();
|
|
230
|
+
};
|
|
231
|
+
control.signal?.addEventListener('abort', abort, { once: true });
|
|
232
|
+
try {
|
|
233
|
+
control.signal?.throwIfAborted();
|
|
234
|
+
await output.start();
|
|
235
|
+
if (audioSource && prepared) {
|
|
236
|
+
// The whole window in one `add` — it is already fully rendered, and
|
|
237
|
+
// audio is small beside VP9, so writing it first lets the muxer
|
|
238
|
+
// interleave against a track it never has to wait on.
|
|
239
|
+
await audioSource.add(prepared.buffer);
|
|
240
|
+
audioSource.close();
|
|
241
|
+
control.signal?.throwIfAborted();
|
|
242
|
+
}
|
|
243
|
+
for (let frame = 0; frame < options.frames; frame++) {
|
|
244
|
+
control.signal?.throwIfAborted();
|
|
245
|
+
control.assertActive();
|
|
246
|
+
if (!container.isConnected) throw new Error('Game surface was removed during video export.');
|
|
247
|
+
if (container.clientWidth !== width || container.clientHeight !== height) {
|
|
248
|
+
throw new Error('Game surface resized during video export.');
|
|
249
|
+
}
|
|
250
|
+
if (frame > 0) for (let tick = 0; tick < 60 / fps; tick++) control.step();
|
|
251
|
+
// No wall-time overlay cache: every exported frame needs its own current
|
|
252
|
+
// HUD. The caller's step commits React before this snapshot is taken.
|
|
253
|
+
await drawPlayCompositeFrame(container, canvas, {
|
|
254
|
+
size: { width, height },
|
|
255
|
+
snapshots,
|
|
256
|
+
canvasFrame: control.canvasFrame,
|
|
257
|
+
});
|
|
258
|
+
control.signal?.throwIfAborted();
|
|
259
|
+
control.assertActive();
|
|
260
|
+
if (container.clientWidth !== width || container.clientHeight !== height) {
|
|
261
|
+
throw new Error('Game surface resized during video export.');
|
|
262
|
+
}
|
|
263
|
+
await source.add(frame / fps, 1 / fps, { keyFrame: frame % (fps * 2) === 0 });
|
|
264
|
+
}
|
|
265
|
+
control.assertActive();
|
|
266
|
+
control.signal?.throwIfAborted();
|
|
267
|
+
await output.finalize();
|
|
268
|
+
control.signal?.throwIfAborted();
|
|
269
|
+
control.assertActive();
|
|
270
|
+
if (!target.buffer) throw new Error('Video encoder produced no output.');
|
|
271
|
+
return {
|
|
272
|
+
blob: new Blob([target.buffer], { type: 'video/webm' }),
|
|
273
|
+
frames: options.frames,
|
|
274
|
+
fps,
|
|
275
|
+
durationMs: (options.frames * 1000) / fps,
|
|
276
|
+
wallMs: performance.now() - started,
|
|
277
|
+
width,
|
|
278
|
+
height,
|
|
279
|
+
audio: prepared?.track ?? false,
|
|
280
|
+
};
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (cancellation) await cancellation;
|
|
283
|
+
else if (output.state !== 'finalized') await output.cancel();
|
|
284
|
+
throw error;
|
|
285
|
+
} finally {
|
|
286
|
+
control.signal?.removeEventListener('abort', abort);
|
|
287
|
+
}
|
|
288
|
+
}
|