Skip to content
Draft
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
29 changes: 28 additions & 1 deletion deps/cloudxr/webxr_client/src/CloudXRUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,10 +603,37 @@ export default function CloudXR3DUI({
{recorder.mode === 'recording'
? `REC ${recorder.recordedFrameCount} frames`
: recorder.mode === 'replaying'
? 'Replaying'
? recorder.replayNeedsCalibration
? 'Replay paused'
: 'Replaying'
: 'Recording'}
</Text>
{recorder.replayNeedsCalibration && recorder.mode === 'replaying' && (
<Text fontSize={24} color="white" textAlign="center">
Return your headset to its recording-start position and heading, then
calibrate.
</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>
)}
{recorder.mode === 'idle' && !recorder.recordingInterrupted && (
<Text fontSize={24} color="white" textAlign="center">
Note your headset position and heading when starting a recording. Use the
same pose to calibrate replay in a new session.
</Text>
)}
<Container flexDirection="row" gap={12} justifyContent="center">
{recorder.mode === 'replaying' && recorder.replayNeedsCalibration && (
<RecordingButton
id="calibrate-replay"
label="Calibrate"
onClick={recorder.calibrateReplay}
/>
)}
{recorder.mode !== 'replaying' && (
<RecordingButton
id="record-input"
Expand Down
4 changes: 3 additions & 1 deletion deps/cloudxr/webxr_client/src/RecorderComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface RecorderComponentProps {
}

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

useFrame(state => {
Expand All @@ -55,6 +55,8 @@ export function RecorderComponent({ isConnected, showTrace }: RecorderComponentP
showTrace && isVisible
);

onFrameState();

if (recorder.mode === 'recording') {
tickRef.current++;
if (tickRef.current % 30 === 0) {
Expand Down
95 changes: 95 additions & 0 deletions deps/cloudxr/webxr_client/src/RecorderContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/** @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('exposes cross-session calibration and resumes after the operator calibrates', () => {
const space = new EventTarget() as XRReferenceSpace;
const original = { inputSources: [] } as unknown as XRSession;
act(() => current.startRecord());
act(() => current.recorder.beginFrame(frame(original), space));
act(() => current.stopRecord());
expect(current.savedRecording?.frames).toHaveLength(1);
act(() => current.startReplay());
const next = { inputSources: [] } as unknown as XRSession;
act(() => {
current.recorder.beginFrame(frame(next), space);
current.onFrameState();
});
expect(current.mode).toBe('replaying');
expect(current.replayNeedsCalibration).toBe(true);
act(() => current.calibrateReplay());
act(() => {
current.recorder.beginFrame(frame(next), space);
current.onFrameState();
});
expect(current.replayNeedsCalibration).toBe(false);
expect(current.recorder.currentFrame).not.toBeNull();
});

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);
});
36 changes: 35 additions & 1 deletion deps/cloudxr/webxr_client/src/RecorderContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@
* to descendant components.
*/

import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import { type Recording, type ReplayPacing, XRInputRecorder } from './xrInputRecorder';

Expand All @@ -37,6 +45,10 @@ export interface RecorderContextValue {
stopRecord: () => void;
startReplay: () => void;
stopReplay: () => void;
calibrateReplay: () => void;
replayNeedsCalibration: boolean;
recordingInterrupted: boolean;
onFrameState: () => void;
setReplayPacing: (pacing: ReplayPacing) => void;
onSaveRecording: () => void;
onLoadRecording: () => void;
Expand All @@ -59,9 +71,11 @@ function setLoadStatus(message: string, type: 'success' | 'error'): void {

export function RecorderProvider({ children }: { children: React.ReactNode }) {
const recorder = useMemo(() => new XRInputRecorder(), []);
useEffect(() => () => recorder.dispose(), [recorder]);
const [mode, setMode] = useState<'idle' | 'recording' | 'replaying'>('idle');
const [savedRecording, setSavedRecordingState] = useState<Recording | null>(null);
const [recordedFrameCount, setRecordedFrameCount] = useState(0);
const [replayNeedsCalibration, setReplayNeedsCalibration] = useState(false);
const [replayPacing, setReplayPacingState] = useState<ReplayPacing>('time');
const fileInputRef = useRef<HTMLInputElement | null>(null);

Expand All @@ -85,6 +99,7 @@ export function RecorderProvider({ children }: { children: React.ReactNode }) {
if (recorder.mode !== 'idle' || !savedRecording) return;
recorder.startReplay(savedRecording, true, replayPacing);
setMode('replaying');
setReplayNeedsCalibration(recorder.replayNeedsCalibration);
}, [recorder, replayPacing, savedRecording]);

const stopReplay = useCallback(() => {
Expand All @@ -93,6 +108,21 @@ export function RecorderProvider({ children }: { children: React.ReactNode }) {
setMode('idle');
}, [recorder]);

const calibrateReplay = useCallback(() => recorder.calibrateReplay(), [recorder]);

const onFrameState = useCallback(() => {
if (mode !== recorder.mode) {
if (mode === 'recording') {
setSavedRecordingState(recorder.getRecording());
setRecordedFrameCount(recorder.recordedFrameCount);
}
setMode(recorder.mode);
}
if (replayNeedsCalibration !== recorder.replayNeedsCalibration) {
setReplayNeedsCalibration(recorder.replayNeedsCalibration);
}
}, [mode, recorder, replayNeedsCalibration]);

const setReplayPacing = useCallback((pacing: ReplayPacing) => {
setReplayPacingState(pacing);
}, []);
Expand Down Expand Up @@ -155,6 +185,10 @@ export function RecorderProvider({ children }: { children: React.ReactNode }) {
stopRecord,
startReplay,
stopReplay,
calibrateReplay,
replayNeedsCalibration,
recordingInterrupted: recorder.recordingInterrupted,
onFrameState,
setReplayPacing,
onSaveRecording,
onLoadRecording,
Expand Down
88 changes: 88 additions & 0 deletions deps/cloudxr/webxr_client/src/TraceVisualization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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 type { ReactElement } from 'react';
import { type Points, Vector3 } from 'three';

import { TraceVisualization } from './TraceVisualization';
import type { RecordedFrame } from './xrInputRecorder';

let mockFrame: () => void;
const sample: RecordedFrame = {
timeMs: 0,
poses: {
leftGrip: { px: 1, py: 0, pz: 0, ox: 0, oy: 0, oz: 0, ow: 1 },
leftAim: null,
rightGrip: null,
rightAim: null,
},
gamepads: { left: null, right: null },
handJoints: { left: {}, right: {} },
};
const mockRecorder = {
currentFrame: sample as RecordedFrame | null,
replaySceneAlignment: { px: 10, py: 0, pz: 0, ox: 0, oy: 0, oz: 0, ow: 1 },
};

jest.mock('@react-three/fiber', () => ({
useFrame: (callback: () => void) => {
mockFrame = callback;
},
}));
jest.mock('./RecorderContext', () => ({
useRecorder: () => ({ recorder: mockRecorder, mode: 'replaying' }),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: () => ({ current: null }),
useEffect: jest.fn(),
}));

test('places the whole replay trail in calibrated scene space and hides it while paused', () => {
const tree = TraceVisualization({ showTrace: true });
const points = (tree.props.children as ReactElement<{ object: Points }>[])[0].props.object;
const worldPoint = (index: number) => {
points.updateMatrixWorld();
return new Vector3()
.fromBufferAttribute(points.geometry.getAttribute('position'), index)
.applyMatrix4(points.matrixWorld);
};
mockFrame();
expect(worldPoint(0).x).toBeCloseTo(11);

// A reset changes the placement of earlier samples as well as the newest one.
mockRecorder.replaySceneAlignment = {
px: 100,
py: 0,
pz: 0,
ox: 0,
oy: Math.SQRT1_2,
oz: 0,
ow: Math.SQRT1_2,
};
mockFrame();
for (const index of [0, 1]) {
expect(worldPoint(index).x).toBeCloseTo(100);
expect(worldPoint(index).z).toBeCloseTo(-1);
}
mockRecorder.currentFrame = null;
mockFrame();
expect(points.visible).toBe(false);
for (const { props } of tree.props.children as ReactElement<{ object: Points }>[]) {
props.object.geometry.dispose();
}
});
16 changes: 13 additions & 3 deletions deps/cloudxr/webxr_client/src/TraceVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,25 @@ export function TraceVisualization({ showTrace }: { showTrace: boolean }) {
);

useFrame(() => {
if (!showTrace) {
const frame = recorder.currentFrame;
if (!showTrace || !frame) {
channels.forEach(channel => {
channel.points.visible = false;
});
return;
}

const frame = recorder.currentFrame;
if (!frame) return;
// Transform the whole trail so origin resets also move its earlier samples.
const alignment = recorder.replaySceneAlignment;
for (const { points } of channels) {
points.position.set(alignment?.px ?? 0, alignment?.py ?? 0, alignment?.pz ?? 0);
points.quaternion.set(
alignment?.ox ?? 0,
alignment?.oy ?? 0,
alignment?.oz ?? 0,
alignment?.ow ?? 1
);
}
updateChannel(channels[0], frame.poses.leftGrip);
updateChannel(channels[1], frame.poses.rightGrip);
updateChannel(channels[2], frame.handJoints.left.wrist);
Expand Down
Loading
Loading