@sailfish-ai/recorder 1.12.5 → 1.12.6

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.
Files changed (141) hide show
  1. package/README.md +8 -0
  2. package/dist/canvas/adapters.js +382 -0
  3. package/dist/canvas/adapters.js.br +0 -0
  4. package/dist/canvas/adapters.js.gz +0 -0
  5. package/dist/canvas/canvasManager.js +1949 -0
  6. package/dist/canvas/canvasManager.js.br +0 -0
  7. package/dist/canvas/canvasManager.js.gz +0 -0
  8. package/dist/canvas/canvasPrivacy.js +233 -0
  9. package/dist/canvas/canvasPrivacy.js.br +0 -0
  10. package/dist/canvas/canvasPrivacy.js.gz +0 -0
  11. package/dist/canvas/codec.js +314 -0
  12. package/dist/canvas/codec.js.br +0 -0
  13. package/dist/canvas/codec.js.gz +0 -0
  14. package/dist/canvas/deviceClass.js +67 -0
  15. package/dist/canvas/deviceClass.js.br +0 -0
  16. package/dist/canvas/deviceClass.js.gz +0 -0
  17. package/dist/canvas/encodeWorker.js +284 -0
  18. package/dist/canvas/encodeWorker.js.br +0 -0
  19. package/dist/canvas/encodeWorker.js.gz +0 -0
  20. package/dist/canvas/governor.js +79 -0
  21. package/dist/canvas/governor.js.br +0 -0
  22. package/dist/canvas/governor.js.gz +0 -0
  23. package/dist/canvas/keyframes.js +183 -0
  24. package/dist/canvas/keyframes.js.br +0 -0
  25. package/dist/canvas/keyframes.js.gz +0 -0
  26. package/dist/canvas/mediaRecorderCapture.js +257 -0
  27. package/dist/canvas/mediaRecorderCapture.js.br +0 -0
  28. package/dist/canvas/mediaRecorderCapture.js.gz +0 -0
  29. package/dist/canvas/objectLog.js +150 -0
  30. package/dist/canvas/objectLog.js.br +0 -0
  31. package/dist/canvas/objectLog.js.gz +0 -0
  32. package/dist/canvas/patch2d.js +1302 -0
  33. package/dist/canvas/patch2d.js.br +0 -0
  34. package/dist/canvas/patch2d.js.gz +0 -0
  35. package/dist/canvas/patchGL.js +371 -0
  36. package/dist/canvas/patchGL.js.br +0 -0
  37. package/dist/canvas/patchGL.js.gz +0 -0
  38. package/dist/canvas/patchWebGPU.js +361 -0
  39. package/dist/canvas/patchWebGPU.js.br +0 -0
  40. package/dist/canvas/patchWebGPU.js.gz +0 -0
  41. package/dist/canvas/piiDetect.js +155 -0
  42. package/dist/canvas/piiDetect.js.br +0 -0
  43. package/dist/canvas/piiDetect.js.gz +0 -0
  44. package/dist/canvas/protocol.js +122 -0
  45. package/dist/canvas/protocol.js.br +0 -0
  46. package/dist/canvas/protocol.js.gz +0 -0
  47. package/dist/canvas/videoCapture.js +252 -0
  48. package/dist/canvas/videoCapture.js.br +0 -0
  49. package/dist/canvas/videoCapture.js.gz +0 -0
  50. package/dist/canvas/workerRelay.js +99 -0
  51. package/dist/canvas/workerRelay.js.br +0 -0
  52. package/dist/canvas/workerRelay.js.gz +0 -0
  53. package/dist/canvas/workerShim.js +227 -0
  54. package/dist/canvas/workerShim.js.br +0 -0
  55. package/dist/canvas/workerShim.js.gz +0 -0
  56. package/dist/canvas-worker.cjs +74 -0
  57. package/dist/canvas-worker.cjs.br +0 -0
  58. package/dist/canvas-worker.cjs.gz +0 -0
  59. package/dist/canvas-worker.js +78 -0
  60. package/dist/canvas-worker.js.br +0 -0
  61. package/dist/canvas-worker.js.gz +0 -0
  62. package/dist/canvasFrameStore.js +198 -0
  63. package/dist/canvasFrameStore.js.br +0 -0
  64. package/dist/canvasFrameStore.js.gz +0 -0
  65. package/dist/chunks/canvasManager-BYn3_oyo.js +1440 -0
  66. package/dist/chunks/canvasManager-BYn3_oyo.js.br +0 -0
  67. package/dist/chunks/canvasManager-BYn3_oyo.js.gz +0 -0
  68. package/dist/chunks/canvasManager-CoX5072G.js +1456 -0
  69. package/dist/chunks/canvasManager-CoX5072G.js.br +0 -0
  70. package/dist/chunks/canvasManager-CoX5072G.js.gz +0 -0
  71. package/dist/chunks/{chunkSerializer-Cd8y9tID.js → chunkSerializer-C_K0Wyk0.js} +1 -1
  72. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.br +0 -0
  73. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.gz +0 -0
  74. package/dist/chunks/{chunkSerializer-pkpgT-FH.js → chunkSerializer-CghPitF4.js} +1 -1
  75. package/dist/chunks/chunkSerializer-CghPitF4.js.br +0 -0
  76. package/dist/chunks/chunkSerializer-CghPitF4.js.gz +0 -0
  77. package/dist/chunks/{index-Bvd8oTpV.js → index-BIj4l3CV.js} +1025 -655
  78. package/dist/chunks/index-BIj4l3CV.js.br +0 -0
  79. package/dist/chunks/index-BIj4l3CV.js.gz +0 -0
  80. package/dist/chunks/{index-CXvHbLRT.js → index-DIYomvdD.js} +1115 -724
  81. package/dist/chunks/index-DIYomvdD.js.br +0 -0
  82. package/dist/chunks/index-DIYomvdD.js.gz +0 -0
  83. package/dist/chunks/patch2d-9voowhJ_.js +816 -0
  84. package/dist/chunks/patch2d-9voowhJ_.js.br +0 -0
  85. package/dist/chunks/patch2d-9voowhJ_.js.gz +0 -0
  86. package/dist/chunks/patch2d-PQxcaE6r.js +886 -0
  87. package/dist/chunks/patch2d-PQxcaE6r.js.br +0 -0
  88. package/dist/chunks/patch2d-PQxcaE6r.js.gz +0 -0
  89. package/dist/graphql.js +12 -1
  90. package/dist/graphql.js.br +0 -0
  91. package/dist/graphql.js.gz +0 -0
  92. package/dist/index.js +144 -2
  93. package/dist/index.js.br +0 -0
  94. package/dist/index.js.gz +0 -0
  95. package/dist/recorder.cjs +2 -2
  96. package/dist/recorder.cjs.br +0 -0
  97. package/dist/recorder.cjs.gz +0 -0
  98. package/dist/recorder.js +52 -41
  99. package/dist/recorder.js.br +0 -0
  100. package/dist/recorder.js.gz +0 -0
  101. package/dist/recorder.umd.cjs +4842 -2215
  102. package/dist/recorder.umd.cjs.br +0 -0
  103. package/dist/recorder.umd.cjs.gz +0 -0
  104. package/dist/recording.js +119 -10
  105. package/dist/recording.js.br +0 -0
  106. package/dist/recording.js.gz +0 -0
  107. package/dist/types/canvas/adapters.d.ts +71 -0
  108. package/dist/types/canvas/canvasManager.d.ts +273 -0
  109. package/dist/types/canvas/canvasPrivacy.d.ts +184 -0
  110. package/dist/types/canvas/codec.d.ts +27 -0
  111. package/dist/types/canvas/deviceClass.d.ts +40 -0
  112. package/dist/types/canvas/encodeWorker.d.ts +54 -0
  113. package/dist/types/canvas/governor.d.ts +44 -0
  114. package/dist/types/canvas/keyframes.d.ts +81 -0
  115. package/dist/types/canvas/mediaRecorderCapture.d.ts +91 -0
  116. package/dist/types/canvas/objectLog.d.ts +75 -0
  117. package/dist/types/canvas/patch2d.d.ts +339 -0
  118. package/dist/types/canvas/patchGL.d.ts +74 -0
  119. package/dist/types/canvas/patchWebGPU.d.ts +37 -0
  120. package/dist/types/canvas/piiDetect.d.ts +60 -0
  121. package/dist/types/canvas/protocol.d.ts +176 -0
  122. package/dist/types/canvas/videoCapture.d.ts +67 -0
  123. package/dist/types/canvas/workerRelay.d.ts +29 -0
  124. package/dist/types/canvas/workerShim.d.ts +10 -0
  125. package/dist/types/canvasFrameStore.d.ts +23 -0
  126. package/dist/types/graphql.d.ts +1 -1
  127. package/dist/types/index.d.ts +99 -1
  128. package/dist/types/types.d.ts +40 -0
  129. package/dist/types/websocket.d.ts +19 -0
  130. package/dist/websocket.js +210 -0
  131. package/dist/websocket.js.br +0 -0
  132. package/dist/websocket.js.gz +0 -0
  133. package/package.json +7 -1
  134. package/dist/chunks/chunkSerializer-Cd8y9tID.js.br +0 -0
  135. package/dist/chunks/chunkSerializer-Cd8y9tID.js.gz +0 -0
  136. package/dist/chunks/chunkSerializer-pkpgT-FH.js.br +0 -0
  137. package/dist/chunks/chunkSerializer-pkpgT-FH.js.gz +0 -0
  138. package/dist/chunks/index-Bvd8oTpV.js.br +0 -0
  139. package/dist/chunks/index-Bvd8oTpV.js.gz +0 -0
  140. package/dist/chunks/index-CXvHbLRT.js.br +0 -0
  141. package/dist/chunks/index-CXvHbLRT.js.gz +0 -0
@@ -0,0 +1,1302 @@
1
+ /**
2
+ * Global 2D-context instrumentation: prototype patches that feed per-context
3
+ * recorders. Port of the validated bench core
4
+ * (test-applications/canvas-recorder-bench/src/capture/patch2d.ts).
5
+ *
6
+ * Captures: every CanvasRenderingContext2D method call + property set,
7
+ * gradient/pattern creation (interned objects), image sources (asset CAS),
8
+ * ImageData, and a shadow state stack (props + transform per save()) that
9
+ * makes keyframe seeking possible.
10
+ *
11
+ * SDK adaptations (everything else is a faithful port):
12
+ * - NO window/document/navigator access at module top level (SSR rule);
13
+ * all installation happens inside exported install functions.
14
+ * - `encodeImage` returns `{size, bytes?}` so encoded webp payloads can be
15
+ * shipped as SFCV asset frames (sink.onAssetData), not just size-counted.
16
+ * - `retainAssets` option: the worker-relay path keeps bitmaps/ImageData for
17
+ * postMessage transfer (bench behavior); the main-thread manager passes
18
+ * false so per-draw dynamic-source assets do not accumulate in memory.
19
+ * - Untracked-context + context-created notifiers so the production manager
20
+ * can discover contexts created BEFORE hooks installed (first method call
21
+ * after install) and AFTER (getContext auto-attach path).
22
+ * - DiscardSink short-circuit: in video mode the recorder stays attached
23
+ * purely for dirtiness; ops are dropped BEFORE argument serialization.
24
+ */
25
+ import { DEFAULT_REDACT_STYLE, maskAllText, rectRegion, REDACTION_COLOR, textLocalBox, textRegion, transformedBox, } from "./canvasPrivacy";
26
+ import { deflate } from "./codec";
27
+ import { getNativePath2D, installFactoryLoggingOnProto, installSharedObjectLogging, objectLogs, OBJECT_FACTORIES, } from "./objectLog";
28
+ import { BufferSink, DIRTY_OP, DiscardSink, } from "./protocol";
29
+ // Re-export the shared protocol types under their bench names so the sibling
30
+ // ports (patchGL/patchWebGPU/workerShim) keep their original import shape.
31
+ export { BufferSink, serializeTypedArray, deserializeTypedArray, TYPED_ARRAY_KINDS, } from "./protocol";
32
+ const TRACKED_PROPS = [
33
+ "fillStyle", "strokeStyle", "globalAlpha", "globalCompositeOperation",
34
+ "lineWidth", "lineCap", "lineJoin", "miterLimit", "lineDashOffset",
35
+ "font", "textAlign", "textBaseline", "direction",
36
+ "shadowBlur", "shadowColor", "shadowOffsetX", "shadowOffsetY",
37
+ "imageSmoothingEnabled", "imageSmoothingQuality", "filter", "letterSpacing", "wordSpacing",
38
+ ];
39
+ // Cross-context object support (gradients/patterns/paths) lives in
40
+ // objectLog.ts so index.tsx can install the creation logging EARLY —
41
+ // synchronously at startRecording() entry, before app scene code constructs
42
+ // objects the recorder could otherwise never serialize. Each recorder emits
43
+ // its own copy of a logged object lazily into whichever command stream first
44
+ // references it (serializeFactoryObject), under a globally unique id.
45
+ let nextAssetId = 1;
46
+ let nextObjId = 1;
47
+ /**
48
+ * A27 safety set. A canvas is added here when `maskCanvasDraws` runs against it
49
+ * while recording is OFF / the canvas isn't recorded (no recorder to track a
50
+ * blank region) — the masked draw still paints real pixels. The manager reads
51
+ * this to skip the A27 initial keyframe for THAT canvas, which could otherwise
52
+ * ship those pixels un-blanked.
53
+ *
54
+ * [audit H2 / Round 2] Scoped PER CANVAS via a WeakSet rather than a module-wide
55
+ * boolean. The Round-1 boolean was set-once / never-reset, so a single
56
+ * off-recording maskCanvasDraws on ANY canvas permanently skipped A27 for EVERY
57
+ * canvas session-wide (and across record stop/restart, since the module global
58
+ * outlives a session). The WeakSet pins the skip to the exact canvas that had a
59
+ * pre-attach masked draw, leaks nothing (GC reclaims entries when the canvas is
60
+ * gone), and needs no session-teardown reset. When the target context has no
61
+ * resolvable canvas (a detached/worker ctx), we fall back to a global flag so an
62
+ * unattributable pre-attach mask still fails safe (skip A27 broadly).
63
+ *
64
+ * Lives here (not canvasManager) because index.tsx imports patch2d but
65
+ * lazy-loads canvasManager. [audit A27 / Round 1]
66
+ */
67
+ const preAttachMaskedCanvases = new WeakSet();
68
+ let preAttachMaskUsedUnattributed = false;
69
+ export function notePreAttachMaskUsed(canvas) {
70
+ if (canvas)
71
+ preAttachMaskedCanvases.add(canvas);
72
+ else
73
+ preAttachMaskUsedUnattributed = true;
74
+ }
75
+ export function wasPreAttachMaskUsed(canvas) {
76
+ if (preAttachMaskUsedUnattributed)
77
+ return true;
78
+ return canvas ? preAttachMaskedCanvases.has(canvas) : false;
79
+ }
80
+ /** Cap on accumulated masked-region bboxes (keyframe blanking) so an incremental
81
+ * canvas that never issues a full clear cannot grow the list without bound. */
82
+ const MAX_MASK_REGIONS = 4_096;
83
+ /** Path-construction ops whose coordinates feed the running LOCAL path bounds
84
+ * (used to size a masked `fill`/`stroke`'s region + block fillRect). */
85
+ const PATH_BUILD_OPS = new Set([
86
+ "beginPath", "closePath", "moveTo", "lineTo", "rect", "roundRect",
87
+ "arc", "arcTo", "ellipse", "bezierCurveTo", "quadraticCurveTo",
88
+ ]);
89
+ /** Paint / image ops whose output is the sensitive pixels a mask block hides. */
90
+ const PAINT_OPS = new Set([
91
+ "fill", "stroke", "fillRect", "strokeRect", "drawImage", "putImageData",
92
+ ]);
93
+ /**
94
+ * CTM-mutating ops. Inside a mask block these carry NO sensitive content (they
95
+ * move the pen, they don't paint), but they MUST still be recorded: the masked
96
+ * shapes we recolor/replace are emitted in LOCAL space, so replay needs the same
97
+ * live transform to place them where the keyframe-blank region (computed under
98
+ * the live CTM) actually is. Dropping them (the Round-1 behavior) painted the
99
+ * redaction at the WRONG device location while the blank covered the right one →
100
+ * under-blank/leak. [audit M-NEW-3] `save`/`restore` are handled separately
101
+ * because they also drive the keyframe shadow-state stack. */
102
+ const TRANSFORM_OPS = new Set([
103
+ "setTransform", "transform", "translate", "scale", "rotate", "resetTransform",
104
+ ]);
105
+ /** Axis-aligned union (bounding box) of a set of regions — used to coalesce the
106
+ * masked-region list when it overflows the cap, bounding memory without dropping
107
+ * any coverage. */
108
+ function unionRegion(rs) {
109
+ if (rs.length === 0)
110
+ return null;
111
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
112
+ for (const r of rs) {
113
+ if (r.x < minX)
114
+ minX = r.x;
115
+ if (r.y < minY)
116
+ minY = r.y;
117
+ if (r.x + r.w > maxX)
118
+ maxX = r.x + r.w;
119
+ if (r.y + r.h > maxY)
120
+ maxY = r.y + r.h;
121
+ }
122
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
123
+ }
124
+ /**
125
+ * A26 — per-recorder asset-metadata cap. Dynamic image sources (videos, and the
126
+ * internal buffer canvases frameworks like Konva paint through) are captured
127
+ * PER DRAW, so on the main-thread manager path (retainAssets:false) the `assets`
128
+ * id→metadata map grows one tiny entry per drawn frame for the controller's
129
+ * whole lifetime — a 30-min session at 30fps would accumulate ~50k entries that
130
+ * nothing ever reads again (their payloads already shipped live via onAssetData).
131
+ * We bound the map LRU-style: once it exceeds the cap, the oldest entries are
132
+ * evicted (Map keeps insertion order). The worker-relay path (retainAssets:true)
133
+ * is NOT evicted here — it transfers the retained bitmaps/ImageData at stop and
134
+ * its recorders are short-lived per batch.
135
+ */
136
+ const MAX_ASSET_ENTRIES = 2_048;
137
+ const recorders = new WeakMap();
138
+ /** Contexts the recorder infrastructure itself owns (keyframe scratch
139
+ * canvases etc.) — excluded from untracked-context discovery. */
140
+ const ignoredContexts = new WeakSet();
141
+ export function ignore2DContext(ctx) {
142
+ ignoredContexts.add(ctx);
143
+ }
144
+ /** Fires (throttled per context by the subscriber) when a PATCHED method runs
145
+ * on a 2D context that has no recorder — i.e. a context created before the
146
+ * hooks were installed. Lets the manager attach mid-stream. */
147
+ let untrackedNotify = null;
148
+ export function setUntracked2DContextNotifier(fn) {
149
+ untrackedNotify = fn;
150
+ }
151
+ /**
152
+ * Return the active recorder bound to a 2D context, or undefined when the
153
+ * context isn't being recorded (recording off, not yet attached, or a
154
+ * non-recorded canvas). Used by the `maskCanvasDraws` public API to toggle
155
+ * per-draw masking. Never throws.
156
+ */
157
+ export function recorderForContext(ctx) {
158
+ if (!ctx)
159
+ return undefined;
160
+ return recorders.get(ctx) ?? autoRecorders.get(ctx);
161
+ }
162
+ /** Fires whenever getContext() succeeds for ANY context type (after
163
+ * contextInfo is recorded) — the manager's late-context discovery path. */
164
+ let contextCreatedNotify = null;
165
+ export function setContextCreatedNotifier(fn) {
166
+ contextCreatedNotify = fn;
167
+ }
168
+ let autoConfig = null;
169
+ const autoRecorders = new Map();
170
+ const contextInfo = new WeakMap();
171
+ export function getContextInfo(canvas) {
172
+ return contextInfo.get(canvas);
173
+ }
174
+ export function setAutoAttach(config) {
175
+ autoConfig = config;
176
+ if (config)
177
+ autoRecorders.clear();
178
+ }
179
+ /** Drop the auto-created (still-buffering) recorder for one canvas the
180
+ * manager decided not to record — e.g. invisible canvases (Konva hit
181
+ * canvases) whose BufferSink would otherwise grow unbounded while they keep
182
+ * drawing. No-op once a strategy sink is bound. */
183
+ export function releaseAutoRecorder(canvas) {
184
+ const rec = autoRecorders.get(canvas);
185
+ if (rec && !rec.bound) {
186
+ recorders.delete(rec.rawCtx);
187
+ autoRecorders.delete(canvas);
188
+ }
189
+ }
190
+ /** Drop auto-created recorders that no strategy bound (e.g. probe canvases). */
191
+ export function pruneUnboundRecorders() {
192
+ for (const [canvas, rec] of autoRecorders) {
193
+ if (!rec.bound) {
194
+ recorders.delete(rec.rawCtx);
195
+ autoRecorders.delete(canvas);
196
+ }
197
+ }
198
+ }
199
+ export class ContextRecorder {
200
+ ctx;
201
+ sink;
202
+ now;
203
+ assetMode;
204
+ encodeImage;
205
+ assets = new Map();
206
+ /** dedupe static image sources by identity */
207
+ assetBySource = new WeakMap();
208
+ // [audit B6] In-flight encode promises. A SET (not an array) with auto-removal
209
+ // so it stays bounded to ACTUALLY-pending work: a canvas that captures a
210
+ // dynamic per-frame source (e.g. a framework's internal buffer canvas, which
211
+ // emits no governor signal and so never demotes) would otherwise grow this by
212
+ // one retained promise PER FRAME for the whole session (~30/s). settle() awaits
213
+ // a snapshot.
214
+ pendingEncodes = new Set();
215
+ props = {};
216
+ stack = [];
217
+ /** accumulated main-thread ms spent in capture code */
218
+ cpuMs = 0;
219
+ /** a strategy sink has been bound (vs still buffering) */
220
+ bound = false;
221
+ /** keep bitmaps/ImageData on assets for postMessage transfer (worker path) */
222
+ retainAssets;
223
+ constructor(
224
+ /** main-thread or OffscreenCanvas 2D context */
225
+ ctx, sink, now, assetMode, encodeImage, opts) {
226
+ this.ctx = ctx;
227
+ this.sink = sink;
228
+ this.now = now;
229
+ this.assetMode = assetMode;
230
+ this.encodeImage = encodeImage;
231
+ this.retainAssets = opts?.retainAssets !== false;
232
+ }
233
+ get rawCtx() {
234
+ return this.ctx;
235
+ }
236
+ /** Bind the strategy's sink, draining anything buffered since getContext. */
237
+ swapSink(sink) {
238
+ const old = this.sink;
239
+ this.sink = sink;
240
+ this.bound = true;
241
+ if (old instanceof BufferSink) {
242
+ for (const op of old.ops)
243
+ sink.onOp(op);
244
+ for (const a of old.assetEvents)
245
+ sink.onAssetBytes(a.id, a.bytes, a.t);
246
+ for (const d of old.assetData)
247
+ sink.onAssetData?.(d.info, d.payload);
248
+ for (const n of old.notes)
249
+ sink.note(n);
250
+ }
251
+ }
252
+ /**
253
+ * A26: register a captured asset's metadata, evicting the oldest entries when
254
+ * the per-recorder cap is exceeded (LRU; Map keeps insertion order). Eviction
255
+ * only happens on the non-retaining manager path — when retainAssets is true
256
+ * (worker relay) the full set is transferred at stop, so we never evict there.
257
+ */
258
+ addAsset(id, asset) {
259
+ this.assets.set(id, asset);
260
+ if (!this.retainAssets && this.assets.size > MAX_ASSET_ENTRIES) {
261
+ const overflow = this.assets.size - MAX_ASSET_ENTRIES;
262
+ let removed = 0;
263
+ for (const key of this.assets.keys()) {
264
+ if (removed >= overflow)
265
+ break;
266
+ this.assets.delete(key);
267
+ removed++;
268
+ }
269
+ }
270
+ }
271
+ // -- called from prototype patches --------------------------------------
272
+ /**
273
+ * Draw-site stamp id for the NEXT recorded op (Phase 3b). Set synchronously
274
+ * by __sfDrawSite (canvasManager) when stamped app code calls
275
+ * `__sfds(ctx, id).method(...)` — the method call runs immediately after, in
276
+ * the same task, so a single pending slot is race-free. Consumed + cleared
277
+ * by the next recordCall/recordSet.
278
+ */
279
+ pendingSiteId;
280
+ /**
281
+ * A13 — privacy text redactor. When set (canvasManager installs it for
282
+ * "redact" and "normal" canvases that have a privacy rule / content-detection
283
+ * flag off), the FIRST string argument of `fillText` / `strokeText` is passed
284
+ * through this fn BEFORE serialization, so the original drawn text never
285
+ * reaches the command stream. Returns the (possibly masked) replacement
286
+ * string; non-string args are never handed to it. Left undefined on the hot
287
+ * path for canvases with no privacy rule, so normal recording pays nothing.
288
+ */
289
+ textRedactor;
290
+ /**
291
+ * Runtime per-draw masking (the `maskCanvasDraws` public API). While
292
+ * `maskDepth > 0`, EVERY recorded op is DROPPED — `fillRect`, path
293
+ * construction (`moveTo`/`lineTo`/`arc`/…), `fill`/`stroke`, `fillText`,
294
+ * `drawImage`, state setters — so the sensitive shape/geometry/text the app
295
+ * draws inside the wrapped block never reaches the command stream. This is the
296
+ * only way to sanitize SPECIFIC content within a canvas: DOM/selector masking
297
+ * is whole-`<canvas>` only (there are no sub-canvas elements to target).
298
+ */
299
+ maskDepth = 0;
300
+ /**
301
+ * Set true the first time a draw is masked by `maskDepth`. The canvas manager
302
+ * polls this to keep the canvas in command mode (a masked canvas must never
303
+ * demote to video — video frames can't be cheaply region-blanked) and to wire
304
+ * `maskedRegions` into the keyframe capturer so the real pixels of masked
305
+ * content are blanked on every keyframe. Replay shows the baked redaction
306
+ * (recolored shapes / asterisks / bars) from the command stream.
307
+ */
308
+ hasMaskedDraws = false;
309
+ /**
310
+ * Resolved redaction style for masked ops — how masked TEXT (asterisks/bar) and
311
+ * SHAPES (color/block/remove) are baked into the command stream. Set by the
312
+ * manager from the global `maskCanvasText` config, and overridden per-block by
313
+ * `maskCanvasDraws(ctx, fn, opts)`.
314
+ */
315
+ redactStyle = DEFAULT_REDACT_STYLE;
316
+ /**
317
+ * Per-block "text only" flag (maskCanvasDraws `keepShapes:true`). While set
318
+ * inside a mask block, only TEXT ops are redacted; shapes/geometry are recorded
319
+ * normally. Default false (mask everything in the block).
320
+ */
321
+ textOnlyMask = false;
322
+ /**
323
+ * Accumulated device-space (canvas-pixel) bounding boxes of masked content
324
+ * since the last full-canvas clear. The keyframe capturer fills each with
325
+ * REDACTION_COLOR before encoding so the real masked pixels never ship. Bounded
326
+ * (oldest evicted past the cap) so an incremental canvas that never clears
327
+ * cannot grow it without limit.
328
+ */
329
+ maskedRegions = [];
330
+ /**
331
+ * Set when a masked op's keyframe-blank region CANNOT be computed (a degenerate
332
+ * transform, or geometry with no trackable bounds such as `ctx.fill(path2d)`).
333
+ * The redaction is already baked into the command stream, so to avoid leaking
334
+ * the real pixels the manager polls this and SUPPRESSES pixel keyframes for the
335
+ * canvas (command-only replay) — safer than blanking the whole canvas on every
336
+ * keyframe. [audit C-NEW-1 / C2 / M-NEW-2]
337
+ */
338
+ keyframeUnsafe = false;
339
+ /** Running LOCAL-space (pre-transform) path bounds for the in-progress path,
340
+ * used to size the masked-region bbox + the synthetic block fillRect for
341
+ * `fill`/`stroke` inside a mask block. Reset on beginPath. */
342
+ maskPathBounds = null;
343
+ takePendingSiteId() {
344
+ const id = this.pendingSiteId;
345
+ this.pendingSiteId = undefined;
346
+ return id;
347
+ }
348
+ recordCall(op, args) {
349
+ // maskCanvasDraws: every op issued inside a runtime mask region is REDACTED
350
+ // per the resolved redactStyle (shapes recolored / blocked / removed, text
351
+ // asterisked / barred) and its bbox tracked for keyframe region-blanking —
352
+ // the sensitive original never enters the command stream. Checked FIRST so it
353
+ // also covers video-demoted canvases (which a masked canvas never becomes).
354
+ if (this.maskDepth > 0) {
355
+ this.maskCall(op, args);
356
+ this.pendingSiteId = undefined;
357
+ return;
358
+ }
359
+ // Video-mode dirtiness only: zero serialize/encode cost.
360
+ if (this.sink instanceof DiscardSink) {
361
+ this.sink.onOp(DIRTY_OP);
362
+ this.pendingSiteId = undefined;
363
+ return;
364
+ }
365
+ // A clearRect wipes pixels in its rect — drop masked regions it fully covers
366
+ // so the next keyframe doesn't blank now-empty areas (handles full, sub-rect,
367
+ // and translated/rotated clears).
368
+ if (op === "clearRect" && this.maskedRegions.length > 0)
369
+ this.clipRegionsToClear(args);
370
+ const t0 = performance.now();
371
+ const t = this.now();
372
+ // A13: redact the rendered text of fillText/strokeText before it is
373
+ // serialized. Guarded so the common (non-text, or no-redactor) path is
374
+ // untouched: only allocates a replacement args array when we actually mask a
375
+ // string. The first arg of fillText/strokeText is the text; args 2/3 are
376
+ // x/y coords and pass through untouched.
377
+ if (this.textRedactor !== undefined && (op === "fillText" || op === "strokeText")) {
378
+ const first = args[0];
379
+ if (typeof first === "string") {
380
+ const masked = this.textRedactor(first);
381
+ if (masked !== first) {
382
+ // Track the ORIGINAL text's bbox so the keyframe blanks its real pixels
383
+ // (the command stream draws asterisks / a bar on top of the blank).
384
+ this.pushMaskRegion(this.textRegionFor(first, args));
385
+ if (this.redactStyle.text === "bar") {
386
+ // bar: drop the glyphs entirely, paint a redaction bar over the box.
387
+ this.emitRedactionBlock(this.textLocalRect(first, args));
388
+ this.cpuMs += performance.now() - t0;
389
+ return;
390
+ }
391
+ args = args.slice();
392
+ args[0] = masked;
393
+ }
394
+ }
395
+ }
396
+ if (op === "save") {
397
+ // A34: the saved frame needs an INDEPENDENT props snapshot because the
398
+ // live `this.props` keeps mutating after the save — this copy is required.
399
+ this.stack.push({ props: { ...this.props }, transform: this.readTransform() });
400
+ }
401
+ else if (op === "restore") {
402
+ // A34: the popped frame is discarded here, so ADOPT its props object as
403
+ // the new live props instead of spreading a copy ({ ...frame.props }).
404
+ // Nothing else references the popped frame, so future props mutations
405
+ // cannot corrupt any stack entry. Saves one object allocation per
406
+ // restore with byte-identical recorded output.
407
+ const frame = this.stack.pop();
408
+ if (frame)
409
+ this.props = frame.props;
410
+ }
411
+ const sfId = this.takePendingSiteId();
412
+ // A34: serialize via a plain loop (serializeArgs) rather than
413
+ // args.map(arrow) — .map allocates a fresh closure object on EVERY recorded
414
+ // call (the hot path runs per draw command). The loop also fast-paths
415
+ // all-primitive arg lists (the common case: fillRect/lineTo/transform).
416
+ const rec = { t, op, args: this.serializeArgs(args, t) };
417
+ if (sfId)
418
+ rec.sfId = sfId;
419
+ this.sink.onOp(rec);
420
+ this.cpuMs += performance.now() - t0;
421
+ }
422
+ /**
423
+ * A34 — hot-path arg serialization. A plain loop instead of
424
+ * `args.map((a) => this.serializeArg(a, t))` to avoid allocating a new arrow
425
+ * closure on every recorded call. Primitives (number / string / boolean /
426
+ * null / undefined) are handled inline so the common all-primitive draw call
427
+ * (fillRect, lineTo, setTransform…) never enters the slower object-dispatch
428
+ * branch of serializeArg. Produces an array byte-identical to the old .map
429
+ * output for the codec — verified by canvasHotPathAlloc.test.ts. The output
430
+ * array itself is freshly allocated (the BufferSink / worker paths retain it,
431
+ * so it cannot be pooled), but the per-call closure garbage is eliminated.
432
+ */
433
+ serializeArgs(args, t) {
434
+ const n = args.length;
435
+ const out = new Array(n);
436
+ for (let i = 0; i < n; i++) {
437
+ const a = args[i];
438
+ switch (typeof a) {
439
+ case "number":
440
+ out[i] = Number.isFinite(a) ? a : 0;
441
+ break;
442
+ case "string":
443
+ out[i] = a;
444
+ break;
445
+ case "boolean":
446
+ out[i] = a;
447
+ break;
448
+ case "undefined":
449
+ out[i] = null;
450
+ break;
451
+ default: out[i] = this.serializeArg(a, t); // null + objects
452
+ }
453
+ }
454
+ return out;
455
+ }
456
+ recordSet(prop, value) {
457
+ // maskCanvasDraws: inside a mask region, drop state setters — masked shapes are
458
+ // recolored to REDACTION_COLOR at paint time (maskCall), so the app's real
459
+ // fill/stroke styles must not reach the stream. (this.props intentionally
460
+ // retains the PRE-block value so post-block draws re-sync.) EXCEPTION: a
461
+ // keepShapes block (textOnlyMask) records shapes — and their styles — normally;
462
+ // only its text is redacted.
463
+ if (this.maskDepth > 0 && !this.textOnlyMask) {
464
+ this.hasMaskedDraws = true;
465
+ this.pendingSiteId = undefined;
466
+ return;
467
+ }
468
+ if (this.maskDepth > 0)
469
+ this.hasMaskedDraws = true; // keepShapes: flag but record
470
+ if (this.sink instanceof DiscardSink) {
471
+ this.sink.onOp(DIRTY_OP);
472
+ this.pendingSiteId = undefined;
473
+ return;
474
+ }
475
+ const t0 = performance.now();
476
+ const t = this.now();
477
+ const v = this.serializeArg(value, t);
478
+ this.props[prop] = v;
479
+ const sfId = this.takePendingSiteId();
480
+ const rec = { t, op: prop, args: [v], setter: true };
481
+ if (sfId)
482
+ rec.sfId = sfId;
483
+ this.sink.onOp(rec);
484
+ this.cpuMs += performance.now() - t0;
485
+ }
486
+ // -- maskCanvasDraws redaction baking -------------------------------------
487
+ //
488
+ // Inside a mask block (maskDepth > 0) every op is rewritten per `redactStyle`
489
+ // and its device-space bbox accumulated into `maskedRegions` so the keyframe
490
+ // capturer can blank the real pixels. Nothing here ever touches the live
491
+ // canvas — only the recorded command stream + the region list.
492
+ /** Redact one op issued inside a mask block. */
493
+ maskCall(op, args) {
494
+ this.hasMaskedDraws = true;
495
+ if (this.sink instanceof DiscardSink) {
496
+ this.sink.onOp(DIRTY_OP);
497
+ return;
498
+ }
499
+ const style = this.redactStyle;
500
+ const isText = op === "fillText" || op === "strokeText";
501
+ // keepShapes: only text is redacted; shapes/geometry recorded normally
502
+ // (recordPassthrough already records transforms + maintains the save/restore
503
+ // shadow stack, so those are covered there).
504
+ if (this.textOnlyMask && !isText) {
505
+ this.recordPassthrough(op, args);
506
+ return;
507
+ }
508
+ // [audit M-NEW-3] Transform + save/restore ops carry no sensitive pixels but
509
+ // MUST be recorded inside a (full) mask block: the recolored / replacement
510
+ // shapes are emitted in LOCAL space, so replay needs the same CTM to land
511
+ // them where the keyframe-blank region (computed under the LIVE CTM) is.
512
+ // recordPassthrough also keeps `this.stack` in sync so a keyframe taken mid
513
+ // mask-block snapshots the correct save-stack depth + per-frame transform.
514
+ if (op === "save" || op === "restore" || TRANSFORM_OPS.has(op)) {
515
+ this.recordPassthrough(op, args);
516
+ return;
517
+ }
518
+ if (isText) {
519
+ const first = args[0];
520
+ if (typeof first !== "string")
521
+ return;
522
+ this.pushMaskRegion(this.textRegionFor(first, args));
523
+ if (style.text === "asterisks") {
524
+ const a = args.slice();
525
+ a[0] = maskAllText(first);
526
+ this.emitSynthCall(op, a);
527
+ }
528
+ else {
529
+ // bar: drop the glyphs, paint a redaction bar over the text box.
530
+ this.emitRedactionBlock(this.textLocalRect(first, args));
531
+ }
532
+ return;
533
+ }
534
+ if (PATH_BUILD_OPS.has(op)) {
535
+ this.updatePathBounds(op, args);
536
+ // 'color' keeps the geometry (recolored at paint time); 'block'/'remove'
537
+ // drop it (the block fillRect / nothing replaces it).
538
+ if (style.shape === "color")
539
+ this.emitSynthCall(op, args);
540
+ return;
541
+ }
542
+ if (PAINT_OPS.has(op)) {
543
+ this.pushMaskRegion(this.shapeRegion(op, args));
544
+ const drop = style.shape === "remove" || op === "putImageData";
545
+ if (!drop) {
546
+ if (style.shape === "color" &&
547
+ (op === "fill" || op === "stroke" || op === "fillRect" || op === "strokeRect")) {
548
+ // Recolor the SAME geometry: bracket the real paint in a redaction
549
+ // color via save/restore so prop state is untouched afterward.
550
+ const colorProp = op === "stroke" || op === "strokeRect" ? "strokeStyle" : "fillStyle";
551
+ this.emitSynthCall("save", []);
552
+ this.emitSynthSetter(colorProp, REDACTION_COLOR);
553
+ this.emitSynthCall(op, op === "fill" || op === "stroke" ? [] : args);
554
+ this.emitSynthCall("restore", []);
555
+ }
556
+ else {
557
+ // 'block' (and 'color' fallback for images): one redaction block.
558
+ this.emitRedactionBlock(this.shapeLocalRect(op, args));
559
+ }
560
+ }
561
+ if (op === "fill" || op === "stroke")
562
+ this.maskPathBounds = null;
563
+ return;
564
+ }
565
+ // Any other op inside a mask block (clip, setLineDash, clearRect, …) — drop.
566
+ }
567
+ /** Record a shape op normally from inside a keepShapes block (text-only mask).
568
+ * A trimmed recordCall tail: maintains the save/restore shadow stack so later
569
+ * keyframe state snapshots stay correct. */
570
+ recordPassthrough(op, args) {
571
+ if (this.sink instanceof DiscardSink) {
572
+ this.sink.onOp(DIRTY_OP);
573
+ return;
574
+ }
575
+ const t = this.now();
576
+ if (op === "save") {
577
+ this.stack.push({ props: { ...this.props }, transform: this.readTransform() });
578
+ }
579
+ else if (op === "restore") {
580
+ const f = this.stack.pop();
581
+ if (f)
582
+ this.props = f.props;
583
+ }
584
+ this.sink.onOp({ t, op, args: this.serializeArgs(args, t) });
585
+ }
586
+ /** Push a non-empty masked region. Past the cap, COALESCE to the union bbox
587
+ * rather than evicting the oldest — dropping a region would leave its real
588
+ * (masked) pixels un-blanked on the kept keyframe (a leak). Coalescing
589
+ * over-blanks within the union but never under-blanks (privacy > fidelity for
590
+ * a pathological canvas that masks >cap draws without ever clearing). */
591
+ pushRegion(r) {
592
+ if (!r || !(r.w > 0 && r.h > 0))
593
+ return;
594
+ this.maskedRegions.push(r);
595
+ if (this.maskedRegions.length > MAX_MASK_REGIONS)
596
+ this.coalesceRegions();
597
+ }
598
+ /**
599
+ * Bound the region list past the cap WITHOUT dropping coverage (dropping would
600
+ * leak) and WITHOUT a one-way ratchet to whole-canvas (a single growing union
601
+ * could never be cleared by a partial clearRect — audit C-NEW-2). Snap regions
602
+ * to a coarse GRID of tiles: bounded count, full coverage, and a later clear can
603
+ * still drop individual tiles (clipRegionsToClear). Falls back to a single union
604
+ * only when the canvas size is unknown (worker/synthetic ctx).
605
+ */
606
+ coalesceRegions() {
607
+ const cs = this.canvasSize();
608
+ if (!cs) {
609
+ const u = unionRegion(this.maskedRegions);
610
+ this.maskedRegions.length = 0;
611
+ if (u)
612
+ this.maskedRegions.push(u);
613
+ return;
614
+ }
615
+ // [audit H3] TILE must be ADAPTIVE: a fixed 64px on an 8192×4096 canvas grids
616
+ // to 128×64 = 8192 tiles > MAX_MASK_REGIONS, leaving the list permanently
617
+ // over-cap (re-coalescing forever, unbounded above). Grow the tile size until
618
+ // the full-canvas grid fits the cap, so cols*rows ≤ MAX_MASK_REGIONS for ANY
619
+ // canvas size (privacy unchanged — coarser tiles still over-blank, never
620
+ // under-blank; a later clearRect can still drop whole tiles).
621
+ let TILE = 64;
622
+ const fits = () => Math.ceil(cs.w / TILE) * Math.ceil(cs.h / TILE) <= MAX_MASK_REGIONS;
623
+ while (!fits())
624
+ TILE *= 2;
625
+ // Clamp every tile index to the IN-BOUNDS grid: a region's span is clamped to
626
+ // [0, maxCol] / [0, maxRow] so out-of-bounds masked geometry (a draw partly
627
+ // off-canvas, or a tiny <TILE canvas — audit H4) snaps onto an in-bounds tile
628
+ // instead of minting an un-droppable tile no clearRect can ever hit.
629
+ const cols = Math.max(1, Math.ceil(cs.w / TILE));
630
+ const maxCol = cols - 1;
631
+ const maxRow = Math.max(0, Math.ceil(cs.h / TILE) - 1);
632
+ const tiles = new Map();
633
+ for (const reg of this.maskedRegions) {
634
+ // c1/r1 use the LAST touched pixel (x+w-1), not x+w: a region exactly
635
+ // filling tile col k spans device px [k*TILE, (k+1)*TILE), whose last pixel
636
+ // is (k+1)*TILE-1 → col k. Using floor((x+w)/TILE) claimed col k+1 too
637
+ // (a boundary double-count) which (a) over-blanked an adjacent untouched
638
+ // tile and (b) broke idempotency — re-coalescing a grid of tiles GREW the
639
+ // set. The -1 makes coalesce a fixed point on already-tiled input. [audit H3]
640
+ const c0 = Math.min(maxCol, Math.max(0, Math.floor(reg.x / TILE)));
641
+ const c1 = Math.min(maxCol, Math.max(c0, Math.floor((reg.x + reg.w - 1) / TILE)));
642
+ const r0 = Math.min(maxRow, Math.max(0, Math.floor(reg.y / TILE)));
643
+ const r1 = Math.min(maxRow, Math.max(r0, Math.floor((reg.y + reg.h - 1) / TILE)));
644
+ for (let cy = r0; cy <= r1; cy++) {
645
+ for (let cx = c0; cx <= c1; cx++) {
646
+ const key = cy * cols + cx;
647
+ if (!tiles.has(key)) {
648
+ const tx = cx * TILE, ty = cy * TILE;
649
+ // Clamp the tile's far edge to the canvas so an edge tile on a small
650
+ // (or non-multiple-of-TILE) canvas never extends PAST the canvas —
651
+ // otherwise a full-canvas clearRect (≤ canvas size) couldn't fully
652
+ // contain it and it would be an un-droppable permanent over-blank
653
+ // (audit H4). A clamped tile is still tile-aligned at its origin.
654
+ tiles.set(key, { x: tx, y: ty, w: Math.min(TILE, cs.w - tx), h: Math.min(TILE, cs.h - ty) });
655
+ }
656
+ }
657
+ }
658
+ }
659
+ this.maskedRegions.length = 0;
660
+ for (const t of tiles.values())
661
+ this.maskedRegions.push(t);
662
+ }
663
+ /** Push the keyframe-blank region for a MASKED op. A redaction was already
664
+ * baked into the command stream, so the pixels MUST be blanked — if the bbox
665
+ * couldn't be computed (degenerate transform, or unbounded geometry like
666
+ * `ctx.fill(path2d)` / a zero-area paint), flag the canvas keyframe-UNSAFE so
667
+ * the manager suppresses pixel keyframes (command-only replay) rather than
668
+ * blanking the whole canvas on every keyframe. [audit C-NEW-1 / M-NEW-2] */
669
+ pushMaskRegion(r) {
670
+ if (r && r.w > 0 && r.h > 0) {
671
+ this.pushRegion(r);
672
+ return;
673
+ }
674
+ this.keyframeUnsafe = true;
675
+ }
676
+ /** Clear accumulated masked regions — called by the manager when the canvas is
677
+ * resized (a `canvas.width`/`height` assignment, or a `reset()`, wipes all
678
+ * pixels, so the prior regions are stale and would over-blank fresh content).
679
+ *
680
+ * [audit H1 / Round 2] Also clears `keyframeUnsafe`. A full-canvas clear/resize
681
+ * wipes whatever unboundable masked content tripped the flag, so the canvas is
682
+ * safe to keyframe again. Without this, a canvas that hit ONE degenerate-bbox
683
+ * mask op and was THEN resized still suppressed pixel keyframes for life. NOTE:
684
+ * this is only effective when the resize lands BEFORE the pump reads the flag —
685
+ * once `suppressPixelCapture` latches `ctl.pixelSuppressed` (tearing down the
686
+ * keyframe capturer + any video encoder), there is no cheap resurrection, so an
687
+ * already-suppressed controller stays suppressed by design (a deliberate
688
+ * one-way safety tradeoff — resurrecting mid-session risks shipping the very
689
+ * pixels we suppressed). Resetting the flag here keeps the recorder-side state
690
+ * honest and avoids a needless suppression for the pre-pump resize race. */
691
+ resetMaskedRegions() {
692
+ this.maskedRegions.length = 0;
693
+ this.maskPathBounds = null;
694
+ this.keyframeUnsafe = false;
695
+ }
696
+ /** A clearRect wiped the pixels in its (device-space) rect — drop every masked
697
+ * region FULLY CONTAINED by it so the next keyframe doesn't blank now-empty
698
+ * areas. Works for a full-canvas clear (all regions inside → all dropped, i.e.
699
+ * a reset), a sub-rect clear, AND a TRANSLATED/SCALED clear (the cleared rect is
700
+ * wherever it actually landed in device space). A region that only PARTIALLY
701
+ * overlaps the cleared rect is KEPT — its still-painted part must stay blanked
702
+ * (over-blank the cleared part; never leak). [audit M1/M2]
703
+ *
704
+ * [audit H5 / Round 2] Containment is only sound when the cleared rect is
705
+ * AXIS-ALIGNED in device space. Under a ROTATED/SKEWED CTM, clearRect clears a
706
+ * PARALLELOGRAM, but `transformedBox` gives its AABB — which is strictly LARGER
707
+ * than the true cleared area. A masked region tucked into an AABB corner that
708
+ * the rotated clear never actually touched would be (wrongly) judged contained
709
+ * and DROPPED → its still-painted pixels leak on the kept keyframe. The clear's
710
+ * AABB over-claiming coverage is the dangerous direction (dropping a region is
711
+ * irreversible), so for a non-axis-aligned CTM we DROP NOTHING — the regions
712
+ * stay and over-blank the (now-cleared) area on the next keyframe, which is
713
+ * privacy-safe. A pure translate/scale/flip (b==0 && c==0, or a==0 && d==0)
714
+ * keeps the exact-rect guarantee and still clips. */
715
+ clipRegionsToClear(args) {
716
+ if (this.maskedRegions.length === 0)
717
+ return;
718
+ const m = this.getMat();
719
+ if (!m)
720
+ return;
721
+ // Axis-aligned device coverage iff the CTM has no rotation/skew: either
722
+ // (b==0,c==0) — translate/scale/flip — or (a==0,d==0) — an exact 90° rotation
723
+ // mapping the rect to another axis-aligned rect. Anything else is a
724
+ // parallelogram whose AABB over-claims → unsafe to use for dropping.
725
+ const axisAligned = (m.b === 0 && m.c === 0) || (m.a === 0 && m.d === 0);
726
+ if (!axisAligned)
727
+ return; // over-blank rather than risk a leak
728
+ const n = (v) => Number(v) || 0;
729
+ const x = n(args[0]), y = n(args[1]);
730
+ // The cleared rect must use the clear's TRUE device coverage (pad 0). Using
731
+ // the +1px-padded rect would over-claim coverage and drop a masked region
732
+ // whose 1px antialiased halo is NOT actually cleared → a sliver leak. [M-NEW-1]
733
+ const cleared = transformedBox(x, y, x + n(args[2]), y + n(args[3]), m, 0);
734
+ if (!cleared)
735
+ return;
736
+ const cx1 = cleared.x + cleared.w, cy1 = cleared.y + cleared.h;
737
+ const kept = this.maskedRegions.filter((r) => !(r.x >= cleared.x && r.y >= cleared.y && r.x + r.w <= cx1 && r.y + r.h <= cy1));
738
+ if (kept.length !== this.maskedRegions.length) {
739
+ this.maskedRegions.length = 0;
740
+ for (const r of kept)
741
+ this.maskedRegions.push(r);
742
+ }
743
+ }
744
+ /** Emit a synthetic recorded method call (bypasses the maskDepth gate). */
745
+ emitSynthCall(op, args) {
746
+ const t = this.now();
747
+ this.sink.onOp({ t, op, args: this.serializeArgs(args, t) });
748
+ }
749
+ /** Emit a synthetic recorded property set (a color override). */
750
+ emitSynthSetter(prop, value) {
751
+ const t = this.now();
752
+ this.sink.onOp({ t, op: prop, args: [value], setter: true });
753
+ }
754
+ /** Emit a redaction block (save → fillStyle=REDACTION_COLOR → fillRect →
755
+ * restore) over a LOCAL-space rect; replay applies the current transform. */
756
+ emitRedactionBlock(rect) {
757
+ if (!rect || rect.w === 0 || rect.h === 0)
758
+ return;
759
+ this.emitSynthCall("save", []);
760
+ this.emitSynthSetter("fillStyle", REDACTION_COLOR);
761
+ this.emitSynthCall("fillRect", [rect.x, rect.y, rect.w, rect.h]);
762
+ this.emitSynthCall("restore", []);
763
+ }
764
+ /** Update the running LOCAL (user-space) path bounds for a path-build op. */
765
+ updatePathBounds(op, args) {
766
+ if (op === "beginPath") {
767
+ this.maskPathBounds = null;
768
+ return;
769
+ }
770
+ const n = (v) => Number(v) || 0;
771
+ const ext = (x, y) => {
772
+ if (!Number.isFinite(x) || !Number.isFinite(y))
773
+ return;
774
+ const b = this.maskPathBounds ?? { minX: x, minY: y, maxX: x, maxY: y };
775
+ b.minX = Math.min(b.minX, x);
776
+ b.minY = Math.min(b.minY, y);
777
+ b.maxX = Math.max(b.maxX, x);
778
+ b.maxY = Math.max(b.maxY, y);
779
+ this.maskPathBounds = b;
780
+ };
781
+ switch (op) {
782
+ case "moveTo":
783
+ case "lineTo":
784
+ ext(n(args[0]), n(args[1]));
785
+ break;
786
+ case "rect":
787
+ case "roundRect":
788
+ ext(n(args[0]), n(args[1]));
789
+ ext(n(args[0]) + n(args[2]), n(args[1]) + n(args[3]));
790
+ break;
791
+ case "arc": {
792
+ const r = n(args[2]);
793
+ ext(n(args[0]) - r, n(args[1]) - r);
794
+ ext(n(args[0]) + r, n(args[1]) + r);
795
+ break;
796
+ }
797
+ case "ellipse": {
798
+ const rx = n(args[2]), ry = n(args[3]);
799
+ ext(n(args[0]) - rx, n(args[1]) - ry);
800
+ ext(n(args[0]) + rx, n(args[1]) + ry);
801
+ break;
802
+ }
803
+ case "arcTo":
804
+ case "quadraticCurveTo":
805
+ ext(n(args[0]), n(args[1]));
806
+ ext(n(args[2]), n(args[3]));
807
+ break;
808
+ case "bezierCurveTo":
809
+ ext(n(args[0]), n(args[1]));
810
+ ext(n(args[2]), n(args[3]));
811
+ ext(n(args[4]), n(args[5]));
812
+ break;
813
+ }
814
+ }
815
+ /** LOCAL (user-space) rect of a paint op — for the block fillRect. */
816
+ shapeLocalRect(op, args) {
817
+ const n = (v) => Number(v) || 0;
818
+ if (op === "fillRect" || op === "strokeRect")
819
+ return { x: n(args[0]), y: n(args[1]), w: n(args[2]), h: n(args[3]) };
820
+ if (op === "fill" || op === "stroke") {
821
+ const b = this.maskPathBounds;
822
+ return b ? { x: b.minX, y: b.minY, w: b.maxX - b.minX, h: b.maxY - b.minY } : null;
823
+ }
824
+ if (op === "drawImage")
825
+ return this.drawImageDest(args);
826
+ return null;
827
+ }
828
+ /** DEVICE-space region of a paint op (its accumulated keyframe-blank bbox). */
829
+ shapeRegion(op, args) {
830
+ const n = (v) => Number(v) || 0;
831
+ if (op === "putImageData") {
832
+ // putImageData ignores the CTM — its dest is already device-space.
833
+ const id = args[0];
834
+ const w = n(id?.width), h = n(id?.height);
835
+ if (!(w > 0 && h > 0))
836
+ return null;
837
+ return { x: n(args[1]), y: n(args[2]), w, h };
838
+ }
839
+ const m = this.getMat();
840
+ const local = this.shapeLocalRect(op, args);
841
+ if (!m || !local)
842
+ return null;
843
+ return rectRegion(local.x, local.y, local.w, local.h, m);
844
+ }
845
+ /** Dest rect (user space) of a drawImage in any of its 3/5/9-arg forms. */
846
+ drawImageDest(args) {
847
+ const n = (v) => Number(v) || 0;
848
+ if (args.length >= 9)
849
+ return { x: n(args[5]), y: n(args[6]), w: n(args[7]), h: n(args[8]) };
850
+ if (args.length >= 5)
851
+ return { x: n(args[1]), y: n(args[2]), w: n(args[3]), h: n(args[4]) };
852
+ if (args.length >= 3) {
853
+ const img = args[0];
854
+ return { x: n(args[1]), y: n(args[2]), w: n(img?.width), h: n(img?.height) };
855
+ }
856
+ return null;
857
+ }
858
+ /** Device-space region of a drawn-text arg (the keyframe-blank bbox). The box
859
+ * comes from the glyph ADVANCE width, so it's padded proportionally to the font
860
+ * size AND the transform's scale to cover italic / emoji ink overhang in device
861
+ * space (under-covering would leak a glyph sliver on the kept keyframe). */
862
+ textRegionFor(text, args) {
863
+ const m = this.getMat();
864
+ if (!m)
865
+ return null;
866
+ const scale = Math.max(Math.hypot(m.a, m.b), Math.hypot(m.c, m.d)) || 1;
867
+ // Proportional to font size + transform scale (to cover ink overhang), but
868
+ // capped so a pathological `font:"999999px"` can't produce an absurd region.
869
+ // [audit H8] The cap is font-relative, not a flat 256px: a legit large font
870
+ // (e.g. 2000px under 2x scale → 4000px device em) can overhang its advance box
871
+ // by well over 256px on emoji / script glyphs, so a flat cap would under-blank
872
+ // and leak a glyph edge. We allow up to ~half the scaled em (covers realistic
873
+ // overhang) with a 256px floor, while the `fontSizePx()*0.15*scale` base keeps
874
+ // the common case tight. The absolute ceiling stays bounded by the em size, so
875
+ // a `999999px` font still can't blow past ~its own size (clipped to canvas on
876
+ // fill anyway). Privacy: over-covering is always safe; under-covering leaks.
877
+ const fs = this.fontSizePx();
878
+ const cap = Math.max(256, fs * 0.5 * scale);
879
+ const pad = Math.min(Math.max(2, fs * 0.15 * scale), cap);
880
+ return textRegion(this.textMetrics(text, args), m, pad);
881
+ }
882
+ /** LOCAL-space rect of a drawn-text arg (for a bar / block). */
883
+ textLocalRect(text, args) {
884
+ const b = textLocalBox(this.textMetrics(text, args));
885
+ return { x: b.x0, y: b.y0, w: b.x1 - b.x0, h: b.y1 - b.y0 };
886
+ }
887
+ /** Measure a drawn-text arg (width/ascent/descent + align/baseline), falling
888
+ * back to a font-size approximation when the context has no measureText (worker
889
+ * OffscreenCanvas always does; the test fake ctx does not). */
890
+ textMetrics(text, args) {
891
+ const x = Number(args[1]) || 0, y = Number(args[2]) || 0;
892
+ const ctx = this.ctx;
893
+ let width = NaN, ascent = NaN, descent = NaN;
894
+ let align, baseline;
895
+ if (typeof ctx.measureText === "function") {
896
+ try {
897
+ const mt = ctx.measureText(text);
898
+ width = mt.width;
899
+ ascent = mt.actualBoundingBoxAscent;
900
+ descent = mt.actualBoundingBoxDescent;
901
+ align = ctx.textAlign;
902
+ baseline = ctx.textBaseline;
903
+ }
904
+ catch { /* fall through to approximation */ }
905
+ }
906
+ const fs = this.fontSizePx();
907
+ if (!Number.isFinite(width))
908
+ width = text.length * fs * 0.6;
909
+ if (!Number.isFinite(ascent) || ascent <= 0)
910
+ ascent = fs * 0.8;
911
+ if (!Number.isFinite(descent) || descent < 0)
912
+ descent = fs * 0.2;
913
+ if (align === undefined && typeof this.props.textAlign === "string")
914
+ align = this.props.textAlign;
915
+ if (baseline === undefined && typeof this.props.textBaseline === "string")
916
+ baseline = this.props.textBaseline;
917
+ return { width, ascent, descent, x, y, align, baseline };
918
+ }
919
+ /** Parse the px size from the recorded `font` prop (fallback 12). */
920
+ fontSizePx() {
921
+ const f = this.props.font;
922
+ if (typeof f === "string") {
923
+ const mm = f.match(/(\d+(?:\.\d+)?)px/);
924
+ if (mm)
925
+ return parseFloat(mm[1]);
926
+ }
927
+ return 12;
928
+ }
929
+ /** The canvas backing the context (null for the test fake / detached ctx). */
930
+ canvasSize() {
931
+ const c = this.ctx.canvas;
932
+ if (c && Number(c.width) > 0 && Number(c.height) > 0)
933
+ return { w: Number(c.width), h: Number(c.height) };
934
+ return null;
935
+ }
936
+ /** Current transform as a plain Mat2D (null when getTransform throws). */
937
+ getMat() {
938
+ try {
939
+ const m = this.ctx.getTransform();
940
+ return { a: m.a, b: m.b, c: m.c, d: m.d, e: m.e, f: m.f };
941
+ }
942
+ catch {
943
+ return null;
944
+ }
945
+ }
946
+ /** per-recorder emission state for cross-context objects */
947
+ objRefs = new WeakMap();
948
+ // -- keyframe support -----------------------------------------------------
949
+ snapshotState() {
950
+ return {
951
+ stack: this.stack.map((f) => ({ props: { ...f.props }, transform: [...f.transform] })),
952
+ current: { props: { ...this.props }, transform: this.readTransform() },
953
+ };
954
+ }
955
+ async settle() {
956
+ // [audit B12] Loop-drain so an encode registered during the await is also
957
+ // awaited (teardown detaches before settle, so in practice one pass suffices).
958
+ while (this.pendingEncodes.size)
959
+ await Promise.all([...this.pendingEncodes]);
960
+ }
961
+ /** [audit B6] In-flight encode count — exported for tests to assert the set
962
+ * stays bounded (auto-removes on completion) rather than growing per frame. */
963
+ pendingEncodeCount() {
964
+ return this.pendingEncodes.size;
965
+ }
966
+ /** [audit B6] Register an in-flight encode so settle() awaits it, and AUTO-REMOVE
967
+ * it on completion so `pendingEncodes` never grows unbounded over a long session
968
+ * of per-frame dynamic-source captures. The internal then/finally swallow keeps
969
+ * the tracking from raising an unhandled rejection (settle still observes the
970
+ * original promise's outcome via the snapshot). */
971
+ trackEncode(p) {
972
+ this.pendingEncodes.add(p);
973
+ p.then(() => { }, () => { }).finally(() => this.pendingEncodes.delete(p));
974
+ }
975
+ readTransform() {
976
+ const m = this.ctx.getTransform();
977
+ return [m.a, m.b, m.c, m.d, m.e, m.f];
978
+ }
979
+ // -- arg serialization ----------------------------------------------------
980
+ serializeArg(a, t) {
981
+ if (a === null)
982
+ return null;
983
+ switch (typeof a) {
984
+ case "number": return Number.isFinite(a) ? a : 0;
985
+ case "string": return a;
986
+ case "boolean": return a;
987
+ case "undefined": return null;
988
+ case "object": break;
989
+ default: return { $: "unsupported", kind: typeof a };
990
+ }
991
+ const obj = a;
992
+ if (objectLogs.has(obj))
993
+ return this.serializeFactoryObject(obj, t);
994
+ const NativePath2D = getNativePath2D();
995
+ if (NativePath2D && obj instanceof NativePath2D) {
996
+ this.sink.note("Path2D created before instrumentation — not serialized");
997
+ return { $: "unsupported", kind: "Path2D" };
998
+ }
999
+ if (Array.isArray(obj))
1000
+ return { $: "arr", v: obj.map((n) => Number(n) || 0) };
1001
+ if (obj instanceof DOMMatrix || obj instanceof DOMMatrixReadOnly) {
1002
+ return { $: "arr", v: [obj.a, obj.b, obj.c, obj.d, obj.e, obj.f] };
1003
+ }
1004
+ if (obj instanceof ImageData)
1005
+ return this.captureImageData(obj, t);
1006
+ if (isImageSource(obj))
1007
+ return this.captureImageSource(obj, t);
1008
+ return { $: "unsupported", kind: obj.constructor?.name ?? "object" };
1009
+ }
1010
+ /**
1011
+ * Factory objects (gradients/patterns/paths) emit lazily into THIS stream
1012
+ * on first use — a __create plus logged method calls; later uses emit only
1013
+ * newly appended calls. Cross-context safe: every stream that references
1014
+ * the object re-creates it locally under the same global id.
1015
+ */
1016
+ serializeFactoryObject(obj, t) {
1017
+ const log = objectLogs.get(obj);
1018
+ let ref = this.objRefs.get(obj);
1019
+ if (!ref) {
1020
+ ref = { id: nextObjId++, emittedCalls: 0 };
1021
+ this.objRefs.set(obj, ref);
1022
+ this.sink.onOp({
1023
+ t, op: "__create", create: true,
1024
+ args: [log.factory, ref.id, ...log.args.map((x) => this.serializeArg(x, t))],
1025
+ });
1026
+ }
1027
+ for (let i = ref.emittedCalls; i < log.calls.length; i++) {
1028
+ const c = log.calls[i];
1029
+ this.sink.onOp({
1030
+ t, op: "__objcall", objcall: true,
1031
+ args: [ref.id, c.method, ...c.args.map((x) => this.serializeArg(x, t))],
1032
+ });
1033
+ }
1034
+ ref.emittedCalls = log.calls.length;
1035
+ return { $: "obj", id: ref.id };
1036
+ }
1037
+ captureImageData(d, t) {
1038
+ const id = nextAssetId++;
1039
+ // copy now — caller may mutate after the call (rrweb's deferred-serialize bug)
1040
+ const copy = new ImageData(new Uint8ClampedArray(d.data), d.width, d.height);
1041
+ const asset = { id, kind: "imagedata", t, bytes: 0, w: d.width, h: d.height };
1042
+ if (this.retainAssets)
1043
+ asset.imageData = copy;
1044
+ this.addAsset(id, asset);
1045
+ this.trackEncode(deflate(new Uint8Array(copy.data.buffer, copy.data.byteOffset, copy.data.byteLength))
1046
+ .then((z) => {
1047
+ asset.bytes = z.length;
1048
+ this.sink.onAssetBytes(id, z.length, t);
1049
+ this.sink.onAssetData?.({ id, format: "imagedata", w: d.width, h: d.height, t }, z);
1050
+ })
1051
+ .catch((e) => this.sink.note(`imagedata deflate failed: ${e.message}`)));
1052
+ return { $: "imagedata", id };
1053
+ }
1054
+ captureImageSource(src, t) {
1055
+ // Only truly static sources dedupe by identity (CAS). Videos AND canvases
1056
+ // (frameworks like Konva paint via internal buffer canvases) change
1057
+ // content between draws and must be captured per-draw.
1058
+ const isVideo = typeof HTMLVideoElement !== "undefined" && src instanceof HTMLVideoElement;
1059
+ const isDynamic = isVideo ||
1060
+ (typeof HTMLCanvasElement !== "undefined" && src instanceof HTMLCanvasElement) ||
1061
+ (typeof OffscreenCanvas !== "undefined" && src instanceof OffscreenCanvas);
1062
+ if (isVideo)
1063
+ this.sink.onVideoSource?.(t);
1064
+ if (!isDynamic && this.assetBySource.has(src)) {
1065
+ return { $: "asset", id: this.assetBySource.get(src) };
1066
+ }
1067
+ const id = nextAssetId++;
1068
+ if (!isDynamic)
1069
+ this.assetBySource.set(src, id);
1070
+ const asset = { id, kind: "image", t, bytes: 0 };
1071
+ this.addAsset(id, asset);
1072
+ if (this.assetMode === "dataurl") {
1073
+ // rrweb-style: synchronous main-thread readback + PNG dataURL.
1074
+ const t0 = performance.now();
1075
+ try {
1076
+ const { w, h } = sourceSize(src);
1077
+ const tmp = document.createElement("canvas");
1078
+ tmp.width = w;
1079
+ tmp.height = h;
1080
+ const tmpCtx = tmp.getContext("2d");
1081
+ ignoredContexts.add(tmpCtx);
1082
+ tmpCtx.drawImage(src, 0, 0);
1083
+ const url = tmp.toDataURL("image/png");
1084
+ asset.bytes = url.length;
1085
+ asset.w = w;
1086
+ asset.h = h;
1087
+ this.sink.onAssetBytes(id, url.length, t);
1088
+ if (this.retainAssets) {
1089
+ this.trackEncode(createImageBitmap(tmp).then((b) => { asset.bitmap = b; }));
1090
+ }
1091
+ }
1092
+ catch (e) {
1093
+ this.sink.note(`asset capture failed: ${e.message}`);
1094
+ if (isTaintError(e))
1095
+ this.sink.onTaint?.(t);
1096
+ }
1097
+ this.cpuMs += performance.now() - t0;
1098
+ }
1099
+ else {
1100
+ // CAS mode: async bitmap copy + worker webp encode (off main thread).
1101
+ const t0 = performance.now();
1102
+ this.trackEncode(createImageBitmap(src)
1103
+ .then(async (bitmap) => {
1104
+ const w = bitmap.width, h = bitmap.height;
1105
+ let encodeTarget = bitmap;
1106
+ if (this.retainAssets) {
1107
+ // encodeImage transfers its bitmap to the worker (detaching it),
1108
+ // so the retained/transfer copy must be a separate bitmap
1109
+ // (bench guardrail: replay-copy-before-encode).
1110
+ asset.bitmap = bitmap;
1111
+ encodeTarget = await createImageBitmap(bitmap);
1112
+ }
1113
+ const r = await this.encodeImage(encodeTarget);
1114
+ asset.bytes = r.size;
1115
+ asset.w = w;
1116
+ asset.h = h;
1117
+ this.sink.onAssetBytes(id, r.size, t);
1118
+ if (r.bytes) {
1119
+ this.sink.onAssetData?.({ id, format: "webp", w, h, t }, new Uint8Array(r.bytes));
1120
+ }
1121
+ })
1122
+ .catch((e) => {
1123
+ this.sink.note(`asset encode failed: ${e.message}`);
1124
+ // CAS mode: a cross-origin/tainted source makes createImageBitmap
1125
+ // (or the subsequent readback) reject with a SecurityError. Signal
1126
+ // taint so the player can render a placeholder.
1127
+ if (isTaintError(e))
1128
+ this.sink.onTaint?.(t);
1129
+ }));
1130
+ this.cpuMs += performance.now() - t0;
1131
+ }
1132
+ return { $: "asset", id };
1133
+ }
1134
+ }
1135
+ /** A cross-origin/tainted canvas readback throws a DOMException named
1136
+ * "SecurityError" (toDataURL / createImageBitmap on a tainted source). Match by
1137
+ * name (and message as a belt-and-braces fallback) so unrelated asset failures
1138
+ * — OOM, codec errors — do NOT get reported as taint. */
1139
+ export function isTaintError(e) {
1140
+ if (!e || typeof e !== "object")
1141
+ return false;
1142
+ const name = e.name;
1143
+ if (name === "SecurityError")
1144
+ return true;
1145
+ const msg = e.message;
1146
+ return typeof msg === "string" && /tainted|cross-origin|insecure/i.test(msg);
1147
+ }
1148
+ function isImageSource(o) {
1149
+ return ((typeof HTMLImageElement !== "undefined" && o instanceof HTMLImageElement) ||
1150
+ (typeof HTMLCanvasElement !== "undefined" && o instanceof HTMLCanvasElement) ||
1151
+ (typeof HTMLVideoElement !== "undefined" && o instanceof HTMLVideoElement) ||
1152
+ (typeof ImageBitmap !== "undefined" && o instanceof ImageBitmap) ||
1153
+ (typeof OffscreenCanvas !== "undefined" && o instanceof OffscreenCanvas));
1154
+ }
1155
+ function sourceSize(src) {
1156
+ if (src instanceof HTMLVideoElement)
1157
+ return { w: src.videoWidth || 1, h: src.videoHeight || 1 };
1158
+ if (src instanceof HTMLImageElement)
1159
+ return { w: src.naturalWidth || 1, h: src.naturalHeight || 1 };
1160
+ const anySrc = src;
1161
+ return { w: Number(anySrc.width) || 1, h: Number(anySrc.height) || 1 };
1162
+ }
1163
+ // ---------------------------------------------------------------------------
1164
+ // Global installation
1165
+ // ---------------------------------------------------------------------------
1166
+ let installed = false;
1167
+ /** Patch one 2D-context prototype (CanvasRenderingContext2D on the main
1168
+ * thread, OffscreenCanvasRenderingContext2D inside workers). */
1169
+ export function patch2DProto(protoObj) {
1170
+ const proto = protoObj;
1171
+ // Factory + gradient/Path2D logging may ALREADY be installed by the early
1172
+ // SDK hook (objectLog.ts); both installs are idempotent. The loop below
1173
+ // skips factory names so their logging wrappers are never double-wrapped
1174
+ // into recordCall instrumentation.
1175
+ installFactoryLoggingOnProto(protoObj);
1176
+ installSharedObjectLogging();
1177
+ for (const name of Object.getOwnPropertyNames(protoObj)) {
1178
+ const desc = Object.getOwnPropertyDescriptor(protoObj, name);
1179
+ if (!desc)
1180
+ continue;
1181
+ if (typeof desc.value === "function" && name !== "constructor") {
1182
+ const orig = desc.value;
1183
+ if (OBJECT_FACTORIES.includes(name)) {
1184
+ // already wrapped by installFactoryLoggingOnProto — nothing to add
1185
+ }
1186
+ else if (name === "getImageData" || name === "getTransform" || name === "isPointInPath" ||
1187
+ name === "isPointInStroke" || name === "measureText" || name === "getLineDash" ||
1188
+ name === "getContextAttributes" || name === "createImageData") {
1189
+ // pure getters — nothing to record
1190
+ }
1191
+ else {
1192
+ proto[name] = function (...args) {
1193
+ const rec = recorders.get(this);
1194
+ if (rec)
1195
+ rec.recordCall(name, args);
1196
+ else if (untrackedNotify && !ignoredContexts.has(this))
1197
+ untrackedNotify(this);
1198
+ return orig.apply(this, args);
1199
+ };
1200
+ }
1201
+ }
1202
+ else if (desc.set && TRACKED_PROPS.includes(name)) {
1203
+ const { get, set } = desc;
1204
+ Object.defineProperty(protoObj, name, {
1205
+ configurable: true,
1206
+ get,
1207
+ set(v) {
1208
+ const rec = recorders.get(this);
1209
+ if (rec)
1210
+ rec.recordSet(name, v);
1211
+ else if (untrackedNotify && !ignoredContexts.has(this))
1212
+ untrackedNotify(this);
1213
+ set.call(this, v);
1214
+ },
1215
+ });
1216
+ }
1217
+ }
1218
+ // CanvasGradient.addColorStop + Path2D construction/segment logging is
1219
+ // installed by installSharedObjectLogging() above (objectLog.ts).
1220
+ }
1221
+ /** Main-thread installation: 2D proto + getContext auto-attach + kind registry. */
1222
+ export function installCommandHooks() {
1223
+ if (installed)
1224
+ return;
1225
+ installed = true;
1226
+ patch2DProto(CanvasRenderingContext2D.prototype);
1227
+ // Auto-attach (build-plugin tier): new 2D contexts get buffering recorders.
1228
+ // ALL context creations record their kind + attrs so the manager can route
1229
+ // per-canvas (2d -> commands, webgl/webgpu -> GL/GPU capture or video).
1230
+ const origGetContext = HTMLCanvasElement.prototype.getContext;
1231
+ HTMLCanvasElement.prototype.getContext = function (type, attrs) {
1232
+ const result = origGetContext.call(this, type, attrs);
1233
+ if (result && !contextInfo.has(this)) {
1234
+ contextInfo.set(this, { type, attrs: attrs ?? undefined });
1235
+ }
1236
+ if (type === "2d" && result && autoConfig && !recorders.has(result)) {
1237
+ const rec = new ContextRecorder(result, new BufferSink(), autoConfig.now, autoConfig.assetMode, autoConfig.encodeImage, { retainAssets: autoConfig.retainAssets });
1238
+ recorders.set(result, rec);
1239
+ autoRecorders.set(this, rec);
1240
+ }
1241
+ if (result && contextCreatedNotify)
1242
+ contextCreatedNotify(this, type);
1243
+ return result;
1244
+ };
1245
+ }
1246
+ /** Attach directly to a known 2D context (worker-side OffscreenCanvas path,
1247
+ * or main-thread contexts created before the hooks were installed). */
1248
+ export function attachRecorderToContext(ctx, sink, now, assetMode, encodeImage, opts) {
1249
+ const rec = new ContextRecorder(ctx, sink, now, assetMode, encodeImage, opts);
1250
+ rec.bound = true;
1251
+ recorders.set(ctx, rec);
1252
+ return rec;
1253
+ }
1254
+ export function attachRecorder(canvas, sink, now, assetMode, encodeImage, opts) {
1255
+ // Build-plugin tier: a buffering recorder already exists from getContext —
1256
+ // bind the strategy sink and drain creation-time ops into it.
1257
+ const auto = autoRecorders.get(canvas);
1258
+ if (auto) {
1259
+ auto.swapSink(sink);
1260
+ return auto;
1261
+ }
1262
+ let ctx = null;
1263
+ try {
1264
+ ctx = canvas.getContext("2d");
1265
+ }
1266
+ catch {
1267
+ return null; // transferred to OffscreenCanvas — main thread cannot see commands
1268
+ }
1269
+ if (!ctx)
1270
+ return null; // not a 2D canvas (webgl/webgpu/placeholder)
1271
+ const rec = new ContextRecorder(ctx, sink, now, assetMode, encodeImage, opts);
1272
+ rec.bound = true;
1273
+ recorders.set(ctx, rec);
1274
+ return rec;
1275
+ }
1276
+ export function detachRecorder(canvas) {
1277
+ // getContext returns the existing context for already-initialized canvases
1278
+ try {
1279
+ const ctx = canvas.getContext("2d");
1280
+ if (ctx)
1281
+ recorders.delete(ctx);
1282
+ }
1283
+ catch {
1284
+ /* context type already fixed to non-2d */
1285
+ }
1286
+ }
1287
+ export function detachRecorderFromContext(ctx) {
1288
+ recorders.delete(ctx);
1289
+ }
1290
+ /**
1291
+ * Draw-site stamp routing (Phase 3b): when stamped app code calls
1292
+ * `__sfds(ctx, id).fillRect(...)`, canvasManager's __sfDrawSite calls this
1293
+ * with the 2D context object; if it has a recorder, the id rides the next op.
1294
+ * Returns true when handled (ctx is a recorded 2D context).
1295
+ */
1296
+ export function noteCtxPendingSiteId(ctx, id) {
1297
+ const rec = recorders.get(ctx);
1298
+ if (!rec)
1299
+ return false;
1300
+ rec.pendingSiteId = id;
1301
+ return true;
1302
+ }