@displayxr/inline3d 1.27.0 → 1.29.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/js/inline3d.js CHANGED
@@ -35,6 +35,10 @@ export { undock, undockAvailable, undockUrl, tileScreenRect };
35
35
  // The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
36
36
  // `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
37
37
  // the same ORDER — as the native apps and the demos. See _requestRenderingModeEased.
38
+ // The device-limit clamp on a backing store (MAX_TEXTURE_SIZE & co.), shared with ./viewer,
39
+ // ./splat and ./model. Dependency-free.
40
+ import { probeBufferLimits, clampEyeBuffer, clampWarning, bufferScale, mismatchWarning } from './inline3d-buffer-limit.js';
41
+
38
42
  import {
39
43
  ModeSwitch,
40
44
  MODE_SWITCH_DEFAULT_DURATION_MS,
@@ -589,6 +593,39 @@ export async function createInline3D(opts = {}) {
589
593
  return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch, untrackedFallback });
590
594
  }
591
595
 
596
+ // The one shared creation in flight, so two callers racing before the first session resolves
597
+ // (a <dxr-call> and a player both mounting on load) share ONE createInline3D() rather than
598
+ // opening two sessions. Cleared once it settles: from then on `liveManager` is the answer.
599
+ let sharedPending = null;
600
+
601
+ /**
602
+ * The document's shared manager: the live `createInline3D()` result if one exists, else a new one
603
+ * (registered as the shared one by construction, since every manager is). Woven-canvas rule 1 —
604
+ * one inline-3D session per document — is what this exists for: a module that needs a wall it
605
+ * did not create (`mountCall`, `<dxr-call>`) must not open a second session next to the page's
606
+ * own, and a page that made its wall first and then adds such a module gets that same wall back.
607
+ *
608
+ * `opts` are `createInline3D`'s and apply only when this call creates the manager; a manager
609
+ * that already exists is returned as it was configured. An unsupported browser resolves to the
610
+ * same `{ supported: false }` shape as `createInline3D`, and it is NOT cached: nothing was
611
+ * created, and a later call re-probes (the weave service can bind after page load).
612
+ *
613
+ * Closing: whoever `close()`s the shared manager ends the document's session for everyone using
614
+ * it; the next `sharedInline3D()` opens a fresh one. Modules that borrow the wall must not close
615
+ * it (the call module never does; it only closes a wall it created — which it no longer does).
616
+ *
617
+ * @param {object} [opts] see {@link createInline3D}
618
+ * @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>}
619
+ */
620
+ export function sharedInline3D(opts = {}) {
621
+ if (liveManager && liveManager._running) return Promise.resolve(liveManager);
622
+ if (sharedPending) return sharedPending;
623
+ sharedPending = createInline3D(opts).finally(() => {
624
+ sharedPending = null;
625
+ });
626
+ return sharedPending;
627
+ }
628
+
592
629
  /**
593
630
  * Back-compatible single-scene helper: open a session, weave one canvas, drive a render
594
631
  * callback with the two eye views each frame. Equivalent to
@@ -637,6 +674,15 @@ function chromeTextPlates(root) {
637
674
  return plates;
638
675
  }
639
676
 
677
+ /** A pending rewoven() settles anyway ('hold-capped') after this many holds from the call. */
678
+ const REWOVEN_MAX_HOLDS = 4;
679
+
680
+ /** The CSS size + dpr of a window's canvas: what a pending rewoven() restarts on. */
681
+ function boxKeyOf(canvas) {
682
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
683
+ return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}`;
684
+ }
685
+
640
686
  class Inline3D {
641
687
  constructor(
642
688
  session,
@@ -1175,6 +1221,20 @@ class Inline3D {
1175
1221
  live = false;
1176
1222
  };
1177
1223
  },
1224
+ /**
1225
+ * {@link firstWoven}, measured from NOW: resolves once this canvas, as it is at the call,
1226
+ * has drawn a stereo frame and then held for `firstWovenHoldMs`. For a canvas that is
1227
+ * already woven but whose rect is about to change (fullscreen, a layout resize): the
1228
+ * browser re-registers the moved rect and the same identity gap as a fresh canvas
1229
+ * applies, so cover the canvas across the change and release on this. A change of the
1230
+ * canvas's CSS size or devicePixelRatio while it is pending restarts the hold (checked every
1231
+ * frame, for every window kind; a move without a resize is not detected), and it settles
1232
+ * anyway with `reason: 'hold-capped'` four holds after the call, so a size that never stops
1233
+ * animating cannot keep a cover up. Calling it again while pending returns the same
1234
+ * promise, restarted. Before the first join it IS `firstWoven`; on a window that will not
1235
+ * weave it resolves that `woven: false` result. Same shape, never rejects.
1236
+ */
1237
+ rewoven: () => this._rewoven(win),
1178
1238
  };
1179
1239
  }
1180
1240
 
@@ -1953,6 +2013,10 @@ class Inline3D {
1953
2013
  fwRegAt: nowMs(),
1954
2014
  fwLayerAt: null,
1955
2015
  fwStereo: false,
2016
+ // handle.rewoven(): null, or the pending { promise, resolve, calledAt, at, stereo, box } —
2017
+ // firstWoven's two halves again, counted from the call (and from each later box change).
2018
+ rw: null,
2019
+ rwGone: null, // the reason this window will never weave again, once one arrived
1956
2020
  };
1957
2021
  win.fwPromise = new Promise((resolve) => {
1958
2022
  win.fwResolve = resolve;
@@ -2047,6 +2111,7 @@ class Inline3D {
2047
2111
  win.fwLayerAt = nowMs();
2048
2112
  win.fwStereo = false;
2049
2113
  }
2114
+ this._restartRewoven(win);
2050
2115
  win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
2051
2116
  // Nothing about the hardware state is re-asserted here, and that is the point: the panel's
2052
2117
  // mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
@@ -2340,17 +2405,108 @@ class Inline3D {
2340
2405
  win.autoExcluded.clear();
2341
2406
  }
2342
2407
 
2343
- /** The per-eye buffer size this window should have right now (explicit, or box × dpr). */
2344
- _eyeSize(win) {
2408
+ /**
2409
+ * The per-eye buffer size this window should have right now: explicit, or box × dpr — capped
2410
+ * at the SOURCE's own per-eye resolution for image and video windows.
2411
+ *
2412
+ * WHY THE CAP (measured, NP02J tablet, DisplayXR Browser 1.0.6): the player sample's 90vw
2413
+ * tile at dpr 2 asked for a 4608×1296 backing store for a 640×360-per-eye clip — 13× the
2414
+ * source's pixels, re-drawn every frame. The page fell to 25 fps and dropped 47 of 97 video
2415
+ * frames; the same clip in a 45vw tile (2304×648) ran at 57 fps with 0 dropped. Drawing a
2416
+ * source into a buffer larger than itself adds no detail: the compositor scales the layer to
2417
+ * the box either way, so upscaling here only spends GPU fill-rate. The cap keeps the BOX
2418
+ * aspect (the eyes stay correctly shaped) and only shrinks, never grows past box × dpr.
2419
+ * An explicit {width, height} is the page's call and is never capped by the source.
2420
+ *
2421
+ * THEN THE DEVICE LIMIT, on every window including an explicit size: the whole store (2 × eye
2422
+ * wide in SBS) must fit min(MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, MAX_VIEWPORT_DIMS), or
2423
+ * the weave cannot take it as one texture. An Android 3D tablet (Adreno 740) reports 4096 where
2424
+ * desktops report 16384. Both axes shrink by one factor (./inline3d-buffer-limit.js), warned
2425
+ * once per window. `win.bufScale` records the total shrink so buffer-px decoration
2426
+ * (cornerRadius, feather) keeps its on-screen size.
2427
+ */
2428
+ _eyeSize(win, sbs = true) {
2345
2429
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
2346
- return {
2347
- w: win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr),
2348
- h: win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr),
2349
- };
2430
+ let w = win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr);
2431
+ let h = win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr);
2432
+ let scale = 1;
2433
+ const eye = !win.reqW && !win.reqH && this._sourceEyeSize(win);
2434
+ if (eye) {
2435
+ // The smallest buffer that still holds every source pixel along the axis that fills the box.
2436
+ const s = Math.max(eye.w / w, eye.h / h);
2437
+ if (s < 1) {
2438
+ scale = s;
2439
+ w = Math.max(1, Math.round(w * s));
2440
+ h = Math.max(1, Math.round(h * s));
2441
+ }
2442
+ }
2443
+ const c = clampEyeBuffer(w, h, probeBufferLimits(), { cols: sbs ? 2 : 1 });
2444
+ if (c.clamped) {
2445
+ scale *= c.scale;
2446
+ w = c.eyeW;
2447
+ h = c.eyeH;
2448
+ if (!win.warnedBufClamp) {
2449
+ win.warnedBufClamp = true;
2450
+ console.warn(clampWarning('[inline3d]', c));
2451
+ }
2452
+ }
2453
+ win.bufScale = scale;
2454
+ return { w, h };
2455
+ }
2456
+
2457
+ /** One eye of an image/video window's source (an SBS pair: left eye = left half), or null. */
2458
+ _sourceEyeSize(win) {
2459
+ if (win.kind !== 'video' && win.kind !== 'image') return null;
2460
+ const src = win.kind === 'video' ? win.video : win.img;
2461
+ if (!src) return null;
2462
+ const sw = src.videoWidth || src.naturalWidth || src.width;
2463
+ const sh = src.videoHeight || src.naturalHeight || src.height;
2464
+ if (!(sw > 1) || !(sh > 0)) return null;
2465
+ return { w: sw / 2, h: sh };
2466
+ }
2467
+
2468
+ /**
2469
+ * A scene canvas is the app's to size, so the core cannot clamp it — but it can SEE the browser
2470
+ * clamp it. Where the store exceeds the device's MAX_TEXTURE_SIZE (4096 on an Adreno 740 tablet)
2471
+ * the drawing buffer silently comes out smaller than canvas.width, while getViewport() keeps
2472
+ * splitting canvas.width: the eye boundary lands off-centre and the panel shows a double image.
2473
+ * Say so once. ./viewer, ./splat and ./model clamp before sizing and never trip this.
2474
+ *
2475
+ * Only called after the app has drawn a stereo frame, so the canvas already HAS its context:
2476
+ * getContext() of the same type returns it, of another type returns null — it never creates one.
2477
+ */
2478
+ _checkSceneBuffer(win) {
2479
+ const c = win.canvas;
2480
+ if (!c || typeof c.getContext !== 'function') return;
2481
+ if (win.sceneGl === undefined) {
2482
+ let gl = null;
2483
+ try {
2484
+ gl = c.getContext('webgl2') || c.getContext('webgl');
2485
+ } catch {
2486
+ gl = null;
2487
+ }
2488
+ win.sceneGl = gl && typeof gl.drawingBufferWidth === 'number' ? gl : null;
2489
+ }
2490
+ if (!win.sceneGl) return;
2491
+ const b = bufferScale(c, win.sceneGl);
2492
+ if (b.mismatch) {
2493
+ win.warnedBufMismatch = true;
2494
+ console.warn(
2495
+ mismatchWarning(
2496
+ '[inline3d]',
2497
+ c,
2498
+ b,
2499
+ 'getViewport() splits canvas.width, so the eyes are off-centre in the woven buffer. This ' +
2500
+ 'scene canvas is sized by the page: keep 2 × eye width and the height within ' +
2501
+ 'MAX_TEXTURE_SIZE / MAX_RENDERBUFFER_SIZE / MAX_VIEWPORT_DIMS, or map getViewport() onto ' +
2502
+ 'gl.drawingBufferWidth/Height.',
2503
+ ),
2504
+ );
2505
+ }
2350
2506
  }
2351
2507
 
2352
2508
  _sizeBuffer(win, sbs) {
2353
- const { w: boxW, h: boxH } = this._eyeSize(win);
2509
+ const { w: boxW, h: boxH } = this._eyeSize(win, sbs);
2354
2510
  win.eyeW = boxW;
2355
2511
  win.eyeH = boxH;
2356
2512
  win.canvas.width = sbs ? boxW * 2 : boxW; // SBS = two eye tiles wide
@@ -2397,8 +2553,12 @@ class Inline3D {
2397
2553
  if (!win.resizePending) return;
2398
2554
  win.resizePending = false;
2399
2555
  if (!win.layer || !win.ownsBuffer) return;
2556
+ // The on-screen box and the buffer are separate questions since the source cap (#88): a
2557
+ // box that moves (fullscreen) re-joins the weave at a new rect even when a capped buffer
2558
+ // stays the same size, so a pending rewoven() restarts on the BOX, not on the buffer.
2559
+ this._noteRewovenBox(win);
2400
2560
  const { w, h } = this._eyeSize(win);
2401
- if (w === win.eyeW && h === win.eyeH) return; // observer fired, geometry didn't move
2561
+ if (w === win.eyeW && h === win.eyeH) return; // observer fired, the buffer didn't move
2402
2562
  this._sizeBuffer(win, /*sbs*/ true);
2403
2563
  this._paint(win, null); // repaint NOW: setting canvas.width cleared the buffer
2404
2564
  };
@@ -2473,10 +2633,25 @@ class Inline3D {
2473
2633
  const srcW = src.videoWidth || src.naturalWidth || src.width;
2474
2634
  const srcH = src.videoHeight || src.naturalHeight || src.height;
2475
2635
  if (!srcW || !srcH) return;
2636
+ // The buffer is capped at the source's per-eye resolution (_eyeSize), and a source's size is
2637
+ // only known once it has decoded — or changes on a new title. Re-derive on a change; the
2638
+ // resize clears the buffer, and the paint right below refills it.
2639
+ const srcKey = srcW + 'x' + srcH;
2640
+ if (win.srcKey !== srcKey) {
2641
+ win.srcKey = srcKey;
2642
+ if (win.ownsBuffer && !(win.reqW && win.reqH)) {
2643
+ const { w, h } = this._eyeSize(win, win.sbs);
2644
+ if (w !== win.eyeW || h !== win.eyeH) this._sizeBuffer(win, win.sbs);
2645
+ }
2646
+ }
2647
+ // cornerRadius / feather are buffer px at the UNCAPPED size; keep their on-screen size.
2648
+ const k = win.bufScale || 1;
2649
+ const radius = win.cornerRadius * k;
2650
+ const feather = win.feather * k;
2476
2651
  ctx.clearRect(0, 0, c.width, c.height);
2477
2652
  if (!win.sbs) {
2478
2653
  // Flat fallback: left eye only, stretched to the square buffer.
2479
- drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, c.width, c.height, win.cornerRadius, win.feather);
2654
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, c.width, c.height, radius, feather);
2480
2655
  return;
2481
2656
  }
2482
2657
  const halfDst = c.width / 2;
@@ -2487,16 +2662,16 @@ class Inline3D {
2487
2662
  // as a squeezed pair). See _trackBakedStereo.
2488
2663
  const stereo = Math.min(this._stereoFactor, this._bakedStereo);
2489
2664
  if (stereo < 1) {
2490
- drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
2491
- drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, win.cornerRadius, win.feather); // L+R
2665
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, radius, feather); // L
2666
+ drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, radius, feather); // L+R
2492
2667
  return;
2493
2668
  }
2494
2669
  // A single stretched draw maps SBS source → SBS buffer (left→left, right→right); the
2495
2670
  // per-eye path is only needed to bake decoration (rounded corners / edge feather), which
2496
2671
  // MUST be applied to each eye separately — see drawEye/featherEye.
2497
- if (win.cornerRadius > 0 || win.feather > 0) {
2498
- drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
2499
- drawEye(ctx, src, srcW / 2, 0, srcW / 2, srcH, halfDst, 0, halfDst, c.height, win.cornerRadius, win.feather); // R
2672
+ if (radius > 0 || feather > 0) {
2673
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, radius, feather); // L
2674
+ drawEye(ctx, src, srcW / 2, 0, srcW / 2, srcH, halfDst, 0, halfDst, c.height, radius, feather); // R
2500
2675
  } else {
2501
2676
  ctx.drawImage(src, 0, 0, srcW, srcH, 0, 0, c.width, c.height);
2502
2677
  }
@@ -2656,7 +2831,11 @@ class Inline3D {
2656
2831
  win.onFrame(views, win.layer, f);
2657
2832
  // A stereo frame the page drew without throwing. A short view list is the load
2658
2833
  // fallback (a mono frame), which is not what a poster is waiting for.
2659
- if (views.length >= 2) win.fwStereo = true;
2834
+ if (views.length >= 2) {
2835
+ win.fwStereo = true;
2836
+ if (!win.warnedBufMismatch) this._checkSceneBuffer(win);
2837
+ if (win.rw) win.rw.stereo = true;
2838
+ }
2660
2839
  } catch (err) {
2661
2840
  if (!win.frameThrewWarned) {
2662
2841
  win.frameThrewWarned = true;
@@ -2679,6 +2858,7 @@ class Inline3D {
2679
2858
  // video has never had a frame) the tile holds nothing worth revealing yet.
2680
2859
  if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) {
2681
2860
  win.fwStereo = true;
2861
+ if (win.rw) win.rw.stereo = true;
2682
2862
  }
2683
2863
  }
2684
2864
  this._tickFirstWoven(win);
@@ -2696,6 +2876,20 @@ class Inline3D {
2696
2876
  * which knows nothing about whether the compositor has matched this canvas yet.
2697
2877
  */
2698
2878
  _tickFirstWoven(win) {
2879
+ if (win.rw) {
2880
+ // Every frame, for every window kind: the ResizeObserver only watches SDK-sized buffers, and
2881
+ // no observer sees a dpr-only change.
2882
+ this._noteRewovenBox(win);
2883
+ const rw = win.rw;
2884
+ const t = nowMs();
2885
+ if (rw.stereo && t - rw.at >= win.fwHoldMs) this._settleRewoven(win, true, 'hold-elapsed');
2886
+ // A box that never stops changing (a long CSS transition on width) would restart the hold
2887
+ // forever and leave a cover up for good: once it has restarted, give up waiting
2888
+ // REWOVEN_MAX_HOLDS holds after the call.
2889
+ else if (rw.at > rw.calledAt && t - rw.calledAt >= REWOVEN_MAX_HOLDS * win.fwHoldMs) {
2890
+ this._settleRewoven(win, true, 'hold-capped');
2891
+ }
2892
+ }
2699
2893
  if (win.fwResult || !win.fwStereo || win.fwLayerAt === null) return;
2700
2894
  if (nowMs() - win.fwLayerAt < win.fwHoldMs) return;
2701
2895
  this._settleFirstWoven(win, true, 'hold-elapsed');
@@ -2703,12 +2897,60 @@ class Inline3D {
2703
2897
 
2704
2898
  /** One-shot: the first call wins, later ones are ignored. */
2705
2899
  _settleFirstWoven(win, woven, reason) {
2900
+ // Every "will not weave" path lands here, so a pending rewoven() is released with it, and a
2901
+ // later one answers at once (firstWoven may have settled woven:true long before).
2902
+ if (!woven) {
2903
+ if (!win.rwGone) win.rwGone = reason;
2904
+ this._settleRewoven(win, false, reason);
2905
+ }
2706
2906
  if (win.fwResult) return;
2707
2907
  win.fwResult = Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - win.fwRegAt) });
2708
2908
  win.fwResolve(win.fwResult);
2709
2909
  win.fwResolve = null;
2710
2910
  }
2711
2911
 
2912
+ /** handle.rewoven(): see the handle's doc comment. */
2913
+ _rewoven(win) {
2914
+ if (!win.fwResult) return win.fwPromise; // still on the first join: the same question
2915
+ if (!win.fwResult.woven) return Promise.resolve(win.fwResult);
2916
+ if (win.rwGone) return Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: win.rwGone, ms: 0 }));
2917
+ if (win.rw) {
2918
+ this._restartRewoven(win);
2919
+ return win.rw.promise;
2920
+ }
2921
+ let resolve;
2922
+ const promise = new Promise((r) => {
2923
+ resolve = r;
2924
+ });
2925
+ const t = nowMs();
2926
+ win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas) };
2927
+ return promise;
2928
+ }
2929
+
2930
+ /** A pending rewoven() restarts when the canvas's CSS size or dpr differs from the last look. */
2931
+ _noteRewovenBox(win) {
2932
+ const rw = win.rw;
2933
+ if (!rw) return;
2934
+ const box = boxKeyOf(win.canvas);
2935
+ if (box === rw.box) return;
2936
+ rw.box = box;
2937
+ this._restartRewoven(win);
2938
+ }
2939
+
2940
+ /** The canvas moved again (a box change, a new layer): the hold starts over from here. */
2941
+ _restartRewoven(win) {
2942
+ if (!win.rw) return;
2943
+ win.rw.at = nowMs();
2944
+ win.rw.stereo = false;
2945
+ }
2946
+
2947
+ _settleRewoven(win, woven, reason) {
2948
+ const rw = win.rw;
2949
+ if (!rw) return;
2950
+ win.rw = null;
2951
+ rw.resolve(Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - rw.calledAt) }));
2952
+ }
2953
+
2712
2954
  // ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
2713
2955
  //
2714
2956
  // A weaved window's rect reaches the compositor from the session's own rAF: every frame the
@@ -0,0 +1,15 @@
1
+ // js/lift/index.js — PLACEHOLDER for the lift module (`@displayxr/inline3d/lift`), which is on
2
+ // the `feat/lift` branch and not yet on main. That branch replaces this file wholesale (resolve
3
+ // any add/add conflict by taking feat/lift's file).
4
+ //
5
+ // It exists so `./call/full` (js/inline3d-call-full.js) can import lift STATICALLY today — the
6
+ // one shape a bundler can follow — and so the `./call` entry's computed import of this path
7
+ // resolves to "a lift module with no `lift` export" (reason `no-lift-export`) rather than a
8
+ // network 404. Either way the call raises `warning { code: 'lift-not-bundled' }` when a 2D
9
+ // participant would have been lifted, and the lobby reads "2D→3D unavailable in this build".
10
+ //
11
+ // `lift` is exported as null (not omitted): webpack turns a namespace access to a MISSING export
12
+ // into a build error, and the point of this file is that `/call/full` builds everywhere today.
13
+
14
+ export const LIFT_PLACEHOLDER = true;
15
+ export const lift = null;
package/model.d.ts CHANGED
@@ -34,7 +34,11 @@ export interface ModelOptions {
34
34
  * so a long subject still fits once the turntable turns it (default true).
35
35
  */
36
36
  fitSweep?: boolean;
37
- /** Per-eye buffer scale; 0.5–0.7 is usually free (default 1). */
37
+ /**
38
+ * Per-eye buffer scale; 0.5–0.7 is usually free (default 1). Clamped to the device's GL limits: the whole store (2 × eye wide in 3D) is kept within
39
+ * min(MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, MAX_VIEWPORT_DIMS), both axes shrunk by one factor,
40
+ * with a one-time console warning. The handle's `renderScale` reports the value in force.
41
+ */
38
42
  renderScale?: number;
39
43
  feather?: number;
40
44
  /**
@@ -176,6 +180,11 @@ export interface ModelHandle {
176
180
  /** The last pose the page set (a copy), or null. */
177
181
  getCameraPose(): SplatCameraPose | null;
178
182
 
183
+ /** The per-eye buffer scale in force: the `renderScale` option times the device-limit clamp. */
184
+ readonly renderScale: number;
185
+ /** The `renderScale` option as given. */
186
+ readonly renderScaleRequested: number;
187
+
179
188
  remove(): void;
180
189
  exclude(el: Element): void;
181
190
  unexclude(el: Element): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -36,16 +36,29 @@
36
36
  "types": "./player.d.ts",
37
37
  "import": "./js/inline3d-player.js"
38
38
  },
39
+ "./call": {
40
+ "types": "./call.d.ts",
41
+ "import": "./js/inline3d-call.js"
42
+ },
43
+ "./call/full": {
44
+ "types": "./call-full.d.ts",
45
+ "import": "./js/inline3d-call-full.js"
46
+ },
39
47
  "./undock": {
40
48
  "types": "./index.d.ts",
41
49
  "import": "./js/inline3d-undock.js"
42
50
  }
43
51
  },
44
- "sideEffects": false,
52
+ "sideEffects": [
53
+ "./js/inline3d-call.js",
54
+ "./js/inline3d-call-full.js",
55
+ "./dist/*.js"
56
+ ],
45
57
  "files": [
46
58
  "js/inline3d.js",
47
59
  "js/inline3d-undock.js",
48
60
  "js/inline3d-mode-switch.js",
61
+ "js/inline3d-buffer-limit.js",
49
62
  "js/inline3d-three.js",
50
63
  "js/inline3d-viewer.js",
51
64
  "js/inline3d-splat.js",
@@ -66,12 +79,19 @@
66
79
  "js/inline3d-pc-look.js",
67
80
  "js/inline3d-playcanvas-engine.js",
68
81
  "js/inline3d-player.js",
82
+ "js/inline3d-call.js",
83
+ "js/inline3d-call-full.js",
84
+ "js/call/",
85
+ "js/lift/",
86
+ "dist/",
69
87
  "index.d.ts",
70
88
  "three.d.ts",
71
89
  "viewer.d.ts",
72
90
  "splat.d.ts",
73
91
  "model.d.ts",
74
92
  "player.d.ts",
93
+ "call.d.ts",
94
+ "call-full.d.ts",
75
95
  "CHANGELOG.md",
76
96
  "README.md",
77
97
  "LICENSE",
@@ -125,10 +145,15 @@
125
145
  },
126
146
  "scripts": {
127
147
  "typecheck": "tsc --noEmit -p tsconfig.json",
128
- "test": "tsc --noEmit -p tsconfig.json && node --test \"test/*.test.mjs\""
148
+ "test": "tsc --noEmit -p tsconfig.json && node --test \"test/*.test.mjs\"",
149
+ "build:dist": "node tools/dist/build.mjs",
150
+ "prepack": "node tools/dist/build.mjs",
151
+ "test:e2e": "node test/e2e/call-element.e2e.mjs",
152
+ "test:bundlers": "node test/bundlers/run.mjs"
129
153
  },
130
154
  "devDependencies": {
131
155
  "typescript": "^5.4.0",
132
- "@types/webxr": "^0.5.0"
156
+ "@types/webxr": "^0.5.0",
157
+ "esbuild": "^0.25.0"
133
158
  }
134
159
  }
package/player.d.ts CHANGED
@@ -91,7 +91,7 @@ export interface PlayerOptions {
91
91
  * tile (e.g. `2.39` or `'2.39:1'` for a scope band in a 16:9 tile); the rest stays clear.
92
92
  * Implies `fit: 'contain'` unless `fit` says otherwise.
93
93
  */
94
- band?: number | string;
94
+ band?: number | string | null;
95
95
  /**
96
96
  * What the poster image is: `'mono'` (one image for both eyes, the default), or a stereo still
97
97
  * laid out like the video (`'sbs'` / `'tb'`), painted eye by eye so it is 3D before the first frame.
@@ -123,6 +123,12 @@ export interface PlayerOptions {
123
123
  * Fullscreen API.
124
124
  */
125
125
  fullscreen?: boolean;
126
+ /**
127
+ * Cover the canvas with black across a fullscreen change until `handle.rewoven()` settles
128
+ * (woven-canvas rule 11). Default false: on the Leia panel fullscreen showed no raw side-by-side
129
+ * pair without it, and the cover costs ~1.2 s with no picture.
130
+ */
131
+ fullscreenCover?: boolean;
126
132
  /** Default: `'anonymous'` iff `src` is a cross-origin URL; unset (browser default) otherwise. */
127
133
  crossOrigin?: 'anonymous' | 'use-credentials';
128
134
  /** Per-eye buffer resolution in px. `'sbs'` + supported wall only — see {@link TileOptions}. */
@@ -135,7 +141,7 @@ export interface PlayerOptions {
135
141
  observe?: Element;
136
142
  }
137
143
 
138
- export type PlayerEvent = 'play' | 'pause' | 'ended' | 'timeupdate' | 'ready' | 'error' | 'titlechange';
144
+ export type PlayerEvent = 'play' | 'pause' | 'ended' | 'timeupdate' | 'ready' | 'error' | 'titlechange' | 'detached';
139
145
 
140
146
  /** One playlist entry (RFC 0001 Addendum A4). */
141
147
  export interface PlayerTitle {
@@ -224,6 +230,44 @@ export function addPlayer(
224
230
  opts?: PlayerOptions,
225
231
  ): PlayerHandle;
226
232
 
233
+ /** {@link attachPlayer}'s options: the player's, plus where its controls go. */
234
+ export interface AttachPlayerOptions extends PlayerOptions {
235
+ /** Where the SDK controls go. Default: the handle's canvas's parent element. */
236
+ chromeContainer?: Element;
237
+ }
238
+
239
+ /** `'detached'`'s payload: something else took (`'superseded'`) or emptied (`'released'`) the slot. */
240
+ export interface PlayerDetachedEvent {
241
+ reason: 'superseded' | 'released';
242
+ }
243
+
244
+ /** What {@link attachPlayer} returns: a {@link PlayerHandle} that can give its slot back. */
245
+ export interface AttachedPlayerHandle extends PlayerHandle {
246
+ /**
247
+ * Give the slot back: `setVideo(null)`, so the splat, pose, lens and declared rig are exactly as
248
+ * they were, then tear down the controls and release the elements. Resolves once restored. Leave
249
+ * a Watch screen with `await player.detach()` before the handle's own `setSource`.
250
+ */
251
+ detach(): Promise<void>;
252
+ }
253
+
254
+ /**
255
+ * PREVIEW. Surface mode (RFC 0001 Addendum A): the same transport, playlist and events as
256
+ * {@link addPlayer}, drawn through an EXISTING `addSplat(…, { engine: 'playcanvas' })` handle's
257
+ * `setVideo` — no canvas, layer or session of its own. The scene stays on screen until the first
258
+ * frame (it is the poster); a source swap lands at the next title's first frame, as a cut or, with
259
+ * `transition: 'crossfade'`, `./splat` setVideo's GPU crossfade. Differences from `addPlayer`:
260
+ * `fullscreen` defaults to false; `band` goes to setVideo's letterbox slot; `poster`, `posterFormat` and the tile options are ignored
261
+ * (warns once). If another `setVideo` takes the slot, the player stops and emits `'detached'`
262
+ * ({@link PlayerDetachedEvent}); it never fights for it.
263
+ */
264
+ export function attachPlayer(
265
+ splat: { setVideo: (...args: any[]) => Promise<unknown>; canvas?: HTMLCanvasElement; videoElement?: HTMLVideoElement | null },
266
+ /** `null` with `opts.titles` loads the first title. */
267
+ src: PlayerSource | null,
268
+ opts?: AttachPlayerOptions,
269
+ ): AttachedPlayerHandle;
270
+
227
271
  /** The `setSource()` transitions a video can mean. */
228
272
  export type PlayerTransition = 'cut' | 'crossfade';
229
273
 
@@ -267,7 +311,8 @@ export type PlayerAccent = 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' |
267
311
 
268
312
  /**
269
313
  * Pick the first source this browser can play from candidates listed best first; each is a URL, or
270
- * `{ src, type }` with a FULL `canPlayType` string (`'video/webm; codecs="vp9, opus"'`). The
271
- * DisplayXR Browser has no H.264/AAC, and a codec-less `'video/mp4'` still answers 'maybe'.
314
+ * `{ src, type }` with a FULL `canPlayType` string (`'video/webm; codecs="vp9, opus"'`). Only
315
+ * DisplayXR Browser 1.0.6+ plays H.264/AAC (via the OS decoders), and a codec-less `'video/mp4'`
316
+ * still answers 'maybe' — so list a VP9 WebM first when you have one.
272
317
  */
273
318
  export function pickSource(candidates: PlayerSource, canPlayType?: (type: string) => string): string | Blob | undefined;