cyborg-hunter 0.7.0 → 0.7.3
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 +60 -0
- package/CITATION.cff +2 -2
- package/README.md +8 -2
- package/bin/cyborg-hunter.js +18 -4
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +1619 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/extension-cyborg-hunter.js +1 -0
- package/dist/extension-guard-friction.js +36 -0
- package/dist/extension-guard-honeypot.js +1 -0
- package/package.json +8 -3
- package/src/cli/extract-core.js +556 -0
- package/src/cli/ingest.js +11 -536
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +6 -1179
- package/src/cli/renderers/replay-assets.js +8 -118
- package/src/cli/renderers/replay-viewer.client.js +179 -16
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +15 -899
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -696
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -192
- package/src/cli/report.js +17 -0
- package/src/cli/update-check.js +110 -0
- package/src/core/monitor.js +17 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +11 -2
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +1 -1
- package/src/shared/paths.js +20 -0
|
@@ -7,130 +7,20 @@
|
|
|
7
7
|
// tags load fine. The report injects the script lazily when the analyst
|
|
8
8
|
// opens a participant's Replay section.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// The wire→viewer time conversion (the second of the two allowed conversion
|
|
11
|
+
// points) lives in replay/viewer-model.js (0.7.2 extraction, pure/no Node
|
|
12
|
+
// APIs so a browser demo can bundle it); this module re-exports it below.
|
|
13
13
|
|
|
14
14
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
16
|
|
|
17
17
|
import { sanitizeId as sanitize } from '../../shared/constants.js';
|
|
18
|
+
import { buildViewerModel } from '../../replay/viewer-model.js';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
export function buildViewerModel(recording) {
|
|
25
|
-
const md = recording.metadata || {};
|
|
26
|
-
const ext = recording.ch_extensions || {};
|
|
27
|
-
|
|
28
|
-
// ── Camera seeding (central, per Sol round-1 finding 8) ──
|
|
29
|
-
// New recordings carry a per-trial view_state seed. Legacy recordings
|
|
30
|
-
// don't — but the full event stream is present, so each trial's starting
|
|
31
|
-
// camera is reconstructed by folding all PRIOR trials' window-scroll and
|
|
32
|
-
// resize events over the session-start viewport. Initial scroll is assumed
|
|
33
|
-
// 0 (a recording that starts pre-scrolled with no scroll events is
|
|
34
|
-
// unrecoverable — that's what the viewer's legacy banner covers).
|
|
35
|
-
const vp = recording.viewport || {};
|
|
36
|
-
const vv = vp.visual_viewport || {};
|
|
37
|
-
// Scrollbar delta: legacy resize events carry only innerWidth/Height; the
|
|
38
|
-
// layout (client) width is estimated as w minus the session-start delta
|
|
39
|
-
// between innerWidth and the layout width (visual_viewport.width).
|
|
40
|
-
const sbW = (vp.width && (vp.client_width || vv.width))
|
|
41
|
-
? vp.width - (vp.client_width || vv.width) : 0;
|
|
42
|
-
const sbH = (vp.height && (vp.client_height || vv.height))
|
|
43
|
-
? vp.height - (vp.client_height || vv.height) : 0;
|
|
44
|
-
let foldState = {
|
|
45
|
-
x: 0, y: 0,
|
|
46
|
-
w: vp.width || null, h: vp.height || null,
|
|
47
|
-
cw: vp.client_width || vv.width || vp.width || null,
|
|
48
|
-
ch: vp.client_height || vv.height || vp.height || null,
|
|
49
|
-
dpr: vp.dpr || 1
|
|
50
|
-
};
|
|
51
|
-
const foldEvent = (state, e) => {
|
|
52
|
-
if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
|
|
53
|
-
state.x = Number(e.x) || 0;
|
|
54
|
-
state.y = Number(e.y) || 0;
|
|
55
|
-
} else if (e.kind === 'resize') {
|
|
56
|
-
state.w = e.w != null ? e.w : state.w;
|
|
57
|
-
state.h = e.h != null ? e.h : state.h;
|
|
58
|
-
state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
|
|
59
|
-
state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
|
|
60
|
-
if (e.dpr != null) state.dpr = e.dpr;
|
|
61
|
-
}
|
|
62
|
-
return state;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// Defensive against malformed/truncated artifacts (a hand-edited or
|
|
66
|
-
// partially-written recording): non-array trials/events and null entries must
|
|
67
|
-
// degrade to empty rather than throw and abort the whole cohort report.
|
|
68
|
-
const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
|
|
69
|
-
const trials = rawTrials.map((trial) => {
|
|
70
|
-
trial = trial || {};
|
|
71
|
-
// Sort by absolute time before anchoring. RAF-coalesced input events flush
|
|
72
|
-
// with an EARLIER timestamp than events pushed after they were enqueued, so
|
|
73
|
-
// the recorded array is not strictly time-ordered; the viewer scrubs by
|
|
74
|
-
// scanning until the first future event and would otherwise mis-apply an
|
|
75
|
-
// out-of-order event on a seek. Stable sort keeps equal-time order.
|
|
76
|
-
const events = (Array.isArray(trial.events) ? trial.events : [])
|
|
77
|
-
.filter((e) => e && typeof e === 'object')
|
|
78
|
-
.slice()
|
|
79
|
-
.sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
|
|
80
|
-
const anchor = trial.t_load != null ? trial.t_load
|
|
81
|
-
: (events.length > 0 ? events[0].t : 0);
|
|
82
|
-
const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
|
|
83
|
-
const end = trial.t_end != null ? trial.t_end : lastT;
|
|
84
|
-
// Camera seed for THIS trial: recorded view_state, else the folded state
|
|
85
|
-
// as of the end of the previous trial. A recorded view_state also
|
|
86
|
-
// RESYNCS the fold — in a mixed recording (some trials seeded, some
|
|
87
|
-
// not: truncation, version mixes) a later unseeded trial must inherit
|
|
88
|
-
// real observed state, not a fold that ignored every observation.
|
|
89
|
-
if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
|
|
90
|
-
const camera = trial.view_state
|
|
91
|
-
? Object.assign({}, trial.view_state, { source: 'view_state' })
|
|
92
|
-
: Object.assign({}, foldState, { source: 'folded' });
|
|
93
|
-
// Advance the fold across this trial's events for the NEXT trial's seed.
|
|
94
|
-
events.forEach((e) => foldEvent(foldState, e));
|
|
95
|
-
return {
|
|
96
|
-
index: trial.trial_index,
|
|
97
|
-
id: trial.trial_id,
|
|
98
|
-
plugin: trial.plugin,
|
|
99
|
-
durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
|
|
100
|
-
initialDom: trial.initial_dom || '',
|
|
101
|
-
camera,
|
|
102
|
-
events: events.map((e) => {
|
|
103
|
-
const out = Object.assign({}, e);
|
|
104
|
-
out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
|
|
105
|
-
return out;
|
|
106
|
-
})
|
|
107
|
-
};
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
|
|
112
|
-
tier: md.tier || 'trace',
|
|
113
|
-
keys: md.keys || null,
|
|
114
|
-
startTime: md.start_time || null,
|
|
115
|
-
endReason: md.end_reason || null,
|
|
116
|
-
recorder: md.recorder || null,
|
|
117
|
-
viewport: recording.viewport || null,
|
|
118
|
-
// Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
|
|
119
|
-
// mixed/truncated ones alike): those trials replay on folded camera
|
|
120
|
-
// state, so the reduced-guarantees banner must show.
|
|
121
|
-
legacy: !rawTrials.every((t) => t && t.view_state),
|
|
122
|
-
markerAttr: ext.marker_attr || null,
|
|
123
|
-
// Session scrollbar delta (innerWidth − layout width): the viewer uses
|
|
124
|
-
// the same fallback chain as the folding above for legacy resize events.
|
|
125
|
-
scrollbar: { w: sbW, h: sbH },
|
|
126
|
-
stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
|
|
127
|
-
scoring: ext.scoring || null,
|
|
128
|
-
guardViolations: ext.guard_violations || [],
|
|
129
|
-
captureStopped: !!ext.capture_stopped,
|
|
130
|
-
captureFailures: (ext.capture_failures || []).map((f) => f.channel),
|
|
131
|
-
trials
|
|
132
|
-
};
|
|
133
|
-
}
|
|
20
|
+
// Load-bearing re-export: renderReplayAssets (below) calls buildViewerModel
|
|
21
|
+
// in-file, and tests/cli/replay-render.test.js + tests/replay/alignment-viewer-model.test.js
|
|
22
|
+
// import it from here.
|
|
23
|
+
export { buildViewerModel };
|
|
134
24
|
|
|
135
25
|
/**
|
|
136
26
|
* Writes replay/<sanitizedPid>.replay.js for every participant with an
|
|
@@ -48,6 +48,17 @@
|
|
|
48
48
|
var TOL_CONTAIN = 2;
|
|
49
49
|
var TOL_STAGE = 2;
|
|
50
50
|
|
|
51
|
+
// Keycast: how long a chip keeps fading after its keyup, in trial-relative
|
|
52
|
+
// ms. Purely a function of playhead (like the click-ripple fade in
|
|
53
|
+
// drawOverlay below), not real time, so seeking lands on the same visual
|
|
54
|
+
// state playing to it would.
|
|
55
|
+
var KEYCAST_FADE_MS = 500;
|
|
56
|
+
|
|
57
|
+
// Buffer-cap explanation fallback: recorder.js's REPLAY_DEFAULTS, used
|
|
58
|
+
// only when a stopped trial's own ch:capture_stopped event (the real
|
|
59
|
+
// configured value for THIS recording) can't be found.
|
|
60
|
+
var CAP_DEFAULTS = { events: 50000, chars: 8000000 };
|
|
61
|
+
|
|
51
62
|
var DISCRETE_KINDS = {
|
|
52
63
|
mousedown: true, mouseup: true, click: true, touchstart: true, touchend: true
|
|
53
64
|
};
|
|
@@ -66,6 +77,17 @@
|
|
|
66
77
|
return m + ':' + (rem < 10 ? '0' : '') + rem;
|
|
67
78
|
}
|
|
68
79
|
|
|
80
|
+
// Human label for a keycast chip. e.key already names special keys well
|
|
81
|
+
// (Enter, Shift, Backspace, ArrowLeft…); the one gap is the space bar,
|
|
82
|
+
// which arrives as a literal ' ' and would render as an invisible chip.
|
|
83
|
+
function formatKeyLabel(key) {
|
|
84
|
+
return key === ' ' ? 'Space' : key;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fmtCount(n) {
|
|
88
|
+
return n.toLocaleString('en-US');
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
// ── DOM reconstruction (tier 'dom') ──
|
|
70
92
|
// The recorded DOM is UNTRUSTED (an adversarial participant or extension
|
|
71
93
|
// may have injected markup). sandbox="" already blocks scripts; the CSP
|
|
@@ -144,6 +166,12 @@
|
|
|
144
166
|
var playhead = 0; // trial-relative ms
|
|
145
167
|
var playing = false;
|
|
146
168
|
var speed = 1;
|
|
169
|
+
// Continuous whole-session playback, default ON: at a trial's end while
|
|
170
|
+
// playing, load the next trial and keep going, so an analyst watches a
|
|
171
|
+
// full session as one video instead of stopping at every boundary. The
|
|
172
|
+
// 'pause at trial boundaries' toggle flips this off, restoring the
|
|
173
|
+
// original per-trial-stop behavior.
|
|
174
|
+
var autoAdvance = true;
|
|
147
175
|
var appliedIdx = 0; // events applied so far (in trial event order)
|
|
148
176
|
var iframeGen = 0; // increments per rebuild; stale onloads bail
|
|
149
177
|
var domReady = false;
|
|
@@ -186,18 +214,64 @@
|
|
|
186
214
|
var o = el('option', null, s + '×'); o.value = String(s); speedSel.appendChild(o);
|
|
187
215
|
});
|
|
188
216
|
var clock = el('span', 'replay-clock', '0:00.0 / 0:00.0');
|
|
217
|
+
var sessionPos = el('span', 'replay-note replay-session-pos', '');
|
|
218
|
+
var pauseLabel = el('label', 'replay-pause-toggle');
|
|
219
|
+
var pauseCheckbox = document.createElement('input');
|
|
220
|
+
pauseCheckbox.type = 'checkbox';
|
|
221
|
+
pauseCheckbox.className = 'replay-pause-checkbox';
|
|
222
|
+
pauseCheckbox.checked = false; // default: continuous playback ON
|
|
223
|
+
pauseCheckbox.addEventListener('change', function () {
|
|
224
|
+
autoAdvance = !pauseCheckbox.checked;
|
|
225
|
+
});
|
|
226
|
+
pauseLabel.appendChild(pauseCheckbox);
|
|
227
|
+
pauseLabel.appendChild(document.createTextNode(' Pause at trial boundaries'));
|
|
189
228
|
header.appendChild(badge);
|
|
190
229
|
header.appendChild(trialSel);
|
|
191
230
|
header.appendChild(playBtn);
|
|
192
231
|
header.appendChild(speedSel);
|
|
193
232
|
header.appendChild(clock);
|
|
233
|
+
header.appendChild(sessionPos);
|
|
234
|
+
if (trials.length > 1) header.appendChild(pauseLabel);
|
|
194
235
|
if (!model.scoring) {
|
|
195
236
|
header.appendChild(el('span', 'replay-note',
|
|
196
|
-
'
|
|
237
|
+
'No integrity score attached (this recording was captured standalone)'));
|
|
197
238
|
}
|
|
198
239
|
if (model.captureStopped) {
|
|
199
|
-
|
|
200
|
-
|
|
240
|
+
// The real configured caps for THIS recording live on the trial's own
|
|
241
|
+
// ch:capture_stopped marker (recorder.js writes it with the limit that
|
|
242
|
+
// was actually crossed) — read those instead of assuming the library
|
|
243
|
+
// defaults, since maxEventsPerTrial/maxCharsPerTrial are configurable.
|
|
244
|
+
var capEventsLimit = null, capCharsLimit = null, capTrialsHit = 0;
|
|
245
|
+
trials.forEach(function (t) {
|
|
246
|
+
var hit = false;
|
|
247
|
+
(t.events || []).forEach(function (e) {
|
|
248
|
+
if (e.kind !== 'ch:capture_stopped') return;
|
|
249
|
+
hit = true;
|
|
250
|
+
if (e.limit != null && capEventsLimit == null) capEventsLimit = e.limit;
|
|
251
|
+
if (e.limit_chars != null && capCharsLimit == null) capCharsLimit = e.limit_chars;
|
|
252
|
+
});
|
|
253
|
+
if (hit) capTrialsHit++;
|
|
254
|
+
});
|
|
255
|
+
if (capEventsLimit == null) capEventsLimit = CAP_DEFAULTS.events;
|
|
256
|
+
if (capCharsLimit == null) capCharsLimit = CAP_DEFAULTS.chars;
|
|
257
|
+
var capTrialsCount = capTrialsHit || 1;
|
|
258
|
+
var capDetails = document.createElement('details');
|
|
259
|
+
capDetails.className = 'replay-note replay-warn replay-cap';
|
|
260
|
+
capDetails.setAttribute('data-ch-cap-note', '');
|
|
261
|
+
var capSummary = document.createElement('summary');
|
|
262
|
+
capSummary.textContent = 'Capture stopped early in ' + capTrialsCount +
|
|
263
|
+
' trial' + (capTrialsCount === 1 ? '' : 's') + ' (buffer cap, details)';
|
|
264
|
+
var capBody = el('p', null,
|
|
265
|
+
'The recorder caps each trial at ' + fmtCount(capEventsLimit) + ' events or about ' +
|
|
266
|
+
fmtCount(capCharsLimit) + ' characters, whichever comes first (the character count ' +
|
|
267
|
+
'starts from that trial’s initial DOM snapshot). Once a trial crosses either cap, ' +
|
|
268
|
+
'that trial stops recording and a marker is written; the next trial starts capturing ' +
|
|
269
|
+
'fresh. The caps limit the damage one runaway trial can do. They do not bound the whole ' +
|
|
270
|
+
'session: a recording with many trials can still add up to a large file. Both caps are ' +
|
|
271
|
+
'configurable when the recorder is attached (maxEventsPerTrial, maxCharsPerTrial).');
|
|
272
|
+
capDetails.appendChild(capSummary);
|
|
273
|
+
capDetails.appendChild(capBody);
|
|
274
|
+
header.appendChild(capDetails);
|
|
201
275
|
}
|
|
202
276
|
// Alignment status chips — the "never fail silently" surface.
|
|
203
277
|
var alignChip = el('span', 'replay-note', '');
|
|
@@ -206,17 +280,17 @@
|
|
|
206
280
|
header.appendChild(alignChip);
|
|
207
281
|
if (model.legacy) {
|
|
208
282
|
var legacyChip = el('span', 'replay-note replay-warn',
|
|
209
|
-
'Legacy recording
|
|
283
|
+
'Legacy recording: alignment guarantees are reduced (coordinates re-projected, unverified)');
|
|
210
284
|
legacyChip.setAttribute('data-ch-legacy', '');
|
|
211
285
|
header.appendChild(legacyChip);
|
|
212
286
|
}
|
|
213
287
|
var iframeChip = el('span', 'replay-note replay-warn',
|
|
214
|
-
'
|
|
288
|
+
'This recording contains iframe content that was not captured. Interactions inside it are not visible here.');
|
|
215
289
|
iframeChip.setAttribute('data-ch-iframe-warn', '');
|
|
216
290
|
iframeChip.style.display = 'none';
|
|
217
291
|
header.appendChild(iframeChip);
|
|
218
292
|
var shadowChip = el('span', 'replay-note replay-warn',
|
|
219
|
-
'
|
|
293
|
+
'This recording contains shadow DOM content that was not captured. Those areas replay empty.');
|
|
220
294
|
shadowChip.setAttribute('data-ch-shadow-warn', '');
|
|
221
295
|
shadowChip.style.display = 'none';
|
|
222
296
|
header.appendChild(shadowChip);
|
|
@@ -238,7 +312,7 @@
|
|
|
238
312
|
return sh.css && /@keyframes/i.test(sh.css);
|
|
239
313
|
});
|
|
240
314
|
if (hasKeyframes) {
|
|
241
|
-
animChip.textContent = '
|
|
315
|
+
animChip.textContent = 'This page uses CSS animations. They replay on the viewer’s own clock, so animated positions are approximate.';
|
|
242
316
|
animChip.style.display = '';
|
|
243
317
|
}
|
|
244
318
|
header.appendChild(animChip);
|
|
@@ -247,7 +321,7 @@
|
|
|
247
321
|
var omittedSheets = (model.stylesheets || []).filter(function (sh) { return !sh.css; }).length;
|
|
248
322
|
if (omittedSheets > 0) {
|
|
249
323
|
var cssNote = el('span', 'replay-note',
|
|
250
|
-
omittedSheets + ' external stylesheet(s) not loaded
|
|
324
|
+
omittedSheets + ' external stylesheet(s) were not loaded. Layout may differ. ');
|
|
251
325
|
var cssBtn = el('button', 'replay-css-btn', 'Load external CSS');
|
|
252
326
|
cssBtn.title = 'Fetches the recorded stylesheet URLs from their origins (network requests leave this machine).';
|
|
253
327
|
cssBtn.addEventListener('click', function () {
|
|
@@ -282,13 +356,21 @@
|
|
|
282
356
|
} else {
|
|
283
357
|
stage.classList.add('replay-neutral');
|
|
284
358
|
stage.appendChild(el('div', 'replay-neutral-label',
|
|
285
|
-
'Trace-only recording
|
|
359
|
+
'Trace-only recording: DOM was not captured at this tier'));
|
|
286
360
|
}
|
|
287
361
|
|
|
288
362
|
var overlay = document.createElement('canvas');
|
|
289
363
|
overlay.className = 'replay-overlay';
|
|
290
364
|
overlay.setAttribute('aria-hidden', 'true');
|
|
291
365
|
stage.appendChild(overlay);
|
|
366
|
+
// Keycast: chips for keys currently down or recently released, mirroring
|
|
367
|
+
// the click-ripple/cursor-trail convention above (a pure function of
|
|
368
|
+
// playhead, drawn alongside them — see drawKeycast). keys:'off'
|
|
369
|
+
// recordings carry no keydown/keyup events, so this stays empty; that
|
|
370
|
+
// absence IS the honest signal, no separate note needed.
|
|
371
|
+
var keycast = el('div', 'replay-keycast');
|
|
372
|
+
keycast.setAttribute('aria-hidden', 'true');
|
|
373
|
+
stage.appendChild(keycast);
|
|
292
374
|
mount.appendChild(stage);
|
|
293
375
|
var ctx = overlay.getContext('2d');
|
|
294
376
|
|
|
@@ -443,6 +525,7 @@
|
|
|
443
525
|
applyCamScroll();
|
|
444
526
|
applyDomUpTo(thenSeekTo);
|
|
445
527
|
drawOverlay();
|
|
528
|
+
drawKeycast();
|
|
446
529
|
updateStatusChips();
|
|
447
530
|
// Late-layout revalidation: web fonts settling after the first
|
|
448
531
|
// layout can shift geometry and stale an initially-passing check.
|
|
@@ -707,6 +790,7 @@
|
|
|
707
790
|
applyDomUpTo(playhead);
|
|
708
791
|
}
|
|
709
792
|
drawOverlay();
|
|
793
|
+
drawKeycast();
|
|
710
794
|
drawLane();
|
|
711
795
|
drawTicker();
|
|
712
796
|
updateStatusChips();
|
|
@@ -813,6 +897,66 @@
|
|
|
813
897
|
}
|
|
814
898
|
}
|
|
815
899
|
|
|
900
|
+
// ── Keycast (bottom-of-stage chips) ──
|
|
901
|
+
// Pairs keydown with its keyup to get each key's [down, up] window, then
|
|
902
|
+
// returns the ones "live" at T: down and not yet released, or released
|
|
903
|
+
// within KEYCAST_FADE_MS. Non-redacted pairs match by e.code (handles
|
|
904
|
+
// overlapping holds, e.g. Shift+A); redacted pairs carry no code (the
|
|
905
|
+
// identity itself is withheld), so they match FIFO against each other —
|
|
906
|
+
// an approximation that assumes redacted keystrokes don't overlap, true
|
|
907
|
+
// for the single-field-typing case this exists to cover.
|
|
908
|
+
function computeKeycastChips(T) {
|
|
909
|
+
var events = trial().events;
|
|
910
|
+
var openByCode = new Map();
|
|
911
|
+
var redactedQueue = [];
|
|
912
|
+
var tokens = [];
|
|
913
|
+
for (var i = 0; i < events.length; i++) {
|
|
914
|
+
var e = events[i];
|
|
915
|
+
if (e.t > T) break;
|
|
916
|
+
if (e.kind !== 'keydown' && e.kind !== 'keyup') continue;
|
|
917
|
+
if (e.redacted) {
|
|
918
|
+
if (e.kind === 'keydown') {
|
|
919
|
+
var rtok = { redacted: true, downT: e.t, upT: null };
|
|
920
|
+
redactedQueue.push(rtok);
|
|
921
|
+
tokens.push(rtok);
|
|
922
|
+
} else {
|
|
923
|
+
var rt = redactedQueue.shift();
|
|
924
|
+
if (rt) rt.upT = e.t;
|
|
925
|
+
}
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (e.kind === 'keydown') {
|
|
929
|
+
// Auto-repeat sends keydown without an intervening keyup; keep the
|
|
930
|
+
// original token open rather than starting a new one.
|
|
931
|
+
if (!openByCode.has(e.code)) {
|
|
932
|
+
var tok = { key: e.key, downT: e.t, upT: null };
|
|
933
|
+
openByCode.set(e.code, tok);
|
|
934
|
+
tokens.push(tok);
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
var open = openByCode.get(e.code);
|
|
938
|
+
if (open) { open.upT = e.t; openByCode.delete(e.code); }
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return tokens.filter(function (tok) {
|
|
942
|
+
return tok.upT == null || T <= tok.upT + KEYCAST_FADE_MS;
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function drawKeycast() {
|
|
947
|
+
var chips = computeKeycastChips(playhead);
|
|
948
|
+
keycast.textContent = '';
|
|
949
|
+
chips.forEach(function (tok) {
|
|
950
|
+
var chip = el('span', 'replay-key-chip' + (tok.redacted ? ' replay-key-chip--redacted' : ''),
|
|
951
|
+
tok.redacted ? '•' : formatKeyLabel(tok.key));
|
|
952
|
+
if (tok.upT != null && playhead > tok.upT) {
|
|
953
|
+
var frac = Math.min(1, (playhead - tok.upT) / KEYCAST_FADE_MS);
|
|
954
|
+
chip.style.opacity = String(1 - frac);
|
|
955
|
+
}
|
|
956
|
+
keycast.appendChild(chip);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
816
960
|
// ── Marker lane (per trial; redrawn per seek so check marks appear) ──
|
|
817
961
|
function drawLane() {
|
|
818
962
|
var lctx = lane.getContext('2d');
|
|
@@ -886,7 +1030,7 @@
|
|
|
886
1030
|
}
|
|
887
1031
|
var fails = counters.patchFailures + counters.scrollFailures;
|
|
888
1032
|
if (fails > 0) {
|
|
889
|
-
failChip.textContent = '⚠ ' + fails + ' recorded change(s) could not be
|
|
1033
|
+
failChip.textContent = '⚠ ' + fails + ' recorded change(s) could not be reapplied. Replay may diverge from the original session.';
|
|
890
1034
|
failChip.style.display = '';
|
|
891
1035
|
} else {
|
|
892
1036
|
failChip.style.display = 'none';
|
|
@@ -919,6 +1063,11 @@
|
|
|
919
1063
|
}
|
|
920
1064
|
lastFrame = ts;
|
|
921
1065
|
if (playhead >= trial().durMs) {
|
|
1066
|
+
if (autoAdvance && trialIdx < trials.length - 1) {
|
|
1067
|
+
loadTrial(trialIdx + 1); // continue playing into the next trial
|
|
1068
|
+
requestAnimationFrame(tick);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
922
1071
|
playing = false;
|
|
923
1072
|
playBtn.textContent = '▶';
|
|
924
1073
|
playBtn.setAttribute('aria-label', 'Play');
|
|
@@ -950,11 +1099,18 @@
|
|
|
950
1099
|
else if (ev.key === 'Home') { seek(0); ev.preventDefault(); }
|
|
951
1100
|
else if (ev.key === 'End') { seek(trial().durMs); ev.preventDefault(); }
|
|
952
1101
|
});
|
|
953
|
-
function
|
|
1102
|
+
function updateSessionPos() {
|
|
1103
|
+
sessionPos.textContent = 'Trial ' + (trialIdx + 1) + ' of ' + trials.length;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Loads trial i's DOM/camera/scrub state WITHOUT touching playing/playBtn
|
|
1107
|
+
// — the continuous-playback boundary in tick() below calls this directly
|
|
1108
|
+
// so play keeps running across the cut; selectTrial() (manual trial-pick
|
|
1109
|
+
// and boot) wraps it with the playing=false reset a user-driven jump
|
|
1110
|
+
// should have.
|
|
1111
|
+
function loadTrial(i) {
|
|
954
1112
|
trialIdx = i;
|
|
955
|
-
|
|
956
|
-
playBtn.textContent = '▶';
|
|
957
|
-
playBtn.setAttribute('aria-label', 'Play');
|
|
1113
|
+
trialSel.value = String(i);
|
|
958
1114
|
scrub.max = String(trial().durMs);
|
|
959
1115
|
trialHadIframe = false;
|
|
960
1116
|
trialHadShadow = false;
|
|
@@ -965,8 +1121,8 @@
|
|
|
965
1121
|
var recDpr = (trial().camera && trial().camera.dpr) ||
|
|
966
1122
|
(model.viewport && model.viewport.dpr) || null;
|
|
967
1123
|
if (recDpr && window.devicePixelRatio && recDpr !== window.devicePixelRatio) {
|
|
968
|
-
dprChip.textContent = 'Recorded at DPR ' + recDpr + ', viewing at DPR ' +
|
|
969
|
-
window.devicePixelRatio + '
|
|
1124
|
+
dprChip.textContent = 'Recorded at DPR ' + recDpr + ', now viewing at DPR ' +
|
|
1125
|
+
window.devicePixelRatio + '. Resolution-dependent styling may differ.';
|
|
970
1126
|
dprChip.style.display = '';
|
|
971
1127
|
} else {
|
|
972
1128
|
dprChip.style.display = 'none';
|
|
@@ -979,6 +1135,13 @@
|
|
|
979
1135
|
flushCamSize(); // trace tier: transform initializes from the seed
|
|
980
1136
|
}
|
|
981
1137
|
seek(0);
|
|
1138
|
+
updateSessionPos();
|
|
1139
|
+
}
|
|
1140
|
+
function selectTrial(i) {
|
|
1141
|
+
playing = false;
|
|
1142
|
+
playBtn.textContent = '▶';
|
|
1143
|
+
playBtn.setAttribute('aria-label', 'Play');
|
|
1144
|
+
loadTrial(i);
|
|
982
1145
|
}
|
|
983
1146
|
trialSel.addEventListener('change', function () {
|
|
984
1147
|
selectTrial(Number(trialSel.value) || 0);
|