@displayxr/inline3d 1.49.3 → 1.51.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.
- package/CHANGELOG.md +56 -0
- package/README.md +6 -1
- package/call.d.ts +16 -0
- package/dist/call.js +6 -6
- package/docs/making-3d-look-right.md +14 -7
- package/docs/woven-canvas-rules.md +30 -1
- package/hands.d.ts +198 -0
- package/index.d.ts +4 -0
- package/js/call/share.js +90 -0
- package/js/camera/capture.js +9 -0
- package/js/camera/half-sbs.js +109 -0
- package/js/inline3d-call.js +1 -1
- package/js/inline3d-hands.js +618 -0
- package/js/inline3d-model-entry.js +3 -1
- package/js/inline3d-splat-playcanvas.js +71 -16
- package/js/inline3d-splat-shared.js +12 -1
- package/js/inline3d-viewer.js +39 -8
- package/llms.txt +2 -2
- package/model.d.ts +10 -0
- package/package.json +7 -1
- package/splat.d.ts +16 -0
- package/viewer.d.ts +12 -0
|
@@ -3,15 +3,22 @@
|
|
|
3
3
|
The SDK's defaults already give comfortable, real-looking 3D. These rules cover the mistakes seen
|
|
4
4
|
on real panels; everything else about how your scene looks is your call.
|
|
5
5
|
|
|
6
|
-
1. **
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
1. **The default depth is the glass. Keep the viewer's placement, and frame the first view.**
|
|
7
|
+
Centre your subject on z = 0 so it straddles the screen: a little in front, most of it just
|
|
8
|
+
behind, with as little total disparity as its shape allows. `addModel`, `addSplat` and
|
|
9
|
+
`SceneViewer` do this for you: they scale the subject to its real proportions and centre it on
|
|
10
|
+
the glass. A scene you build yourself follows the same rule. Don't push it back, shrink it or
|
|
11
|
+
flatten it to make it "more comfortable": recession costs disparity and crosstalk like pop-out
|
|
12
|
+
does, and only buys softness. Recede content only where it must clear something flat in front
|
|
13
|
+
of it (an HTML label over the scene), and only that part; HTML beside the scene needs no room.
|
|
14
|
+
The first view shows the whole subject, centred: a globe or a tank
|
|
10
15
|
that starts low and cut off by the frame reads as broken. If the user can zoom, cap zoom-in
|
|
11
16
|
before the subject comes uncomfortably far out, and leave room to zoom out.
|
|
12
|
-
2. **
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
2. **Tilt flat arrangements; don't push them back.** Orbits, a map, a board game: tilt the plane
|
|
18
|
+
they lie in about an axis through the glass (about 25–40° from face-on is plenty), so its near
|
|
19
|
+
edge comes slightly forward, its far edge recedes and its centre stays on the glass. Only the
|
|
20
|
+
exactly face-on view reads as flat circles; a fairly top-down view is fine. A chart, a figure
|
|
21
|
+
or a building starts upright.
|
|
15
22
|
3. **Moving things: keep the forward swing small.** Objects that orbit, fly or swing toward the
|
|
16
23
|
viewer are where pop-out becomes uncomfortable. Keep their paths mostly at or behind the glass,
|
|
17
24
|
and fade them out as they come close to the viewer. A follow camera keeps its subject in frame
|
|
@@ -159,7 +159,16 @@ one region are **refused as ambiguous**, and neither is drawn from. Cut between
|
|
|
159
159
|
two pictures, blend them **inside one canvas**
|
|
160
160
|
([motion, §3 Crossfade](authoring-motion-and-effects.md#crossfade)).
|
|
161
161
|
|
|
162
|
-
Many woven canvases in one frame (a gallery wall) are fine. Each one joins by its own identity
|
|
162
|
+
Many woven canvases in one frame (a gallery wall) are fine. Each one joins by its own identity,
|
|
163
|
+
**as long as no two of them overlap.**
|
|
164
|
+
|
|
165
|
+
**Never leave two woven canvases overlapping.** This is the static case of the crossfade: a splat
|
|
166
|
+
canvas laid over a full-page scene canvas, both live. The Android DisplayXR Browser logs
|
|
167
|
+
`[DisplayXR] inline-3D OVERLAPPING tiles …` and an `inline-3D recovery draw`; on a
|
|
168
|
+
tablet the top canvas head-tracked but showed no stereo, and adding a second splat crashed the tab.
|
|
169
|
+
The same page looked fine on a Windows panel, so a desktop check does not clear it. Hide the canvas
|
|
170
|
+
underneath with `display: none` while the other one shows, and restore it when that one goes, so
|
|
171
|
+
only one woven tile covers any point of the screen.
|
|
163
172
|
|
|
164
173
|
### 7. No CSS effects on a woven canvas or on any of its ancestors
|
|
165
174
|
|
|
@@ -362,6 +371,26 @@ like a runtime or browser bug on the panel and turned out to be the page.
|
|
|
362
371
|
`wall.hardwareDisplayState === '2d'`. The SDK's eased mode switch ramps disparity out before the
|
|
363
372
|
panel flips and in only after it reports 3D. The mode is display-wide: every woven tile goes flat
|
|
364
373
|
while it is in force.
|
|
374
|
+
- **A 2D/3D toggle: try the mode switch, then fall back.** Not every browser can forward a mode
|
|
375
|
+
request yet. The DisplayXR Browser for Android rejects `setStereoEnabled` /
|
|
376
|
+
`requestRenderingMode` with a `NotSupportedError` while still listing its modes as
|
|
377
|
+
`isRequestable: true`. You can't detect this in advance:
|
|
378
|
+
`isRequestable` overstates it, and the Android browser sends a desktop user agent. So call
|
|
379
|
+
`setStereoEnabled(false)` and, if it rejects with a `NotSupportedError`, flatten the image
|
|
380
|
+
yourself: ease your rig's `ipdFactor` and `parallaxFactor` to 0 over ~300 ms (the SDK's own
|
|
381
|
+
ramp length), and back to your values for 3D. Both views become identical and the image reads
|
|
382
|
+
flat, though the panel's lens stays on, so it is a softer 2D than the real 1-view mode. Keep
|
|
383
|
+
the button visible either way; only a different rejection (no 1-view mode listed at all) is a
|
|
384
|
+
reason to hide it.
|
|
385
|
+
```js
|
|
386
|
+
async function setFlat(flat) {
|
|
387
|
+
try { await wall.setStereoEnabled(!flat); }
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (e.name !== 'NotSupportedError') throw e;
|
|
390
|
+
easeRigFactors(flat ? 0 : 1, 300); // your rig: ipdFactor / parallaxFactor × k
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
365
394
|
- **Keep the frames after `setSource` resolves free of per-splat CPU work.** A slideshow ran 105
|
|
366
395
|
`pick()` calls (24 to plan an animation path, 81 for a diagnostic depth grid) right after every
|
|
367
396
|
swap. On the Windows panel that was a 1.3 s main-thread stall at the end of every transition,
|
package/hands.d.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Type definitions for @displayxr/inline3d/hands.
|
|
2
|
+
// EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
|
|
3
|
+
//
|
|
4
|
+
// Tracked hands on an inline-3D session (WebXR Hand Input, DisplayXR Browser 1.8.0+ on Windows
|
|
5
|
+
// with a hand provider), and the pinch gestures that drive a viewer's orbit from them. Inert
|
|
6
|
+
// everywhere else: `watchHands` reports no hands and `handControls` moves nothing.
|
|
7
|
+
|
|
8
|
+
import type { Inline3D, Inline3DUnsupported } from './index.js';
|
|
9
|
+
|
|
10
|
+
/** The 25 WebXR hand joint names, in `XRHand` iteration order. */
|
|
11
|
+
export const HAND_JOINTS: ReadonlyArray<string>;
|
|
12
|
+
|
|
13
|
+
/** Gesture tuning. Metres in the joints' frame (the rig frame), radians. */
|
|
14
|
+
export interface HandGestureConfig {
|
|
15
|
+
/** Thumb-tip to index-tip distance that starts a pinch, for a hand of `referencePalmM` (default 0.025). */
|
|
16
|
+
pinchEnterM: number;
|
|
17
|
+
/** Distance that ends it, > enter (default 0.04). */
|
|
18
|
+
pinchExitM: number;
|
|
19
|
+
/**
|
|
20
|
+
* Palm length every distance here is stated for; a sample's own palm length scales them (pinch
|
|
21
|
+
* thresholds, orbit gain, separation guard), so a scaled hand behaves the same. 0 = never scale
|
|
22
|
+
* (default 0.095).
|
|
23
|
+
*/
|
|
24
|
+
referencePalmM: number;
|
|
25
|
+
/** Radians of orbit per metre of pinch-point drag, for a reference-size hand (default 10). */
|
|
26
|
+
orbitRadPerM: number;
|
|
27
|
+
/** EMA time constant on the pinch point, seconds; 0 = off (default 0.05). */
|
|
28
|
+
smoothTauS: number;
|
|
29
|
+
/** Per-frame zoom ratio clamp, both ways (default 1.25). */
|
|
30
|
+
maxZoomStep: number;
|
|
31
|
+
/** Two-hand gestures are ignored while the hands are further apart than this (default 1.2). */
|
|
32
|
+
maxHandSeparationM: number;
|
|
33
|
+
}
|
|
34
|
+
export const HAND_GESTURE_DEFAULTS: Readonly<HandGestureConfig>;
|
|
35
|
+
|
|
36
|
+
/** Two-threshold pinch state. */
|
|
37
|
+
export declare class PinchDetector {
|
|
38
|
+
readonly pinched: boolean;
|
|
39
|
+
reset(): void;
|
|
40
|
+
/** `tracked: false` releases regardless of distance. Returns the new state. */
|
|
41
|
+
update(tracked: boolean, tipDistance: number, enter: number, exit: number): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One hand's input to the gesture controller. Positions `[x, y, z]`, metres. */
|
|
45
|
+
export interface HandGestureSample {
|
|
46
|
+
tracked?: boolean;
|
|
47
|
+
thumbTip: ArrayLike<number>;
|
|
48
|
+
indexTip: ArrayLike<number>;
|
|
49
|
+
/** Wrist → middle knuckle, metres; 0 / absent = unknown (the metre thresholds apply as given). */
|
|
50
|
+
palmLength?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The pose delta for one frame, in the SUBJECT's convention (what `SceneViewer.nudgePose` takes):
|
|
55
|
+
* yaw / pitch / roll in radians to ADD, zoom a ratio to MULTIPLY. A pinch dragged right → yaw +;
|
|
56
|
+
* dragged up → pitch −; two hands turning counter-clockwise → roll +; spreading → zoom > 1.
|
|
57
|
+
*/
|
|
58
|
+
export interface HandGestureDelta {
|
|
59
|
+
yaw: number;
|
|
60
|
+
pitch: number;
|
|
61
|
+
roll: number;
|
|
62
|
+
zoom: number;
|
|
63
|
+
/** Exactly one hand pinched this frame. */
|
|
64
|
+
oneHand: boolean;
|
|
65
|
+
/** Both hands pinched this frame. */
|
|
66
|
+
twoHand: boolean;
|
|
67
|
+
any: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pinch gestures → pose deltas, per frame. Pure arithmetic (no DOM, no WebXR): a port of the
|
|
72
|
+
* DisplayXR Model Viewer's controls. Hand-relative pinch (thresholds scale by palm length), one
|
|
73
|
+
* pinched hand orbits, two pinched hands zoom (separation ratio), roll (twist in the display
|
|
74
|
+
* plane) and orbit (midpoint drag). The first frame of a gesture anchors; a hand gained or lost
|
|
75
|
+
* re-anchors, so a mode change never jumps.
|
|
76
|
+
*/
|
|
77
|
+
export declare class HandGestureController {
|
|
78
|
+
constructor(config?: Partial<HandGestureConfig>);
|
|
79
|
+
config: HandGestureConfig;
|
|
80
|
+
update(frame: {
|
|
81
|
+
/** Seconds, any origin (drives the EMA). */
|
|
82
|
+
time?: number;
|
|
83
|
+
left?: HandGestureSample | null;
|
|
84
|
+
right?: HandGestureSample | null;
|
|
85
|
+
/** Multiplies every position first (default 1). */
|
|
86
|
+
lengthScale?: number;
|
|
87
|
+
}): HandGestureDelta;
|
|
88
|
+
/** Pinch state after the last update: `0` / `'left'`, `1` / `'right'`. */
|
|
89
|
+
pinched(hand: 0 | 1 | 'left' | 'right'): boolean;
|
|
90
|
+
/** Last thumb-index tip distance, metres; < 0 when untracked. */
|
|
91
|
+
tipDistance(hand: 0 | 1 | 'left' | 'right'): number;
|
|
92
|
+
/** Forget every anchor. */
|
|
93
|
+
reset(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Can this document deliver hand sources at all (`XRHand` present)? */
|
|
97
|
+
export function handInputAvailable(): boolean;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The browser's per-frame hand gate: `'available'`, a withheld reason (`'camera-rig'`,
|
|
101
|
+
* `'posed-rig'`, `'rig-factors'`, `'no-canvas'`, `'not-fullscreen'`, `'scaled-rig'`), or
|
|
102
|
+
* `'unknown'` where the session has no such surface.
|
|
103
|
+
*/
|
|
104
|
+
export function handGate(session: XRSession | null | undefined): string;
|
|
105
|
+
|
|
106
|
+
/** One tracked hand as `watchHands` reports it. Positions `[x, y, z]` metres in the watch's reference space. */
|
|
107
|
+
export interface HandSample {
|
|
108
|
+
readonly tracked: boolean;
|
|
109
|
+
readonly handedness: 'left' | 'right' | 'none';
|
|
110
|
+
/** 25 × 16 column-major joint transforms (`XRFrame.fillPoses`). */
|
|
111
|
+
readonly poses: Float32Array;
|
|
112
|
+
/** 25 joint radii, metres. */
|
|
113
|
+
readonly radii: Float32Array;
|
|
114
|
+
/** Position of a joint by WebXR name, or null for an unknown name. */
|
|
115
|
+
joint(name: string): number[] | null;
|
|
116
|
+
readonly wrist: number[];
|
|
117
|
+
readonly thumbTip: number[];
|
|
118
|
+
readonly indexTip: number[];
|
|
119
|
+
/** Wrist → middle knuckle, metres. */
|
|
120
|
+
readonly palmLength: number;
|
|
121
|
+
readonly source: XRInputSource;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface HandsFrame {
|
|
125
|
+
/** Seconds (the session frame time / 1000). */
|
|
126
|
+
time: number;
|
|
127
|
+
/** As `handGate()`. */
|
|
128
|
+
gate: string;
|
|
129
|
+
left: HandSample | null;
|
|
130
|
+
right: HandSample | null;
|
|
131
|
+
/** Hands tracked this frame. */
|
|
132
|
+
hands: number;
|
|
133
|
+
frame: XRFrame;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface HandsWatch {
|
|
137
|
+
stop(): void;
|
|
138
|
+
readonly running: boolean;
|
|
139
|
+
readonly state: { gate: string; hands: number; supported: boolean };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface WatchHandsOptions {
|
|
143
|
+
/** Joints are located in this space (default: the wall's `refSpace`). */
|
|
144
|
+
referenceSpace?: XRReferenceSpace;
|
|
145
|
+
/** `inputsourceschange`, hand sources only. */
|
|
146
|
+
onChange?: (e: { added: XRInputSource[]; removed: XRInputSource[] }) => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Watch the hands on an inline-3D session: `onFrame` every session frame with the tracked hands
|
|
151
|
+
* (or none). Runs its own `session.requestAnimationFrame` loop beside the SDK's; stops itself
|
|
152
|
+
* when the session ends.
|
|
153
|
+
*/
|
|
154
|
+
export function watchHands(
|
|
155
|
+
wall: Inline3D | Inline3DUnsupported | null | undefined,
|
|
156
|
+
onFrame: (f: HandsFrame) => void,
|
|
157
|
+
opts?: WatchHandsOptions,
|
|
158
|
+
): HandsWatch;
|
|
159
|
+
|
|
160
|
+
export interface HandControlOptions extends WatchHandsOptions {
|
|
161
|
+
/** Controller tuning. */
|
|
162
|
+
gestures?: Partial<HandGestureConfig>;
|
|
163
|
+
/** Multiplies every hand position before the gesture math (default 1). */
|
|
164
|
+
lengthScale?: number;
|
|
165
|
+
/** Apply the two-hand roll where the viewer has a roll axis (default true). */
|
|
166
|
+
roll?: boolean;
|
|
167
|
+
/** Apply the two-hand zoom (default true). */
|
|
168
|
+
zoom?: boolean;
|
|
169
|
+
/** After each frame: the hands frame and the gesture delta. */
|
|
170
|
+
onFrame?: (f: HandsFrame, d: HandGestureDelta) => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What `handControls` returns, and what `handle.hands` is on an `addModel` / `addSplat` handle. */
|
|
174
|
+
export interface HandControls {
|
|
175
|
+
stop(): void;
|
|
176
|
+
readonly running: boolean;
|
|
177
|
+
readonly controller: HandGestureController;
|
|
178
|
+
readonly state: { gate: string; hands: number; supported: boolean };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Anything with the viewer pose surface: a SceneViewer, the PlayCanvas viewer, or a handle whose `viewer` is one. */
|
|
182
|
+
export interface HandControlTarget {
|
|
183
|
+
nudgePose?(delta: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
|
|
184
|
+
getPose?(opts?: { target?: boolean }): { yaw: number; pitch: number; zoom: number; roll?: number };
|
|
185
|
+
setPose?(pose: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
|
|
186
|
+
readonly viewer?: HandControlTarget | null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Drive a viewer's orbit from the hands: one pinch dragged turns it, two pinches spread / twist
|
|
191
|
+
* / dragged zoom / roll / turn it. Prefers `viewer.nudgePose()` (eased, counts as input for the
|
|
192
|
+
* idle turntable), falls back to `getPose` + `setPose`. Pointer and wheel keep working.
|
|
193
|
+
*/
|
|
194
|
+
export function handControls(
|
|
195
|
+
wall: Inline3D | Inline3DUnsupported | null | undefined,
|
|
196
|
+
target: HandControlTarget,
|
|
197
|
+
opts?: HandControlOptions,
|
|
198
|
+
): HandControls;
|
package/index.d.ts
CHANGED
|
@@ -522,6 +522,10 @@ export interface TileHandle {
|
|
|
522
522
|
* its layer, and a window that never set a rig at all.
|
|
523
523
|
*
|
|
524
524
|
* Rejects with an `Error` when no such mode is listed, otherwise as `requestRenderingMode` does.
|
|
525
|
+
* A browser that cannot forward mode requests (the Android DisplayXR Browser today)
|
|
526
|
+
* rejects with a `NotSupportedError` even though its modes read
|
|
527
|
+
* `isRequestable: true`. Try, then fall back to easing your rig's `ipdFactor`/`parallaxFactor`
|
|
528
|
+
* to 0 — docs/woven-canvas-rules.md, "A 2D/3D toggle".
|
|
525
529
|
*/
|
|
526
530
|
setStereoEnabled(enabled: boolean): Promise<boolean>;
|
|
527
531
|
/** Listen for one display event, re-emitted on this handle. Returns an unsubscribe function. */
|
package/js/call/share.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { isAppShareDevice, isAppShareHint, legacyAppCandidate, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE, APP_SHARE_LEGACY_LABEL_RE } from '../camera/app-share.js';
|
|
26
26
|
import { sanitizeName, CONV_MAX_FRAC } from './wire.js';
|
|
27
|
+
import { unsqueezeTrack } from '../camera/half-sbs.js';
|
|
27
28
|
|
|
28
29
|
export { isAppShareDevice, isAppShareHint, legacyAppCandidate, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE, APP_SHARE_LEGACY_LABEL_RE };
|
|
29
30
|
|
|
@@ -177,3 +178,92 @@ export async function openAppShare(deviceId, o = {}) {
|
|
|
177
178
|
}
|
|
178
179
|
return { stream, track, descriptor: describeAppShare(track) };
|
|
179
180
|
}
|
|
181
|
+
|
|
182
|
+
/** The hint layout of a stereo tab capture: each eye squeezed to half the frame's width. */
|
|
183
|
+
export const TAB_SHARE_LAYOUT = 'side-by-side-half';
|
|
184
|
+
|
|
185
|
+
/** Does this `displayxrStereo` hint describe a stereo tab capture? Pure. */
|
|
186
|
+
export function isTabShareHint(hint) {
|
|
187
|
+
return !!hint && typeof hint === 'object' && hint.source === 'tab' && hint.layout === TAB_SHARE_LAYOUT;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Let the user pick a browser tab to share, in 3D where the browser can. The browser's own picker
|
|
192
|
+
* is the consent. On a browser that captures tabs in stereo, a picked tab comes back as a true
|
|
193
|
+
* side-by-side track (`format: 'sbs'`), the same shape an app share has, so receivers need nothing
|
|
194
|
+
* new. A window or a screen, or a browser without stereo capture, comes back flat (`format: 'mono'`).
|
|
195
|
+
* The track ends when the sharing stops or the tab closes. Stop it yourself when you stop sharing.
|
|
196
|
+
*
|
|
197
|
+
* Cancelling the picker rejects with `code: 'share-denied'`; anything else with `'share-failed'`.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ width?: number, frameRate?: number, mediaDevices?: MediaDevices, unsqueeze?: typeof unsqueezeTrack }} [o]
|
|
200
|
+
*/
|
|
201
|
+
export async function openTabShare(o = {}) {
|
|
202
|
+
const md = o.mediaDevices || (typeof navigator !== 'undefined' ? navigator.mediaDevices : null);
|
|
203
|
+
if (!md || typeof md.getDisplayMedia !== 'function') {
|
|
204
|
+
throw Object.assign(new Error('this browser cannot share a tab'), { code: 'share-failed' });
|
|
205
|
+
}
|
|
206
|
+
const frameRate = o.frameRate > 0 ? o.frameRate : SHARE_CONSTRAINTS.frameRate;
|
|
207
|
+
let stream;
|
|
208
|
+
try {
|
|
209
|
+
stream = await md.getDisplayMedia({
|
|
210
|
+
video: { width: { ideal: o.width > 0 ? o.width : SHARE_CONSTRAINTS.width }, frameRate: { ideal: frameRate } },
|
|
211
|
+
audio: false,
|
|
212
|
+
displayxrStereo: true,
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const denied = err && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
|
216
|
+
throw Object.assign(new Error(denied ? 'the share was declined' : 'the tab could not be shared'), {
|
|
217
|
+
code: denied ? 'share-denied' : 'share-failed',
|
|
218
|
+
cause: err,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const source = stream.getVideoTracks()[0];
|
|
222
|
+
const st = (source && typeof source.getSettings === 'function' && source.getSettings()) || {};
|
|
223
|
+
const hint = st.displayxrStereo;
|
|
224
|
+
const flat = {
|
|
225
|
+
stream,
|
|
226
|
+
track: source,
|
|
227
|
+
stereo: false,
|
|
228
|
+
descriptor: {
|
|
229
|
+
kind: 'content',
|
|
230
|
+
format: 'mono',
|
|
231
|
+
width: st.width > 0 ? st.width : 0,
|
|
232
|
+
height: st.height > 0 ? st.height : 0,
|
|
233
|
+
convergence: { known: true, frac: 0 },
|
|
234
|
+
appName: null,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
let out = flat;
|
|
238
|
+
if (isTabShareHint(hint)) {
|
|
239
|
+
const pair = (o.unsqueeze || unsqueezeTrack)(source, st.width, st.height, st.frameRate > 0 ? st.frameRate : frameRate);
|
|
240
|
+
if (!pair) {
|
|
241
|
+
// Sent as it is, every receiver would see two squeezed halves: refuse instead.
|
|
242
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
243
|
+
throw Object.assign(new Error('this browser cannot convert a stereo tab capture'), { code: 'share-failed' });
|
|
244
|
+
}
|
|
245
|
+
const z = hint.zeroDisparityShiftFrac;
|
|
246
|
+
out = {
|
|
247
|
+
stream: pair.stream,
|
|
248
|
+
track: pair.track,
|
|
249
|
+
stereo: true,
|
|
250
|
+
descriptor: {
|
|
251
|
+
kind: 'content',
|
|
252
|
+
format: 'sbs',
|
|
253
|
+
width: pair.width,
|
|
254
|
+
height: pair.height,
|
|
255
|
+
convergence: {
|
|
256
|
+
known: true,
|
|
257
|
+
frac: typeof z === 'number' && Number.isFinite(z) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, z)) : 0,
|
|
258
|
+
},
|
|
259
|
+
appName: null,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
out.track.contentHint = 'detail'; // text and resolution over frame rate
|
|
265
|
+
} catch {
|
|
266
|
+
/* older engines */
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
package/js/camera/capture.js
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
// `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
|
|
42
42
|
|
|
43
43
|
import { looksSbs } from './geometry.js';
|
|
44
|
+
import { isHalfSbsCamera, unsqueezeHalfSbs } from './half-sbs.js';
|
|
44
45
|
import { isAppShareDevice, isAppShareHint } from './app-share.js';
|
|
45
46
|
|
|
46
47
|
const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
|
|
@@ -213,6 +214,14 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
213
214
|
// or — on a browser without the app-share device patch, which lists the app as a "3D Camera
|
|
214
215
|
// (DisplayXR) - <app>" — baseline 0 and FOV 0, which no real camera advertises. Every probe
|
|
215
216
|
// path checks this on the OPENED track (rejectOpened), since the label cannot tell them apart.
|
|
217
|
+
// A half-SBS webcam: send true side-by-side eyes, whichever path opened it. 'mono' stays raw.
|
|
218
|
+
if (want !== 'mono' && isHalfSbsCamera(t.label)) {
|
|
219
|
+
const pair = await unsqueezeHalfSbs(t);
|
|
220
|
+
if (pair) {
|
|
221
|
+
log('camera-half-sbs', { label: t.label, width: pair.width, height: pair.height });
|
|
222
|
+
return { ...pair, deviceId: st.deviceId || null, label: t.label, stereo: null, appShare: false };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
216
225
|
const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || isAppShareHint(st.displayxrStereo);
|
|
217
226
|
return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo, appShare };
|
|
218
227
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// camera/half-sbs.js — USB stereo webcams that send a half-width side-by-side frame: one 16:9
|
|
2
|
+
// image whose left and right halves are each an eye squeezed 2:1 in width.
|
|
3
|
+
//
|
|
4
|
+
// Shape cannot identify these: a 3840x2160 half-SBS frame looks exactly like a 4K webcam's, so they
|
|
5
|
+
// are listed by USB id. Asking one for a wide mode does not help either: the browser reaches
|
|
6
|
+
// 3840x1080 by cropping the middle rows of 3840x2160, which still reads as a pair but leaves each
|
|
7
|
+
// eye 2x too tall with half the vertical view. So the camera is read in its native mode and every
|
|
8
|
+
// frame is redrawn, eyes un-squeezed, into a true side-by-side track.
|
|
9
|
+
|
|
10
|
+
/** USB `vvvv:pppp` ids of cameras that send half-SBS (the browser's label ends with the id). */
|
|
11
|
+
export const HALF_SBS_USB_IDS = Object.freeze(['0733:011a']);
|
|
12
|
+
|
|
13
|
+
/** The widest eye sent: the runtime's own output for such a camera (2560x720 side by side). */
|
|
14
|
+
const MAX_EYE_WIDTH = 1280;
|
|
15
|
+
const NATIVE_CONSTRAINTS = { width: { ideal: 3840 }, height: { ideal: 2160 }, frameRate: { ideal: 30 }, resizeMode: 'none' };
|
|
16
|
+
|
|
17
|
+
/** Does this device label name a half-SBS camera? */
|
|
18
|
+
export function isHalfSbsCamera(label) {
|
|
19
|
+
const l = String(label || '').toLowerCase();
|
|
20
|
+
return HALF_SBS_USB_IDS.some((id) => l.includes(`(${id})`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The full-SBS frame size for a half-SBS source: square-pixel eyes, no upscaling, even numbers. */
|
|
24
|
+
export function unsqueezedSize(width, height) {
|
|
25
|
+
const eyeW = Math.min(MAX_EYE_WIDTH, width);
|
|
26
|
+
const eyeH = Math.max(2, Math.round((eyeW * height) / width / 2) * 2);
|
|
27
|
+
return { width: eyeW * 2, height: eyeH };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Put `source` in its native mode and return a side-by-side track with the eyes un-squeezed, or
|
|
32
|
+
* null when that cannot be done here (the engine has no MediaStreamTrackProcessor or the camera
|
|
33
|
+
* refuses a 16:9 mode); the source is then left open as it is. Stopping the returned track stops
|
|
34
|
+
* the source.
|
|
35
|
+
*
|
|
36
|
+
* @param {MediaStreamTrack} source
|
|
37
|
+
* @returns {Promise<{stream: MediaStream, track: MediaStreamTrack, width: number, height: number}|null>}
|
|
38
|
+
*/
|
|
39
|
+
export async function unsqueezeHalfSbs(source) {
|
|
40
|
+
if (typeof MediaStreamTrackProcessor !== 'function' || typeof document === 'undefined') return null;
|
|
41
|
+
try {
|
|
42
|
+
await source.applyConstraints(NATIVE_CONSTRAINTS);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const st = source.getSettings();
|
|
47
|
+
if (!(st.width > 0) || !(st.height > 0) || Math.abs(st.width / st.height - 16 / 9) > 0.01) return null;
|
|
48
|
+
return unsqueezeTrack(source, st.width, st.height, st.frameRate);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Redraw a half-width side-by-side `source` of `srcW`x`srcH` into a true side-by-side track, or null
|
|
53
|
+
* when the engine cannot. Each eye keeps the frame's aspect. Stopping the returned track stops the
|
|
54
|
+
* source; the source ending ends the returned track.
|
|
55
|
+
*
|
|
56
|
+
* @param {MediaStreamTrack} source
|
|
57
|
+
* @param {number} srcW
|
|
58
|
+
* @param {number} srcH
|
|
59
|
+
* @param {number} [frameRate]
|
|
60
|
+
* @returns {{stream: MediaStream, track: MediaStreamTrack, width: number, height: number}|null}
|
|
61
|
+
*/
|
|
62
|
+
export function unsqueezeTrack(source, srcW, srcH, frameRate) {
|
|
63
|
+
if (typeof MediaStreamTrackProcessor !== 'function' || typeof document === 'undefined') return null;
|
|
64
|
+
let { width, height } = unsqueezedSize(srcW, srcH);
|
|
65
|
+
let srcSeenW = srcW;
|
|
66
|
+
let srcSeenH = srcH;
|
|
67
|
+
const canvas = document.createElement('canvas');
|
|
68
|
+
canvas.width = width;
|
|
69
|
+
canvas.height = height;
|
|
70
|
+
const ctx = canvas.getContext('2d', { alpha: false });
|
|
71
|
+
if (!ctx) return null;
|
|
72
|
+
const stream = canvas.captureStream(frameRate > 0 ? frameRate : 30);
|
|
73
|
+
const track = stream.getVideoTracks()[0];
|
|
74
|
+
const reader = new MediaStreamTrackProcessor({ track: source }).readable.getReader();
|
|
75
|
+
|
|
76
|
+
let stopped = false;
|
|
77
|
+
const stopTrack = track.stop.bind(track);
|
|
78
|
+
track.stop = () => {
|
|
79
|
+
stopped = true;
|
|
80
|
+
reader.cancel().catch(() => {});
|
|
81
|
+
source.stop();
|
|
82
|
+
stopTrack();
|
|
83
|
+
};
|
|
84
|
+
(async () => {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const { value: frame, done } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
// Follow the frames, not the settings: a captured tab's frames are not the size its track
|
|
89
|
+
// reports, and it changes when the tab resizes.
|
|
90
|
+
if (frame.displayWidth > 0 && frame.displayHeight > 0 && (frame.displayWidth !== srcSeenW || frame.displayHeight !== srcSeenH)) {
|
|
91
|
+
srcSeenW = frame.displayWidth;
|
|
92
|
+
srcSeenH = frame.displayHeight;
|
|
93
|
+
const next = unsqueezedSize(srcSeenW, srcSeenH);
|
|
94
|
+
canvas.width = width = next.width;
|
|
95
|
+
canvas.height = height = next.height;
|
|
96
|
+
}
|
|
97
|
+
ctx.drawImage(frame, 0, 0, width, height);
|
|
98
|
+
frame.close();
|
|
99
|
+
}
|
|
100
|
+
})()
|
|
101
|
+
.catch(() => {})
|
|
102
|
+
.finally(() => {
|
|
103
|
+
if (stopped) return;
|
|
104
|
+
// The camera went away: this track ends with it, like the device's own would.
|
|
105
|
+
track.stop();
|
|
106
|
+
track.dispatchEvent(new Event('ended'));
|
|
107
|
+
});
|
|
108
|
+
return { stream, track, width, height };
|
|
109
|
+
}
|
package/js/inline3d-call.js
CHANGED
|
@@ -116,7 +116,7 @@ export { DxrCallElement, defineCallElement, attrsToOpts, CALL_EVENT_PREFIX } fro
|
|
|
116
116
|
export { dxrSignaling, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
|
|
117
117
|
export { parseInviteLink };
|
|
118
118
|
// 3D screen share (runtime ADR-061): find and open a running DisplayXR app as content.
|
|
119
|
-
export { isAppShareDevice, listShareableApps, watchShareableApps, openAppShare } from './call/share.js';
|
|
119
|
+
export { isAppShareDevice, listShareableApps, watchShareableApps, openAppShare, openTabShare } from './call/share.js';
|
|
120
120
|
|
|
121
121
|
const TAG = '[inline3d/call]';
|
|
122
122
|
const HELLO_WAIT_MS = 2000;
|