mujoco-react 9.2.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,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
+ }
package/src/spark.tsx CHANGED
@@ -15,9 +15,14 @@ import * as THREE from 'three';
15
15
  import {
16
16
  SplatEnvironment,
17
17
  useSplatEnvironment,
18
+ useSplatSceneConfig,
18
19
  } from './components/VisualScenario';
19
20
  import type {
21
+ PairedSplatEnvironmentConfig,
22
+ SceneConfig,
20
23
  SplatEnvironmentProps,
24
+ SplatEnvironmentReadiness,
25
+ VisualScenarioConfig,
21
26
  } from './types';
22
27
 
23
28
  type SparkModule = typeof import('@sparkjsdev/spark');
@@ -26,9 +31,22 @@ type SparkSplatMeshInstance = InstanceType<SparkModule['SplatMesh']>;
26
31
  type SparkDisposable = {
27
32
  dispose?: () => unknown;
28
33
  };
34
+ type SparkWorkerMessage = {
35
+ reject?: (error: unknown) => void;
36
+ };
37
+ type SparkWorkerLike = {
38
+ messages?: Record<string, SparkWorkerMessage>;
39
+ };
40
+ type SparkResourceWithWorkers = SparkDisposable & {
41
+ worker?: SparkWorkerLike;
42
+ sortWorker?: SparkWorkerLike;
43
+ lodWorker?: SparkWorkerLike;
44
+ };
29
45
 
30
46
  export type SparkSplatStatus = 'idle' | 'loading' | 'ready' | 'error';
31
47
 
48
+ let sparkDisposeRejectionHandlerRegistered = false;
49
+
32
50
  export interface SparkSplatLifecycle {
33
51
  status: SparkSplatStatus;
34
52
  error: Error | null;
@@ -39,6 +57,18 @@ export interface SparkSplatLifecycle {
39
57
  reset: () => void;
40
58
  }
41
59
 
60
+ export interface SparkSplatEnvironmentState {
61
+ environment: PairedSplatEnvironmentConfig | undefined;
62
+ sceneConfig: SceneConfig;
63
+ readiness: SplatEnvironmentReadiness;
64
+ lifecycle: SparkSplatLifecycle;
65
+ props: Pick<
66
+ SparkSplatEnvironmentProps,
67
+ 'environment' | 'scenario' | 'src' | 'format' | 'onStatusChange' | 'onError'
68
+ >;
69
+ enabled: boolean;
70
+ }
71
+
42
72
  export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
43
73
  /** Enable Spark LoD handling for large splat assets. Default: true. */
44
74
  lod?: boolean | 'quality';
@@ -53,6 +83,67 @@ export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
53
83
  onError?: (error: Error) => void;
54
84
  }
55
85
 
86
+ /**
87
+ * Resolve a visual scenario's paired splat environment, compose its MJCF
88
+ * collision proxy into the MuJoCo scene config, and expose Spark lifecycle
89
+ * props for `<SparkSplatEnvironment />`.
90
+ */
91
+ export function useSparkSplatEnvironment({
92
+ sceneConfig,
93
+ scenario,
94
+ environment,
95
+ enabled = true,
96
+ renderer = 'spark',
97
+ onError,
98
+ onStatusChange,
99
+ }: {
100
+ sceneConfig: SceneConfig;
101
+ scenario?: VisualScenarioConfig;
102
+ environment?: PairedSplatEnvironmentConfig;
103
+ enabled?: boolean;
104
+ renderer?: 'spark';
105
+ onError?: (error: Error) => void;
106
+ onStatusChange?: (status: SparkSplatStatus) => void;
107
+ }): SparkSplatEnvironmentState {
108
+ const splatScene = useSplatSceneConfig({
109
+ sceneConfig,
110
+ scenario,
111
+ environment,
112
+ enabled,
113
+ renderer,
114
+ });
115
+ const metadata = useSplatEnvironment({
116
+ scenario,
117
+ environment: splatScene.environment,
118
+ renderer,
119
+ });
120
+ const renderEnabled = enabled && Boolean(metadata.src);
121
+ const readiness = enabled ? metadata.readiness : splatScene.readiness;
122
+ const lifecycle = useSparkSplatLifecycle({
123
+ enabled: renderEnabled,
124
+ onError,
125
+ onStatusChange,
126
+ });
127
+
128
+ return useMemo(
129
+ () => ({
130
+ environment: splatScene.environment,
131
+ sceneConfig: splatScene.sceneConfig,
132
+ readiness,
133
+ lifecycle,
134
+ props: {
135
+ environment: splatScene.environment,
136
+ scenario: enabled ? scenario : undefined,
137
+ src: enabled ? metadata.src : undefined,
138
+ format: metadata.format,
139
+ ...lifecycle.props,
140
+ },
141
+ enabled: renderEnabled,
142
+ }),
143
+ [enabled, lifecycle, metadata, readiness, renderEnabled, scenario, splatScene]
144
+ );
145
+ }
146
+
56
147
  /**
57
148
  * Tracks Spark 3DGS loading state for UI that wraps `SparkSplatEnvironment`.
58
149
  *
@@ -177,6 +268,7 @@ export function SparkSplatEnvironment({
177
268
 
178
269
  useEffect(() => {
179
270
  let disposed = false;
271
+ ensureSparkDisposeRejectionHandler();
180
272
 
181
273
  function setLifecycleStatus(nextStatus: SparkSplatStatus) {
182
274
  setStatus(nextStatus);
@@ -306,6 +398,7 @@ export function SparkSplatEnvironment({
306
398
 
307
399
  function safelyDisposeSparkResource(resource: SparkDisposable) {
308
400
  try {
401
+ silenceSparkWorkerTerminateRejections(resource);
309
402
  const result = resource.dispose?.();
310
403
  if (isPromiseLike(result)) {
311
404
  void Promise.resolve(result).catch(handleSparkDisposeError);
@@ -324,6 +417,33 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
324
417
  );
325
418
  }
326
419
 
420
+ function silenceSparkWorkerTerminateRejections(resource: SparkDisposable) {
421
+ const workers = getSparkWorkers(resource);
422
+ for (const worker of workers) {
423
+ if (!worker.messages) continue;
424
+
425
+ for (const message of Object.values(worker.messages)) {
426
+ const reject = message.reject;
427
+ if (!reject) continue;
428
+
429
+ message.reject = (error: unknown) => {
430
+ if (!isSparkWorkerTerminateError(error)) {
431
+ reject(error);
432
+ }
433
+ };
434
+ }
435
+ }
436
+ }
437
+
438
+ function getSparkWorkers(resource: SparkDisposable): SparkWorkerLike[] {
439
+ const sparkResource = resource as SparkResourceWithWorkers;
440
+ return [
441
+ sparkResource.worker,
442
+ sparkResource.sortWorker,
443
+ sparkResource.lodWorker,
444
+ ].filter((worker): worker is SparkWorkerLike => Boolean(worker));
445
+ }
446
+
327
447
  function handleSparkDisposeError(error: unknown) {
328
448
  if (
329
449
  error instanceof Error &&
@@ -334,3 +454,27 @@ function handleSparkDisposeError(error: unknown) {
334
454
 
335
455
  console.warn('[mujoco-react] Spark resource disposal failed.', error);
336
456
  }
457
+
458
+ function ensureSparkDisposeRejectionHandler() {
459
+ if (
460
+ sparkDisposeRejectionHandlerRegistered ||
461
+ typeof window === 'undefined' ||
462
+ typeof window.addEventListener !== 'function'
463
+ ) {
464
+ return;
465
+ }
466
+
467
+ sparkDisposeRejectionHandlerRegistered = true;
468
+ window.addEventListener('unhandledrejection', (event) => {
469
+ if (isSparkWorkerTerminateError(event.reason)) {
470
+ event.preventDefault();
471
+ }
472
+ });
473
+ }
474
+
475
+ function isSparkWorkerTerminateError(reason: unknown) {
476
+ return (
477
+ reason instanceof Error &&
478
+ reason.message.toLowerCase().includes('worker terminate')
479
+ );
480
+ }