mujoco-react 9.4.0 → 9.6.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.
@@ -24,11 +24,66 @@ export interface CameraFrameCaptureSession {
24
24
  height: number;
25
25
  source: CameraFrameCaptureSource;
26
26
  };
27
+ captureAsync(options?: CameraFrameCaptureOptions): Promise<{
28
+ canvas: HTMLCanvasElement;
29
+ camera: THREE.Camera;
30
+ width: number;
31
+ height: number;
32
+ source: CameraFrameCaptureSource;
33
+ }>;
27
34
  captureDataUrl(options?: CameraFrameCaptureOptions): CameraFrameCaptureResult;
35
+ captureDataUrlAsync(
36
+ options?: CameraFrameCaptureOptions
37
+ ): Promise<CameraFrameCaptureResult>;
28
38
  captureBlob(options?: CameraFrameCaptureOptions): Promise<CameraFrameCaptureBlobResult>;
29
39
  dispose(): void;
30
40
  }
31
41
 
42
+ export const CAMERA_FRAME_CAPTURE_RENDER_USER_DATA_KEY =
43
+ 'mujocoReactCameraFrameCaptureRender';
44
+ export const CAPTURE_EXCLUDE_KEY =
45
+ 'mujoco.capture.exclude';
46
+
47
+ export type CameraFrameCaptureRenderInput = {
48
+ renderer: THREE.WebGLRenderer;
49
+ scene: THREE.Scene;
50
+ camera: THREE.Camera;
51
+ target: THREE.WebGLRenderTarget;
52
+ width: number;
53
+ height: number;
54
+ };
55
+
56
+ export type CameraFrameCaptureRenderResult = {
57
+ pixels: Uint8Array;
58
+ width?: number;
59
+ height?: number;
60
+ flipY?: boolean;
61
+ };
62
+
63
+ type CameraFrameCaptureRender = (
64
+ input: CameraFrameCaptureRenderInput
65
+ ) =>
66
+ | CameraFrameCaptureRenderResult
67
+ | null
68
+ | undefined
69
+ | Promise<CameraFrameCaptureRenderResult | null | undefined>;
70
+
71
+ type RendererState = {
72
+ target: THREE.WebGLRenderTarget | null;
73
+ xrEnabled: boolean;
74
+ viewport: THREE.Vector4;
75
+ scissor: THREE.Vector4;
76
+ scissorTest: boolean;
77
+ clearColor: THREE.Color;
78
+ clearAlpha: number;
79
+ autoClear: boolean;
80
+ };
81
+
82
+ type VisibilityState = {
83
+ object: THREE.Object3D;
84
+ visible: boolean;
85
+ };
86
+
32
87
  function toVector3(
33
88
  value: CameraFrameCaptureVector3 | undefined,
34
89
  fallback: THREE.Vector3
@@ -136,21 +191,79 @@ function readRenderTargetToCanvas(
136
191
  pixels: Uint8Array,
137
192
  imageData: ImageData,
138
193
  width: number,
139
- height: number
194
+ height: number,
195
+ outputColorSpace: string
140
196
  ) {
141
197
  renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
142
198
 
143
199
  const rowBytes = width * 4;
200
+ const encodeSrgb = outputColorSpace === THREE.SRGBColorSpace;
144
201
  for (let y = 0; y < height; y += 1) {
145
202
  const sourceStart = (height - y - 1) * rowBytes;
146
203
  const targetStart = y * rowBytes;
204
+ const row = pixels.subarray(sourceStart, sourceStart + rowBytes);
205
+ if (!encodeSrgb) {
206
+ imageData.data.set(row, targetStart);
207
+ continue;
208
+ }
209
+
210
+ for (let x = 0; x < rowBytes; x += 4) {
211
+ const pixelOffset = targetStart + x;
212
+ imageData.data[pixelOffset] = linearByteToSrgbByte(row[x]);
213
+ imageData.data[pixelOffset + 1] = linearByteToSrgbByte(row[x + 1]);
214
+ imageData.data[pixelOffset + 2] = linearByteToSrgbByte(row[x + 2]);
215
+ imageData.data[pixelOffset + 3] = row[x + 3];
216
+ }
217
+ }
218
+ context.putImageData(imageData, 0, 0);
219
+ return canvas;
220
+ }
221
+
222
+ function linearByteToSrgbByte(value: number) {
223
+ const normalized = value / 255;
224
+ const encoded =
225
+ normalized <= 0.0031308
226
+ ? normalized * 12.92
227
+ : 1.055 * Math.pow(normalized, 1 / 2.4) - 0.055;
228
+ return Math.min(255, Math.max(0, Math.round(encoded * 255)));
229
+ }
230
+
231
+ function readPixelsToCanvas(
232
+ pixels: Uint8Array,
233
+ context: CanvasRenderingContext2D,
234
+ imageData: ImageData,
235
+ width: number,
236
+ height: number,
237
+ flipY = true
238
+ ) {
239
+ const rowBytes = width * 4;
240
+ for (let y = 0; y < height; y += 1) {
241
+ const sourceY = flipY ? height - y - 1 : y;
242
+ const sourceStart = sourceY * rowBytes;
243
+ const targetStart = y * rowBytes;
147
244
  imageData.data.set(
148
245
  pixels.subarray(sourceStart, sourceStart + rowBytes),
149
246
  targetStart
150
247
  );
151
248
  }
152
249
  context.putImageData(imageData, 0, 0);
153
- return canvas;
250
+ }
251
+
252
+ function hideExcludedCaptureObjects(scene: THREE.Scene): VisibilityState[] {
253
+ const hidden: VisibilityState[] = [];
254
+ scene.traverse((object) => {
255
+ if (!object.visible) return;
256
+ if (!object.userData[CAPTURE_EXCLUDE_KEY]) return;
257
+ hidden.push({ object, visible: object.visible });
258
+ object.visible = false;
259
+ });
260
+ return hidden;
261
+ }
262
+
263
+ function restoreObjectVisibility(hidden: VisibilityState[]) {
264
+ for (const { object, visible } of hidden) {
265
+ object.visible = visible;
266
+ }
154
267
  }
155
268
 
156
269
  function getCameraFrameCaptureSource(
@@ -173,6 +286,52 @@ function getCameraFrameCaptureSource(
173
286
  return { kind: 'fallback-camera' };
174
287
  }
175
288
 
289
+ function saveRendererState(renderer: THREE.WebGLRenderer): RendererState {
290
+ const viewport = new THREE.Vector4();
291
+ const scissor = new THREE.Vector4();
292
+ const clearColor = new THREE.Color();
293
+ renderer.getViewport(viewport);
294
+ renderer.getScissor(scissor);
295
+ renderer.getClearColor(clearColor);
296
+ return {
297
+ target: renderer.getRenderTarget(),
298
+ xrEnabled: renderer.xr.enabled,
299
+ viewport,
300
+ scissor,
301
+ scissorTest: renderer.getScissorTest(),
302
+ clearColor,
303
+ clearAlpha: renderer.getClearAlpha(),
304
+ autoClear: renderer.autoClear,
305
+ };
306
+ }
307
+
308
+ function restoreRendererState(
309
+ renderer: THREE.WebGLRenderer,
310
+ state: RendererState
311
+ ) {
312
+ renderer.setRenderTarget(state.target);
313
+ renderer.xr.enabled = state.xrEnabled;
314
+ renderer.setViewport(state.viewport);
315
+ renderer.setScissor(state.scissor);
316
+ renderer.setScissorTest(state.scissorTest);
317
+ renderer.setClearColor(state.clearColor, state.clearAlpha);
318
+ renderer.autoClear = state.autoClear;
319
+ }
320
+
321
+ function getCaptureRenderer(
322
+ scene: THREE.Scene
323
+ ): CameraFrameCaptureRender | null {
324
+ const renderers: CameraFrameCaptureRender[] = [];
325
+ scene.traverse((object) => {
326
+ if (renderers.length) return;
327
+ const render = object.userData[
328
+ CAMERA_FRAME_CAPTURE_RENDER_USER_DATA_KEY
329
+ ] as CameraFrameCaptureRender | undefined;
330
+ if (typeof render === 'function') renderers.push(render);
331
+ });
332
+ return renderers[0] ?? null;
333
+ }
334
+
176
335
  export function createCameraFrameCaptureSession(
177
336
  renderer: THREE.WebGLRenderer,
178
337
  scene: THREE.Scene,
@@ -198,7 +357,7 @@ export function createCameraFrameCaptureSession(
198
357
  const pixels = new Uint8Array(width * height * 4);
199
358
  const imageData = drawContext.createImageData(width, height);
200
359
 
201
- function capture(nextOptions: CameraFrameCaptureOptions = {}) {
360
+ function resolveCaptureOptions(nextOptions: CameraFrameCaptureOptions = {}) {
202
361
  const captureOptions = { ...options, ...nextOptions };
203
362
  const nextDimensions = getCaptureDimensions(renderer, captureOptions);
204
363
  if (
@@ -218,13 +377,20 @@ export function createCameraFrameCaptureSession(
218
377
  height
219
378
  );
220
379
 
221
- const previousTarget = renderer.getRenderTarget();
222
- const previousXrEnabled = renderer.xr.enabled;
380
+ return captureOptions;
381
+ }
382
+
383
+ function renderPreparedCapture(captureOptions: CameraFrameCaptureOptions) {
384
+ const previousState = saveRendererState(renderer);
385
+ const hidden = hideExcludedCaptureObjects(scene);
223
386
 
224
387
  scene.updateMatrixWorld(true);
225
388
  try {
226
389
  renderer.xr.enabled = false;
227
390
  renderer.setRenderTarget(target);
391
+ renderer.setViewport(0, 0, width, height);
392
+ renderer.setScissor(0, 0, width, height);
393
+ renderer.setScissorTest(false);
228
394
  renderer.clear();
229
395
  renderer.render(scene, camera);
230
396
  readRenderTargetToCanvas(
@@ -235,7 +401,8 @@ export function createCameraFrameCaptureSession(
235
401
  pixels,
236
402
  imageData,
237
403
  width,
238
- height
404
+ height,
405
+ renderer.outputColorSpace
239
406
  );
240
407
  return {
241
408
  canvas,
@@ -245,15 +412,69 @@ export function createCameraFrameCaptureSession(
245
412
  source: getCameraFrameCaptureSource(captureOptions),
246
413
  };
247
414
  } finally {
248
- renderer.setRenderTarget(previousTarget);
249
- renderer.xr.enabled = previousXrEnabled;
415
+ restoreObjectVisibility(hidden);
416
+ restoreRendererState(renderer, previousState);
417
+ }
418
+ }
419
+
420
+ function capture(nextOptions: CameraFrameCaptureOptions = {}) {
421
+ return renderPreparedCapture(resolveCaptureOptions(nextOptions));
422
+ }
423
+
424
+ async function captureAsync(nextOptions: CameraFrameCaptureOptions = {}) {
425
+ const captureOptions = resolveCaptureOptions(nextOptions);
426
+ scene.updateMatrixWorld(true);
427
+ const captureRenderer = getCaptureRenderer(scene);
428
+ if (captureRenderer) {
429
+ const previousState = saveRendererState(renderer);
430
+ const hidden = hideExcludedCaptureObjects(scene);
431
+ try {
432
+ renderer.xr.enabled = false;
433
+ const captureResult = await captureRenderer({
434
+ renderer,
435
+ scene,
436
+ camera,
437
+ target,
438
+ width,
439
+ height,
440
+ });
441
+ if (captureResult) {
442
+ const captureWidth = captureResult.width ?? width;
443
+ const captureHeight = captureResult.height ?? height;
444
+ if (captureWidth !== width || captureHeight !== height) {
445
+ throw new Error(
446
+ 'Camera frame capture renderer returned unexpected dimensions.'
447
+ );
448
+ }
449
+ readPixelsToCanvas(
450
+ captureResult.pixels,
451
+ drawContext,
452
+ imageData,
453
+ width,
454
+ height,
455
+ captureResult.flipY ?? true
456
+ );
457
+ return {
458
+ canvas,
459
+ camera,
460
+ width,
461
+ height,
462
+ source: getCameraFrameCaptureSource(captureOptions),
463
+ };
464
+ }
465
+ } finally {
466
+ restoreObjectVisibility(hidden);
467
+ restoreRendererState(renderer, previousState);
468
+ }
250
469
  }
470
+ return renderPreparedCapture(captureOptions);
251
471
  }
252
472
 
253
473
  return {
254
474
  width,
255
475
  height,
256
476
  capture,
477
+ captureAsync,
257
478
  captureDataUrl(nextOptions = {}) {
258
479
  const type = nextOptions.type ?? options.type ?? 'image/png';
259
480
  const result = capture(nextOptions);
@@ -266,9 +487,21 @@ export function createCameraFrameCaptureSession(
266
487
  type,
267
488
  };
268
489
  },
490
+ async captureDataUrlAsync(nextOptions = {}) {
491
+ const type = nextOptions.type ?? options.type ?? 'image/png';
492
+ const result = await captureAsync(nextOptions);
493
+ return {
494
+ ...result,
495
+ dataUrl: result.canvas.toDataURL(
496
+ type,
497
+ nextOptions.quality ?? options.quality
498
+ ),
499
+ type,
500
+ };
501
+ },
269
502
  async captureBlob(nextOptions = {}) {
270
503
  const type = nextOptions.type ?? options.type ?? 'image/png';
271
- const result = capture(nextOptions);
504
+ const result = await captureAsync(nextOptions);
272
505
  const blob = await new Promise<Blob>((resolve, reject) => {
273
506
  result.canvas.toBlob(
274
507
  (nextBlob) => {
@@ -313,17 +546,22 @@ export async function captureCameraFrame(
313
546
  options: CameraFrameCaptureOptions = {}
314
547
  ): Promise<CameraFrameCaptureResult> {
315
548
  const type = options.type ?? 'image/png';
316
- const result = renderCameraFrameToCanvas(
549
+ const session = createCameraFrameCaptureSession(
317
550
  renderer,
318
551
  scene,
319
552
  fallbackCamera,
320
553
  options
321
554
  );
322
- return {
323
- ...result,
324
- dataUrl: result.canvas.toDataURL(type, options.quality),
325
- type,
326
- };
555
+ try {
556
+ const result = await session.captureAsync();
557
+ return {
558
+ ...result,
559
+ dataUrl: result.canvas.toDataURL(type, options.quality),
560
+ type,
561
+ };
562
+ } finally {
563
+ session.dispose();
564
+ }
327
565
  }
328
566
 
329
567
  export async function captureCameraFrameBlob(
@@ -332,22 +570,15 @@ export async function captureCameraFrameBlob(
332
570
  fallbackCamera: THREE.Camera,
333
571
  options: CameraFrameCaptureOptions = {}
334
572
  ): Promise<CameraFrameCaptureBlobResult> {
335
- const type = options.type ?? 'image/png';
336
- const result = renderCameraFrameToCanvas(
573
+ const session = createCameraFrameCaptureSession(
337
574
  renderer,
338
575
  scene,
339
576
  fallbackCamera,
340
577
  options
341
578
  );
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 };
579
+ try {
580
+ return await session.captureBlob();
581
+ } finally {
582
+ session.dispose();
583
+ }
353
584
  }
@@ -471,9 +471,8 @@ export function resolveMountedCameraFrameSource(
471
471
  { bodyName: key },
472
472
  ];
473
473
  const aliasCandidates = normalizeAliasCandidates(options.aliases?.[key]);
474
- const candidates = [...directCandidates, ...aliasCandidates];
475
474
 
476
- for (const selector of candidates) {
475
+ for (const selector of aliasCandidates) {
477
476
  if (!isSelectorMounted(selector, cameraNames, siteNames, bodyNames)) {
478
477
  continue;
479
478
  }
@@ -499,6 +498,15 @@ export function resolveMountedCameraFrameSource(
499
498
  }
500
499
  }
501
500
 
501
+ for (const selector of directCandidates) {
502
+ if (!isSelectorMounted(selector, cameraNames, siteNames, bodyNames)) {
503
+ continue;
504
+ }
505
+ const source = getMountedCameraFrameCaptureSource(selector);
506
+ if (!source) continue;
507
+ return { key, selector, source };
508
+ }
509
+
502
510
  return null;
503
511
  }
504
512