@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.
- package/CHANGELOG.md +51 -0
- package/js/inline3d-splat-diag.js +599 -0
- package/js/inline3d-splat-effects.js +101 -0
- package/js/inline3d-splat-live.js +81 -3
- package/js/inline3d-splat-playcanvas.js +493 -26
- package/package.json +2 -1
- package/splat.d.ts +61 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,57 @@ 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
|
+
## 1.21.0 — 2026-09-24
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Minor: a new `setSource`
|
|
11
|
+
option. No pixel change for any existing transition, and the default (`cut`) is unchanged.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Sequence transitions: one splat at a time** (#36). `setSource(next, { transition: 'reassemble' })`
|
|
16
|
+
disperses the current photo (`assemble` played backwards), releases it, loads the next one and
|
|
17
|
+
assembles it: 3000 ms, 45 % out, a 10 % empty beat, 45 % in. The general form
|
|
18
|
+
`transition: { type: 'sequence', out, in, durationMs?, beat?, easing? }` runs any two of
|
|
19
|
+
`assemble`, `dissolve-in`, `converge`, `shimmer`, `sweep`, `fade` (each draws nothing at its start,
|
|
20
|
+
which is what makes the swap invisible; `inflate` and `dissolve` are refused).
|
|
21
|
+
- No second camera, live-outgoing layer, overlay target or frozen capture: the eye camera renders
|
|
22
|
+
every frame with one asset in the scene, so the transition machinery cannot hold an image still
|
|
23
|
+
under a moving head. The fallback if a panel still pauses on the other transitions.
|
|
24
|
+
- One splat resident: the next file is only FETCHED during the out phase, and reaches the engine
|
|
25
|
+
after the old asset is destroyed and unloaded. `prepareSource(src, { transition: 'reassemble' })`
|
|
26
|
+
therefore only fetches and compiles (`numSplats` is `null`); `resident: true` opts back into the
|
|
27
|
+
full prepare. Measured peak: 1 asset, 1,179,648 gaussians, 58.8 MB of GPU textures, the still
|
|
28
|
+
photo's (a live `crossfade`: 2 assets, 131.6 MB).
|
|
29
|
+
- Latest wins: a newer sequence takes over from the amount on screen; any other call ends it as it
|
|
30
|
+
ends any transition. An HTTP error on the next file brings the current photo back and rejects.
|
|
31
|
+
- Both bodies are pre-warmed (by `prepareSource`, or before the out clock starts); `?dxrdiag`
|
|
32
|
+
records the sequence with its steps. See `docs/splat-effects.md` § Sequence transitions.
|
|
33
|
+
|
|
34
|
+
## 1.20.0 — 2026-09-24
|
|
35
|
+
|
|
36
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel change at rest; the
|
|
37
|
+
first frames of a live `crossfade` / `wavefront` / particle transition change (see Fixed).
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **Transition diagnostics: `diag` / `?dxrdiag`** (#36). Off by default. Records every woven
|
|
42
|
+
frame (interval, whether its eye poses are bit-identical to the previous frame's, what the
|
|
43
|
+
transition overlay shows), every `setViewRig` push with its values, long tasks and the
|
|
44
|
+
`setSource` phases. Shows a small overlay excluded from the weave, logs one
|
|
45
|
+
`[dxr-diag] transition #N <verdict>` line per transition, and exposes `window.__dxrDiag`
|
|
46
|
+
(`copy(__dxrDiag.dump())`). Kill switches for A/B: `norig`, `frozen`, `nowarm`, `cold`,
|
|
47
|
+
`nooverlay`. See `docs/playcanvas-adapter.md` § Diagnosing transition stalls.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **The live outgoing photo no longer starts as a still** (#36). A live transition put the old
|
|
52
|
+
photo on a FRESH gsplat manager at the swap, and until that manager's first sort came back the
|
|
53
|
+
overlay showed the frozen capture at full weight: the whole picture stood still for 2–7 frames
|
|
54
|
+
right as the transition started. The live camera now pre-sorts the current photo BEFORE the swap
|
|
55
|
+
(it stays on screen, untouched, meanwhile), so the overlay samples the live image from the swap
|
|
56
|
+
frame on: 0 frozen frames. The transition starts later by about the length of that sort instead
|
|
57
|
+
(bounded at 1.5 s, then the old bridge). `?dxrdiag=cold` restores the 1.19.2 path.
|
|
58
|
+
|
|
8
59
|
## 1.19.3 — 2026-09-24
|
|
9
60
|
|
|
10
61
|
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
|
+
}
|