@displayxr/inline3d 1.19.3 → 1.20.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 CHANGED
@@ -5,6 +5,31 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## Unreleased
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel change at rest; the
11
+ first frames of a live `crossfade` / `wavefront` / particle transition change (see Fixed).
12
+
13
+ ### Added
14
+
15
+ - **Transition diagnostics: `diag` / `?dxrdiag`** (#36). Off by default. Records every woven
16
+ frame (interval, whether its eye poses are bit-identical to the previous frame's, what the
17
+ transition overlay shows), every `setViewRig` push with its values, long tasks and the
18
+ `setSource` phases. Shows a small overlay excluded from the weave, logs one
19
+ `[dxr-diag] transition #N <verdict>` line per transition, and exposes `window.__dxrDiag`
20
+ (`copy(__dxrDiag.dump())`). Kill switches for A/B: `norig`, `frozen`, `nowarm`, `cold`,
21
+ `nooverlay`. See `docs/playcanvas-adapter.md` § Diagnosing transition stalls.
22
+
23
+ ### Fixed
24
+
25
+ - **The live outgoing photo no longer starts as a still** (#36). A live transition put the old
26
+ photo on a FRESH gsplat manager at the swap, and until that manager's first sort came back the
27
+ overlay showed the frozen capture at full weight: the whole picture stood still for 2–7 frames
28
+ right as the transition started. The live camera now pre-sorts the current photo BEFORE the swap
29
+ (it stays on screen, untouched, meanwhile), so the overlay samples the live image from the swap
30
+ frame on: 0 frozen frames. The transition starts later by about the length of that sort instead
31
+ (bounded at 1.5 s, then the old bridge). `?dxrdiag=cold` restores the 1.19.2 path.
32
+
8
33
  ## 1.19.3 — 2026-09-24
9
34
 
10
35
  Touches the **preview tier** (`./splat`, `./model`). No API change and no pixel change on a core that
@@ -0,0 +1,599 @@
1
+ // inline3d-splat-diag.js — transition diagnostics for the PlayCanvas splat handle (#36).
2
+ //
3
+ // Internal. Used by ./inline3d-splat-playcanvas.js only. Off unless the page asks for it:
4
+ // `addSplat(..., { engine: 'playcanvas', diag: true })`, or `?dxrdiag=1` on the page URL.
5
+ //
6
+ // WHY. "Head tracking stops for a moment at every photo transition" has four candidate causes
7
+ // that look identical on the panel, and only one of them is visible from a headless browser:
8
+ // - a MAIN-THREAD stall: the page misses session frames, so nothing new is drawn. Shows here as
9
+ // a gap in the session-frame intervals (and in the window rAF), usually with a long task.
10
+ // - a TRACKING hold: frames keep coming, but the eye poses in them do not move. The browser
11
+ // keeps the last good views when a locate reply comes back empty, and reuses the previous
12
+ // reply while its UI thread is busy. Shows here as frames whose XRView transforms are
13
+ // BIT-IDENTICAL to the previous frame's (a live eye tracker never repeats itself exactly).
14
+ // - a FROZEN IMAGE: frames come, poses move, but what is on screen is a still capture (the
15
+ // crossfade's frozen bridge, shown until the live outgoing camera has sorted). Shows here as
16
+ // frames where the transition overlay samples the frozen capture at a high weight.
17
+ // - a RIG change applied late or badly: every setViewRig push is logged with its values, so a
18
+ // pose jump or a hold can be lined up against the rig it followed.
19
+ // Each frame of the session is recorded with all four, so one run on the panel says which.
20
+ //
21
+ // KILL SWITCHES, for A/B on the panel (comma-separated, in `diag` or in `?dxrdiag=`):
22
+ // norig — the rig declared before the first setSource stays declared; every later
23
+ // re-declaration is DROPPED (logged as dropped). Tests "the rig change freezes it".
24
+ // frozen — force `outgoing: 'frozen'` (the 1.12.1 frozen outgoing photo) on every setSource.
25
+ // nowarm — skip the transition shader pre-warm (prepareSource / setSource compile nothing).
26
+ // cold — skip the live outgoing PRE-SORT (the 1.19.2 behaviour: the frozen capture bridges
27
+ // until a fresh manager has sorted).
28
+ // nooverlay — record + console + window.__dxrDiag, but no on-screen overlay.
29
+
30
+ /** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
31
+ export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay']);
32
+ const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
33
+
34
+ /**
35
+ * `diag` option (true | string | string[]) and/or the page query, into { on, switches }.
36
+ * The option wins when it is given (false turns diag off even with `?dxrdiag` on the URL).
37
+ * Unknown tokens are returned in `unknown` (warned once by the caller), never thrown on: a
38
+ * diagnostic flag must not be able to break a page.
39
+ */
40
+ export function resolveDiag(opt, search = pageSearch()) {
41
+ let raw = null;
42
+ if (opt === false) return { on: false, switches: new Set(), unknown: [] };
43
+ if (opt === true) raw = '1';
44
+ else if (typeof opt === 'string') raw = opt;
45
+ else if (Array.isArray(opt)) raw = opt.join(',');
46
+ else if (opt === undefined || opt === null) raw = queryValue(search);
47
+ if (raw === null) return { on: false, switches: new Set(), unknown: [] };
48
+ const switches = new Set();
49
+ const unknown = [];
50
+ for (const tok of String(raw).split(/[,\s+]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)) {
51
+ if (ON_TOKENS.has(tok)) continue;
52
+ if (tok === '0' || tok === 'off' || tok === 'false') return { on: false, switches: new Set(), unknown: [] };
53
+ if (DIAG_SWITCHES.includes(tok)) switches.add(tok);
54
+ else unknown.push(tok);
55
+ }
56
+ return { on: true, switches, unknown };
57
+ }
58
+
59
+ function pageSearch() {
60
+ try {
61
+ return typeof location !== 'undefined' && typeof location.search === 'string' ? location.search : '';
62
+ } catch {
63
+ return '';
64
+ }
65
+ }
66
+
67
+ /** `?dxrdiag=...` (every occurrence, joined), or null when absent. `?dxrdiag` alone = on. */
68
+ function queryValue(search) {
69
+ if (!search) return null;
70
+ let q;
71
+ try {
72
+ q = new URLSearchParams(search);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (!q.has('dxrdiag')) return null;
77
+ const all = q.getAll('dxrdiag').map((v) => v || '1');
78
+ return all.join(',');
79
+ }
80
+
81
+ /**
82
+ * One frame's eye-pose metrics against the previous frame's views.
83
+ * held — every view's transform AND projection bit-identical to the previous frame (a hold:
84
+ * a live tracker never repeats itself to the last bit).
85
+ * delta — the largest eye-position move, in the views' world units.
86
+ * ipd — the eye separation this frame, same units (it scales with the rig).
87
+ * rel — delta / ipd: head motion in eye separations, comparable across rigs.
88
+ * `prev` is the previous frame's packed state (or null); returns { m, state }, `state` being
89
+ * this frame's packed copy for the next call.
90
+ */
91
+ export function poseDelta(views, prev) {
92
+ const n = views ? views.length : 0;
93
+ const state = new Float64Array(n * 32);
94
+ for (let i = 0; i < n; i++) {
95
+ const t = views[i]?.transform?.matrix;
96
+ const p = views[i]?.projectionMatrix;
97
+ for (let k = 0; k < 16; k++) {
98
+ state[i * 32 + k] = t ? t[k] : NaN;
99
+ state[i * 32 + 16 + k] = p ? p[k] : NaN;
100
+ }
101
+ }
102
+ const m = { views: n, held: false, delta: NaN, ipd: NaN, rel: NaN };
103
+ if (n >= 2) {
104
+ const dx = state[12] - state[32 + 12];
105
+ const dy = state[13] - state[32 + 13];
106
+ const dz = state[14] - state[32 + 14];
107
+ m.ipd = Math.hypot(dx, dy, dz);
108
+ }
109
+ if (prev && prev.length === state.length && n > 0) {
110
+ let same = true;
111
+ for (let k = 0; k < state.length; k++) {
112
+ if (!Object.is(state[k], prev[k])) {
113
+ same = false;
114
+ break;
115
+ }
116
+ }
117
+ m.held = same;
118
+ let d = 0;
119
+ for (let i = 0; i < n; i++) {
120
+ const o = i * 32;
121
+ d = Math.max(d, Math.hypot(state[o + 12] - prev[o + 12], state[o + 13] - prev[o + 13], state[o + 14] - prev[o + 14]));
122
+ }
123
+ m.delta = d;
124
+ m.rel = m.ipd > 0 ? d / m.ipd : NaN;
125
+ }
126
+ return { m, state };
127
+ }
128
+
129
+ /** A view rig descriptor, summarised for the log (numbers rounded; nothing else kept). */
130
+ export function summarizeRig(rig) {
131
+ if (!rig || typeof rig !== 'object') return { type: rig === null ? 'none' : typeof rig };
132
+ const r4 = (x) => (Number.isFinite(x) ? Math.round(x * 1e4) / 1e4 : x);
133
+ const out = {};
134
+ const walk = (o, prefix, depth) => {
135
+ for (const [k, v] of Object.entries(o)) {
136
+ const key = prefix ? `${prefix}.${k}` : k;
137
+ if (typeof v === 'number') out[key] = r4(v);
138
+ else if (typeof v === 'string' || typeof v === 'boolean') out[key] = v;
139
+ else if (v && typeof v === 'object' && depth < 2 && !ArrayBuffer.isView(v)) walk(v, key, depth + 1);
140
+ }
141
+ };
142
+ walk(rig, '', 0);
143
+ return out;
144
+ }
145
+
146
+ /** Phase names, in order: prepare (prepareSource), swap (setSource → adopt), window, settle. */
147
+ export const DIAG_PHASES = Object.freeze(['idle', 'prepare', 'swap', 'window', 'settle']);
148
+
149
+ const FRAME_CAP = 1800; // ~30 s at 60 Hz
150
+ const EVENT_CAP = 600;
151
+ const SETTLE_MS = 1000; // a transition's summary covers its window + this much after it
152
+ const PRE_MS = 300; // ... and this much before the setSource call
153
+
154
+ /**
155
+ * The recorder. `now` is injectable (tests); everything else is plain data, so the recorder runs
156
+ * (and is tested) without a DOM. The overlay (attachOverlay) is optional on top.
157
+ */
158
+ export class DiagRecorder {
159
+ constructor({ switches = new Set(), now = () => performance.now(), log = defaultLog, label = 'splat' } = {}) {
160
+ this.switches = switches;
161
+ this.now = now;
162
+ this.log = log;
163
+ this.label = label;
164
+ this.frames = [];
165
+ this.events = [];
166
+ this.transitions = [];
167
+ this.phase = 'idle';
168
+ this.phaseAt = 0;
169
+ this.callAt = NaN; // the current / last setSource call
170
+ this._prevState = null;
171
+ this._lastFrameAt = NaN;
172
+ this._lastRafAt = NaN;
173
+ this._rigAt = -Infinity;
174
+ this._rigFrame = -Infinity;
175
+ this._frameNo = 0;
176
+ this._open = null; // the transition being recorded
177
+ this.imageState = null; // () => { overlay: 'none' | 'frozen' | 'live', w }
178
+ this.longTasks = [];
179
+ this._lto = null;
180
+ this.rigLocked = null; // norig: the summary of the rig kept
181
+ this._listeners = new Set();
182
+ }
183
+
184
+ has(sw) {
185
+ return this.switches.has(sw);
186
+ }
187
+
188
+ /** Observe long tasks (where the browser has the API). */
189
+ observeLongTasks() {
190
+ try {
191
+ if (typeof PerformanceObserver !== 'function') return;
192
+ this._lto = new PerformanceObserver((list) => {
193
+ for (const e of list.getEntries()) this.longTask(e.startTime, e.duration);
194
+ });
195
+ this._lto.observe({ type: 'longtask', buffered: false });
196
+ } catch {
197
+ this._lto = null;
198
+ }
199
+ }
200
+
201
+ longTask(start, duration) {
202
+ const lt = { s: round1(start), d: round1(duration) };
203
+ this.longTasks.push(lt);
204
+ if (this.longTasks.length > EVENT_CAP) this.longTasks.splice(0, this.longTasks.length - EVENT_CAP);
205
+ this._event('longtask', lt, start);
206
+ }
207
+
208
+ /** A window rAF tick (the diag's own loop): main-thread cadence independent of the session. */
209
+ raf(t = this.now()) {
210
+ const gap = Number.isFinite(this._lastRafAt) ? t - this._lastRafAt : NaN;
211
+ this._lastRafAt = t;
212
+ if (this._open && Number.isFinite(gap)) this._open.maxRafGap = Math.max(this._open.maxRafGap, gap);
213
+ return gap;
214
+ }
215
+
216
+ /** One session frame (the woven path's onFrame), BEFORE the draw. */
217
+ frame(views, t = this.now()) {
218
+ const { m, state } = poseDelta(views, this._prevState);
219
+ this._prevState = state;
220
+ const dt = Number.isFinite(this._lastFrameAt) ? t - this._lastFrameAt : NaN;
221
+ this._lastFrameAt = t;
222
+ this._frameNo++;
223
+ const img = this.imageState ? safe(this.imageState) : null;
224
+ const f = {
225
+ n: this._frameNo,
226
+ t: round1(t),
227
+ dt: round1(dt),
228
+ phase: this.phase,
229
+ sinceCall: Number.isFinite(this.callAt) ? round1(t - this.callAt) : null,
230
+ views: m.views,
231
+ held: m.held,
232
+ delta: round6(m.delta),
233
+ rel: round4(m.rel),
234
+ ipd: round6(m.ipd),
235
+ // A rig push in the last 3 frames: a pose jump here is the rig, not the head.
236
+ afterRig: this._frameNo - this._rigFrame <= 3,
237
+ img: img ? img.overlay : 'none',
238
+ imgW: img ? round4(img.w) : 0,
239
+ };
240
+ this.frames.push(f);
241
+ if (this.frames.length > FRAME_CAP) this.frames.splice(0, this.frames.length - FRAME_CAP);
242
+ if (this._open) this._accumulate(this._open, f);
243
+ this._maybeClose(t);
244
+ this._emit();
245
+ return f;
246
+ }
247
+
248
+ /** A setViewRig push (or, under `norig`, a dropped one). */
249
+ rig(rig, { dropped = false } = {}) {
250
+ const t = this.now();
251
+ const s = summarizeRig(rig);
252
+ // controls:'page' re-declares every frame: only a CHANGED rig is an event.
253
+ const key = (dropped ? 'd' : 'p') + JSON.stringify(s);
254
+ if (key === this._lastRigKey) {
255
+ this.rigRepeats = (this.rigRepeats || 0) + 1;
256
+ return null;
257
+ }
258
+ this._lastRigKey = key;
259
+ this._rigAt = t;
260
+ if (!dropped) this._rigFrame = this._frameNo;
261
+ const ev = this._event(dropped ? 'rig-dropped' : 'rig', { rig: s }, t);
262
+ if (this._open) this._open.rigs.push({ at: round1(t - this._open.callAt), dropped, rig: s });
263
+ this.log(`${dropped ? 'rig DROPPED (norig)' : 'rig'} ${fmtT(this, t)} ${JSON.stringify(s)}`);
264
+ return ev;
265
+ }
266
+
267
+ /** Enter a phase ('prepare', 'swap', 'window', 'settle', 'idle'); `detail` is logged with it. */
268
+ setPhase(phase, detail) {
269
+ const t = this.now();
270
+ if (phase === 'swap') this._openTransition(t, detail);
271
+ this.phase = phase;
272
+ this.phaseAt = t;
273
+ if (this._open) this._open.phases.push({ phase, at: round1(t - this._open.callAt), ...(detail ? { detail } : {}) });
274
+ this._event('phase', { phase, ...(detail ? { detail } : {}) }, t);
275
+ this.log(`phase ${phase} ${fmtT(this, t)}${detail ? ' ' + JSON.stringify(detail) : ''}`);
276
+ if (phase === 'settle' && this._open) this._open.settleAt = t;
277
+ }
278
+
279
+ /** A named instant inside the current transition (e.g. 'presorted', 'live-shown'). */
280
+ mark(name, detail) {
281
+ const t = this.now();
282
+ if (this._open) this._open.marks.push({ name, at: round1(t - this._open.callAt), ...(detail ? { detail } : {}) });
283
+ this._event('mark', { name, ...(detail ? { detail } : {}) }, t);
284
+ this.log(`mark ${name} ${fmtT(this, t)}${detail ? ' ' + JSON.stringify(detail) : ''}`);
285
+ }
286
+
287
+ _openTransition(t, detail) {
288
+ if (this._open) this._close(this._open, t, 'superseded');
289
+ this.callAt = t;
290
+ this._open = {
291
+ id: this.transitions.length + 1,
292
+ callAt: t,
293
+ detail: detail || null,
294
+ settleAt: NaN,
295
+ phases: [],
296
+ marks: [],
297
+ rigs: [],
298
+ frames: 0,
299
+ maxFrameGap: 0,
300
+ maxRafGap: 0,
301
+ held: 0,
302
+ heldRun: 0,
303
+ heldRunMax: 0,
304
+ heldRunMaxMs: 0,
305
+ _heldRunStart: NaN,
306
+ frozenImg: 0,
307
+ frozenImgMs: 0,
308
+ _lastT: NaN,
309
+ firstFrameAt: NaN,
310
+ lastFrameAt: NaN,
311
+ window: [], // the frame records kept for the frozen overlay strip
312
+ };
313
+ // Frames already recorded in the lead-in (PRE_MS before the call) belong to it too.
314
+ for (const f of this.frames) if (f.t >= t - PRE_MS) this._open.window.push(f);
315
+ }
316
+
317
+ _accumulate(o, f) {
318
+ o.frames++;
319
+ o.window.push(f);
320
+ if (!Number.isFinite(o.firstFrameAt)) o.firstFrameAt = f.t;
321
+ o.lastFrameAt = f.t;
322
+ if (Number.isFinite(f.dt)) o.maxFrameGap = Math.max(o.maxFrameGap, f.dt);
323
+ if (f.held) {
324
+ o.held++;
325
+ if (o.heldRun === 0) o._heldRunStart = Number.isFinite(f.dt) ? f.t - f.dt : f.t;
326
+ o.heldRun++;
327
+ if (o.heldRun > o.heldRunMax) {
328
+ o.heldRunMax = o.heldRun;
329
+ o.heldRunMaxMs = round1(f.t - o._heldRunStart);
330
+ }
331
+ } else o.heldRun = 0;
332
+ if (f.img === 'frozen' && f.imgW >= 0.5) {
333
+ o.frozenImg++;
334
+ if (Number.isFinite(f.dt)) o.frozenImgMs = round1(o.frozenImgMs + f.dt);
335
+ }
336
+ }
337
+
338
+ _maybeClose(t) {
339
+ const o = this._open;
340
+ if (o && Number.isFinite(o.settleAt) && t - o.settleAt >= SETTLE_MS) this._close(o, t, 'settled');
341
+ }
342
+
343
+ /** Force the open transition's summary now (tests, or a page that wants it early). */
344
+ flush() {
345
+ if (this._open) this._close(this._open, this.now(), 'flushed');
346
+ }
347
+
348
+ _close(o, t, how) {
349
+ this._open = null;
350
+ if (how === 'settled' && this.phase === 'settle') {
351
+ this.phase = 'idle';
352
+ this.phaseAt = t;
353
+ }
354
+ const lts = this.longTasks.filter((e) => e.s + e.d > o.callAt - PRE_MS && e.s < t);
355
+ const s = {
356
+ id: o.id,
357
+ how,
358
+ detail: o.detail,
359
+ at: round1(o.callAt),
360
+ windowMs: Number.isFinite(o.settleAt) ? round1(o.settleAt - o.callAt) : null,
361
+ phases: o.phases,
362
+ marks: o.marks,
363
+ rigs: o.rigs,
364
+ frames: o.frames,
365
+ maxFrameGapMs: round1(o.maxFrameGap),
366
+ maxRafGapMs: round1(o.maxRafGap),
367
+ longTasks: lts.map((e) => ({ at: round1(e.s - o.callAt), d: e.d })),
368
+ longestTaskMs: lts.length ? Math.max(...lts.map((e) => e.d)) : 0,
369
+ heldFrames: o.held,
370
+ heldRunMax: o.heldRunMax,
371
+ heldRunMaxMs: o.heldRunMaxMs,
372
+ frozenImageFrames: o.frozenImg,
373
+ frozenImageMs: o.frozenImgMs,
374
+ };
375
+ s.verdict = verdict(s);
376
+ s.window = o.window.map((f) => ({ ...f, t: round1(f.t - o.callAt) }));
377
+ this.transitions.push(s);
378
+ if (this.transitions.length > 50) this.transitions.shift();
379
+ const { window: _w, ...brief } = s;
380
+ this.log(`transition #${s.id} ${s.verdict} ${JSON.stringify(brief)}`);
381
+ this._emit(s);
382
+ }
383
+
384
+ _event(type, data, t = this.now()) {
385
+ const ev = { ...data, type, t: round1(t), sinceCall: Number.isFinite(this.callAt) ? round1(t - this.callAt) : null };
386
+ this.events.push(ev);
387
+ if (this.events.length > EVENT_CAP) this.events.splice(0, this.events.length - EVENT_CAP);
388
+ return ev;
389
+ }
390
+
391
+ onUpdate(cb) {
392
+ this._listeners.add(cb);
393
+ return () => this._listeners.delete(cb);
394
+ }
395
+
396
+ _emit(summary) {
397
+ for (const cb of this._listeners) safe(() => cb(summary));
398
+ }
399
+
400
+ /** Everything, as plain JSON (what David pastes back). */
401
+ toJSON() {
402
+ return {
403
+ label: this.label,
404
+ switches: [...this.switches],
405
+ rigLocked: this.rigLocked,
406
+ transitions: this.transitions,
407
+ events: this.events,
408
+ frames: this.frames,
409
+ };
410
+ }
411
+
412
+ dump() {
413
+ return JSON.stringify(this.toJSON());
414
+ }
415
+
416
+ dispose() {
417
+ try {
418
+ this._lto?.disconnect();
419
+ } catch {
420
+ /* already gone */
421
+ }
422
+ this._lto = null;
423
+ this._listeners.clear();
424
+ }
425
+ }
426
+
427
+ /**
428
+ * The one-line verdict. Thresholds: a session-frame gap > 50 ms (three missed frames at 60 Hz) is
429
+ * a stall; 2+ consecutive bit-identical frames is a hold; 2+ frames of the frozen capture at
430
+ * weight >= 0.5 is a frozen image.
431
+ */
432
+ export function verdict(s) {
433
+ const out = [];
434
+ if (s.heldRunMax >= 2) out.push(`TRACKING-HELD ${s.heldRunMax} frames (${s.heldRunMaxMs} ms) with frames still arriving`);
435
+ if (s.maxFrameGapMs > 50) out.push(`MAIN-THREAD gap ${s.maxFrameGapMs} ms (longest task ${s.longestTaskMs} ms)`);
436
+ if (s.frozenImageFrames >= 2) out.push(`IMAGE-FROZEN ${s.frozenImageFrames} frames (${s.frozenImageMs} ms) on the frozen capture`);
437
+ return out.length ? out.join(' + ') : 'CLEAN (no hold, no gap > 50 ms, no frozen image)';
438
+ }
439
+
440
+ function defaultLog(msg) {
441
+ if (typeof console !== 'undefined') console.info(`[dxr-diag] ${msg}`);
442
+ }
443
+ function fmtT(rec, t) {
444
+ return Number.isFinite(rec.callAt) ? `t=${round1(t - rec.callAt)}ms` : `t=${round1(t)}`;
445
+ }
446
+ function safe(fn) {
447
+ try {
448
+ return fn();
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ const round1 = (x) => (Number.isFinite(x) ? Math.round(x * 10) / 10 : x);
454
+ const round4 = (x) => (Number.isFinite(x) ? Math.round(x * 1e4) / 1e4 : x);
455
+ const round6 = (x) => (Number.isFinite(x) ? Math.round(x * 1e6) / 1e6 : x);
456
+
457
+ // ── the on-screen overlay (2D DOM; the caller excludes it from the weave) ─────────────────────
458
+
459
+ const W = 360;
460
+ const STRIP_H = 44;
461
+ const WINDOW_MS = 3000;
462
+
463
+ /**
464
+ * A compact overlay, fixed to the viewport's bottom-left: two strips (the rolling last 3 s, and
465
+ * the last transition, frozen at its settle) plus a text line. Per session frame, one bar: its
466
+ * height is the frame interval (0–100 ms), its colour what the frame was — blue normal, RED held
467
+ * (bit-identical poses), ORANGE the frozen capture on screen, GREY a rig push just before; the
468
+ * white line is the head motion (delta / eye separation). Repainted at ~10 Hz by the diag's own
469
+ * window rAF, which also measures the main thread's cadence (also without the overlay).
470
+ * Returns { el, remove } (el null without the overlay).
471
+ */
472
+ export function startDiagLoop(rec, { overlay = true, doc = typeof document !== 'undefined' ? document : null } = {}) {
473
+ const hasRaf = typeof requestAnimationFrame === 'function';
474
+ if (!overlay || !doc?.createElement || !doc.body) {
475
+ // No overlay: the window rAF cadence is still measured (the main thread's own clock).
476
+ let raf = 0;
477
+ const tick = (t) => {
478
+ rec.raf(t);
479
+ raf = requestAnimationFrame(tick);
480
+ };
481
+ if (hasRaf) raf = requestAnimationFrame(tick);
482
+ return { el: null, remove: () => raf && typeof cancelAnimationFrame === 'function' && cancelAnimationFrame(raf) };
483
+ }
484
+ const el = doc.createElement('div');
485
+ el.setAttribute('data-dxr-diag', '');
486
+ el.style.cssText =
487
+ 'position:fixed;left:8px;bottom:8px;z-index:2147483647;width:' +
488
+ W +
489
+ 'px;padding:6px;background:rgba(0,0,0,0.78);color:#e8e8e8;font:11px/1.3 ui-monospace,Menlo,Consolas,monospace;' +
490
+ 'border-radius:6px;pointer-events:none;white-space:pre-wrap;';
491
+ const head = doc.createElement('div');
492
+ const cv = doc.createElement('canvas');
493
+ cv.width = W;
494
+ cv.height = STRIP_H * 2 + 18;
495
+ cv.style.cssText = `display:block;width:${W}px;height:${STRIP_H * 2 + 18}px;margin:4px 0`;
496
+ const foot = doc.createElement('div');
497
+ el.append(head, cv, foot);
498
+ doc.body.appendChild(el);
499
+ const g = cv.getContext && cv.getContext('2d');
500
+ let frozen = null; // the last transition's summary
501
+ let raf = 0;
502
+ let lastPaint = 0;
503
+ const off = rec.onUpdate((s) => {
504
+ if (s) frozen = s;
505
+ });
506
+ const strip = (frames, y0, t0, t1, callT) => {
507
+ if (!g) return;
508
+ g.fillStyle = '#111';
509
+ g.fillRect(0, y0, W, STRIP_H);
510
+ const x = (t) => ((t - t0) / (t1 - t0)) * W;
511
+ if (Number.isFinite(callT)) {
512
+ g.fillStyle = '#555';
513
+ g.fillRect(Math.round(x(callT)), y0, 1, STRIP_H);
514
+ }
515
+ let prev = null;
516
+ for (const f of frames) {
517
+ if (f.t < t0 || f.t > t1) continue;
518
+ const h = Math.min(STRIP_H, (Number.isFinite(f.dt) ? f.dt : 0) * (STRIP_H / 100));
519
+ g.fillStyle = f.held ? '#ff3b30' : f.img === 'frozen' && f.imgW >= 0.5 ? '#ff9f0a' : f.afterRig ? '#8e8e93' : '#0a84ff';
520
+ g.fillRect(Math.round(x(f.t)), y0 + STRIP_H - h, 2, h);
521
+ if (Number.isFinite(f.rel)) {
522
+ const yy = y0 + STRIP_H - Math.min(STRIP_H, f.rel * STRIP_H * 20);
523
+ if (prev) {
524
+ g.strokeStyle = '#fff';
525
+ g.beginPath();
526
+ g.moveTo(prev[0], prev[1]);
527
+ g.lineTo(x(f.t), yy);
528
+ g.stroke();
529
+ }
530
+ prev = [x(f.t), yy];
531
+ } else prev = null;
532
+ }
533
+ };
534
+ const paint = (t) => {
535
+ rec.raf(t);
536
+ raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(paint) : 0;
537
+ if (t - lastPaint < 100) return;
538
+ lastPaint = t;
539
+ const last = rec.frames[rec.frames.length - 1];
540
+ const now = last ? last.t : t;
541
+ head.textContent =
542
+ `dxr-diag ${rec.label} phase ${rec.phase}` +
543
+ (Number.isFinite(rec.callAt) ? ` +${Math.round(now - rec.callAt)} ms since setSource` : '') +
544
+ (rec.switches.size ? ` [${[...rec.switches].join(',')}]` : '') +
545
+ (last ? `\nframe dt ${last.dt} ms held ${last.held ? 'YES' : 'no'} img ${last.img}${last.img !== 'none' ? ' ' + last.imgW : ''}` : '');
546
+ strip(rec.frames, 0, now - WINDOW_MS, now, rec.callAt);
547
+ if (frozen && g) {
548
+ strip(frozen.window.map((f) => ({ ...f })), STRIP_H + 18, -PRE_MS, WINDOW_MS - PRE_MS, 0);
549
+ }
550
+ if (g) {
551
+ g.clearRect(0, STRIP_H, W, 18); // the label band: repainted, never accumulated
552
+ g.fillStyle = '#e8e8e8';
553
+ g.font = '10px ui-monospace, monospace';
554
+ g.fillText(frozen ? `last transition #${frozen.id} (frozen)` : 'last transition: none yet', 4, STRIP_H + 13);
555
+ }
556
+ foot.textContent = frozen ? `#${frozen.id}: ${frozen.verdict}` : 'red = held poses · orange = frozen image · bar = frame interval';
557
+ };
558
+ raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(paint) : 0;
559
+ return {
560
+ el,
561
+ remove() {
562
+ off();
563
+ if (raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf);
564
+ el.remove?.();
565
+ },
566
+ };
567
+ }
568
+
569
+ /**
570
+ * `window.__dxrDiag`: every diag tile on the page. `dump()` is the JSON to paste back;
571
+ * `last` the most recent tile's recorder; `transitions` every tile's transition summaries.
572
+ */
573
+ export function registerDiag(rec) {
574
+ if (typeof window === 'undefined' || !window) return null;
575
+ let g = window.__dxrDiag;
576
+ if (!g || !Array.isArray(g.tiles)) {
577
+ g = window.__dxrDiag = {
578
+ tiles: [],
579
+ get last() {
580
+ return this.tiles[this.tiles.length - 1] || null;
581
+ },
582
+ get transitions() {
583
+ return this.tiles.flatMap((r) => r.transitions.map((s) => ({ tile: r.label, ...s })));
584
+ },
585
+ dump() {
586
+ return JSON.stringify({ ua: typeof navigator !== 'undefined' ? navigator.userAgent : null, at: new Date().toISOString(), tiles: this.tiles.map((r) => r.toJSON()) });
587
+ },
588
+ };
589
+ }
590
+ rec.label = `splat#${g.tiles.length + 1}`;
591
+ g.tiles.push(rec);
592
+ const dispose = rec.dispose.bind(rec);
593
+ rec.dispose = () => {
594
+ dispose();
595
+ const i = g.tiles.indexOf(rec);
596
+ if (i >= 0) g.tiles.splice(i, 1);
597
+ };
598
+ return g;
599
+ }
@@ -148,6 +148,9 @@ export class LiveOutgoing {
148
148
  this.frames = 0;
149
149
  this.views = [];
150
150
  this._frustumKey = '';
151
+ /** PRE-SORT (warm): the current asset is ALSO on the live layer, before the swap. */
152
+ this.warming = false;
153
+ this._warmLayers = null;
151
154
  }
152
155
 
153
156
  /** Engine support for this path: RenderViews on one camera, render targets, layers. */
@@ -212,21 +215,94 @@ export class LiveOutgoing {
212
215
  this.w = this.h = 0;
213
216
  }
214
217
 
218
+ /**
219
+ * PRE-SORT the live camera BEFORE the swap (the fix for the frozen bridge, #36). The current
220
+ * asset goes on the live layer IN ADDITION to its own (the eye keeps drawing it, untouched: its
221
+ * World placement set is unchanged, so its manager neither rebuilds nor re-sorts), and the live
222
+ * camera starts rendering it, through the same views, into its target. Its manager builds its
223
+ * work buffer and runs its first sort NOW, while the photo is still on screen and live. At the
224
+ * swap, start() takes the asset OFF World only: the live layer's placement set does not change,
225
+ * so its manager keeps its sorted state and the overlay can sample it on the very first frame.
226
+ *
227
+ * Without it, start() moved the asset onto a FRESH manager at the swap, and the overlay showed
228
+ * the frozen capture until that manager's first sort came back from the sort worker — a still
229
+ * image under a moving head for as long as that takes (the "tracking stops for a moment at
230
+ * every transition" report). Returns false if this engine build cannot (nothing changed).
231
+ */
232
+ warm(entity) {
233
+ const v = this.viewer;
234
+ if (this.active || !LiveOutgoing.supported(v) || !entity?.gsplat) return false;
235
+ if (this.warming) {
236
+ if (this.entity === entity) return true;
237
+ this.cancelWarm();
238
+ }
239
+ try {
240
+ this._ensureEngineObjects();
241
+ this._ensureTarget(v.canvas.width, v.canvas.height);
242
+ const own = Array.isArray(entity.gsplat.layers) ? entity.gsplat.layers.slice() : [];
243
+ this._warmLayers = own;
244
+ entity.gsplat.layers = [...own.filter((id) => id !== this.layer.id), this.layer.id];
245
+ this.entity = entity;
246
+ this.oldFrame = null; // same asset, same rig: the chain is the rig node itself
247
+ this.frames = 0;
248
+ this.cam.enabled = true;
249
+ this.warming = true;
250
+ return true;
251
+ } catch (err) {
252
+ console.warn('[inline3d/splat] live outgoing pre-sort unavailable; the transition keeps the frozen bridge', err);
253
+ this.cancelWarm();
254
+ return false;
255
+ }
256
+ }
257
+
258
+ /** The pre-sorted manager has drawn a sorted frame (what start() will then need no bridge for). */
259
+ get warmReady() {
260
+ if (!this.warming) return false;
261
+ const mgr = this.manager;
262
+ if (mgr === undefined) return this.frames >= 3;
263
+ const w = mgr?.world;
264
+ const st = w?.getState?.(w.lastWorldStateVersion);
265
+ return !!st?.sortedBefore && this.frames >= 1;
266
+ }
267
+
268
+ /** Drop a pre-sort that did not become a live window: the asset back on only its own layers. */
269
+ cancelWarm() {
270
+ if (!this.warming) return;
271
+ const e = this.entity;
272
+ const own = this._warmLayers;
273
+ this.warming = false;
274
+ this._warmLayers = null;
275
+ this.entity = null;
276
+ if (this.cam) this.cam.enabled = false;
277
+ this._destroyTarget();
278
+ try {
279
+ if (e?.gsplat && own) e.gsplat.layers = own;
280
+ } catch {
281
+ /* entity already gone */
282
+ }
283
+ }
284
+
215
285
  /**
216
286
  * Take `entity` (the outgoing splat, still enabled) onto the live layer. `oldFrame` is
217
287
  * viewer.lensFrame() read BEFORE the incoming asset's rig was adopted. Returns false if this
218
- * engine build cannot (the caller keeps the frozen frame).
288
+ * engine build cannot (the caller keeps the frozen frame). After warm(entity) the live
289
+ * manager is kept (only the World placement goes), so `ready` holds from the first frame.
219
290
  */
220
291
  start(entity, oldFrame) {
221
292
  const v = this.viewer;
222
293
  if (!LiveOutgoing.supported(v) || !entity?.gsplat) return false;
294
+ const warmed = this.warming && this.entity === entity;
295
+ if (this.warming && !warmed) this.cancelWarm();
223
296
  try {
224
297
  this._ensureEngineObjects();
225
298
  this._ensureTarget(v.canvas.width, v.canvas.height);
226
299
  entity.gsplat.layers = [this.layer.id];
227
300
  this.entity = entity;
228
301
  this.oldFrame = oldFrame;
229
- this.frames = 0;
302
+ if (!warmed) this.frames = 0;
303
+ this.warming = false;
304
+ this._warmLayers = null;
305
+ this.warmed = warmed;
230
306
  this.cam.enabled = true;
231
307
  this.active = true;
232
308
  return true;
@@ -275,7 +351,7 @@ export class LiveOutgoing {
275
351
  * viewport), the eye's node pose, and the rig chain.
276
352
  */
277
353
  sync(entries, rect, frustum) {
278
- if (!this.active) return;
354
+ if (!this.active && !this.warming) return;
279
355
  const v = this.viewer;
280
356
  const pc = v.pc;
281
357
  const rvs = this.views;
@@ -322,9 +398,11 @@ export class LiveOutgoing {
322
398
  stop() {
323
399
  // The outgoing entity is NOT put back on its old layer: the caller releases it right after,
324
400
  // and a round trip through World would dirty the eye's manager (a work-buffer rebuild).
401
+ if (this.warming) return this.cancelWarm();
325
402
  this.entity = null;
326
403
  this.oldFrame = null;
327
404
  this.active = false;
405
+ this.warmed = false;
328
406
  if (this.cam) this.cam.enabled = false;
329
407
  this._destroyTarget();
330
408
  }
@@ -64,6 +64,7 @@ import {
64
64
  } from './inline3d-splat-effects.js';
65
65
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
66
66
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
67
+ import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag } from './inline3d-splat-diag.js';
67
68
  import {
68
69
  clamp,
69
70
  finite,
@@ -1406,6 +1407,11 @@ export class PlayCanvasSplatViewer {
1406
1407
  if (!s?.parts) return false;
1407
1408
  const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
1408
1409
  const show = alpha > 0 && fits;
1410
+ // What the overlay shows, for diagnostics (diagImageState): its weight on screen.
1411
+ const st = (this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false });
1412
+ st.alpha = show ? Math.min(1, alpha) : 0;
1413
+ st.wipe = wipe ? wipe.t : -2;
1414
+ st.over = !!mix?.over;
1409
1415
  for (const p of s.parts) {
1410
1416
  p.mi.visible = show;
1411
1417
  if (show) {
@@ -1429,6 +1435,7 @@ export class PlayCanvasSplatViewer {
1429
1435
  setSnapshotSource(tex) {
1430
1436
  const s = this._snap;
1431
1437
  if (!s?.parts) return;
1438
+ (this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false }).live = !!tex;
1432
1439
  for (const p of s.parts) {
1433
1440
  p.mat.setParameter('dxrSnap', tex || s.tex);
1434
1441
  p.mat.update();
@@ -1489,6 +1496,55 @@ export class PlayCanvasSplatViewer {
1489
1496
  return this._live.start(entity, oldFrame) ? this._live : null;
1490
1497
  }
1491
1498
 
1499
+ /**
1500
+ * Diagnostics: what the transition overlay puts on screen this frame — 'none', or the 'frozen'
1501
+ * capture / the 'live' outgoing target with `w`, the share of the picture it covers (the wipe's
1502
+ * uncommitted part times the lerp weight).
1503
+ */
1504
+ diagImageState() {
1505
+ const st = this._snapState;
1506
+ if (!st || !(st.alpha > 0)) return { overlay: 'none', w: 0 };
1507
+ const left = st.wipe < -1 ? 1 : Math.min(1, Math.max(0, 1 - st.wipe));
1508
+ return { overlay: st.live ? 'live' : 'frozen', w: st.alpha * left };
1509
+ }
1510
+
1511
+ /** Pre-sort the live outgoing camera on `entity` before a swap (see LiveOutgoing.warm). */
1512
+ warmLiveOutgoing(entity) {
1513
+ this._live ||= new LiveOutgoing(this);
1514
+ return this._live.warm(entity);
1515
+ }
1516
+
1517
+ /**
1518
+ * Resolves true once the pre-sorted live manager has drawn a sorted frame, false after
1519
+ * `timeoutMs` (a hidden tab, a stalled sorter) or once the pre-sort was dropped.
1520
+ */
1521
+ liveWarmed(timeoutMs = 1500) {
1522
+ const live = this._live;
1523
+ if (!live?.warming) return Promise.resolve(false);
1524
+ if (live.warmReady) return Promise.resolve(true);
1525
+ return new Promise((resolve) => {
1526
+ let done = false;
1527
+ const end = (v) => {
1528
+ if (done) return;
1529
+ done = true;
1530
+ clearTimeout(timer);
1531
+ resolve(v);
1532
+ };
1533
+ const timer = setTimeout(() => end(false), timeoutMs);
1534
+ this._hooks.push(() => {
1535
+ if (done) return false;
1536
+ if (this._disposed || !live.warming) return end(false), false;
1537
+ if (live.warmReady) return end(true), false;
1538
+ return true;
1539
+ });
1540
+ });
1541
+ }
1542
+
1543
+ /** Drop a pre-sort that is not going to become a live window. */
1544
+ cancelLiveWarm() {
1545
+ this._live?.cancelWarm();
1546
+ }
1547
+
1492
1548
  /** End the live window: overlay back on the frozen capture, camera off, target freed. */
1493
1549
  stopLiveOutgoing() {
1494
1550
  if (!this._live?.active) return;
@@ -1673,7 +1729,7 @@ export class PlayCanvasSplatViewer {
1673
1729
  // compose the node's PARENT with their own pose). Park it on the first eye.
1674
1730
  placeNode(this.eye, entries[0].pose);
1675
1731
  // 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);
1732
+ if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
1677
1733
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1678
1734
  this.onBeforeRender?.(entries, rect);
1679
1735
  } else {
@@ -2561,6 +2617,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2561
2617
 
2562
2618
  let handle = null;
2563
2619
  let unbindFocusInput = null;
2620
+
2621
+ // ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
2622
+ const diagCfg = resolveDiag(opts.diag);
2623
+ if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: norig, frozen, nowarm, cold, nooverlay.`);
2624
+ const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
2625
+ /** norig: armed by the first setSource — from then on the declared rig is kept. */
2626
+ let rigLockArmed = false;
2627
+ if (diag) {
2628
+ diag.imageState = () => viewer.diagImageState();
2629
+ diag.observeLongTasks();
2630
+ diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
2631
+ }
2564
2632
  let removed = false;
2565
2633
  /** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
2566
2634
  const preparedAssets = new WeakMap();
@@ -2675,6 +2743,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2675
2743
  fx = null;
2676
2744
  unbindFocusInput?.();
2677
2745
  viewer.onFocusChange = null;
2746
+ diagLoop?.remove();
2747
+ diag?.dispose();
2678
2748
  handle?.remove();
2679
2749
  viewer.dispose(); // app.destroy(): every entity — ours and any a page added — goes with it
2680
2750
  },
@@ -2749,7 +2819,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2749
2819
  const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
2750
2820
 
2751
2821
  if (wall && wall.supported) {
2752
- handle = wall.addScene(canvas, viewer.onFrame, {
2822
+ const onFrame = diag
2823
+ ? (views, layer) => {
2824
+ const t = performance.now();
2825
+ viewer.onFrame(views, layer);
2826
+ diag.frame(views, t); // after the draw: the overlay state is what this frame showed
2827
+ }
2828
+ : viewer.onFrame;
2829
+ handle = wall.addScene(canvas, onFrame, {
2753
2830
  // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
2754
2831
  // and the waterfall say otherwise); the display rig's height means nothing there.
2755
2832
  ...(pageMode
@@ -2762,6 +2839,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2762
2839
  } else {
2763
2840
  viewer.startMono();
2764
2841
  }
2842
+ let diagLoop = null;
2843
+ if (diag) {
2844
+ // Every rig push, logged with its values; under `norig`, every push after the first
2845
+ // setSource is DROPPED (the rig declared before it stays in force).
2846
+ if (handle && typeof handle.setViewRig === 'function') {
2847
+ const push = handle.setViewRig;
2848
+ handle.setViewRig = (rig) => {
2849
+ if (diag.has('norig') && rigLockArmed) {
2850
+ diag.rig(rig, { dropped: true });
2851
+ return false;
2852
+ }
2853
+ diag.rig(rig);
2854
+ return push(rig);
2855
+ };
2856
+ }
2857
+ diagLoop = startDiagLoop(diag, { overlay: !diag.has('nooverlay') && !!handle });
2858
+ // 2D DOM over the woven canvas: out of the weave (a no-op where occlusion is automatic).
2859
+ if (diagLoop?.el) handle?.exclude?.(diagLoop.el);
2860
+ registerDiag(diag);
2861
+ }
2765
2862
  // Settle the stub's `firstWoven` (addSplatDeferred) with the core handle's own.
2766
2863
  if (typeof out._resolveFirstWoven === 'function') {
2767
2864
  out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
@@ -3709,8 +3806,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3709
3806
  await first.catch(() => null);
3710
3807
  if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
3711
3808
  const t0 = performance.now();
3712
- const loaded = await loadOne(pcModule, app, src, { background: true });
3809
+ if (diag && diag.phase === 'idle') diag.setPhase('prepare');
3810
+ let loaded;
3811
+ try {
3812
+ loaded = await loadOne(pcModule, app, src, { background: true });
3813
+ } finally {
3814
+ if (diag?.phase === 'prepare') diag.setPhase('idle');
3815
+ }
3713
3816
  perfSpan('prepareSource', t0);
3817
+ diag?.mark('prepared', { ms: Math.round(performance.now() - t0) });
3714
3818
  // The transition the page declared: compile its shader now, in the dwell, not on its first frame.
3715
3819
  await prewarmTransition(warm); // null (no transition declared): the overlay only
3716
3820
  const entry = { loaded, state: 'ready', dispose: null };
@@ -3753,6 +3857,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3753
3857
  * engine whose internals differ just compiles on the transition's first frame, as before.
3754
3858
  */
3755
3859
  async function prewarmTransition(plan) {
3860
+ if (diag?.has('nowarm')) return;
3756
3861
  // The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
3757
3862
  // (crossfade, wavefront and the particles all composite through them; created on the first
3758
3863
  // capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
@@ -3877,8 +3982,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3877
3982
  }
3878
3983
  async function setSourceNow(next, o = {}) {
3879
3984
  const plan = resolveSwap(o);
3985
+ if (diag?.has('frozen')) plan.outgoing = 'frozen'; // kill switch: the 1.12.1 frozen outgoing
3880
3986
  const { resetPose = false } = o;
3881
3987
  const gen = ++sourceGen;
3988
+ if (diag) {
3989
+ rigLockArmed = true;
3990
+ diag.setPhase('swap', { transition: plan.transition, outgoing: plan.outgoing || 'default', prepared: preparedAssets.has(next) });
3991
+ }
3882
3992
  // A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
3883
3993
  const prep = preparedAssets.get(next) || null;
3884
3994
  if (prep) {
@@ -3910,6 +4020,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3910
4020
  // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
3911
4021
  // drawn. The overlay goes up in the same task, so no frame shows neither photo.
3912
4022
  const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
4023
+ const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
4024
+ // PRE-SORT the live outgoing camera on the current asset BEFORE the swap (LiveOutgoing.warm):
4025
+ // its first sort runs while the photo is still on screen and live, so the frozen capture has
4026
+ // nothing to bridge. The transition starts once it has sorted (bounded; a hidden tab or a
4027
+ // stalled sorter falls back to the bridge). `?dxrdiag=cold` skips it (the 1.19.2 path).
4028
+ if (wantsSnapshot && outgoingMode === 'live' && viewer.canLiveOutgoing && !diag?.has('cold') && viewer.warmLiveOutgoing(prev.entity)) {
4029
+ const tw = performance.now();
4030
+ const ok = await viewer.liveWarmed();
4031
+ perfSpan('setSource:presort(async)', tw);
4032
+ diag?.mark('presorted', { ok, ms: Math.round(performance.now() - tw) });
4033
+ if (removed || gen !== sourceGen) {
4034
+ // A newer setSource owns the pre-sort now (it warms the same asset, or drops it).
4035
+ app.assets.remove(loaded.asset);
4036
+ loaded.asset.unload?.();
4037
+ return out;
4038
+ }
4039
+ }
3913
4040
  const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
3914
4041
  if (removed || gen !== sourceGen) {
3915
4042
  app.assets.remove(loaded.asset);
@@ -3922,16 +4049,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3922
4049
  // its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
3923
4050
  // A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
3924
4051
  // 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
4052
  const live =
3927
4053
  snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
4054
+ if (!live) viewer.cancelLiveWarm(); // a pre-sort that is not becoming a live window
4055
+ diag?.mark('outgoing', { mode: live ? (live.warmed ? 'live (pre-sorted)' : 'live (cold: frozen bridge until sorted)') : snapped ? 'frozen' : 'none' });
3928
4056
  /** Each frame of the window: once the live camera is ready, the overlay samples it. */
3929
4057
  let liveShown = false;
3930
4058
  const pumpLive = () => {
3931
4059
  if (!live || liveShown || !live.active || !live.ready) return;
3932
4060
  viewer.setSnapshotSource(live.texture);
3933
4061
  liveShown = true;
4062
+ diag?.mark('live-shown');
3934
4063
  };
4064
+ // Pre-sorted: the live target is already sorted, so the overlay samples it from the swap on.
4065
+ if (live?.warmed) pumpLive();
3935
4066
 
3936
4067
  const release = (p) => {
3937
4068
  if (!p) return;
@@ -3960,6 +4091,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3960
4091
  const kept = applyLoaded(loaded);
3961
4092
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
3962
4093
  if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
4094
+ diag?.setPhase('window');
3963
4095
  };
3964
4096
 
3965
4097
  const entity = viewer.addSplatAsset(loaded.asset);
@@ -3971,6 +4103,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3971
4103
  if (finished) return;
3972
4104
  finished = true;
3973
4105
  for (const f of finishers) f();
4106
+ if (gen === sourceGen) diag?.setPhase('settle');
3974
4107
  if (pendingSwap?.finish === finish) pendingSwap = null;
3975
4108
  settle();
3976
4109
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.19.3",
3
+ "version": "1.20.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",
@@ -52,6 +52,7 @@
52
52
  "js/inline3d-splat-shared.js",
53
53
  "js/inline3d-splat-effects.js",
54
54
  "js/inline3d-splat-live.js",
55
+ "js/inline3d-splat-diag.js",
55
56
  "js/inline3d-splat-video.js",
56
57
  "js/inline3d-model.js",
57
58
  "js/inline3d-model-entry.js",
package/splat.d.ts CHANGED
@@ -219,6 +219,23 @@ export interface SplatOptions {
219
219
  * URL with `engine: 'spark'`.
220
220
  */
221
221
  engine?: 'spark' | 'playcanvas';
222
+ /**
223
+ * `engine: 'playcanvas'` only: TRANSITION DIAGNOSTICS (#36). Off by default; `true` (or the page
224
+ * URL's `?dxrdiag=1`) records every woven frame — its interval, whether its eye poses are
225
+ * bit-identical to the previous frame's (a tracking HOLD), whether the transition overlay shows
226
+ * the frozen capture — plus every `setViewRig` push with its values, long tasks, and the
227
+ * setSource phases (`prepare`, `swap`, `window`, `settle`). It shows a small overlay (excluded
228
+ * from the weave), logs `[dxr-diag]` console lines (one summary with a verdict per transition),
229
+ * and exposes `window.__dxrDiag` (`copy(__dxrDiag.dump())` for the JSON).
230
+ *
231
+ * A string / array adds A/B kill switches (also accepted as `?dxrdiag=norig,frozen`): `'norig'`
232
+ * keeps the rig declared before the first setSource (drops every re-declaration), `'frozen'`
233
+ * forces `outgoing: 'frozen'`, `'nowarm'` skips the transition shader pre-warm, `'cold'` skips
234
+ * the live outgoing pre-sort, `'nooverlay'` records without the overlay. The option wins over the
235
+ * URL; `false` turns it off even with `?dxrdiag` present. See docs/playcanvas-adapter.md
236
+ * § Diagnosing transition stalls.
237
+ */
238
+ diag?: boolean | string | ReadonlyArray<'norig' | 'frozen' | 'nowarm' | 'cold' | 'nooverlay' | '1'>;
222
239
  /**
223
240
  * `engine: 'playcanvas'` only: the WebGL context's `preserveDrawingBuffer` (default false) —
224
241
  * the knob for the weave's zero-copy read race on large canvases.