mujoco-react 9.1.0 → 9.3.0

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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * Offscreen camera-frame capture for R3F/MuJoCo scenes.
6
+ */
7
+
8
+ import * as THREE from 'three';
9
+ import type {
10
+ CameraFrameCaptureBlobResult,
11
+ CameraFrameCaptureOptions,
12
+ CameraFrameCaptureResult,
13
+ CameraFrameCaptureSource,
14
+ CameraFrameCaptureVector3,
15
+ } from '../types';
16
+
17
+ export interface CameraFrameCaptureSession {
18
+ readonly width: number;
19
+ readonly height: number;
20
+ capture(options?: CameraFrameCaptureOptions): {
21
+ canvas: HTMLCanvasElement;
22
+ camera: THREE.Camera;
23
+ width: number;
24
+ height: number;
25
+ source: CameraFrameCaptureSource;
26
+ };
27
+ captureDataUrl(options?: CameraFrameCaptureOptions): CameraFrameCaptureResult;
28
+ captureBlob(options?: CameraFrameCaptureOptions): Promise<CameraFrameCaptureBlobResult>;
29
+ dispose(): void;
30
+ }
31
+
32
+ function toVector3(
33
+ value: CameraFrameCaptureVector3 | undefined,
34
+ fallback: THREE.Vector3
35
+ ): THREE.Vector3 {
36
+ if (!value) return fallback.clone();
37
+ return value instanceof THREE.Vector3
38
+ ? value.clone()
39
+ : new THREE.Vector3(value[0], value[1], value[2]);
40
+ }
41
+
42
+ function applyCameraPose(
43
+ camera: THREE.Camera,
44
+ options: CameraFrameCaptureOptions,
45
+ fallbackCamera: THREE.Camera
46
+ ) {
47
+ camera.position.copy(toVector3(options.position, fallbackCamera.position));
48
+ camera.up.copy(toVector3(options.up, fallbackCamera.up));
49
+
50
+ if (options.quaternion) {
51
+ if (options.quaternion instanceof THREE.Quaternion) {
52
+ camera.quaternion.copy(options.quaternion);
53
+ } else {
54
+ camera.quaternion.set(
55
+ options.quaternion[0],
56
+ options.quaternion[1],
57
+ options.quaternion[2],
58
+ options.quaternion[3]
59
+ );
60
+ }
61
+ } else if (options.lookAt) {
62
+ camera.lookAt(toVector3(options.lookAt, new THREE.Vector3()));
63
+ } else {
64
+ camera.quaternion.copy(fallbackCamera.quaternion);
65
+ }
66
+
67
+ camera.updateMatrixWorld();
68
+ }
69
+
70
+ function createCaptureCamera(
71
+ options: CameraFrameCaptureOptions,
72
+ fallbackCamera: THREE.Camera,
73
+ width: number,
74
+ height: number
75
+ ): THREE.Camera {
76
+ const camera = options.camera
77
+ ? options.camera.clone()
78
+ : fallbackCamera instanceof THREE.PerspectiveCamera
79
+ ? fallbackCamera.clone()
80
+ : new THREE.PerspectiveCamera(45, width / height, 0.01, 100);
81
+
82
+ if (camera instanceof THREE.PerspectiveCamera) {
83
+ camera.aspect = width / height;
84
+ camera.fov = options.fov ?? camera.fov;
85
+ camera.near = options.near ?? camera.near;
86
+ camera.far = options.far ?? camera.far;
87
+ camera.updateProjectionMatrix();
88
+ }
89
+
90
+ applyCameraPose(camera, options, fallbackCamera);
91
+ return camera;
92
+ }
93
+
94
+ function getCaptureDimensions(
95
+ renderer: THREE.WebGLRenderer,
96
+ options: CameraFrameCaptureOptions
97
+ ) {
98
+ const width = Math.max(
99
+ 1,
100
+ Math.floor(options.width ?? renderer.domElement.width)
101
+ );
102
+ const height = Math.max(
103
+ 1,
104
+ Math.floor(options.height ?? renderer.domElement.height)
105
+ );
106
+ return { width, height };
107
+ }
108
+
109
+ function prepareCaptureCamera(
110
+ camera: THREE.Camera,
111
+ options: CameraFrameCaptureOptions,
112
+ fallbackCamera: THREE.Camera,
113
+ width: number,
114
+ height: number
115
+ ) {
116
+ if (options.camera) {
117
+ camera.copy(options.camera);
118
+ }
119
+
120
+ if (camera instanceof THREE.PerspectiveCamera) {
121
+ camera.aspect = width / height;
122
+ camera.fov = options.fov ?? camera.fov;
123
+ camera.near = options.near ?? camera.near;
124
+ camera.far = options.far ?? camera.far;
125
+ camera.updateProjectionMatrix();
126
+ }
127
+
128
+ applyCameraPose(camera, options, fallbackCamera);
129
+ }
130
+
131
+ function readRenderTargetToCanvas(
132
+ renderer: THREE.WebGLRenderer,
133
+ target: THREE.WebGLRenderTarget,
134
+ canvas: HTMLCanvasElement,
135
+ context: CanvasRenderingContext2D,
136
+ pixels: Uint8Array,
137
+ imageData: ImageData,
138
+ width: number,
139
+ height: number
140
+ ) {
141
+ renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
142
+
143
+ const rowBytes = width * 4;
144
+ for (let y = 0; y < height; y += 1) {
145
+ const sourceStart = (height - y - 1) * rowBytes;
146
+ const targetStart = y * rowBytes;
147
+ imageData.data.set(
148
+ pixels.subarray(sourceStart, sourceStart + rowBytes),
149
+ targetStart
150
+ );
151
+ }
152
+ context.putImageData(imageData, 0, 0);
153
+ return canvas;
154
+ }
155
+
156
+ function getCameraFrameCaptureSource(
157
+ options: CameraFrameCaptureOptions
158
+ ): CameraFrameCaptureSource {
159
+ if (options.source) return options.source;
160
+ if (options.cameraName) {
161
+ return { kind: 'mujoco-camera', cameraName: options.cameraName };
162
+ }
163
+ if (options.siteName) {
164
+ return { kind: 'mujoco-site', siteName: options.siteName };
165
+ }
166
+ if (options.bodyName) {
167
+ return { kind: 'mujoco-body', bodyName: options.bodyName };
168
+ }
169
+ if (options.camera) return { kind: 'custom-camera' };
170
+ if (options.position || options.lookAt || options.quaternion) {
171
+ return { kind: 'explicit-pose' };
172
+ }
173
+ return { kind: 'fallback-camera' };
174
+ }
175
+
176
+ export function createCameraFrameCaptureSession(
177
+ renderer: THREE.WebGLRenderer,
178
+ scene: THREE.Scene,
179
+ fallbackCamera: THREE.Camera,
180
+ options: CameraFrameCaptureOptions = {}
181
+ ): CameraFrameCaptureSession {
182
+ const { width, height } = getCaptureDimensions(renderer, options);
183
+ const camera = createCaptureCamera(options, fallbackCamera, width, height);
184
+ const target = new THREE.WebGLRenderTarget(width, height, {
185
+ format: THREE.RGBAFormat,
186
+ type: THREE.UnsignedByteType,
187
+ });
188
+ const canvas = document.createElement('canvas');
189
+ canvas.width = width;
190
+ canvas.height = height;
191
+ const context = canvas.getContext('2d');
192
+ if (!context) {
193
+ target.dispose();
194
+ throw new Error('Unable to create a 2D canvas for camera frame capture.');
195
+ }
196
+ const drawContext = context;
197
+
198
+ const pixels = new Uint8Array(width * height * 4);
199
+ const imageData = drawContext.createImageData(width, height);
200
+
201
+ function capture(nextOptions: CameraFrameCaptureOptions = {}) {
202
+ const captureOptions = { ...options, ...nextOptions };
203
+ const nextDimensions = getCaptureDimensions(renderer, captureOptions);
204
+ if (
205
+ nextDimensions.width !== width ||
206
+ nextDimensions.height !== height
207
+ ) {
208
+ throw new Error(
209
+ 'Camera frame capture sessions require stable width and height.'
210
+ );
211
+ }
212
+
213
+ prepareCaptureCamera(
214
+ camera,
215
+ captureOptions,
216
+ fallbackCamera,
217
+ width,
218
+ height
219
+ );
220
+
221
+ const previousTarget = renderer.getRenderTarget();
222
+ const previousXrEnabled = renderer.xr.enabled;
223
+
224
+ scene.updateMatrixWorld(true);
225
+ try {
226
+ renderer.xr.enabled = false;
227
+ renderer.setRenderTarget(target);
228
+ renderer.clear();
229
+ renderer.render(scene, camera);
230
+ readRenderTargetToCanvas(
231
+ renderer,
232
+ target,
233
+ canvas,
234
+ drawContext,
235
+ pixels,
236
+ imageData,
237
+ width,
238
+ height
239
+ );
240
+ return {
241
+ canvas,
242
+ camera,
243
+ width,
244
+ height,
245
+ source: getCameraFrameCaptureSource(captureOptions),
246
+ };
247
+ } finally {
248
+ renderer.setRenderTarget(previousTarget);
249
+ renderer.xr.enabled = previousXrEnabled;
250
+ }
251
+ }
252
+
253
+ return {
254
+ width,
255
+ height,
256
+ capture,
257
+ captureDataUrl(nextOptions = {}) {
258
+ const type = nextOptions.type ?? options.type ?? 'image/png';
259
+ const result = capture(nextOptions);
260
+ return {
261
+ ...result,
262
+ dataUrl: result.canvas.toDataURL(
263
+ type,
264
+ nextOptions.quality ?? options.quality
265
+ ),
266
+ type,
267
+ };
268
+ },
269
+ async captureBlob(nextOptions = {}) {
270
+ const type = nextOptions.type ?? options.type ?? 'image/png';
271
+ const result = capture(nextOptions);
272
+ const blob = await new Promise<Blob>((resolve, reject) => {
273
+ result.canvas.toBlob(
274
+ (nextBlob) => {
275
+ if (nextBlob) resolve(nextBlob);
276
+ else reject(new Error('Camera frame capture did not produce a Blob.'));
277
+ },
278
+ type,
279
+ nextOptions.quality ?? options.quality
280
+ );
281
+ });
282
+ return { ...result, blob, type };
283
+ },
284
+ dispose() {
285
+ target.dispose();
286
+ },
287
+ };
288
+ }
289
+
290
+ export function renderCameraFrameToCanvas(
291
+ renderer: THREE.WebGLRenderer,
292
+ scene: THREE.Scene,
293
+ fallbackCamera: THREE.Camera,
294
+ options: CameraFrameCaptureOptions = {}
295
+ ) {
296
+ const session = createCameraFrameCaptureSession(
297
+ renderer,
298
+ scene,
299
+ fallbackCamera,
300
+ options
301
+ );
302
+ try {
303
+ return session.capture();
304
+ } finally {
305
+ session.dispose();
306
+ }
307
+ }
308
+
309
+ export async function captureCameraFrame(
310
+ renderer: THREE.WebGLRenderer,
311
+ scene: THREE.Scene,
312
+ fallbackCamera: THREE.Camera,
313
+ options: CameraFrameCaptureOptions = {}
314
+ ): Promise<CameraFrameCaptureResult> {
315
+ const type = options.type ?? 'image/png';
316
+ const result = renderCameraFrameToCanvas(
317
+ renderer,
318
+ scene,
319
+ fallbackCamera,
320
+ options
321
+ );
322
+ return {
323
+ ...result,
324
+ dataUrl: result.canvas.toDataURL(type, options.quality),
325
+ type,
326
+ };
327
+ }
328
+
329
+ export async function captureCameraFrameBlob(
330
+ renderer: THREE.WebGLRenderer,
331
+ scene: THREE.Scene,
332
+ fallbackCamera: THREE.Camera,
333
+ options: CameraFrameCaptureOptions = {}
334
+ ): Promise<CameraFrameCaptureBlobResult> {
335
+ const type = options.type ?? 'image/png';
336
+ const result = renderCameraFrameToCanvas(
337
+ renderer,
338
+ scene,
339
+ fallbackCamera,
340
+ options
341
+ );
342
+ const blob = await new Promise<Blob>((resolve, reject) => {
343
+ result.canvas.toBlob(
344
+ (nextBlob) => {
345
+ if (nextBlob) resolve(nextBlob);
346
+ else reject(new Error('Camera frame capture did not produce a Blob.'));
347
+ },
348
+ type,
349
+ options.quality
350
+ );
351
+ });
352
+ return { ...result, blob, type };
353
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * Helpers for resolving dataset camera streams to mounted MuJoCo resources.
6
+ */
7
+
8
+ import type {
9
+ Bodies,
10
+ CameraFrameCaptureOptions,
11
+ CameraFrameCaptureSource,
12
+ CameraFrameSequenceCamera,
13
+ CameraFrameSequenceOptions,
14
+ CameraFrameSequenceResult,
15
+ Cameras,
16
+ MujocoSimAPI,
17
+ Sites,
18
+ } from '../types';
19
+
20
+ export type MountedCameraFrameCaptureSource = Extract<
21
+ CameraFrameCaptureSource,
22
+ | { kind: 'mujoco-camera' }
23
+ | { kind: 'mujoco-site' }
24
+ | { kind: 'mujoco-body' }
25
+ >;
26
+
27
+ export type CameraFrameMountSelector =
28
+ | { cameraName: Cameras; siteName?: never; bodyName?: never }
29
+ | { siteName: Sites; cameraName?: never; bodyName?: never }
30
+ | { bodyName: Bodies; cameraName?: never; siteName?: never };
31
+
32
+ export interface NamedCameraFrameResource {
33
+ name: string | null | undefined;
34
+ }
35
+
36
+ export interface ResolveMountedCameraFrameSourceOptions {
37
+ cameras?: readonly (Cameras | NamedCameraFrameResource | null | undefined)[];
38
+ sites?: readonly (Sites | NamedCameraFrameResource | null | undefined)[];
39
+ bodies?: readonly (Bodies | NamedCameraFrameResource | null | undefined)[];
40
+ aliases?: Record<
41
+ string,
42
+ CameraFrameMountSelector | readonly CameraFrameMountSelector[]
43
+ >;
44
+ /**
45
+ * Accept the first valid alias selector even when the current resource
46
+ * inventory cannot verify it. This is useful when aliases come from a
47
+ * previously validated model inventory and the actual provider will validate
48
+ * again during capture.
49
+ */
50
+ allowAliasFallback?: boolean;
51
+ }
52
+
53
+ export interface ResolvedMountedCameraFrameSource {
54
+ key: string;
55
+ selector: CameraFrameMountSelector;
56
+ source: MountedCameraFrameCaptureSource;
57
+ }
58
+
59
+ export type MountedCameraFrameSequenceDefaults = Omit<
60
+ CameraFrameSequenceCamera,
61
+ 'key' | 'cameraName' | 'siteName' | 'bodyName' | 'source'
62
+ >;
63
+
64
+ export type MountedCameraFrameSequenceCameraOptions = Partial<
65
+ MountedCameraFrameSequenceDefaults
66
+ >;
67
+
68
+ export interface CreateMountedCameraFrameSequencePlanOptions
69
+ extends ResolveMountedCameraFrameSourceOptions {
70
+ defaults?: MountedCameraFrameSequenceDefaults;
71
+ cameraOptions?: Record<string, MountedCameraFrameSequenceCameraOptions>;
72
+ requireAll?: boolean;
73
+ }
74
+
75
+ export interface MountedCameraFrameSequencePlan {
76
+ cameraKeys: string[];
77
+ cameras: CameraFrameSequenceCamera[];
78
+ resolved: Record<string, ResolvedMountedCameraFrameSource>;
79
+ missingKeys: string[];
80
+ }
81
+
82
+ export const MountedCameraFrameSequenceReadinessStatus = {
83
+ Ready: 'ready',
84
+ Partial: 'partial',
85
+ Missing: 'missing',
86
+ } as const;
87
+
88
+ export type MountedCameraFrameSequenceReadinessStatus =
89
+ (typeof MountedCameraFrameSequenceReadinessStatus)[keyof typeof MountedCameraFrameSequenceReadinessStatus];
90
+
91
+ export interface MountedCameraFrameSequenceSourceReadiness {
92
+ key: string;
93
+ ready: boolean;
94
+ selector?: CameraFrameMountSelector;
95
+ source?: MountedCameraFrameCaptureSource;
96
+ message: string;
97
+ }
98
+
99
+ export interface MountedCameraFrameSequenceReadiness {
100
+ ready: boolean;
101
+ status: MountedCameraFrameSequenceReadinessStatus;
102
+ cameraKeys: string[];
103
+ resolvedKeys: string[];
104
+ missingKeys: string[];
105
+ cameras: Record<string, MountedCameraFrameSequenceSourceReadiness>;
106
+ message: string;
107
+ }
108
+
109
+ export type MountedCameraFrameSequencePlanOptions = Omit<
110
+ CreateMountedCameraFrameSequencePlanOptions,
111
+ 'cameras' | 'sites' | 'bodies'
112
+ >;
113
+
114
+ export interface MountedCameraFrameSequenceRecordOptions
115
+ extends Omit<CameraFrameSequenceOptions, 'cameras'>,
116
+ MountedCameraFrameSequencePlanOptions {
117
+ cameraKeys: readonly string[];
118
+ }
119
+
120
+ export interface MountedCameraFrameSequenceRecordResult
121
+ extends CameraFrameSequenceResult {
122
+ plan: MountedCameraFrameSequencePlan;
123
+ readiness: MountedCameraFrameSequenceReadiness;
124
+ }
125
+
126
+ export type MountedCameraFrameSequenceRecorderTarget = Pick<
127
+ MujocoSimAPI,
128
+ 'getCameras' | 'getSites' | 'getBodies' | 'recordCameraSequence'
129
+ >;
130
+
131
+ function getResourceName(
132
+ resource: string | NamedCameraFrameResource | null | undefined
133
+ ) {
134
+ if (!resource) return null;
135
+ return typeof resource === 'string' ? resource : resource.name ?? null;
136
+ }
137
+
138
+ function createNameSet(
139
+ resources:
140
+ | readonly (string | NamedCameraFrameResource | null | undefined)[]
141
+ | undefined
142
+ ) {
143
+ return new Set(
144
+ (resources ?? [])
145
+ .map((resource) => getResourceName(resource))
146
+ .filter((name): name is string => Boolean(name))
147
+ );
148
+ }
149
+
150
+ function normalizeAliasCandidates(
151
+ value: CameraFrameMountSelector | readonly CameraFrameMountSelector[] | undefined
152
+ ) {
153
+ if (!value) return [];
154
+ return Array.isArray(value) ? value : [value];
155
+ }
156
+
157
+ function countMountedSelectors(selector: CameraFrameMountSelector) {
158
+ return Number(Boolean(selector.cameraName)) +
159
+ Number(Boolean(selector.siteName)) +
160
+ Number(Boolean(selector.bodyName));
161
+ }
162
+
163
+ export function getMountedCameraFrameCaptureSource(
164
+ selector: CameraFrameMountSelector
165
+ ): MountedCameraFrameCaptureSource | null {
166
+ if (countMountedSelectors(selector) !== 1) return null;
167
+ if (selector.cameraName) {
168
+ return { kind: 'mujoco-camera', cameraName: selector.cameraName };
169
+ }
170
+ if (selector.siteName) {
171
+ return { kind: 'mujoco-site', siteName: selector.siteName };
172
+ }
173
+ if (selector.bodyName) {
174
+ return { kind: 'mujoco-body', bodyName: selector.bodyName };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ export function isMountedCameraFrameCaptureSource(
180
+ source: CameraFrameCaptureSource
181
+ ): source is MountedCameraFrameCaptureSource {
182
+ return (
183
+ source.kind === 'mujoco-camera' ||
184
+ source.kind === 'mujoco-site' ||
185
+ source.kind === 'mujoco-body'
186
+ );
187
+ }
188
+
189
+ export function getCameraFrameCaptureSourceTarget(
190
+ source: CameraFrameCaptureSource
191
+ ) {
192
+ if (source.kind === 'mujoco-camera') return source.cameraName;
193
+ if (source.kind === 'mujoco-site') return source.siteName;
194
+ if (source.kind === 'mujoco-body') return source.bodyName;
195
+ if (source.kind === 'custom-camera') return 'custom camera';
196
+ if (source.kind === 'explicit-pose') return 'explicit pose';
197
+ return 'fallback camera';
198
+ }
199
+
200
+ function isSelectorMounted(
201
+ selector: CameraFrameMountSelector,
202
+ cameraNames: Set<string>,
203
+ siteNames: Set<string>,
204
+ bodyNames: Set<string>
205
+ ) {
206
+ if (countMountedSelectors(selector) !== 1) return false;
207
+ return (
208
+ (selector.cameraName ? cameraNames.has(selector.cameraName) : false) ||
209
+ (selector.siteName ? siteNames.has(selector.siteName) : false) ||
210
+ (selector.bodyName ? bodyNames.has(selector.bodyName) : false)
211
+ );
212
+ }
213
+
214
+ export function resolveMountedCameraFrameSource(
215
+ key: string,
216
+ options: ResolveMountedCameraFrameSourceOptions
217
+ ): ResolvedMountedCameraFrameSource | null {
218
+ const cameraNames = createNameSet(options.cameras);
219
+ const siteNames = createNameSet(options.sites);
220
+ const bodyNames = createNameSet(options.bodies);
221
+ const directCandidates: CameraFrameMountSelector[] = [
222
+ { cameraName: key },
223
+ { siteName: key },
224
+ { bodyName: key },
225
+ ];
226
+ const aliasCandidates = normalizeAliasCandidates(options.aliases?.[key]);
227
+ const candidates = [...directCandidates, ...aliasCandidates];
228
+
229
+ for (const selector of candidates) {
230
+ if (!isSelectorMounted(selector, cameraNames, siteNames, bodyNames)) {
231
+ continue;
232
+ }
233
+ const source = getMountedCameraFrameCaptureSource(selector);
234
+ if (!source) continue;
235
+ return { key, selector, source };
236
+ }
237
+
238
+ if (options.allowAliasFallback) {
239
+ for (const selector of aliasCandidates) {
240
+ const source = getMountedCameraFrameCaptureSource(selector);
241
+ if (!source) continue;
242
+ return { key, selector, source };
243
+ }
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ export function createMountedCameraFrameSequencePlan(
250
+ cameraKeys: readonly string[],
251
+ options: CreateMountedCameraFrameSequencePlanOptions
252
+ ): MountedCameraFrameSequencePlan {
253
+ const cameras: CameraFrameSequenceCamera[] = [];
254
+ const resolved: Record<string, ResolvedMountedCameraFrameSource> = {};
255
+ const missingKeys: string[] = [];
256
+
257
+ for (const key of cameraKeys) {
258
+ const mountedSource = resolveMountedCameraFrameSource(key, options);
259
+ if (!mountedSource) {
260
+ missingKeys.push(key);
261
+ continue;
262
+ }
263
+
264
+ resolved[key] = mountedSource;
265
+ cameras.push({
266
+ key,
267
+ ...options.defaults,
268
+ ...options.cameraOptions?.[key],
269
+ ...mountedSource.selector,
270
+ source: mountedSource.source,
271
+ });
272
+ }
273
+
274
+ if (options.requireAll && missingKeys.length > 0) {
275
+ throw new Error(
276
+ `Unable to resolve mounted MuJoCo camera source${
277
+ missingKeys.length === 1 ? '' : 's'
278
+ } for ${missingKeys.join(', ')}.`
279
+ );
280
+ }
281
+
282
+ return {
283
+ cameraKeys: [...cameraKeys],
284
+ cameras,
285
+ resolved,
286
+ missingKeys,
287
+ };
288
+ }
289
+
290
+ export function createMountedCameraFrameSequenceReadiness(
291
+ plan: MountedCameraFrameSequencePlan
292
+ ): MountedCameraFrameSequenceReadiness {
293
+ const cameras: Record<string, MountedCameraFrameSequenceSourceReadiness> = {};
294
+ const resolvedKeys = plan.cameraKeys.filter((key) => Boolean(plan.resolved[key]));
295
+
296
+ for (const key of plan.cameraKeys) {
297
+ const resolved = plan.resolved[key];
298
+ cameras[key] = resolved
299
+ ? {
300
+ key,
301
+ ready: true,
302
+ selector: resolved.selector,
303
+ source: resolved.source,
304
+ message: `Camera stream "${key}" resolves to ${resolved.source.kind}:${getCameraFrameCaptureSourceTarget(resolved.source)}.`,
305
+ }
306
+ : {
307
+ key,
308
+ ready: false,
309
+ message: `Camera stream "${key}" does not resolve to a mounted MuJoCo camera, site, or body.`,
310
+ };
311
+ }
312
+
313
+ const missingKeys = [...plan.missingKeys];
314
+ const ready = missingKeys.length === 0;
315
+ const status: MountedCameraFrameSequenceReadinessStatus = ready
316
+ ? MountedCameraFrameSequenceReadinessStatus.Ready
317
+ : resolvedKeys.length > 0
318
+ ? MountedCameraFrameSequenceReadinessStatus.Partial
319
+ : MountedCameraFrameSequenceReadinessStatus.Missing;
320
+
321
+ return {
322
+ ready,
323
+ status,
324
+ cameraKeys: [...plan.cameraKeys],
325
+ resolvedKeys,
326
+ missingKeys,
327
+ cameras,
328
+ message: ready
329
+ ? `All ${plan.cameraKeys.length} requested camera stream${
330
+ plan.cameraKeys.length === 1 ? '' : 's'
331
+ } resolve to mounted MuJoCo sources.`
332
+ : `Missing mounted MuJoCo source${
333
+ missingKeys.length === 1 ? '' : 's'
334
+ } for ${missingKeys.join(', ')}.`,
335
+ };
336
+ }
337
+
338
+ export function createMountedCameraFrameSequencePlanFromApi(
339
+ api: MountedCameraFrameSequenceRecorderTarget,
340
+ cameraKeys: readonly string[],
341
+ options: MountedCameraFrameSequencePlanOptions = {}
342
+ ): MountedCameraFrameSequencePlan {
343
+ return createMountedCameraFrameSequencePlan(cameraKeys, {
344
+ ...options,
345
+ cameras: api.getCameras(),
346
+ sites: api.getSites(),
347
+ bodies: api.getBodies(),
348
+ });
349
+ }
350
+
351
+ export async function recordMountedCameraFrameSequence(
352
+ api: MountedCameraFrameSequenceRecorderTarget,
353
+ options: MountedCameraFrameSequenceRecordOptions
354
+ ): Promise<MountedCameraFrameSequenceRecordResult> {
355
+ const { cameraKeys, ...restOptions } = options;
356
+ const requireAll =
357
+ restOptions.requireAll ?? restOptions.requireMountedSources ?? true;
358
+ const plan = createMountedCameraFrameSequencePlanFromApi(
359
+ api,
360
+ cameraKeys,
361
+ { ...restOptions, requireAll }
362
+ );
363
+ const readiness = createMountedCameraFrameSequenceReadiness(plan);
364
+ const result = await api.recordCameraSequence({
365
+ ...restOptions,
366
+ cameras: plan.cameras,
367
+ requireMountedSources: restOptions.requireMountedSources ?? true,
368
+ });
369
+
370
+ return {
371
+ ...result,
372
+ plan,
373
+ readiness,
374
+ };
375
+ }