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.
- package/README.md +121 -17
- package/dist/{chunk-33CV6HSV.js → chunk-T3GVZJ4F.js} +222 -8
- package/dist/chunk-T3GVZJ4F.js.map +1 -0
- package/dist/index.d.ts +198 -6
- package/dist/index.js +1109 -216
- package/dist/index.js.map +1 -1
- package/dist/spark.d.ts +24 -2
- package/dist/spark.js +89 -3
- package/dist/spark.js.map +1 -1
- package/dist/{types-C5gTvR7b.d.ts → types-oxbxOkAx.d.ts} +190 -2
- package/dist/vite.d.ts +1 -1
- package/dist/vite.js +6 -3
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/src/components/VisualScenario.tsx +178 -1
- package/src/core/MujocoSimProvider.tsx +473 -11
- package/src/core/SceneLoader.ts +13 -0
- package/src/core/createController.tsx +6 -2
- package/src/hooks/useCameraFrameCapture.ts +94 -0
- package/src/hooks/useCameraSequenceRecorder.ts +59 -0
- package/src/hooks/useMountedCameraSequenceRecorder.ts +107 -0
- package/src/index.ts +67 -0
- package/src/rendering/cameraFrameCapture.ts +353 -0
- package/src/rendering/cameraFrameSource.ts +375 -0
- package/src/spark.tsx +144 -0
- package/src/types.ts +212 -2
- package/src/vite.ts +5 -2
- package/dist/chunk-33CV6HSV.js.map +0 -1
|
@@ -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
|
+
}
|