You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/reference/session-recording-schema.md
+28-3Lines changed: 28 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -117,12 +117,11 @@ Every node in `initial_dom` is assigned a monotonically-increasing integer `id`.
117
117
118
118
**Element-specific extras.**
119
119
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.
121
121
-`media_src`: present on `<video>` and `<audio>` elements; carries `currentSrc` if available, falling back to the `src` attribute.
122
122
123
123
**What is not captured.**
124
124
125
-
- Canvas/WebGL pixel content. Only the element and its dimensions are recorded.
126
125
- Audio/video media data. Only playback events (`media.play`, `media.pause`, etc.) and the source URL are recorded.
127
126
- Shadow DOM. jsPsych does not use it in core; recordings will not capture mutations inside shadow roots.
128
127
- 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 =
192
191
|ClipboardRecord
193
192
|MediaRecord
194
193
|FocusRecord
195
-
|ScrollRecord;
194
+
|ScrollRecord
195
+
|CanvasSnapshot;
196
196
```
197
197
198
198
### DOM mutations
@@ -288,6 +288,31 @@ type MediaRecord = {
288
288
289
289
`media.time` records throttled `timeupdate` events at roughly 4 Hz and is intended for replay scrubbing rather than exact synchronization.
290
290
291
+
### Canvas snapshots
292
+
293
+
```ts
294
+
interfaceCanvasSnapshot {
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.
0 commit comments