Skip to content

Commit 4b2445f

Browse files
committed
feat(jspsych): capture <canvas> pixel state for replay fidelity
The MutationObserver cannot see drawing operations inside <canvas> — the pixels are not in the DOM tree. Without explicit snapshots, replay reconstructs the canvas element but renders it blank, so anything the participant drew (sketchpad strokes, plugin-rendered visualizations) is invisible in the playback even though cursor motion is recorded. Introduce a generic canvas.snapshot RecordedEvent that carries a PNG data URL keyed by node id. Every <canvas> in the trial display element is tracked; snapshots fire on gesture release (mouseup/touchend, RAF- deferred so the post-gesture paint has landed), at the moment a canvas is removed from the DOM (jsPsych core clears the display element via innerHTML = "" between trials, which would otherwise lose final state), and on stop() of an in-flight trial. Per-canvas snapshots are throttled to 250ms and deduped by data URL so unchanged canvases don't produce noise. Tainted canvases (cross-origin images without CORS) throw on toDataURL — these are caught and skipped so the recording survives. The implementation is plugin-agnostic: it walks the display element for any <canvas>, has no awareness of plugin-sketchpad, and works equally for any plugin or experiment that draws to a canvas.
1 parent 61e35b5 commit 4b2445f

3 files changed

Lines changed: 269 additions & 4 deletions

File tree

docs/reference/session-recording-schema.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,11 @@ Every node in `initial_dom` is assigned a monotonically-increasing integer `id`.
117117

118118
**Element-specific extras.**
119119

120-
- `canvas_size`: present on `<canvas>` elements; carries `width`/`height` attributes at snapshot time. Note that the *contents* of a canvas (the rendered pixels) are not captured.
120+
- `canvas_size`: present on `<canvas>` elements; carries `width`/`height` attributes at snapshot time. The pixel contents at any later moment are recorded as separate [`canvas.snapshot`](#canvas-snapshots) events keyed by node id.
121121
- `media_src`: present on `<video>` and `<audio>` elements; carries `currentSrc` if available, falling back to the `src` attribute.
122122

123123
**What is not captured.**
124124

125-
- Canvas/WebGL pixel content. Only the element and its dimensions are recorded.
126125
- Audio/video media data. Only playback events (`media.play`, `media.pause`, etc.) and the source URL are recorded.
127126
- Shadow DOM. jsPsych does not use it in core; recordings will not capture mutations inside shadow roots.
128127
- CSSOM rule-level edits (`sheet.insertRule`, `deleteRule`, etc.) made on a captured stylesheet after `start()` without touching the owning element's children. These bypass `MutationObserver` and so are not reflected in [`stylesheet_events`](#stylesheet-events). Edits via `<style>.textContent = …` or `appendChild`/`removeChild` on the inner text node are tracked.
@@ -192,7 +191,8 @@ type RecordedEvent =
192191
| ClipboardRecord
193192
| MediaRecord
194193
| FocusRecord
195-
| ScrollRecord;
194+
| ScrollRecord
195+
| CanvasSnapshot;
196196
```
197197

198198
### DOM mutations
@@ -288,6 +288,31 @@ type MediaRecord = {
288288

289289
`media.time` records throttled `timeupdate` events at roughly 4 Hz and is intended for replay scrubbing rather than exact synchronization.
290290

291+
### Canvas snapshots
292+
293+
```ts
294+
interface CanvasSnapshot {
295+
type: "canvas.snapshot";
296+
t: number;
297+
node: number; // id of the <canvas> element
298+
data_url: string; // PNG data URL: "data:image/png;base64,…"
299+
}
300+
```
301+
302+
The `MutationObserver` cannot see drawing operations inside `<canvas>` (the pixels are not in the DOM tree). Without these events, replay reconstructs the element but renders it blank, so anything the participant drew (e.g. plugin-sketchpad strokes) would be invisible.
303+
304+
**Capture timing.** Snapshots are taken at three moments:
305+
306+
1. After a gesture release (`mouseup` or `touchend`) anywhere within the display element. The actual `toDataURL` call is deferred to the next animation frame so the page has finished painting the post-gesture state.
307+
2. When a tracked canvas is removed from the DOM mid-trial (jsPsych core clears the display element via `innerHTML = ""` between trials, which would otherwise lose the final pixel state).
308+
3. When a recording is stopped while a trial is in flight (`stop("aborted")` etc.).
309+
310+
**Throttling and dedupe.** Per-canvas, gesture-driven snapshots are throttled to one every 250 ms; removal- and stop-driven snapshots bypass the throttle so the final state always lands. Identical consecutive data URLs are skipped, so a canvas whose pixels did not change does not produce noise.
311+
312+
**Tainted canvases.** If a canvas drew cross-origin images without CORS headers, `toDataURL` throws `SecurityError`. The recorder catches this, skips the offending canvas, and continues — the recording is never broken by an unreadable canvas.
313+
314+
**Replay procedure.** Track each `<canvas>` you instantiate from `initial_dom` by node id. When a `canvas.snapshot` event arrives, decode `data_url` (e.g. into an `Image` whose `src` is the data URL) and `drawImage` it onto the canvas at `(0, 0)`. This overwrites whatever was there and brings the canvas to the recorded state.
315+
291316
### Focus events
292317

293318
```ts

packages/jspsych/src/modules/recording.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export type RecordedEvent =
113113
| ClipboardRecord
114114
| MediaRecord
115115
| FocusRecord
116-
| ScrollRecord;
116+
| ScrollRecord
117+
| CanvasSnapshot;
117118

118119
export type DomMutation =
119120
| { type: "dom.add"; t: number; parent: number; before: number | null; node: DomNode }
@@ -178,6 +179,19 @@ export type ScrollRecord =
178179
| { type: "scroll.window"; t: number; x: number; y: number }
179180
| { type: "scroll.element"; t: number; node: number; x: number; y: number };
180181

182+
// Captures `<canvas>` pixel state as a PNG data URL. The MutationObserver
183+
// can't see drawing operations inside `<canvas>`; without these events a
184+
// replayer reconstructs the element but renders it blank, so anything the
185+
// participant drew (e.g. plugin-sketchpad strokes) would be invisible.
186+
// Emitted at gesture boundaries (mouseup/touchend) and at trial end, with
187+
// per-canvas throttling and a same-as-last-snapshot dedupe.
188+
export interface CanvasSnapshot {
189+
type: "canvas.snapshot";
190+
t: number;
191+
node: number;
192+
data_url: string;
193+
}
194+
181195
export interface RngCall {
182196
t: number;
183197
fn: string;
@@ -192,6 +206,10 @@ export interface RngCall {
192206
const SCHEMA_VERSION = 1;
193207
const VIEWPORT_DEBOUNCE_MS = 100;
194208
const MEDIA_TIME_THROTTLE_MS = 250;
209+
// Per-canvas minimum gap between gesture-driven snapshots. Bounds the
210+
// `toDataURL` cost for users who rapidly click/release; trial-end
211+
// captures bypass this so the final state always lands.
212+
const CANVAS_SNAPSHOT_MIN_INTERVAL_MS = 250;
195213

196214
export interface SessionRecorderOptions {
197215
jspsychVersion: string;
@@ -242,6 +260,14 @@ export class SessionRecorder {
242260
// remove their listeners when the trial ends. Cleared on detach.
243261
private mediaTrackedElements = new Set<HTMLMediaElement>();
244262

263+
// Strong ref to canvas elements within the current trial's display
264+
// subtree. Used to drive deferred snapshotting after gestures and at
265+
// trial end. Per-canvas throttle/dedupe state is held alongside.
266+
private canvasTrackedElements = new Set<HTMLCanvasElement>();
267+
private canvasLastSnapshot = new WeakMap<HTMLCanvasElement, string>();
268+
private canvasLastSnapshotTime = new WeakMap<HTMLCanvasElement, number>();
269+
private canvasSnapshotScheduled = false;
270+
245271
// Reference to whatever `Math.random` was immediately before the recorder
246272
// started. Restored verbatim on stop so opting into recording does not
247273
// permanently alter `Math.random`, even if we auto-seeded.
@@ -335,6 +361,7 @@ export class SessionRecorder {
335361
this.flushPendingMouse();
336362
this.flushPendingScroll();
337363
this.flushPendingInput();
364+
this.captureCanvasSnapshots(true);
338365
this.currentTrial.t_end = this.t();
339366
this.currentTrial = null;
340367
}
@@ -374,13 +401,15 @@ export class SessionRecorder {
374401
this.currentTrial.initial_dom = this.serializeNode(this.displayElement);
375402
this.attachTrialListeners();
376403
this.scanForMediaElements(this.displayElement);
404+
this.scanForCanvasElements(this.displayElement);
377405
}
378406

379407
onTrialFinish(trialData: unknown) {
380408
if (!this.currentTrial) return;
381409
this.flushPendingMouse();
382410
this.flushPendingScroll();
383411
this.flushPendingInput();
412+
this.captureCanvasSnapshots(true);
384413
this.detachTrialListeners();
385414
this.currentTrial.t_end = this.t();
386415
this.currentTrial.trial_data = serializeJson(trialData);
@@ -506,6 +535,8 @@ export class SessionRecorder {
506535
this.mutationObserver = null;
507536
}
508537
this.detachMediaListeners();
538+
this.canvasTrackedElements.clear();
539+
this.canvasSnapshotScheduled = false;
509540
// Tear down only the trial-scoped handlers; session-scoped handlers
510541
// (resize, focus, blur, fullscreenchange) stay attached until stop().
511542
const remaining: typeof this.boundHandlers = [];
@@ -543,6 +574,8 @@ export class SessionRecorder {
543574
this.pushEvent({ type: "dom.add", t, parent: parentId, before, node });
544575
if (added instanceof HTMLMediaElement) this.attachMediaListeners(added);
545576
else if (added instanceof Element) this.scanForMediaElements(added);
577+
if (added instanceof HTMLCanvasElement) this.trackCanvasElement(added);
578+
else if (added instanceof Element) this.scanForCanvasElements(added);
546579
}
547580
} else if (r.type === "attributes") {
548581
const id = this.nodeIds.get(r.target);
@@ -578,6 +611,14 @@ export class SessionRecorder {
578611
}
579612

580613
private releaseSubtree(node: Node) {
614+
if (node instanceof HTMLCanvasElement && this.canvasTrackedElements.has(node)) {
615+
// jsPsych core clears the display element via `innerHTML = ""`
616+
// immediately after each trial, before `onTrialFinish` fires. Take
617+
// a final snapshot here so the canvas's last pixel state is in the
618+
// recording instead of being silently dropped on removal.
619+
this.snapshotCanvas(node, this.t(), true);
620+
this.canvasTrackedElements.delete(node);
621+
}
581622
if (this.nodeIds.has(node)) {
582623
this.nodeIds.delete(node);
583624
}
@@ -919,6 +960,9 @@ export class SessionRecorder {
919960
button: e.button,
920961
target: this.targetId(e.target),
921962
});
963+
// Gesture release is the moment a stroke completes; snapshot any
964+
// tracked canvases so the drawing it produced reaches the replay.
965+
if (type === "mouse.up") this.scheduleCanvasSnapshot();
922966
};
923967
}
924968

@@ -931,6 +975,7 @@ export class SessionRecorder {
931975
y: tt.clientY,
932976
}));
933977
this.pushEvent({ type, t: this.t(), touches });
978+
if (type === "touch.end") this.scheduleCanvasSnapshot();
934979
};
935980
}
936981

@@ -1034,6 +1079,81 @@ export class SessionRecorder {
10341079
this.mediaTrackedElements.clear();
10351080
}
10361081

1082+
// -------- canvas snapshotting --------
1083+
1084+
// Walks `root` for `<canvas>` elements and registers each one for
1085+
// snapshotting. Called on trial load and when subtrees are added
1086+
// mid-trial via `dom.add`.
1087+
private scanForCanvasElements(root: Element) {
1088+
if (root instanceof HTMLCanvasElement) {
1089+
this.trackCanvasElement(root);
1090+
return;
1091+
}
1092+
const els = root.querySelectorAll("canvas");
1093+
for (const el of Array.from(els)) {
1094+
this.trackCanvasElement(el as HTMLCanvasElement);
1095+
}
1096+
}
1097+
1098+
private trackCanvasElement(canvas: HTMLCanvasElement) {
1099+
this.canvasTrackedElements.add(canvas);
1100+
}
1101+
1102+
// Defers actual snapshotting to the next animation frame so we wait
1103+
// until the page has had a chance to paint the post-gesture state
1104+
// (otherwise `toDataURL` could return the canvas as it was *before*
1105+
// the up event's listeners ran). Coalesced via a single scheduled flag
1106+
// so a flurry of mouseups doesn't queue redundant work.
1107+
private scheduleCanvasSnapshot() {
1108+
if (this.canvasSnapshotScheduled) return;
1109+
if (this.canvasTrackedElements.size === 0) return;
1110+
this.canvasSnapshotScheduled = true;
1111+
requestAnimationFrame(() => {
1112+
this.canvasSnapshotScheduled = false;
1113+
this.captureCanvasSnapshots(false);
1114+
});
1115+
}
1116+
1117+
// Throttles to at most one snapshot per canvas per
1118+
// `CANVAS_SNAPSHOT_MIN_INTERVAL_MS`, and dedupes by data URL so a
1119+
// canvas whose pixels did not actually change does not produce noise.
1120+
// `force` bypasses the throttle for trial-end captures so the final
1121+
// state of every canvas is always recorded.
1122+
private captureCanvasSnapshots(force: boolean) {
1123+
if (this.canvasTrackedElements.size === 0) return;
1124+
const now = this.t();
1125+
for (const canvas of this.canvasTrackedElements) {
1126+
this.snapshotCanvas(canvas, now, force);
1127+
}
1128+
}
1129+
1130+
// Pushes a `canvas.snapshot` event for one canvas, applying the
1131+
// per-canvas throttle (unless `force`) and skipping when the data URL
1132+
// is unchanged from the last snapshot. Pulled out of the loop above so
1133+
// it can also be called from `releaseSubtree` to capture the final
1134+
// pixel state at the moment a canvas is removed from the trial DOM —
1135+
// jsPsych core clears the display element via `innerHTML = ""` before
1136+
// `onTrialFinish` runs, so a trial-end-only flush would miss it.
1137+
private snapshotCanvas(canvas: HTMLCanvasElement, t: number, force: boolean) {
1138+
const id = this.nodeIds.get(canvas);
1139+
if (id === undefined) return;
1140+
if (!force) {
1141+
const last = this.canvasLastSnapshotTime.get(canvas) ?? -Infinity;
1142+
if (t - last < CANVAS_SNAPSHOT_MIN_INTERVAL_MS) return;
1143+
}
1144+
try {
1145+
const dataUrl = canvas.toDataURL();
1146+
if (this.canvasLastSnapshot.get(canvas) === dataUrl) return;
1147+
this.canvasLastSnapshot.set(canvas, dataUrl);
1148+
this.canvasLastSnapshotTime.set(canvas, t);
1149+
this.pushEvent({ type: "canvas.snapshot", t, node: id, data_url: dataUrl });
1150+
} catch {
1151+
// toDataURL throws SecurityError on tainted canvases (canvases
1152+
// that drew cross-origin images without CORS headers). Skip and
1153+
// never break the experiment.
1154+
}
1155+
}
1156+
10371157
// -------- RNG --------
10381158

10391159
private isNativeMathRandom(fn: typeof Math.random) {

packages/jspsych/tests/core/record-session.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,126 @@ describe("record_session option", () => {
329329
});
330330
});
331331

332+
describe("canvas snapshot capture", () => {
333+
test("captures a final canvas.snapshot at trial end for canvases in the initial DOM", async () => {
334+
const jsPsych = initJsPsych({ record_session: true });
335+
await startTimeline(
336+
[
337+
{
338+
type: htmlKeyboardResponse,
339+
stimulus: '<canvas id="c" width="50" height="50"></canvas>',
340+
on_load: () => {
341+
const c = document.getElementById("c") as HTMLCanvasElement;
342+
const ctx = c.getContext("2d")!;
343+
ctx.fillRect(10, 10, 20, 20);
344+
},
345+
},
346+
],
347+
jsPsych
348+
);
349+
await pressKey("a");
350+
351+
const rec = jsPsych.getSessionRecording()!;
352+
const snaps = rec.trials[0].events.filter((e) => e.type === "canvas.snapshot");
353+
expect(snaps.length).toBeGreaterThan(0);
354+
const last = snaps[snaps.length - 1];
355+
expect(typeof last.node).toBe("number");
356+
expect(last.data_url).toEqual(expect.stringMatching(/^data:image\/png/));
357+
});
358+
359+
test("emits no canvas.snapshot events when the trial has no canvas", async () => {
360+
const jsPsych = initJsPsych({ record_session: true });
361+
await startTimeline(
362+
[{ type: htmlKeyboardResponse, stimulus: "<p>no canvas here</p>" }],
363+
jsPsych
364+
);
365+
await pressKey("a");
366+
367+
const rec = jsPsych.getSessionRecording()!;
368+
const snaps = rec.trials[0].events.filter((e) => e.type === "canvas.snapshot");
369+
expect(snaps).toHaveLength(0);
370+
});
371+
372+
test("tracks canvases added mid-trial via dom.add", async () => {
373+
const jsPsych = initJsPsych({ record_session: true });
374+
await startTimeline(
375+
[
376+
{
377+
type: htmlKeyboardResponse,
378+
stimulus: '<div id="host"></div>',
379+
on_load: () => {
380+
const c = document.createElement("canvas");
381+
c.id = "late";
382+
c.width = 32;
383+
c.height = 32;
384+
document.getElementById("host")!.appendChild(c);
385+
},
386+
},
387+
],
388+
jsPsych
389+
);
390+
await pressKey("a");
391+
392+
const rec = jsPsych.getSessionRecording()!;
393+
const snaps = rec.trials[0].events.filter((e) => e.type === "canvas.snapshot");
394+
expect(snaps.length).toBeGreaterThan(0);
395+
});
396+
397+
test("captures the canvas's final pixel state when it is removed mid-trial", async () => {
398+
// jsPsych core clears the display element via `innerHTML = ""` after
399+
// every trial, before `onTrialFinish` fires. The recorder must take
400+
// its final snapshot at the moment of removal — a trial-end-only
401+
// flush would race with the cleanup and miss the canvas entirely.
402+
const jsPsych = initJsPsych({ record_session: true });
403+
await startTimeline(
404+
[
405+
{
406+
type: htmlKeyboardResponse,
407+
stimulus: '<canvas id="c" width="20" height="20"></canvas>',
408+
on_load: () => {
409+
const c = document.getElementById("c") as HTMLCanvasElement;
410+
c.getContext("2d")!.fillRect(0, 0, 10, 10);
411+
c.remove();
412+
},
413+
},
414+
],
415+
jsPsych
416+
);
417+
await pressKey("a");
418+
419+
const rec = jsPsych.getSessionRecording()!;
420+
const snaps = rec.trials[0].events.filter((e) => e.type === "canvas.snapshot");
421+
expect(snaps).toHaveLength(1);
422+
expect(snaps[0].data_url).toEqual(expect.stringMatching(/^data:image\/png/));
423+
});
424+
425+
test("a tainted canvas (toDataURL throws) does not break the recording", async () => {
426+
const jsPsych = initJsPsych({ record_session: true });
427+
await startTimeline(
428+
[
429+
{
430+
type: htmlKeyboardResponse,
431+
stimulus: '<canvas id="c" width="10" height="10"></canvas>',
432+
on_load: () => {
433+
const c = document.getElementById("c") as HTMLCanvasElement;
434+
c.toDataURL = () => {
435+
throw new DOMException("tainted", "SecurityError");
436+
};
437+
},
438+
},
439+
],
440+
jsPsych
441+
);
442+
await pressKey("a");
443+
444+
const rec = jsPsych.getSessionRecording()!;
445+
// Recording still ends cleanly and the trial completes.
446+
expect(rec.end_reason).toBe("finished");
447+
const snaps = rec.trials[0].events.filter((e) => e.type === "canvas.snapshot");
448+
expect(snaps).toHaveLength(0);
449+
});
450+
});
451+
332452
describe("form-state capture", () => {
333453
test("captures input.value events for typed text", async () => {
334454
const jsPsych = initJsPsych({ record_session: true });

0 commit comments

Comments
 (0)