@displayxr/inline3d 1.19.3 → 1.21.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.
@@ -61,9 +61,14 @@ import {
61
61
  PARTICLE_TRANSITIONS,
62
62
  PARTICLE_TRANSITION_OPTIONS,
63
63
  particleSpan,
64
+ SEQUENCE_REVEALS,
65
+ SEQUENCE_TRANSITIONS,
66
+ emptyAtZero,
67
+ sequenceSpans,
64
68
  } from './inline3d-splat-effects.js';
65
69
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
66
70
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
71
+ import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag } from './inline3d-splat-diag.js';
67
72
  import {
68
73
  clamp,
69
74
  finite,
@@ -1406,6 +1411,11 @@ export class PlayCanvasSplatViewer {
1406
1411
  if (!s?.parts) return false;
1407
1412
  const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
1408
1413
  const show = alpha > 0 && fits;
1414
+ // What the overlay shows, for diagnostics (diagImageState): its weight on screen.
1415
+ const st = (this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false });
1416
+ st.alpha = show ? Math.min(1, alpha) : 0;
1417
+ st.wipe = wipe ? wipe.t : -2;
1418
+ st.over = !!mix?.over;
1409
1419
  for (const p of s.parts) {
1410
1420
  p.mi.visible = show;
1411
1421
  if (show) {
@@ -1429,6 +1439,7 @@ export class PlayCanvasSplatViewer {
1429
1439
  setSnapshotSource(tex) {
1430
1440
  const s = this._snap;
1431
1441
  if (!s?.parts) return;
1442
+ (this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false }).live = !!tex;
1432
1443
  for (const p of s.parts) {
1433
1444
  p.mat.setParameter('dxrSnap', tex || s.tex);
1434
1445
  p.mat.update();
@@ -1489,6 +1500,55 @@ export class PlayCanvasSplatViewer {
1489
1500
  return this._live.start(entity, oldFrame) ? this._live : null;
1490
1501
  }
1491
1502
 
1503
+ /**
1504
+ * Diagnostics: what the transition overlay puts on screen this frame — 'none', or the 'frozen'
1505
+ * capture / the 'live' outgoing target with `w`, the share of the picture it covers (the wipe's
1506
+ * uncommitted part times the lerp weight).
1507
+ */
1508
+ diagImageState() {
1509
+ const st = this._snapState;
1510
+ if (!st || !(st.alpha > 0)) return { overlay: 'none', w: 0 };
1511
+ const left = st.wipe < -1 ? 1 : Math.min(1, Math.max(0, 1 - st.wipe));
1512
+ return { overlay: st.live ? 'live' : 'frozen', w: st.alpha * left };
1513
+ }
1514
+
1515
+ /** Pre-sort the live outgoing camera on `entity` before a swap (see LiveOutgoing.warm). */
1516
+ warmLiveOutgoing(entity) {
1517
+ this._live ||= new LiveOutgoing(this);
1518
+ return this._live.warm(entity);
1519
+ }
1520
+
1521
+ /**
1522
+ * Resolves true once the pre-sorted live manager has drawn a sorted frame, false after
1523
+ * `timeoutMs` (a hidden tab, a stalled sorter) or once the pre-sort was dropped.
1524
+ */
1525
+ liveWarmed(timeoutMs = 1500) {
1526
+ const live = this._live;
1527
+ if (!live?.warming) return Promise.resolve(false);
1528
+ if (live.warmReady) return Promise.resolve(true);
1529
+ return new Promise((resolve) => {
1530
+ let done = false;
1531
+ const end = (v) => {
1532
+ if (done) return;
1533
+ done = true;
1534
+ clearTimeout(timer);
1535
+ resolve(v);
1536
+ };
1537
+ const timer = setTimeout(() => end(false), timeoutMs);
1538
+ this._hooks.push(() => {
1539
+ if (done) return false;
1540
+ if (this._disposed || !live.warming) return end(false), false;
1541
+ if (live.warmReady) return end(true), false;
1542
+ return true;
1543
+ });
1544
+ });
1545
+ }
1546
+
1547
+ /** Drop a pre-sort that is not going to become a live window. */
1548
+ cancelLiveWarm() {
1549
+ this._live?.cancelWarm();
1550
+ }
1551
+
1492
1552
  /** End the live window: overlay back on the frozen capture, camera off, target freed. */
1493
1553
  stopLiveOutgoing() {
1494
1554
  if (!this._live?.active) return;
@@ -1673,7 +1733,7 @@ export class PlayCanvasSplatViewer {
1673
1733
  // compose the node's PARENT with their own pose). Park it on the first eye.
1674
1734
  placeNode(this.eye, entries[0].pose);
1675
1735
  // setSource's live outgoing: the same views on its own camera, into its own target.
1676
- if (this._live?.active) this._live.sync(entries, rect, f);
1736
+ if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
1677
1737
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1678
1738
  this.onBeforeRender?.(entries, rect);
1679
1739
  } else {
@@ -2430,8 +2490,56 @@ export const SOURCE_TRANSITIONS = Object.freeze({
2430
2490
  wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
2431
2491
  // the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
2432
2492
  ...Object.fromEntries(Object.entries(PARTICLE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, particle: true }])),
2493
+ // the named sequences (./inline3d-splat-effects.js SEQUENCE_TRANSITIONS); the general form is
2494
+ // `transition: { type: 'sequence', out, in }`
2495
+ ...Object.fromEntries(Object.entries(SEQUENCE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, sequence: true }])),
2433
2496
  });
2434
2497
 
2498
+ /** A sequence's own defaults (the general form, `{ type: 'sequence', out, in }`). */
2499
+ const SEQUENCE_DEFAULTS = Object.freeze({ durationMs: 3000, easing: 'linear', beat: 0.1 });
2500
+
2501
+ /**
2502
+ * A sequence transition's plan: { name, out: { effect, opts }, in: { effect, opts }, beat } —
2503
+ * each side's defaults (SEQUENCE_REVEALS), then the page's shared particle options (particle
2504
+ * sides only), then `outgoingFx` / `incomingFx`. Every side is validated as its effect, and must
2505
+ * draw nothing at amount 0 (the swap happens there). `spec` = the named sequence or the object.
2506
+ */
2507
+ export function resolveSequence(name, spec, o = {}) {
2508
+ const want = (k) => {
2509
+ const e = spec[k];
2510
+ if (typeof e !== 'string' || !(e in SEQUENCE_REVEALS)) {
2511
+ throw new Error(
2512
+ `@displayxr/inline3d/splat: setSource sequence ${k} '${e}' — expected one of ${Object.keys(SEQUENCE_REVEALS).join(', ')} ` +
2513
+ "(a reveal that draws nothing at its start; inflate's start is a flat photo — use transition 'flip').",
2514
+ );
2515
+ }
2516
+ return e;
2517
+ };
2518
+ const outEffect = want('out');
2519
+ const inEffect = want('in');
2520
+ for (const k of ['outgoingFx', 'incomingFx']) {
2521
+ if (o[k] !== undefined && (o[k] === null || typeof o[k] !== 'object')) throw new TypeError(`@displayxr/inline3d/splat: setSource ${k} must be an object of effect options.`);
2522
+ }
2523
+ const beat = o.beat ?? spec.beat ?? SEQUENCE_DEFAULTS.beat;
2524
+ if (!(typeof beat === 'number' && beat >= 0 && beat <= 0.9)) throw new RangeError(`@displayxr/inline3d/splat: setSource beat must be a number in [0, 0.9], got ${o.beat ?? spec.beat}.`);
2525
+ const side = (which, effect, extra) => {
2526
+ const shared = {};
2527
+ // sweep / fade take only the origin of the shared options
2528
+ const keys = EFFECTS[effect].particle ? PARTICLE_TRANSITION_OPTIONS : ['origin'];
2529
+ for (const k of keys) if (o[k] !== undefined) shared[k] = o[k];
2530
+ const opts = { ...SEQUENCE_REVEALS[effect][which], ...shared, ...(extra || {}) };
2531
+ if (opts.order === 'layers') {
2532
+ throw new Error(`@displayxr/inline3d/splat: setSource sequence: order 'layers' is a reveal-only order (it needs the file index, which a render-time body does not have).`);
2533
+ }
2534
+ const r = resolveEffectOptions(effect, { ...opts, scope: 'tile' }, 'set', { internal: true }); // throws on a bad one
2535
+ if (!emptyAtZero(effect, r)) {
2536
+ throw new Error(`@displayxr/inline3d/splat: setSource sequence ${which}: '${effect}' with these options still draws at its start (vanish 0?) — the swap would pop. Give it vanish > 0.`);
2537
+ }
2538
+ return { effect, opts };
2539
+ };
2540
+ return { name, out: side('out', outEffect, o.outgoingFx), in: side('in', inEffect, o.incomingFx), beat };
2541
+ }
2542
+
2435
2543
  /**
2436
2544
  * A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
2437
2545
  * overlap } — the table's defaults, then the page's shared particle options, then its
@@ -2462,11 +2570,34 @@ export function resolveParticleTransition(name, o = {}) {
2462
2570
  return { out, in: inc, overlap };
2463
2571
  }
2464
2572
 
2573
+ /** resolveSwap for a sequence: the plan carries `sequence` (resolveSequence) and no outgoing image. */
2574
+ function resolveSequenceSwap(name, spec, o) {
2575
+ if (resolveRevealOption(o.reveal)) throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${name}' is its own reveal.`);
2576
+ if (o.outgoing !== undefined && o.outgoing !== null) {
2577
+ resolveOutgoingOption(o.outgoing); // a bad value is still a bad value
2578
+ throw new Error(`@displayxr/inline3d/splat: setSource outgoing does not apply to '${name}' — one photo at a time, no outgoing image.`);
2579
+ }
2580
+ const durationMs = o.durationMs ?? spec.durationMs ?? SEQUENCE_DEFAULTS.durationMs;
2581
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new RangeError(`@displayxr/inline3d/splat: setSource durationMs must be ≥ 0, got ${o.durationMs}.`);
2582
+ const easing = o.easing ?? spec.easing ?? SEQUENCE_DEFAULTS.easing;
2583
+ if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
2584
+ const sequence = resolveSequence(name, spec, o);
2585
+ return { transition: 'sequence', durationMs, easing, reveal: null, outgoing: null, particles: null, sequence };
2586
+ }
2587
+
2465
2588
  /** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
2466
2589
  export function resolveSwap(o = {}) {
2467
2590
  if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
2591
+ // The general sequence form: transition: { type: 'sequence', out, in, durationMs?, beat?, easing? }
2592
+ // — its own keys read like setSource's (setSource's win when both are given).
2593
+ if (o.transition !== null && typeof o.transition === 'object') {
2594
+ const { type, out: outName, in: inName, ...rest } = o.transition;
2595
+ if (type !== 'sequence') throw new Error(`@displayxr/inline3d/splat: setSource transition object — expected { type: 'sequence', out, in }, got type '${type}'.`);
2596
+ return resolveSequenceSwap('sequence', { out: outName, in: inName, ...SEQUENCE_DEFAULTS }, { ...rest, ...o, transition: 'sequence' });
2597
+ }
2468
2598
  const fadeMs = Number.isFinite(o.fadeMs) && o.fadeMs > 0 ? o.fadeMs : 0;
2469
2599
  const transition = o.transition ?? (fadeMs > 0 ? 'crossfade' : 'cut');
2600
+ if (SOURCE_TRANSITIONS[transition]?.sequence) return resolveSequenceSwap(transition, SEQUENCE_TRANSITIONS[transition], o);
2470
2601
  if (!(transition in SOURCE_TRANSITIONS)) {
2471
2602
  throw new Error(`@displayxr/inline3d/splat: setSource transition '${transition}' — expected one of ${Object.keys(SOURCE_TRANSITIONS).join(', ')}.`);
2472
2603
  }
@@ -2561,6 +2692,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2561
2692
 
2562
2693
  let handle = null;
2563
2694
  let unbindFocusInput = null;
2695
+
2696
+ // ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
2697
+ const diagCfg = resolveDiag(opts.diag);
2698
+ if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: norig, frozen, nowarm, cold, nooverlay.`);
2699
+ const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
2700
+ /** norig: armed by the first setSource — from then on the declared rig is kept. */
2701
+ let rigLockArmed = false;
2702
+ if (diag) {
2703
+ diag.imageState = () => viewer.diagImageState();
2704
+ diag.observeLongTasks();
2705
+ diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
2706
+ }
2564
2707
  let removed = false;
2565
2708
  /** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
2566
2709
  const preparedAssets = new WeakMap();
@@ -2675,6 +2818,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2675
2818
  fx = null;
2676
2819
  unbindFocusInput?.();
2677
2820
  viewer.onFocusChange = null;
2821
+ diagLoop?.remove();
2822
+ diag?.dispose();
2678
2823
  handle?.remove();
2679
2824
  viewer.dispose(); // app.destroy(): every entity — ours and any a page added — goes with it
2680
2825
  },
@@ -2749,7 +2894,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2749
2894
  const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
2750
2895
 
2751
2896
  if (wall && wall.supported) {
2752
- handle = wall.addScene(canvas, viewer.onFrame, {
2897
+ const onFrame = diag
2898
+ ? (views, layer) => {
2899
+ const t = performance.now();
2900
+ viewer.onFrame(views, layer);
2901
+ diag.frame(views, t); // after the draw: the overlay state is what this frame showed
2902
+ }
2903
+ : viewer.onFrame;
2904
+ handle = wall.addScene(canvas, onFrame, {
2753
2905
  // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
2754
2906
  // and the waterfall say otherwise); the display rig's height means nothing there.
2755
2907
  ...(pageMode
@@ -2762,6 +2914,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2762
2914
  } else {
2763
2915
  viewer.startMono();
2764
2916
  }
2917
+ let diagLoop = null;
2918
+ if (diag) {
2919
+ // Every rig push, logged with its values; under `norig`, every push after the first
2920
+ // setSource is DROPPED (the rig declared before it stays in force).
2921
+ if (handle && typeof handle.setViewRig === 'function') {
2922
+ const push = handle.setViewRig;
2923
+ handle.setViewRig = (rig) => {
2924
+ if (diag.has('norig') && rigLockArmed) {
2925
+ diag.rig(rig, { dropped: true });
2926
+ return false;
2927
+ }
2928
+ diag.rig(rig);
2929
+ return push(rig);
2930
+ };
2931
+ }
2932
+ diagLoop = startDiagLoop(diag, { overlay: !diag.has('nooverlay') && !!handle });
2933
+ // 2D DOM over the woven canvas: out of the weave (a no-op where occlusion is automatic).
2934
+ if (diagLoop?.el) handle?.exclude?.(diagLoop.el);
2935
+ registerDiag(diag);
2936
+ }
2765
2937
  // Settle the stub's `firstWoven` (addSplatDeferred) with the core handle's own.
2766
2938
  if (typeof out._resolveFirstWoven === 'function') {
2767
2939
  out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
@@ -3709,15 +3881,35 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3709
3881
  await first.catch(() => null);
3710
3882
  if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
3711
3883
  const t0 = performance.now();
3712
- const loaded = await loadOne(pcModule, app, src, { background: true });
3884
+ if (diag && diag.phase === 'idle') diag.setPhase('prepare');
3885
+ // A SEQUENCE keeps one splat resident: its prepare only FETCHES the bytes (nothing goes to the
3886
+ // engine — no decode, no GPU upload) and compiles its shaders; the engine load runs at the swap,
3887
+ // once the current asset is gone, in the empty beat. `resident: true` opts back into the full
3888
+ // prepare (decoded + uploaded now, not in the scene): no load at the swap, two assets resident
3889
+ // during the dwell.
3890
+ if (po.resident !== undefined && typeof po.resident !== 'boolean') throw new TypeError('@displayxr/inline3d/splat: prepareSource resident must be a boolean.');
3891
+ const fetchOnly = !!warm?.sequence && po.resident !== true;
3892
+ let loaded = null;
3893
+ let source = null;
3894
+ try {
3895
+ if (fetchOnly) {
3896
+ const got = await fetchSourceBytes(src);
3897
+ if (!got.ok) throw new Error(`@displayxr/inline3d/splat: prepareSource could not fetch ${src} (HTTP ${got.status}).`);
3898
+ source = got.source;
3899
+ }
3900
+ else loaded = await loadOne(pcModule, app, src, { background: true });
3901
+ } finally {
3902
+ if (diag?.phase === 'prepare') diag.setPhase('idle');
3903
+ }
3713
3904
  perfSpan('prepareSource', t0);
3905
+ diag?.mark('prepared', { ms: Math.round(performance.now() - t0), fetchOnly });
3714
3906
  // The transition the page declared: compile its shader now, in the dwell, not on its first frame.
3715
3907
  await prewarmTransition(warm); // null (no transition declared): the overlay only
3716
- const entry = { loaded, state: 'ready', dispose: null };
3908
+ const entry = { loaded, source, state: 'ready', dispose: null };
3717
3909
  const prepared = {
3718
3910
  [PREPARED_TAG]: true,
3719
- /** The asset's own count (every splat of a flat source). */
3720
- numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
3911
+ /** The asset's own count (every splat of a flat source); null for a fetch-only (sequence) prepare. */
3912
+ numSplats: loaded ? loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0 : null,
3721
3913
  /** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
3722
3914
  get state() {
3723
3915
  return entry.state;
@@ -3726,8 +3918,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3726
3918
  if (entry.state !== 'ready') return;
3727
3919
  entry.state = 'disposed';
3728
3920
  livePrepared.delete(entry);
3729
- app.assets.remove(loaded.asset);
3730
- loaded.asset.unload?.();
3921
+ entry.source = null;
3922
+ if (loaded) {
3923
+ app.assets.remove(loaded.asset);
3924
+ loaded.asset.unload?.();
3925
+ }
3731
3926
  },
3732
3927
  };
3733
3928
  entry.dispose = prepared.dispose;
@@ -3753,30 +3948,32 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3753
3948
  * engine whose internals differ just compiles on the transition's first frame, as before.
3754
3949
  */
3755
3950
  async function prewarmTransition(plan) {
3951
+ if (diag?.has('nowarm')) return;
3756
3952
  // The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
3757
3953
  // (crossfade, wavefront and the particles all composite through them; created on the first
3758
3954
  // capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
3759
- const chunkKey = plan?.particles
3760
- ? `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`
3955
+ // [key, code()] per variant: a particle transition's one body, the wavefront's ridge + cull, a
3956
+ // sequence's two (its out body, then its in body — one program each, or one when they match).
3957
+ const orderOf = (side) => (EFFECTS[side.effect].particle ? { order: side.opts.order } : {});
3958
+ const variants = plan?.particles
3959
+ ? [[`${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })]]
3761
3960
  : plan?.transition === 'wavefront'
3762
- ? 'wavefront'
3763
- : null;
3764
- const wantChunk = !!(fx && chunkKey && !prewarmed.has(chunkKey));
3765
- const wantOverlay = !prewarmed.has('overlay');
3766
- if (!wantChunk && !wantOverlay) return;
3961
+ ? [['wavefront', () => fx.sharedChunkCodeFor([['transition', 'wavefront', {}], ['transition-cull', 'wipecull', {}]])]]
3962
+ : plan?.sequence
3963
+ ? [plan.sequence.out, plan.sequence.in].map((side) => [`${side.effect}|${side.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', side.effect, orderOf(side))])
3964
+ : [];
3965
+ const wantChunks = fx ? variants.filter(([k], i) => !prewarmed.has(k) && variants.findIndex((v) => v[0] === k) === i) : [];
3966
+ // A sequence draws through no overlay: nothing to warm there.
3967
+ const wantOverlay = !prewarmed.has('overlay') && !plan?.sequence;
3968
+ if (!wantChunks.length && !wantOverlay) return;
3767
3969
  await yieldIdle();
3768
3970
  if (removed) return;
3769
3971
  try {
3770
3972
  const pc = pcModule;
3771
3973
  const made = [];
3772
- if (wantChunk && fx) {
3974
+ for (const [chunkKey, codeOf] of fx ? wantChunks : []) {
3773
3975
  const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
3774
- const code = plan.particles
3775
- ? fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })
3776
- : fx.sharedChunkCodeFor([
3777
- ['transition', 'wavefront', {}],
3778
- ['transition-cull', 'wipecull', {}],
3779
- ]);
3976
+ const code = codeOf();
3780
3977
  let issued = 0;
3781
3978
  for (const cam of cams) {
3782
3979
  const mi = managerMi(cam, null);
@@ -3877,8 +4074,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3877
4074
  }
3878
4075
  async function setSourceNow(next, o = {}) {
3879
4076
  const plan = resolveSwap(o);
4077
+ if (diag?.has('frozen')) plan.outgoing = 'frozen'; // kill switch: the 1.12.1 frozen outgoing
3880
4078
  const { resetPose = false } = o;
3881
4079
  const gen = ++sourceGen;
4080
+ if (diag) {
4081
+ rigLockArmed = true;
4082
+ diag.setPhase('swap', {
4083
+ transition: plan.sequence ? `${plan.sequence.name}(${plan.sequence.out.effect}>${plan.sequence.in.effect})` : plan.transition,
4084
+ outgoing: plan.sequence ? 'none (one splat)' : plan.outgoing || 'default',
4085
+ prepared: preparedAssets.has(next),
4086
+ });
4087
+ }
3882
4088
  // A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
3883
4089
  const prep = preparedAssets.get(next) || null;
3884
4090
  if (prep) {
@@ -3894,10 +4100,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3894
4100
  await first.catch(() => null); // a failed first asset may be replaced
3895
4101
  if (!app || removed) return out;
3896
4102
  const pc = pcModule;
4103
+ // A sequence: one splat at a time (playSequence). Without the effects runner (no asset ever
4104
+ // loaded) there is nothing to play out or in: a cut.
4105
+ if (plan.sequence && fx) return playSequence({ next, plan, gen, prep, resetPose, app, pc });
3897
4106
  // Not prepared: compile what the transition draws with (its chunk, the overlay) while the asset
3898
4107
  // loads, rather than on the window's first frames. Best effort, not awaited.
3899
- if (!prep && current && plan.transition !== 'cut') prewarmTransition(plan);
3900
- const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
4108
+ if (!prep && current && plan.transition !== 'cut' && !plan.sequence) prewarmTransition(plan);
4109
+ // (a fetch-only prepare — prepareSource for a sequence — loads here, from its bytes)
4110
+ const loaded = prep ? prep.loaded || (await loadOne(pc, app, prep.source)) : await loadOne(pc, app, next);
3901
4111
  if (removed || gen !== sourceGen) {
3902
4112
  app.assets.remove(loaded.asset);
3903
4113
  loaded.asset.unload?.();
@@ -3905,11 +4115,28 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3905
4115
  }
3906
4116
  const prev = current;
3907
4117
  pendingSwap?.finish();
3908
- const transition = prev ? plan.transition : 'cut';
4118
+ const transition = prev && !plan.sequence ? plan.transition : 'cut';
3909
4119
  const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
3910
4120
  // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
3911
4121
  // drawn. The overlay goes up in the same task, so no frame shows neither photo.
3912
4122
  const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
4123
+ const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
4124
+ // PRE-SORT the live outgoing camera on the current asset BEFORE the swap (LiveOutgoing.warm):
4125
+ // its first sort runs while the photo is still on screen and live, so the frozen capture has
4126
+ // nothing to bridge. The transition starts once it has sorted (bounded; a hidden tab or a
4127
+ // stalled sorter falls back to the bridge). `?dxrdiag=cold` skips it (the 1.19.2 path).
4128
+ if (wantsSnapshot && outgoingMode === 'live' && viewer.canLiveOutgoing && !diag?.has('cold') && viewer.warmLiveOutgoing(prev.entity)) {
4129
+ const tw = performance.now();
4130
+ const ok = await viewer.liveWarmed();
4131
+ perfSpan('setSource:presort(async)', tw);
4132
+ diag?.mark('presorted', { ok, ms: Math.round(performance.now() - tw) });
4133
+ if (removed || gen !== sourceGen) {
4134
+ // A newer setSource owns the pre-sort now (it warms the same asset, or drops it).
4135
+ app.assets.remove(loaded.asset);
4136
+ loaded.asset.unload?.();
4137
+ return out;
4138
+ }
4139
+ }
3913
4140
  const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
3914
4141
  if (removed || gen !== sourceGen) {
3915
4142
  app.assets.remove(loaded.asset);
@@ -3922,16 +4149,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3922
4149
  // its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
3923
4150
  // A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
3924
4151
  // the RenderView path); 'frozen' there = its snapshot fades out while the new one plays in.
3925
- const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
3926
4152
  const live =
3927
4153
  snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
4154
+ if (!live) viewer.cancelLiveWarm(); // a pre-sort that is not becoming a live window
4155
+ diag?.mark('outgoing', { mode: live ? (live.warmed ? 'live (pre-sorted)' : 'live (cold: frozen bridge until sorted)') : snapped ? 'frozen' : 'none' });
3928
4156
  /** Each frame of the window: once the live camera is ready, the overlay samples it. */
3929
4157
  let liveShown = false;
3930
4158
  const pumpLive = () => {
3931
4159
  if (!live || liveShown || !live.active || !live.ready) return;
3932
4160
  viewer.setSnapshotSource(live.texture);
3933
4161
  liveShown = true;
4162
+ diag?.mark('live-shown');
3934
4163
  };
4164
+ // Pre-sorted: the live target is already sorted, so the overlay samples it from the swap on.
4165
+ if (live?.warmed) pumpLive();
3935
4166
 
3936
4167
  const release = (p) => {
3937
4168
  if (!p) return;
@@ -3960,6 +4191,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3960
4191
  const kept = applyLoaded(loaded);
3961
4192
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
3962
4193
  if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
4194
+ diag?.setPhase('window');
3963
4195
  };
3964
4196
 
3965
4197
  const entity = viewer.addSplatAsset(loaded.asset);
@@ -3971,6 +4203,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3971
4203
  if (finished) return;
3972
4204
  finished = true;
3973
4205
  for (const f of finishers) f();
4206
+ if (gen === sourceGen) diag?.setPhase('settle');
3974
4207
  if (pendingSwap?.finish === finish) pendingSwap = null;
3975
4208
  settle();
3976
4209
  };
@@ -4125,6 +4358,240 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4125
4358
  return out;
4126
4359
  }
4127
4360
 
4361
+ /**
4362
+ * The BYTES of a source, fetched without touching the engine (a sequence's preload: CPU memory
4363
+ * only — no decode, no GPU upload). Bytes / a Blob are returned as they are. A URL the engine
4364
+ * must resolve itself (a Streamed SOG, an unbundled SOG's meta.json) is returned as the URL, and
4365
+ * so is any URL whose fetch THROWS here (CORS, a scheme fetch does not take…): the engine's own
4366
+ * load then runs at the swap. Resolves { source, ok }: ok = false only when the server answered
4367
+ * and it was not the file (an HTTP error) — the one case known before the current photo goes.
4368
+ */
4369
+ async function fetchSourceBytes(src, signal) {
4370
+ if (typeof src !== 'string') return { source: src, ok: true };
4371
+ const fmt = engineFormatFor(src, null, opts.fileName, opts.fileType);
4372
+ if (!fmt || fmt.streamed || fmt.ext === 'json' || typeof fetch !== 'function') return { source: src, ok: true };
4373
+ try {
4374
+ const r = await fetch(src, signal ? { signal } : undefined);
4375
+ if (!r.ok) return { source: src, ok: false, status: r.status };
4376
+ return { source: new Uint8Array(await r.arrayBuffer()), ok: true };
4377
+ } catch (err) {
4378
+ if (signal?.aborted) return { source: null, ok: false, aborted: true };
4379
+ return { source: src, ok: true, fetchError: err };
4380
+ }
4381
+ }
4382
+
4383
+ /** The sequence on screen: the asset its effect is on and how much of it shows (a newer sequence starts from there). */
4384
+ let seqShown = null;
4385
+
4386
+ /**
4387
+ * setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }): ONE splat at
4388
+ * a time.
4389
+ * out — the current photo plays `out` backwards on the eye camera (a tile-scope body at
4390
+ * render time) until nothing of it is drawn. Meanwhile the next file's BYTES are
4391
+ * fetched (a URL; a prepareSource() result is already in hand).
4392
+ * swap — the current asset is released and DESTROYED (entity + GPU resource) before the next
4393
+ * one is given to the engine; then it is decoded, uploaded, placed and adopted (rig
4394
+ * waterfall, rig declared) in one task with its effect at amount 0, so its first frame
4395
+ * draws nothing.
4396
+ * in — after the empty beat (and at least 3 frames, for its work buffer and sort and the
4397
+ * rig to land), its effect's frame is re-taken under the new rig and it plays `in`
4398
+ * forwards. The end removes the body: the exact baseline.
4399
+ * No second camera, layer, target, overlay or capture. Latest wins: a newer sequence takes over
4400
+ * from where this one stands (its out phase starts at the amount shown); a newer other call
4401
+ * ends this one when its asset has loaded (or at once, once this one's photo is gone).
4402
+ */
4403
+ async function playSequence({ next, plan, gen, prep, resetPose, app, pc }) {
4404
+ const seq = plan.sequence;
4405
+ const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
4406
+ const spans = sequenceSpans(seq.beat);
4407
+ const outMs = plan.durationMs * spans.out;
4408
+ const beatMs = plan.durationMs * spans.beat;
4409
+ const inMs = plan.durationMs * spans.in;
4410
+ let finished = false;
4411
+ const finishers = [];
4412
+ /** Resolvers of the waits in flight: ending this call resolves them at once, not on a later frame. */
4413
+ const wakers = new Set();
4414
+ const finish = () => {
4415
+ if (finished) return;
4416
+ finished = true;
4417
+ for (const f of finishers) f();
4418
+ for (const w of [...wakers]) w();
4419
+ wakers.clear();
4420
+ viewer._transitionState = null;
4421
+ if (gen === sourceGen) diag?.setPhase('settle');
4422
+ if (pendingSwap?.finish === finish) pendingSwap = null;
4423
+ };
4424
+ const ac = typeof AbortController === 'function' ? new AbortController() : null;
4425
+ finishers.push(() => ac?.abort());
4426
+ let shown = null; // this call's seqShown
4427
+ finishers.push(() => {
4428
+ if (seqShown && seqShown === shown) seqShown = null;
4429
+ });
4430
+ const dropLoaded = (l) => {
4431
+ if (!l) return;
4432
+ app.assets.remove(l.asset);
4433
+ l.asset.unload?.();
4434
+ };
4435
+ /** Superseded (a newer setSource) or removed: this call stops at its next step. */
4436
+ const stale = () => finished || removed || gen !== sourceGen;
4437
+ /** Run `step(k, s)` over `ms` of viewer ticks (k 0 → 1, s = seconds); false if ended early. */
4438
+ const span = (ms, step) =>
4439
+ new Promise((resolve) => {
4440
+ let t0 = null;
4441
+ const wake = () => resolve(false);
4442
+ wakers.add(wake);
4443
+ viewer._hooks.push((t) => {
4444
+ if (finished || removed) return (resolve(false), false);
4445
+ if (!wakers.has(wake)) return false;
4446
+ if (t0 === null) t0 = t;
4447
+ const k = ms > 0 ? Math.min(1, Math.max(0, (t - t0) / ms)) : 1;
4448
+ step(k, (t - t0) / 1000);
4449
+ if (k >= 1) return (wakers.delete(wake), resolve(true), false);
4450
+ return true;
4451
+ });
4452
+ });
4453
+ // The next asset's bytes, fetched during the out phase. Nothing of it goes to the engine yet.
4454
+ const sourceP = prep ? Promise.resolve({ source: prep.loaded ? null : prep.source, ok: true }) : fetchSourceBytes(next, ac?.signal);
4455
+ // Both bodies compiled before the clock starts. The photo stays on screen meanwhile, live — and
4456
+ // a sequence already playing on it keeps playing: it is taken over only once this one can run.
4457
+ await prewarmTransition(plan);
4458
+ if (stale()) {
4459
+ if (prep) dropLoaded(prep.loaded);
4460
+ finish();
4461
+ return out;
4462
+ }
4463
+ // a sequence already on this photo: take over from how much of it shows, in this same task
4464
+ const startAmount = seqShown && current && seqShown.entity === current.entity ? seqShown.amount : 1;
4465
+ pendingSwap?.finish(); // latest wins: whatever was in flight ends now (at its end state)
4466
+ pendingSwap = { finish };
4467
+ viewer._transitionPath = 'sequence'; // diagnostics
4468
+ diag?.mark('sequence', { name: seq.name, out: seq.out.effect, in: seq.in.effect, beat: seq.beat, from: +startAmount.toFixed(3), prepared: prep ? (prep.loaded ? 'resident' : 'fetched') : 'no' });
4469
+
4470
+ // ── out ──
4471
+ const prev = current;
4472
+ diag?.setPhase('window');
4473
+ if (prev) {
4474
+ const fxOut = fx.driveTile('transition', seq.out.effect, seq.out.opts); // its frame: the current rig's
4475
+ finishers.push(() => fxOut.remove());
4476
+ fxOut.set(startAmount, 0);
4477
+ shown = seqShown = { entity: prev.entity, amount: startAmount };
4478
+ const full = await span(outMs * startAmount, (k, s) => {
4479
+ const a = startAmount * (1 - ease(k));
4480
+ fxOut.set(a, s);
4481
+ shown.amount = a;
4482
+ viewer._transitionState = { phase: 'out', raw: k }; // diagnostics
4483
+ });
4484
+ if (!full) {
4485
+ // taken over (a newer call finished this one) or removed
4486
+ if (prep) dropLoaded(prep.loaded);
4487
+ return out;
4488
+ }
4489
+ diag?.mark('out-done');
4490
+ // Nothing of the current photo is drawn now. Its file must have arrived before it is let go:
4491
+ // if the next one cannot be fetched, the current one comes back instead.
4492
+ const got = await sourceP;
4493
+ if (!got.ok && !got.aborted && !stale()) {
4494
+ diag?.mark('fetch-failed', { status: got.status ?? null });
4495
+ viewer._transitionState = { phase: 'back', raw: 0 };
4496
+ await span(inMs, (k, s) => {
4497
+ const a = ease(k);
4498
+ fxOut.set(a, s);
4499
+ shown.amount = a;
4500
+ });
4501
+ finish();
4502
+ throw new Error(`@displayxr/inline3d/splat: setSource could not fetch ${typeof next === 'string' ? next : 'the source'} (HTTP ${got.status}); the current photo stays.`);
4503
+ }
4504
+ // COMMITTED: release it, and wait until it is gone (entity destroyed, resource unloaded)
4505
+ // before the next one is made resident. A newer call from here on finds nothing on screen.
4506
+ current = null;
4507
+ out.mesh = null;
4508
+ shown = null;
4509
+ seqShown = null;
4510
+ await new Promise((resolve) => {
4511
+ prev.entity.enabled = false;
4512
+ fx?.dropEntity(prev.entity);
4513
+ let frames = 0;
4514
+ // the engine's gsplat world keeps the placement until its next rebuild: destroy a few frames later
4515
+ viewer._hooks.push(() => {
4516
+ if (++frames < 4 && !removed) return true;
4517
+ prev.entity.destroy?.();
4518
+ app.assets.remove(prev.asset);
4519
+ prev.asset.unload?.();
4520
+ resolve();
4521
+ return false;
4522
+ });
4523
+ });
4524
+ diag?.mark('released');
4525
+ }
4526
+ const outEndAt = now();
4527
+ if (stale()) {
4528
+ if (prep) dropLoaded(prep.loaded);
4529
+ finish();
4530
+ return out;
4531
+ }
4532
+
4533
+ // ── swap ──
4534
+ let loaded;
4535
+ try {
4536
+ const got = await sourceP;
4537
+ viewer._transitionState = { phase: 'load', raw: 0 };
4538
+ const loadP = prep?.loaded ? Promise.resolve(prep.loaded) : loadOne(pc, app, got.source ?? next);
4539
+ // a newer call ends this one at once; the asset, when it lands, is dropped unseen
4540
+ const r = await Promise.race([loadP.then((l) => ({ l })), new Promise((resolve) => wakers.add(() => resolve(null)))]);
4541
+ if (!r) {
4542
+ loadP.then(dropLoaded, () => {});
4543
+ return out;
4544
+ }
4545
+ loaded = r.l;
4546
+ } catch (err) {
4547
+ finish();
4548
+ if (stale()) return out;
4549
+ throw err; // the tile is empty: the current photo was already gone
4550
+ }
4551
+ if (stale()) {
4552
+ dropLoaded(loaded);
4553
+ finish();
4554
+ return out;
4555
+ }
4556
+ diag?.mark('loaded');
4557
+ const entity = viewer.addSplatAsset(loaded.asset);
4558
+ out.mesh = { numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0, entity, asset: loaded.asset, resource: loaded.res };
4559
+ const kept = applyLoaded(loaded); // the rig waterfall; the rig is declared here, before any frame of it
4560
+ current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
4561
+ if (resetPose && !pageMode) viewer.resetPose();
4562
+ // same task: the in body at amount 0 (tile material values), so its first frame draws nothing
4563
+ const fxIn = fx.driveTile('transition', seq.in.effect, seq.in.opts);
4564
+ finishers.push(() => fxIn.remove());
4565
+ fxIn.set(0, 0);
4566
+ shown = seqShown = { entity, amount: 0 };
4567
+ diag?.mark('adopted');
4568
+
4569
+ // ── the empty beat: at least `beat` since the old photo went, and 3 frames of the new one ──
4570
+ await new Promise((resolve) => {
4571
+ let ticks = 0;
4572
+ wakers.add(resolve);
4573
+ viewer._hooks.push((t) => {
4574
+ if (finished || removed) return (resolve(), false);
4575
+ viewer._transitionState = { phase: 'beat', raw: 0 };
4576
+ if (++ticks >= 3 && t - outEndAt >= beatMs) return (resolve(), false);
4577
+ return true;
4578
+ });
4579
+ });
4580
+ if (finished || removed) return out;
4581
+
4582
+ // ── in ──
4583
+ fxIn.restart(); // its frame: the new photo under its own rig, now declared and drawn
4584
+ diag?.mark('in-start');
4585
+ const full = await span(inMs, (k, s) => {
4586
+ const a = ease(k);
4587
+ fxIn.set(a, s);
4588
+ shown.amount = a;
4589
+ viewer._transitionState = { phase: 'in', raw: k }; // diagnostics
4590
+ });
4591
+ if (full) finish(); // removes the body: the plain render, exactly
4592
+ return out;
4593
+ }
4594
+
4128
4595
  /** The mesh instance of the gsplat manager that `cam` draws `layer` with (any layer: null). */
4129
4596
  const managerMi = (cam, layer) => {
4130
4597
  const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);