@sailfish-ai/recorder 1.12.5 → 1.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/canvas/adapters.js +382 -0
- package/dist/canvas/adapters.js.br +0 -0
- package/dist/canvas/adapters.js.gz +0 -0
- package/dist/canvas/canvasManager.js +1949 -0
- package/dist/canvas/canvasManager.js.br +0 -0
- package/dist/canvas/canvasManager.js.gz +0 -0
- package/dist/canvas/canvasPrivacy.js +233 -0
- package/dist/canvas/canvasPrivacy.js.br +0 -0
- package/dist/canvas/canvasPrivacy.js.gz +0 -0
- package/dist/canvas/codec.js +314 -0
- package/dist/canvas/codec.js.br +0 -0
- package/dist/canvas/codec.js.gz +0 -0
- package/dist/canvas/deviceClass.js +67 -0
- package/dist/canvas/deviceClass.js.br +0 -0
- package/dist/canvas/deviceClass.js.gz +0 -0
- package/dist/canvas/encodeWorker.js +284 -0
- package/dist/canvas/encodeWorker.js.br +0 -0
- package/dist/canvas/encodeWorker.js.gz +0 -0
- package/dist/canvas/governor.js +79 -0
- package/dist/canvas/governor.js.br +0 -0
- package/dist/canvas/governor.js.gz +0 -0
- package/dist/canvas/keyframes.js +183 -0
- package/dist/canvas/keyframes.js.br +0 -0
- package/dist/canvas/keyframes.js.gz +0 -0
- package/dist/canvas/mediaRecorderCapture.js +257 -0
- package/dist/canvas/mediaRecorderCapture.js.br +0 -0
- package/dist/canvas/mediaRecorderCapture.js.gz +0 -0
- package/dist/canvas/objectLog.js +150 -0
- package/dist/canvas/objectLog.js.br +0 -0
- package/dist/canvas/objectLog.js.gz +0 -0
- package/dist/canvas/patch2d.js +1302 -0
- package/dist/canvas/patch2d.js.br +0 -0
- package/dist/canvas/patch2d.js.gz +0 -0
- package/dist/canvas/patchGL.js +371 -0
- package/dist/canvas/patchGL.js.br +0 -0
- package/dist/canvas/patchGL.js.gz +0 -0
- package/dist/canvas/patchWebGPU.js +361 -0
- package/dist/canvas/patchWebGPU.js.br +0 -0
- package/dist/canvas/patchWebGPU.js.gz +0 -0
- package/dist/canvas/piiDetect.js +155 -0
- package/dist/canvas/piiDetect.js.br +0 -0
- package/dist/canvas/piiDetect.js.gz +0 -0
- package/dist/canvas/protocol.js +122 -0
- package/dist/canvas/protocol.js.br +0 -0
- package/dist/canvas/protocol.js.gz +0 -0
- package/dist/canvas/videoCapture.js +252 -0
- package/dist/canvas/videoCapture.js.br +0 -0
- package/dist/canvas/videoCapture.js.gz +0 -0
- package/dist/canvas/workerRelay.js +99 -0
- package/dist/canvas/workerRelay.js.br +0 -0
- package/dist/canvas/workerRelay.js.gz +0 -0
- package/dist/canvas/workerShim.js +227 -0
- package/dist/canvas/workerShim.js.br +0 -0
- package/dist/canvas/workerShim.js.gz +0 -0
- package/dist/canvas-worker.cjs +74 -0
- package/dist/canvas-worker.cjs.br +0 -0
- package/dist/canvas-worker.cjs.gz +0 -0
- package/dist/canvas-worker.js +78 -0
- package/dist/canvas-worker.js.br +0 -0
- package/dist/canvas-worker.js.gz +0 -0
- package/dist/canvasFrameStore.js +198 -0
- package/dist/canvasFrameStore.js.br +0 -0
- package/dist/canvasFrameStore.js.gz +0 -0
- package/dist/chunks/canvasManager-B9uQ-PFS.js +1456 -0
- package/dist/chunks/canvasManager-B9uQ-PFS.js.br +0 -0
- package/dist/chunks/canvasManager-B9uQ-PFS.js.gz +0 -0
- package/dist/chunks/canvasManager-Ce7vazTt.js +1440 -0
- package/dist/chunks/canvasManager-Ce7vazTt.js.br +0 -0
- package/dist/chunks/canvasManager-Ce7vazTt.js.gz +0 -0
- package/dist/chunks/{chunkSerializer-pkpgT-FH.js → chunkSerializer-B-z7p0r-.js} +1 -1
- package/dist/chunks/chunkSerializer-B-z7p0r-.js.br +0 -0
- package/dist/chunks/chunkSerializer-B-z7p0r-.js.gz +0 -0
- package/dist/chunks/{chunkSerializer-Cd8y9tID.js → chunkSerializer-C1knjCQ5.js} +1 -1
- package/dist/chunks/chunkSerializer-C1knjCQ5.js.br +0 -0
- package/dist/chunks/chunkSerializer-C1knjCQ5.js.gz +0 -0
- package/dist/chunks/{index-CXvHbLRT.js → index-BMHQ-0NY.js} +1115 -724
- package/dist/chunks/index-BMHQ-0NY.js.br +0 -0
- package/dist/chunks/index-BMHQ-0NY.js.gz +0 -0
- package/dist/chunks/{index-Bvd8oTpV.js → index-qgM1hFXO.js} +1025 -655
- package/dist/chunks/index-qgM1hFXO.js.br +0 -0
- package/dist/chunks/index-qgM1hFXO.js.gz +0 -0
- package/dist/chunks/patch2d-9voowhJ_.js +816 -0
- package/dist/chunks/patch2d-9voowhJ_.js.br +0 -0
- package/dist/chunks/patch2d-9voowhJ_.js.gz +0 -0
- package/dist/chunks/patch2d-PQxcaE6r.js +886 -0
- package/dist/chunks/patch2d-PQxcaE6r.js.br +0 -0
- package/dist/chunks/patch2d-PQxcaE6r.js.gz +0 -0
- package/dist/graphql.js +12 -1
- package/dist/graphql.js.br +0 -0
- package/dist/graphql.js.gz +0 -0
- package/dist/inAppReportIssueModal/index.js +10 -1
- package/dist/inAppReportIssueModal/index.js.br +0 -0
- package/dist/inAppReportIssueModal/index.js.gz +0 -0
- package/dist/index.js +144 -2
- package/dist/index.js.br +0 -0
- package/dist/index.js.gz +0 -0
- package/dist/recorder.cjs +2 -2
- package/dist/recorder.cjs.br +0 -0
- package/dist/recorder.cjs.gz +0 -0
- package/dist/recorder.js +52 -41
- package/dist/recorder.js.br +0 -0
- package/dist/recorder.js.gz +0 -0
- package/dist/recorder.umd.cjs +4842 -2215
- package/dist/recorder.umd.cjs.br +0 -0
- package/dist/recorder.umd.cjs.gz +0 -0
- package/dist/recording.js +119 -10
- package/dist/recording.js.br +0 -0
- package/dist/recording.js.gz +0 -0
- package/dist/types/canvas/adapters.d.ts +71 -0
- package/dist/types/canvas/canvasManager.d.ts +273 -0
- package/dist/types/canvas/canvasPrivacy.d.ts +184 -0
- package/dist/types/canvas/codec.d.ts +27 -0
- package/dist/types/canvas/deviceClass.d.ts +40 -0
- package/dist/types/canvas/encodeWorker.d.ts +54 -0
- package/dist/types/canvas/governor.d.ts +44 -0
- package/dist/types/canvas/keyframes.d.ts +81 -0
- package/dist/types/canvas/mediaRecorderCapture.d.ts +91 -0
- package/dist/types/canvas/objectLog.d.ts +75 -0
- package/dist/types/canvas/patch2d.d.ts +339 -0
- package/dist/types/canvas/patchGL.d.ts +74 -0
- package/dist/types/canvas/patchWebGPU.d.ts +37 -0
- package/dist/types/canvas/piiDetect.d.ts +60 -0
- package/dist/types/canvas/protocol.d.ts +176 -0
- package/dist/types/canvas/videoCapture.d.ts +67 -0
- package/dist/types/canvas/workerRelay.d.ts +29 -0
- package/dist/types/canvas/workerShim.d.ts +10 -0
- package/dist/types/canvasFrameStore.d.ts +23 -0
- package/dist/types/graphql.d.ts +1 -1
- package/dist/types/index.d.ts +99 -1
- package/dist/types/types.d.ts +40 -0
- package/dist/types/websocket.d.ts +19 -0
- package/dist/websocket.js +210 -0
- package/dist/websocket.js.br +0 -0
- package/dist/websocket.js.gz +0 -0
- package/package.json +7 -1
- package/dist/chunks/chunkSerializer-Cd8y9tID.js.br +0 -0
- package/dist/chunks/chunkSerializer-Cd8y9tID.js.gz +0 -0
- package/dist/chunks/chunkSerializer-pkpgT-FH.js.br +0 -0
- package/dist/chunks/chunkSerializer-pkpgT-FH.js.gz +0 -0
- package/dist/chunks/index-Bvd8oTpV.js.br +0 -0
- package/dist/chunks/index-Bvd8oTpV.js.gz +0 -0
- package/dist/chunks/index-CXvHbLRT.js.br +0 -0
- 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
|
+
}
|