Add CloudXR VR streaming support for PICO 4 Ultra (Early Access)
Replaces manual H264/TCP stereo streaming with NVIDIA CloudXR for
higher-quality stereoscopic rendering and lower latency.
Changes:
- teleop_xr_agent.py: add --cloudxr flag (enables Isaac Sim XR mode,
disables manual StreamingManager)
- deps/cloudxr/: NVIDIA CloudXR.js SDK (Early Access) with Isaac Lab
teleop React web client
- deps/cloudxr/Dockerfile.wss.proxy: HAProxy WSS proxy for PICO 4 Ultra
HTTPS mode (routes wss://48322 → ws://49100)
- deps/cloudxr/isaac/webpack.dev.js: disable file watching to avoid
EMFILE errors with large node_modules
- deps/cloudxr/INSTALL.md: full setup guide
Usage:
# Start CloudXR Runtime + Isaac Lab
cd ~/IsaacLab && ./docker/container.py start \
--files docker-compose.cloudxr-runtime.patch.yaml \
--env-file .env.cloudxr-runtime
# Run teleop with CloudXR
~/IsaacLab/isaaclab.sh -p teleop_xr_agent.py \
--task Isaac-MindRobot-2i-DualArm-IK-Abs-v0 --cloudxr
# Serve web client
cd deps/cloudxr/isaac && npm run dev-server:https
This commit is contained in:
125
deps/cloudxr/helpers/BrowserCapabilities.ts
vendored
Normal file
125
deps/cloudxr/helpers/BrowserCapabilities.ts
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
interface CapabilityCheck {
|
||||
name: string;
|
||||
required: boolean;
|
||||
check: () => boolean | Promise<boolean>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const capabilities: CapabilityCheck[] = [
|
||||
{
|
||||
name: 'WebGL2',
|
||||
required: true,
|
||||
check: () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl2');
|
||||
return gl !== null;
|
||||
},
|
||||
message: 'WebGL2 is required for rendering',
|
||||
},
|
||||
{
|
||||
name: 'WebXR',
|
||||
required: true,
|
||||
check: () => {
|
||||
return 'xr' in navigator;
|
||||
},
|
||||
message: 'WebXR is required for VR/AR functionality',
|
||||
},
|
||||
{
|
||||
name: 'RTCPeerConnection',
|
||||
required: true,
|
||||
check: () => {
|
||||
return 'RTCPeerConnection' in window;
|
||||
},
|
||||
message: 'RTCPeerConnection is required for WebRTC streaming',
|
||||
},
|
||||
{
|
||||
name: 'requestVideoFrameCallback',
|
||||
required: true,
|
||||
check: () => {
|
||||
const video = document.createElement('video');
|
||||
return typeof video.requestVideoFrameCallback === 'function';
|
||||
},
|
||||
message: 'HTMLVideoElement.requestVideoFrameCallback is required for video frame processing',
|
||||
},
|
||||
{
|
||||
name: 'Canvas.captureStream',
|
||||
required: true,
|
||||
check: () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
return typeof canvas.captureStream === 'function';
|
||||
},
|
||||
message: 'Canvas.captureStream is required for video streaming',
|
||||
},
|
||||
{
|
||||
name: 'AV1 Codec Support',
|
||||
required: false,
|
||||
check: async () => {
|
||||
try {
|
||||
// Check if MediaCapabilities API is available
|
||||
if (!navigator.mediaCapabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check MediaCapabilities for AV1 decoding support
|
||||
const config = {
|
||||
type: 'webrtc' as MediaDecodingType,
|
||||
video: {
|
||||
contentType: 'video/av1',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
framerate: 60,
|
||||
bitrate: 15000000, // 15 Mbps
|
||||
},
|
||||
};
|
||||
|
||||
const result = await navigator.mediaCapabilities.decodingInfo(config);
|
||||
return result.supported;
|
||||
} catch (error) {
|
||||
console.warn('Error checking AV1 support:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: 'AV1 codec support is recommended for optimal streaming quality',
|
||||
},
|
||||
];
|
||||
|
||||
export async function checkCapabilities(): Promise<{
|
||||
success: boolean;
|
||||
failures: string[];
|
||||
warnings: string[];
|
||||
}> {
|
||||
const failures: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const requiredFailures: string[] = [];
|
||||
|
||||
for (const capability of capabilities) {
|
||||
try {
|
||||
const result = await Promise.resolve(capability.check());
|
||||
if (!result) {
|
||||
if (capability.required) {
|
||||
requiredFailures.push(capability.message);
|
||||
console.error(`Required capability missing: ${capability.message}`);
|
||||
} else {
|
||||
warnings.push(capability.message);
|
||||
console.warn(`Optional capability missing: ${capability.message}`);
|
||||
}
|
||||
failures.push(capability.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (capability.required) {
|
||||
requiredFailures.push(capability.message);
|
||||
console.error(`Error checking required capability ${capability.name}:`, error);
|
||||
} else {
|
||||
warnings.push(capability.message);
|
||||
console.warn(`Error checking optional capability ${capability.name}:`, error);
|
||||
}
|
||||
failures.push(capability.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: requiredFailures.length === 0,
|
||||
failures,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
98
deps/cloudxr/helpers/LoadIWER.ts
vendored
Normal file
98
deps/cloudxr/helpers/LoadIWER.ts
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
const IWER_version = '2.1.1';
|
||||
const IWER_DEVUI_version = '1.1.2';
|
||||
|
||||
export interface IWERLoadResult {
|
||||
supportsImmersive: boolean;
|
||||
iwerLoaded: boolean;
|
||||
}
|
||||
|
||||
export async function loadIWERIfNeeded(): Promise<IWERLoadResult> {
|
||||
let supportsImmersive = false;
|
||||
let iwerLoaded = false;
|
||||
|
||||
if ('xr' in navigator) {
|
||||
try {
|
||||
const vr = await (navigator.xr as XRSystem).isSessionSupported?.('immersive-vr');
|
||||
const ar = await (navigator.xr as XRSystem).isSessionSupported?.('immersive-ar');
|
||||
supportsImmersive = Boolean(vr || ar);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!supportsImmersive) {
|
||||
console.info('Immersive mode not supported, loading IWER as fallback.');
|
||||
|
||||
// Load IWER first
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://unpkg.com/iwer@${IWER_version}/build/iwer.min.js`;
|
||||
script.async = true;
|
||||
script.integrity = 'sha384-ZOdYbNlfA4q9jkBGcdmjy2ZYmjxy2uzncU6it3cPOHi12/WF048bamSU0Z5N+V5u';
|
||||
script.crossOrigin = 'anonymous';
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
script.onload = async () => {
|
||||
console.info('IWER loaded as fallback.');
|
||||
const IWERGlobal = (window as any).IWER || (globalThis as any).IWER;
|
||||
if (!IWERGlobal) {
|
||||
console.warn('IWER global not found after script load.');
|
||||
supportsImmersive = false;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load iwer-devui after IWER
|
||||
const devUIScript = document.createElement('script');
|
||||
devUIScript.src = `https://unpkg.com/@iwer/devui@${IWER_DEVUI_version}/build/iwer-devui.min.js`;
|
||||
devUIScript.async = true;
|
||||
devUIScript.integrity =
|
||||
'sha384-CG/gISX6PadiSzc8i2paU7CYLVsnVJaJ0tgoVnAPq/gyiTX6bddG5rwOgMDGlq74';
|
||||
devUIScript.crossOrigin = 'anonymous';
|
||||
|
||||
await new Promise<void>(devUIResolve => {
|
||||
devUIScript.onload = () => {
|
||||
console.info('IWER DevUI loaded.');
|
||||
devUIResolve();
|
||||
};
|
||||
devUIScript.onerror = error => {
|
||||
console.warn('Failed to load IWER DevUI:', error);
|
||||
devUIResolve();
|
||||
};
|
||||
document.head.appendChild(devUIScript);
|
||||
});
|
||||
|
||||
try {
|
||||
// Create XRDevice with Meta Quest 3 profile
|
||||
const xrDevice = new IWERGlobal.XRDevice(IWERGlobal.metaQuest3);
|
||||
|
||||
// Initialize DevUI with the XR device
|
||||
const IWER_DevUI = (window as any).IWER_DevUI || (globalThis as any).IWER_DevUI;
|
||||
if (IWER_DevUI?.DevUI) {
|
||||
xrDevice.installDevUI(IWER_DevUI.DevUI);
|
||||
console.info('IWER DevUI initialized with XR device.');
|
||||
} else {
|
||||
console.warn('IWER DevUI not found after script load, continuing without DevUI.');
|
||||
}
|
||||
|
||||
// Install the runtime and wait for it to be ready
|
||||
const maybePromise = xrDevice.installRuntime?.();
|
||||
if (maybePromise && typeof maybePromise.then === 'function') {
|
||||
await maybePromise;
|
||||
}
|
||||
supportsImmersive = true;
|
||||
iwerLoaded = true;
|
||||
} catch (e) {
|
||||
console.warn('IWER runtime install failed:', e);
|
||||
supportsImmersive = false;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('Failed to load IWER.');
|
||||
supportsImmersive = false;
|
||||
resolve();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
return { supportsImmersive, iwerLoaded };
|
||||
}
|
||||
2685
deps/cloudxr/helpers/WebGLState.ts
vendored
Normal file
2685
deps/cloudxr/helpers/WebGLState.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1220
deps/cloudxr/helpers/WebGLStateApply.ts
vendored
Normal file
1220
deps/cloudxr/helpers/WebGLStateApply.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
286
deps/cloudxr/helpers/WebGLStateBinding.ts
vendored
Normal file
286
deps/cloudxr/helpers/WebGLStateBinding.ts
vendored
Normal file
@@ -0,0 +1,286 @@
|
||||
import { WebGLStateTracker, WebGLState } from './WebGLState';
|
||||
import { apply } from './WebGLStateApply';
|
||||
|
||||
/**
|
||||
* BoundWebGLState - A WebGL2 context with automatic state tracking
|
||||
*
|
||||
* This is the original WebGL2 context with state-changing functions rebound
|
||||
* to call the tracker before forwarding to the original implementation.
|
||||
*/
|
||||
export class BoundWebGLState {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private tracker: WebGLStateTracker;
|
||||
private originalFunctions: Map<string, Function>;
|
||||
private savedState?: WebGLState;
|
||||
private trackingEnabled = true;
|
||||
|
||||
constructor(
|
||||
gl: WebGL2RenderingContext,
|
||||
tracker: WebGLStateTracker,
|
||||
originalFunctions: Map<string, Function>
|
||||
) {
|
||||
this.gl = gl;
|
||||
this.tracker = tracker;
|
||||
this.originalFunctions = originalFunctions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the internal state tracker
|
||||
*/
|
||||
private getTracker(): WebGLStateTracker {
|
||||
return this.tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current tracked WebGL state
|
||||
* This clones the state so it can be restored later
|
||||
*/
|
||||
save(): void {
|
||||
this.savedState = this.tracker.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the previously saved WebGL state
|
||||
* This applies the saved state back to the WebGL context
|
||||
* @throws {Error} If no state has been saved
|
||||
*/
|
||||
restore(): void {
|
||||
if (!this.savedState) {
|
||||
throw new Error('No state has been saved. Call save() before restore().');
|
||||
}
|
||||
|
||||
// Save the current tracking state and enable tracking during restore
|
||||
// This ensures the tracker stays synchronized with actual GL state
|
||||
const wasTrackingEnabled = this.trackingEnabled;
|
||||
this.trackingEnabled = true;
|
||||
|
||||
const currentState = this.tracker.getState();
|
||||
apply(this.gl, this.savedState, currentState);
|
||||
|
||||
// Restore the original tracking state
|
||||
this.trackingEnabled = wasTrackingEnabled;
|
||||
}
|
||||
|
||||
enableTracking(enable: boolean): void {
|
||||
this.trackingEnabled = enable;
|
||||
}
|
||||
|
||||
_enabled(): boolean {
|
||||
return this.trackingEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert all tracked functions back to their original implementations
|
||||
* This removes state tracking from the WebGL context
|
||||
*/
|
||||
revert(): void {
|
||||
const glAny = this.gl as any;
|
||||
|
||||
for (const [name, originalFunction] of this.originalFunctions.entries()) {
|
||||
glAny[name] = originalFunction;
|
||||
}
|
||||
|
||||
// Clear the map
|
||||
this.originalFunctions.clear();
|
||||
|
||||
// Remove the stored BoundWebGLState from the GL context
|
||||
delete glAny.__cloudxrBoundState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a WebGL2 context with automatic state tracking
|
||||
*
|
||||
* Rebinds state-changing methods on the WebGL context to automatically track
|
||||
* state changes before forwarding to the original implementation.
|
||||
*
|
||||
* @param gl - The WebGL2RenderingContext to wrap
|
||||
* @returns A BoundWebGLState instance that provides access to the tracker and revert functionality
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
||||
* const gl = canvas.getContext('webgl2')!;
|
||||
* const binding = bindGL(gl);
|
||||
*
|
||||
* // Use gl like a normal WebGL context - it's now tracked
|
||||
* gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
* gl.bindVertexArray(vao);
|
||||
*
|
||||
* // Save the current state
|
||||
* binding.save();
|
||||
*
|
||||
* // Make some temporary changes
|
||||
* gl.bindBuffer(gl.ARRAY_BUFFER, tempBuffer);
|
||||
* gl.enable(gl.BLEND);
|
||||
*
|
||||
* // Restore the saved state
|
||||
* binding.restore();
|
||||
*
|
||||
* // Access tracked state
|
||||
* const state = binding.getTracker().getState();
|
||||
* console.log(state.buffers?.arrayBuffer); // The bound buffer
|
||||
*
|
||||
* // When done, revert to stop tracking
|
||||
* binding.revert();
|
||||
* ```
|
||||
*/
|
||||
export function bindGL(gl: WebGL2RenderingContext): BoundWebGLState {
|
||||
const glAny = gl as any;
|
||||
|
||||
// Check if this GL context is already wrapped - prevent double-wrapping
|
||||
if (glAny.__cloudxrBoundState) {
|
||||
console.warn(
|
||||
'WebGL context is already wrapped with state tracking. Returning existing BoundWebGLState.'
|
||||
);
|
||||
return glAny.__cloudxrBoundState;
|
||||
}
|
||||
|
||||
// Create the tracker
|
||||
const tracker = new WebGLStateTracker();
|
||||
|
||||
// Store original functions for later reversion
|
||||
const originalFunctions = new Map<string, Function>();
|
||||
const wrappedFunctions = new Map<string, Function>();
|
||||
|
||||
const state = new BoundWebGLState(gl, tracker, originalFunctions);
|
||||
|
||||
// Store the BoundWebGLState on the GL context to prevent double-wrapping
|
||||
glAny.__cloudxrBoundState = state;
|
||||
|
||||
// Helper function to bind a method
|
||||
const bind = (name: string, trackerMethod: Function) => {
|
||||
// CRITICAL: Store the original BEFORE we replace it, otherwise we'll store the wrapper
|
||||
const originalMethod = glAny[name];
|
||||
if (!originalMethod) {
|
||||
throw new Error('Original method not found for ' + name);
|
||||
}
|
||||
if (originalMethod === wrappedFunctions.get(name)) {
|
||||
throw new Error('Wrapped function already bound for ' + name);
|
||||
}
|
||||
|
||||
const original = originalMethod.bind(gl);
|
||||
originalFunctions.set(name, original);
|
||||
const wrappedFunction = (...args: any[]) => {
|
||||
if (state._enabled()) {
|
||||
trackerMethod.apply(tracker, args);
|
||||
}
|
||||
return original(...args);
|
||||
};
|
||||
wrappedFunctions.set(name, wrappedFunction);
|
||||
|
||||
glAny[name] = wrappedFunction;
|
||||
};
|
||||
|
||||
// Buffer bindings
|
||||
bind('bindBuffer', tracker.bindBuffer);
|
||||
bind('bindBufferBase', tracker.bindBufferBase);
|
||||
bind('bindBufferRange', tracker.bindBufferRange);
|
||||
|
||||
// Buffer lifecycle tracking (for validation without GPU calls)
|
||||
const originalCreateBuffer = glAny.createBuffer.bind(gl);
|
||||
originalFunctions.set('createBuffer', originalCreateBuffer);
|
||||
glAny.createBuffer = (): WebGLBuffer | null => {
|
||||
const buffer = originalCreateBuffer();
|
||||
if (buffer) {
|
||||
tracker.createBuffer(buffer);
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
bind('deleteBuffer', tracker.deleteBuffer);
|
||||
|
||||
// VAO and vertex attributes
|
||||
bind('bindVertexArray', tracker.bindVertexArray);
|
||||
bind('deleteVertexArray', tracker.deleteVertexArray);
|
||||
bind('enableVertexAttribArray', tracker.enableVertexAttribArray);
|
||||
bind('disableVertexAttribArray', tracker.disableVertexAttribArray);
|
||||
bind('vertexAttribPointer', tracker.vertexAttribPointer);
|
||||
bind('vertexAttribIPointer', tracker.vertexAttribIPointer);
|
||||
bind('vertexAttribDivisor', tracker.vertexAttribDivisor);
|
||||
|
||||
// Texture bindings
|
||||
bind('activeTexture', tracker.activeTexture);
|
||||
bind('bindTexture', tracker.bindTexture);
|
||||
|
||||
// Program binding
|
||||
bind('useProgram', tracker.useProgram);
|
||||
|
||||
// Framebuffer bindings
|
||||
bind('bindFramebuffer', tracker.bindFramebuffer);
|
||||
bind('framebufferTexture2D', tracker.framebufferTexture2D);
|
||||
bind('framebufferRenderbuffer', tracker.framebufferRenderbuffer);
|
||||
bind('framebufferTextureLayer', tracker.framebufferTextureLayer);
|
||||
bind('drawBuffers', tracker.drawBuffers);
|
||||
bind('readBuffer', tracker.readBuffer);
|
||||
|
||||
// Renderbuffer binding
|
||||
bind('bindRenderbuffer', tracker.bindRenderbuffer);
|
||||
|
||||
// Transform feedback
|
||||
bind('bindTransformFeedback', tracker.bindTransformFeedback);
|
||||
bind('beginTransformFeedback', tracker.beginTransformFeedback);
|
||||
bind('endTransformFeedback', tracker.endTransformFeedback);
|
||||
bind('pauseTransformFeedback', tracker.pauseTransformFeedback);
|
||||
bind('resumeTransformFeedback', tracker.resumeTransformFeedback);
|
||||
|
||||
// Capabilities (enable/disable)
|
||||
bind('enable', tracker.enable);
|
||||
bind('disable', tracker.disable);
|
||||
|
||||
// Viewport and scissor
|
||||
bind('viewport', tracker.viewport);
|
||||
bind('scissor', tracker.scissor);
|
||||
|
||||
// Clear values
|
||||
bind('clearColor', tracker.clearColor);
|
||||
bind('clearDepth', tracker.clearDepth);
|
||||
bind('clearStencil', tracker.clearStencil);
|
||||
|
||||
// Blend state
|
||||
bind('blendColor', tracker.blendColor);
|
||||
bind('blendEquation', tracker.blendEquation);
|
||||
bind('blendEquationSeparate', tracker.blendEquationSeparate);
|
||||
bind('blendFunc', tracker.blendFunc);
|
||||
bind('blendFuncSeparate', tracker.blendFuncSeparate);
|
||||
|
||||
// Depth state
|
||||
bind('depthFunc', tracker.depthFunc);
|
||||
bind('depthMask', tracker.depthMask);
|
||||
bind('depthRange', tracker.depthRange);
|
||||
|
||||
// Stencil state
|
||||
bind('stencilFunc', tracker.stencilFunc);
|
||||
bind('stencilFuncSeparate', tracker.stencilFuncSeparate);
|
||||
bind('stencilMask', tracker.stencilMask);
|
||||
bind('stencilMaskSeparate', tracker.stencilMaskSeparate);
|
||||
bind('stencilOp', tracker.stencilOp);
|
||||
bind('stencilOpSeparate', tracker.stencilOpSeparate);
|
||||
|
||||
// Color mask
|
||||
bind('colorMask', tracker.colorMask);
|
||||
|
||||
// Culling and face orientation
|
||||
bind('cullFace', tracker.cullFace);
|
||||
bind('frontFace', tracker.frontFace);
|
||||
|
||||
// Line width
|
||||
bind('lineWidth', tracker.lineWidth);
|
||||
|
||||
// Polygon offset
|
||||
bind('polygonOffset', tracker.polygonOffset);
|
||||
|
||||
// Sample coverage
|
||||
bind('sampleCoverage', tracker.sampleCoverage);
|
||||
|
||||
// Pixel store parameters
|
||||
bind('pixelStorei', tracker.pixelStorei);
|
||||
|
||||
// Sampler binding
|
||||
bind('bindSampler', tracker.bindSampler);
|
||||
|
||||
// Query operations
|
||||
bind('beginQuery', tracker.beginQuery);
|
||||
bind('endQuery', tracker.endQuery);
|
||||
|
||||
return state;
|
||||
}
|
||||
54
deps/cloudxr/helpers/WebGlUtils.ts
vendored
Normal file
54
deps/cloudxr/helpers/WebGlUtils.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
export function getOrCreateCanvas(
|
||||
id: string,
|
||||
resolution?: { width: number; height: number }
|
||||
): HTMLCanvasElement {
|
||||
let canvas = document.getElementById(id) as HTMLCanvasElement | null;
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas') as HTMLCanvasElement;
|
||||
canvas.id = id;
|
||||
// canvas.style.display = "none";
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
if (!canvas) {
|
||||
throw new Error('Failed to create canvas');
|
||||
}
|
||||
if (resolution) {
|
||||
canvas.width = resolution.width;
|
||||
canvas.height = resolution.height;
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function logOrThrow(tagString: string, gl: WebGL2RenderingContext) {
|
||||
const err = gl.getError();
|
||||
if (err !== gl.NO_ERROR) {
|
||||
let errorString;
|
||||
switch (err) {
|
||||
case gl.INVALID_ENUM:
|
||||
errorString = 'INVALID_ENUM';
|
||||
break;
|
||||
case gl.INVALID_VALUE:
|
||||
errorString = 'INVALID_VALUE';
|
||||
break;
|
||||
case gl.INVALID_OPERATION:
|
||||
errorString = 'INVALID_OPERATION';
|
||||
break;
|
||||
case gl.INVALID_FRAMEBUFFER_OPERATION:
|
||||
errorString = 'INVALID_FRAMEBUFFER_OPERATION';
|
||||
break;
|
||||
case gl.OUT_OF_MEMORY:
|
||||
errorString = 'OUT_OF_MEMORY';
|
||||
break;
|
||||
case gl.CONTEXT_LOST_WEBGL:
|
||||
errorString = 'CONTEXT_LOST_WEBGL';
|
||||
break;
|
||||
default:
|
||||
errorString = 'UNKNOWN_ERROR';
|
||||
break;
|
||||
}
|
||||
|
||||
throw new Error('WebGL error: ' + tagString + ': ' + errorString + ' (' + err + ')');
|
||||
} else {
|
||||
console.debug('WebGL no-error: ' + tagString);
|
||||
}
|
||||
}
|
||||
35
deps/cloudxr/helpers/overridePressureObserver.ts
vendored
Normal file
35
deps/cloudxr/helpers/overridePressureObserver.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Override PressureObserver to catch errors from unexpected browser implementations.
|
||||
*
|
||||
* Some browsers have buggy PressureObserver implementations that throw errors
|
||||
* when observe() is called. This wrapper catches and logs those errors instead
|
||||
* of letting them propagate.
|
||||
*
|
||||
* This should be called early in your application, before any code attempts
|
||||
* to use PressureObserver.
|
||||
*/
|
||||
export function overridePressureObserver(): void {
|
||||
if (typeof window === 'undefined' || !(window as any).PressureObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OriginalPressureObserver = (window as any).PressureObserver;
|
||||
|
||||
(window as any).PressureObserver = class PressureObserver extends OriginalPressureObserver {
|
||||
observe(source: any) {
|
||||
try {
|
||||
const result = super.observe(source);
|
||||
if (result && typeof result.catch === 'function') {
|
||||
return result.catch((e: Error) => {
|
||||
console.warn('PressureObserver.observe() failed:', e.message);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
console.warn('PressureObserver.observe() failed:', e.message);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
293
deps/cloudxr/helpers/utils.ts
vendored
Normal file
293
deps/cloudxr/helpers/utils.ts
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Parses URL parameters and returns them as an object
|
||||
* @param location - Optional location object (defaults to window.location)
|
||||
* @returns Object with URL parameters as key-value pairs
|
||||
*/
|
||||
export function getUrlParams(location: Location = window.location): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const queryString = location.search.substring(1);
|
||||
|
||||
if (queryString) {
|
||||
const pairs = queryString.split('&');
|
||||
for (const pair of pairs) {
|
||||
const [key, value = ''] = pair.split('=');
|
||||
params[decodeURIComponent(key)] = decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables localStorage functionality for form elements
|
||||
* @param element - The HTML input or select element to enable localStorage for
|
||||
* @param key - The localStorage key to use for saving/loading the value
|
||||
*/
|
||||
export function enableLocalStorage(element: HTMLInputElement | HTMLSelectElement, key: string) {
|
||||
// Check if localStorage is already enabled for this element and key
|
||||
const localStorageKey = `__localStorageEnabled_${key}`;
|
||||
if ((element as any)[localStorageKey]) {
|
||||
console.warn(`localStorage already enabled for ${key}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved value from localStorage
|
||||
try {
|
||||
// Check if the key exists in localStorage, not just if it has values
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
const savedValue = localStorage.getItem(key);
|
||||
element.value = savedValue || '';
|
||||
console.info(`Loaded saved ${key} from localStorage:`, savedValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${key}: Failed to load saved value from localStorage:`, error);
|
||||
}
|
||||
|
||||
// Set up event listener to save value when changed
|
||||
const changeHandler = () => {
|
||||
try {
|
||||
// Always save the value, even if it's empty
|
||||
localStorage.setItem(key, element.value);
|
||||
console.info(`Saved ${key} to localStorage:`, JSON.stringify(element.value));
|
||||
} catch (error) {
|
||||
console.warn(`${key}: Failed to save to localStorage:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('change', changeHandler);
|
||||
|
||||
// Mark this element as having localStorage enabled for this key
|
||||
(element as any)[localStorageKey] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips protocol prefixes (http:// or https://) from a URL string
|
||||
* @param url - The URL string to clean
|
||||
* @returns The URL without protocol prefix
|
||||
*/
|
||||
function stripProtocol(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection configuration object containing server connection details
|
||||
*/
|
||||
export interface ConnectionConfiguration {
|
||||
/** Final server IP address (may be modified for HTTPS proxy routing) */
|
||||
serverIP: string;
|
||||
|
||||
/** Final port number (fixed to 443 for HTTPS, user-provided for HTTP) */
|
||||
port: number;
|
||||
|
||||
/** Whether the connection will use secure protocol (HTTPS/WSS) */
|
||||
useSecureConnection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CloudXR configuration interface containing all streaming settings
|
||||
*/
|
||||
export interface CloudXRConfig {
|
||||
/** IP address of the CloudXR streaming server */
|
||||
serverIP: string;
|
||||
|
||||
/** Port number for the CloudXR server connection */
|
||||
port: number;
|
||||
|
||||
/** Whether to use secure connection (HTTPS/WSS) or insecure (HTTP/WS) */
|
||||
useSecureConnection: boolean;
|
||||
|
||||
/** Width of each eye in pixels (must be multiple of 16) */
|
||||
perEyeWidth: number;
|
||||
|
||||
/** Height of each eye in pixels (must be multiple of 16) */
|
||||
perEyeHeight: number;
|
||||
|
||||
/** Target frame rate for the XR device in frames per second (FPS) */
|
||||
deviceFrameRate: number;
|
||||
|
||||
/** Maximum streaming bitrate in Megabits per second (Mbps) */
|
||||
maxStreamingBitrateMbps: number;
|
||||
|
||||
/** XR immersive mode: 'ar' for augmented reality, 'vr' for virtual reality */
|
||||
immersiveMode: 'ar' | 'vr';
|
||||
|
||||
/** Application identifier string for the CloudXR session */
|
||||
app: string;
|
||||
|
||||
/** Type of server being connected to */
|
||||
serverType: string;
|
||||
|
||||
/** Optional proxy URL for HTTPS routing (e.g., 'https://proxy.example.com/'); if empty, uses direct WSS connection */
|
||||
proxyUrl: string;
|
||||
|
||||
/** Preferred XR reference space for tracking and positioning */
|
||||
referenceSpaceType: 'auto' | 'local-floor' | 'local' | 'viewer' | 'unbounded';
|
||||
|
||||
/** XR reference space offset along X axis in meters (positive is right) */
|
||||
xrOffsetX?: number;
|
||||
/** XR reference space offset along Y axis in meters (positive is up) */
|
||||
xrOffsetY?: number;
|
||||
/** XR reference space offset along Z axis in meters (positive is backward) */
|
||||
xrOffsetZ?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines connection configuration based on protocol and user inputs
|
||||
* Supports both direct WSS connections and proxy routing for HTTPS
|
||||
*
|
||||
* @param serverIP - The user-provided server IP address
|
||||
* @param port - The user-provided port number
|
||||
* @param proxyUrl - Optional proxy URL for HTTPS routing (if provided, uses proxy routing; otherwise direct connection)
|
||||
* @param location - Optional location object (defaults to window.location)
|
||||
* @returns Object containing server IP, port, and security settings
|
||||
* @throws {Error} When proxy URL format is invalid (must start with https://)
|
||||
*/
|
||||
export function getConnectionConfig(
|
||||
serverIP: string,
|
||||
port: number,
|
||||
proxyUrl: string,
|
||||
location: Location = window.location
|
||||
): ConnectionConfiguration {
|
||||
let finalServerIP = '';
|
||||
let finalPort = port;
|
||||
let finalUseSecureConnection = false;
|
||||
|
||||
// Determine if we should use secure connection based on page protocol
|
||||
if (location.protocol === 'https:') {
|
||||
console.info('Running on HTTPS protocol - using secure WebSocket (WSS)');
|
||||
finalUseSecureConnection = true;
|
||||
|
||||
// Check if proxy URL is provided for routing
|
||||
const trimmedProxyUrl = proxyUrl?.trim();
|
||||
if (trimmedProxyUrl) {
|
||||
// Proxy routing mode
|
||||
console.info('Proxy URL provided - using proxy routing mode');
|
||||
|
||||
if (!trimmedProxyUrl.startsWith('https://')) {
|
||||
throw new Error('Proxy URL must start with https://. Received: ' + trimmedProxyUrl);
|
||||
}
|
||||
|
||||
// Use port 443 for proxy routing (standard HTTPS port)
|
||||
finalPort = 443;
|
||||
|
||||
// Route through proxy: if specific server IP provided, append it to proxy URL
|
||||
if (serverIP && serverIP !== 'localhost' && serverIP !== '127.0.0.1') {
|
||||
// Route to proxy with IP
|
||||
const cleanServerIP = stripProtocol(serverIP);
|
||||
// Clean proxy URL - strip protocol and trailing slash
|
||||
const cleanProxyUrl = stripProtocol(trimmedProxyUrl).replace(/\/$/, '');
|
||||
finalServerIP = `${cleanProxyUrl}/${cleanServerIP}`;
|
||||
console.info(`Using HTTPS proxy with IP: ${finalServerIP}`);
|
||||
} else {
|
||||
// Route to proxy without IP
|
||||
finalServerIP = stripProtocol(trimmedProxyUrl).replace(/\/$/, '');
|
||||
console.info(`Using HTTPS proxy without specific IP: ${finalServerIP}`);
|
||||
}
|
||||
} else {
|
||||
// Direct WSS connection mode
|
||||
console.info('No proxy URL - using direct WSS connection');
|
||||
|
||||
// Handle server IP input
|
||||
if (serverIP && serverIP !== 'localhost' && serverIP !== '127.0.0.1') {
|
||||
finalServerIP = stripProtocol(serverIP);
|
||||
console.info('Using user-provided server IP:', finalServerIP);
|
||||
} else {
|
||||
finalServerIP = new URL(location.href).hostname;
|
||||
console.info('Using default server IP from window location:', finalServerIP);
|
||||
}
|
||||
|
||||
// Use user-provided port for direct WSS
|
||||
if (port && !isNaN(port)) {
|
||||
finalPort = port;
|
||||
console.info('Using user-provided port:', finalPort);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HTTP protocol - direct WS connection
|
||||
console.info('Running on HTTP protocol - using insecure WebSocket (WS)');
|
||||
finalUseSecureConnection = false;
|
||||
|
||||
// Handle server IP input
|
||||
if (serverIP && serverIP !== 'localhost' && serverIP !== '127.0.0.1') {
|
||||
finalServerIP = stripProtocol(serverIP);
|
||||
console.info('Using user-provided server IP:', finalServerIP);
|
||||
} else {
|
||||
finalServerIP = new URL(location.href).hostname;
|
||||
console.info('Using default server IP from window location:', finalServerIP);
|
||||
}
|
||||
|
||||
// Handle port input
|
||||
if (port && !isNaN(port)) {
|
||||
finalPort = port;
|
||||
console.info('Using user-provided port:', finalPort);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverIP: finalServerIP,
|
||||
port: finalPort,
|
||||
useSecureConnection: finalUseSecureConnection,
|
||||
} as ConnectionConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up certificate acceptance link for self-signed certificates in HTTPS mode
|
||||
* Shows a link to accept certificates when using direct WSS connection (no proxy)
|
||||
*
|
||||
* @param serverIpInput - Input element for server IP address
|
||||
* @param portInput - Input element for port number
|
||||
* @param proxyUrlInput - Input element for proxy URL
|
||||
* @param certAcceptanceLink - Container element for the certificate link
|
||||
* @param certLink - Anchor element for the certificate URL
|
||||
* @param location - Optional location object (defaults to window.location)
|
||||
* @returns Cleanup function to remove event listeners
|
||||
*/
|
||||
export function setupCertificateAcceptanceLink(
|
||||
serverIpInput: HTMLInputElement,
|
||||
portInput: HTMLInputElement,
|
||||
proxyUrlInput: HTMLInputElement,
|
||||
certAcceptanceLink: HTMLElement,
|
||||
certLink: HTMLAnchorElement,
|
||||
location: Location = window.location
|
||||
): () => void {
|
||||
/**
|
||||
* Updates the certificate acceptance link based on current configuration
|
||||
* Shows link only when in HTTPS mode without proxy (direct WSS)
|
||||
*/
|
||||
const updateCertLink = () => {
|
||||
const isHttps = location.protocol === 'https:';
|
||||
const hasProxy = proxyUrlInput.value.trim().length > 0;
|
||||
const portValue = parseInt(portInput.value, 10);
|
||||
const defaultPort = hasProxy ? 443 : 48322;
|
||||
const port = portValue || defaultPort;
|
||||
|
||||
// Show link only in HTTPS mode without proxy
|
||||
if (isHttps && !hasProxy) {
|
||||
let serverIp = serverIpInput.value.trim();
|
||||
if (!serverIp) {
|
||||
serverIp = new URL(location.href).hostname;
|
||||
}
|
||||
const url = `https://${serverIp}:${port}/`;
|
||||
certAcceptanceLink.style.display = 'block';
|
||||
certLink.href = url;
|
||||
certLink.textContent = `Click ${url} to accept cert`;
|
||||
} else {
|
||||
certAcceptanceLink.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners to update link when inputs change
|
||||
serverIpInput.addEventListener('input', updateCertLink);
|
||||
portInput.addEventListener('input', updateCertLink);
|
||||
proxyUrlInput.addEventListener('input', updateCertLink);
|
||||
|
||||
// Initial update after localStorage values are restored
|
||||
setTimeout(updateCertLink, 0);
|
||||
|
||||
// Return cleanup function to remove event listeners
|
||||
return () => {
|
||||
serverIpInput.removeEventListener('input', updateCertLink);
|
||||
portInput.removeEventListener('input', updateCertLink);
|
||||
proxyUrlInput.removeEventListener('input', updateCertLink);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user