Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ interface CloudXRComponentProps {
/** Callback fired with the resolved server address after proxy configuration is applied. */
onServerAddress?: (address: string) => void;

/**
* Callback fired with the reference space poses are actually streamed in: the one
* `config.referenceSpaceType` resolved to (including the 'auto' fallback order and the
* WebXRManager last resort), with `config.xrOffset*` already applied. Anything that
* needs to express poses in the same frame as the stream should take this rather than
* request its own space, which would miss the offset and could resolve differently.
* Fired on each session creation; not fired when no space could be resolved.
*/
onReferenceSpace?: (space: XRReferenceSpace) => void;

/**
* Callback fired with render performance metrics ({@link CloudXR.MetricsCadence.PerRender}).
* Payloads are partial - only the fields sampled on this tick are present.
Expand Down Expand Up @@ -303,6 +313,7 @@ export default function CloudXRComponent({
warmupBeginTimeoutMs,
warmupEndTimeoutMs,
onServerAddress,
onReferenceSpace,
onRenderPerformanceMetrics,
onStreamingPerformanceMetrics,
onNetworkPerformanceMetrics,
Expand Down Expand Up @@ -720,6 +731,11 @@ export default function CloudXRComponent({
referenceSpace = referenceSpace.getOffsetReferenceSpace(offsetTransform);
}

// Resolved only now: this is the space the session streams in, after both
// the fallback chain and the offset. Published per CloudXR session rather
// than here, so a reconnect does not leave the parent without one.
const streamingReferenceSpace = referenceSpace;

// Fill in CloudXR session options.
const cloudXROptions: CloudXR.SessionOptions = {
serverAddress: connectionConfig.serverIP,
Expand Down Expand Up @@ -1085,6 +1101,9 @@ export default function CloudXRComponent({

// Notify parent that session is ready
onSessionReady?.(cxrSession);
// Republished on every attempt, reconnects included: the parent drops its
// copy whenever onSessionReady(null) fires, so the two have to stay paired.
onReferenceSpace?.(streamingReferenceSpace);

// Start session (synchronous call that initiates connection)
try {
Expand Down
14 changes: 12 additions & 2 deletions deps/cloudxr/webxr_client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ import { RecorderProvider, useRecorder } from './RecorderContext';
import { SuppressWebGLRendererWhenHeadless } from './SuppressWebGLRendererWhenHeadless';
import { TraceVisualization } from './TraceVisualization';
import {
SystemNotice,
formatSystemNotice,
formatSystemNoticeBody,
isSystemNoticeMessage,
SystemNotice,
} from './types/serverMessages';

// Performance metrics signals - raw numeric data backing the in-XR HUD.
Expand Down Expand Up @@ -212,6 +212,10 @@ function AppContent() {
const [errorMessage, setErrorMessage] = useState('');
// CloudXR session reference
const [cloudXRSession, setCloudXRSession] = useState<CloudXR.Session | null>(null);
// The space CloudXR resolved for the current session, so the recorder can express
// poses in the same frame the stream uses. Cleared with the session: a space from an
// ended session yields no poses.
const [streamingSpace, setStreamingSpace] = useState<XRReferenceSpace | null>(null);
// XR mode state for UI visibility
const [isXRMode, setIsXRMode] = useState(false);
// Server address being used for connection
Expand Down Expand Up @@ -701,6 +705,7 @@ function AppContent() {
stopStreamTestCountdown();
streamTest.value = null;
setCloudXRSession(session);
if (!session) setStreamingSpace(null);
};

const systemNoticeTimerRef = useRef<number | null>(null);
Expand Down Expand Up @@ -1193,12 +1198,17 @@ function AppContent() {
<XROrigin />
{cloudXR2DUI && config && (
<>
<RecorderComponent isConnected={isConnected} showTrace={config.showTrace ?? false} />
<RecorderComponent
isConnected={isConnected}
showTrace={config.showTrace ?? false}
streamingSpace={streamingSpace}
/>
<TraceVisualization showTrace={config.showTrace ?? false} />
<CloudXRComponent
config={config}
applicationName={`Isaac Teleop Web Client (${config.teleopPath})`}
trackingFrameAdapter={recorder.adaptTrackingFrame}
onReferenceSpace={setStreamingSpace}
iceServers={iceServersConfig}
reconnect={
config?.reconnectEnabled
Expand Down
42 changes: 32 additions & 10 deletions deps/cloudxr/webxr_client/src/CloudXRUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
import arrowLeftStartOnRectangleSvg from './icons/arrow-left-start-on-rectangle.svg';
import arrowUturnLeftSvg from './icons/arrow-uturn-left.svg';
import playCircleSvg from './icons/play-circle.svg';
import { useRecorder } from './RecorderContext';
import { isEditableKeyboardTarget, useRecorder, useTeleopShortcutHandler } from './RecorderContext';

// Face-camera rotation constants
const FACE_CAMERA_DAMPING = 10; // Higher = faster rotation toward camera
Expand Down Expand Up @@ -297,6 +297,15 @@ export default function CloudXR3DUI({
onDismissSystemNotice,
}: CloudXRUIProps) {
const recorder = useRecorder();
// T/C/P/S/X. Delivered from a real keyboard, or synthesised over CDP so a host
// can drive an unattended run. Record is on C because 'r' is already taken by
// the panel reset below, which shares this component's one keydown listener.
const handleTeleopShortcut = useTeleopShortcutHandler({
recordingEnabled: showRecordingControls,
startTeleop: onStartTeleop,
teleopStartDisabled: playInProgress,
resetTeleop: onResetTeleop,
});
const MINIMIZE_ON_PLAY_KEY = 'cxr.isaac.minimizeOnPlay';

const groupRef = useRef<Group>(null);
Expand Down Expand Up @@ -512,24 +521,31 @@ export default function CloudXR3DUI({

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== RESET_PANEL_KEY) {
return;
}
// Unlike a function key, a plain letter can be typed into any of the 2D settings form's
// text inputs - don't reset the panel out from under someone typing a server IP.
const target = event.target as HTMLElement | null;
// text inputs - don't reset the panel out from under someone typing a server IP. A held
// key or a browser chord (Ctrl+R to reload) is not a shortcut press either.
if (
target?.tagName === 'INPUT' ||
target?.tagName === 'TEXTAREA' ||
target?.isContentEditable
event.repeat ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
isEditableKeyboardTarget(event.target)
) {
return;
}
// The recorder keys get first refusal, then the panel key; one listener, so the
// two sets cannot both fire on the same press.
if (handleTeleopShortcut(event)) {
return;
}
if (event.key.toLowerCase() !== RESET_PANEL_KEY) {
return;
}
resetPanelRelativeToHead(camera);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [camera, resetPanelRelativeToHead]);
}, [camera, resetPanelRelativeToHead, handleTeleopShortcut]);

const isCompact = minimizeOnPlay && playInProgress;
const isMinimizedLayout = isCompact || panelHidden;
Expand Down Expand Up @@ -875,6 +891,12 @@ export default function CloudXR3DUI({
? 'Replaying'
: 'Recording'}
</Text>
{recorder.mode === 'idle' && recorder.recordingInterrupted && (
<Text fontSize={24} color="white" textAlign="center">
Recording stopped because the tracking origin changed. Save it or start a
new recording.
</Text>
)}
<Container flexDirection="row" gap={12} justifyContent="center">
{recorder.mode !== 'replaying' && (
<RecordingButton
Expand Down
27 changes: 23 additions & 4 deletions deps/cloudxr/webxr_client/src/RecorderComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,31 @@ import { useRef } from 'react';
import { useRecorder } from './RecorderContext';

interface RecorderComponentProps {
/** True when the CloudXR session is in the Connected state — gates replay frame advancement. */
/** True when the CloudXR session is in the Connected state - gates replay frame advancement. */
isConnected: boolean;
/** Capture live input while idle so the trace can render it. */
showTrace: boolean;
/**
* The space CloudXR streams poses in, as reported by CloudXRComponent's onReferenceSpace.
*
* three.js pins WebXRManager to 'local-floor' (origin on the floor) while CloudXR requests
* `config.referenceSpaceType` ('local' by default, origin at the headset). Recording in one
* frame and streaming in the other makes hand poses absolute above the floor while the scene
* stays anchored to the headset, so entering at a different height moves the table but not
* the trajectory. Taken from CloudXRComponent rather than resolved again here so the two
* cannot disagree, and so `config.xrOffset*` is included.
*
* Null before a session exists; the WebXRManager space is used until then.
*/
streamingSpace: XRReferenceSpace | null;
}

export function RecorderComponent({ isConnected, showTrace }: RecorderComponentProps) {
const { recorder, onFrameRecord } = useRecorder();
export function RecorderComponent({
isConnected,
showTrace,
streamingSpace,
}: RecorderComponentProps) {
const { recorder, onFrameRecord, onFrameState } = useRecorder();
const tickRef = useRef(0);

useFrame(state => {
Expand All @@ -50,11 +67,13 @@ export function RecorderComponent({ isConnected, showTrace }: RecorderComponentP

recorder.beginFrame(
xrFrame,
state.gl.xr.getReferenceSpace(),
streamingSpace ?? state.gl.xr.getReferenceSpace(),
isConnected && isVisible,
showTrace && isVisible
);

onFrameState();

if (recorder.mode === 'recording') {
tickRef.current++;
if (tickRef.current % 30 === 0) {
Expand Down
71 changes: 71 additions & 0 deletions deps/cloudxr/webxr_client/src/RecorderContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** @jest-environment jsdom */

/*
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';

import { type RecorderContextValue, RecorderProvider, useRecorder } from './RecorderContext';

let current: RecorderContextValue;
let root: Root;

function Probe() {
current = useRecorder();
return null;
}

function frame(session: XRSession): XRFrame {
return {
session,
predictedDisplayTime: 0,
getViewerPose: () => ({
transform: { position: { x: 0, y: 0, z: 0 }, orientation: { x: 0, y: 0, z: 0, w: 1 } },
}),
} as unknown as XRFrame;
}

beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
root = createRoot(document.createElement('div'));
act(() =>
root.render(
<RecorderProvider>
<Probe />
</RecorderProvider>
)
);
});

afterEach(() => act(() => root.unmount()));

test('saves interrupted recording and updates the UI when the tracking origin changes', () => {
const session = { inputSources: [] } as unknown as XRSession;
const space = new EventTarget() as XRReferenceSpace;
act(() => current.startRecord());
act(() => current.recorder.beginFrame(frame(session), space));
act(() => {
space.dispatchEvent(Object.assign(new Event('reset'), { transform: null }));
current.onFrameState();
});
expect(current.mode).toBe('idle');
expect(current.recordingInterrupted).toBe(true);
expect(current.savedRecording?.frames).toHaveLength(1);
act(() => current.startRecord());
expect(current.recordingInterrupted).toBe(false);
});
Loading