@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.
- 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-BYn3_oyo.js +1440 -0
- package/dist/chunks/canvasManager-BYn3_oyo.js.br +0 -0
- package/dist/chunks/canvasManager-BYn3_oyo.js.gz +0 -0
- package/dist/chunks/canvasManager-CoX5072G.js +1456 -0
- package/dist/chunks/canvasManager-CoX5072G.js.br +0 -0
- package/dist/chunks/canvasManager-CoX5072G.js.gz +0 -0
- package/dist/chunks/{chunkSerializer-Cd8y9tID.js → chunkSerializer-C_K0Wyk0.js} +1 -1
- package/dist/chunks/chunkSerializer-C_K0Wyk0.js.br +0 -0
- package/dist/chunks/chunkSerializer-C_K0Wyk0.js.gz +0 -0
- package/dist/chunks/{chunkSerializer-pkpgT-FH.js → chunkSerializer-CghPitF4.js} +1 -1
- package/dist/chunks/chunkSerializer-CghPitF4.js.br +0 -0
- package/dist/chunks/chunkSerializer-CghPitF4.js.gz +0 -0
- package/dist/chunks/{index-Bvd8oTpV.js → index-BIj4l3CV.js} +1025 -655
- package/dist/chunks/index-BIj4l3CV.js.br +0 -0
- package/dist/chunks/index-BIj4l3CV.js.gz +0 -0
- package/dist/chunks/{index-CXvHbLRT.js → index-DIYomvdD.js} +1115 -724
- package/dist/chunks/index-DIYomvdD.js.br +0 -0
- package/dist/chunks/index-DIYomvdD.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/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,1949 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production canvas-recording controller (contract §5) — the SDK-side
|
|
3
|
+
* orchestrator over the ported bench capture core. NO driver loop: draw-call
|
|
4
|
+
* hooks set a per-canvas dirty flag and a queueMicrotask pump runs keyframe /
|
|
5
|
+
* video capture IN THE SAME TASK as the render (guardrail #6 — microtasks run
|
|
6
|
+
* before the task ends, so WebGPU textures and pdb:false WebGL buffers are
|
|
7
|
+
* still readable).
|
|
8
|
+
*
|
|
9
|
+
* Mode routing per canvas (bench commandCore lineage):
|
|
10
|
+
* 2d / webgl(2) / webgpu -> commands + pixel keyframes (hybrid)
|
|
11
|
+
* worker + canvas-worker shim -> worker command relay
|
|
12
|
+
* worker without shim -> video capture of the placeholder
|
|
13
|
+
* governor demotion -> video capture (commands recorder kept
|
|
14
|
+
* attached with a DiscardSink for dirtiness)
|
|
15
|
+
* no WebCodecs -> keyframes-only
|
|
16
|
+
*
|
|
17
|
+
* All heavy bytes leave as SFCV binary frames via deps.sendBinary; index
|
|
18
|
+
* events (start/mode/stop/masked) ride the existing rrweb event pipeline via
|
|
19
|
+
* deps.addSailfishEvent (contract §2).
|
|
20
|
+
*
|
|
21
|
+
* SSR rule: no window/document/navigator access at module scope; everything
|
|
22
|
+
* installs inside startCanvasRecording().
|
|
23
|
+
*/
|
|
24
|
+
import { installAdapters, uninstallAdapters } from "./adapters";
|
|
25
|
+
import { CommandEncoder, deflate } from "./codec";
|
|
26
|
+
import { encodeBitmap, terminateImgEncoder } from "./encodeWorker";
|
|
27
|
+
import { CanvasGovernor } from "./governor";
|
|
28
|
+
import { KeyframeCapturer } from "./keyframes";
|
|
29
|
+
import { attachRecorder, attachRecorderToContext, detachRecorderFromContext, getContextInfo, installCommandHooks, noteCtxPendingSiteId, pruneUnboundRecorders, releaseAutoRecorder, setAutoAttach, setContextCreatedNotifier, setUntracked2DContextNotifier, wasPreAttachMaskUsed, } from "./patch2d";
|
|
30
|
+
import { attachGLRecorder, attachGLRecorderToContext, detachGLRecorderFromContext, installGLHooks, noteGLPendingSiteId, pruneUnboundGLRecorders, setGLAutoAttach, setUntrackedGLContextNotifier, } from "./patchGL";
|
|
31
|
+
import { attachGPURecorder, installGPUHooks, pruneUnboundGPURecorders, setGPUAutoAttach, setGPUDeviceLostNotifier, swapGPUSink, } from "./patchWebGPU";
|
|
32
|
+
import { BufferSink, DiscardSink, buildFrame, bytesToBase64, } from "./protocol";
|
|
33
|
+
import { MediaRecorderCapture } from "./mediaRecorderCapture";
|
|
34
|
+
import { VideoCapture } from "./videoCapture";
|
|
35
|
+
import { getWorkerForCanvas, installWorkerRelay, pingWorkerShim, setWorkerAssociationNotifier, } from "./workerRelay";
|
|
36
|
+
import { classifyCanvasPrivacy, DEFAULT_REDACT_STYLE, redactCanvasText, resolveRedactStyle, suppressReason, } from "./canvasPrivacy";
|
|
37
|
+
import { resolveDeviceBudgets } from "./deviceClass";
|
|
38
|
+
const DEFAULT_MASK_CLASS = "sailfishSanitize";
|
|
39
|
+
/** backend rejects frames > 8 MiB (contract §3) — drop SDK-side */
|
|
40
|
+
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
41
|
+
/** A30: SDK-side per-session canvas byte budget is now device-class scaled (see
|
|
42
|
+
* `resolveDeviceBudgets` → `state.budgets.sessionByteBudget`; desktop = 256 MiB,
|
|
43
|
+
* mobile = 96 MiB, low-end = 48 MiB). The backend backstop is 512 MiB. */
|
|
44
|
+
/** [audit B3] The byte budget is enforced as a LEAKY BUCKET over this window
|
|
45
|
+
* rather than a lifetime total: `state.totalBytes` decays toward 0 at
|
|
46
|
+
* `sessionByteBudget / BUDGET_WINDOW_MS` bytes/ms, so it tracks RECENT upload
|
|
47
|
+
* volume. A burst that trips the budget pauses sending, but a subsequent quiet
|
|
48
|
+
* period drains it back under budget and capture RESUMES automatically — vs the
|
|
49
|
+
* old permanent latch that killed ALL canvas for the rest of the session after a
|
|
50
|
+
* single front-loaded burst. Bounds sustained cost while tolerating bursts. */
|
|
51
|
+
const BUDGET_WINDOW_MS = 60_000;
|
|
52
|
+
/** [audit B3] Decay a leaky-bucket byte counter by `elapsedMs` at a drain rate of
|
|
53
|
+
* `budget / windowMs` bytes/ms (never below 0). Exported for tests. */
|
|
54
|
+
export function decayBytes(recentBytes, budget, windowMs, elapsedMs) {
|
|
55
|
+
if (elapsedMs <= 0 || budget <= 0 || windowMs <= 0)
|
|
56
|
+
return Math.max(0, recentBytes);
|
|
57
|
+
return Math.max(0, recentBytes - elapsedMs * (budget / windowMs));
|
|
58
|
+
}
|
|
59
|
+
/** command batch flush cadence (bench pattern) */
|
|
60
|
+
const BATCH_MS = 1_000;
|
|
61
|
+
/**
|
|
62
|
+
* A25 — global cap on simultaneously active hardware video encoders. Each
|
|
63
|
+
* video-mode canvas spins its own `VideoEncoder` (one codec session per
|
|
64
|
+
* encoder); a page with many heavy canvases can exhaust the OS's limited pool
|
|
65
|
+
* of codec sessions, after which every further `configure()` fails and ALL
|
|
66
|
+
* video capture silently dies. We cap the number of concurrent video-mode
|
|
67
|
+
* controllers PER SESSION: a canvas that would demote to video while the cap is
|
|
68
|
+
* reached stays on the KEYFRAME tier instead (degrade, don't fail). A slot frees
|
|
69
|
+
* when a video controller stops / tears down, and the next demotion can claim it.
|
|
70
|
+
*/
|
|
71
|
+
export const MAX_CONCURRENT_VIDEO_CANVASES = 4;
|
|
72
|
+
/**
|
|
73
|
+
* A25 — the per-session pool of concurrent video-encoder slots. A fresh pool is
|
|
74
|
+
* created in each `startCanvasRecording` (so the cap is per-session, not global
|
|
75
|
+
* across the page's lifetime). `acquire` hands out at most `max` slots;
|
|
76
|
+
* `release` returns one and is idempotent at the boundaries (never drops below
|
|
77
|
+
* zero). The manager tracks per-controller ownership separately so a double
|
|
78
|
+
* teardown cannot over-release.
|
|
79
|
+
*/
|
|
80
|
+
export class VideoSlotPool {
|
|
81
|
+
max;
|
|
82
|
+
used = 0;
|
|
83
|
+
constructor(max = MAX_CONCURRENT_VIDEO_CANVASES) {
|
|
84
|
+
this.max = max;
|
|
85
|
+
}
|
|
86
|
+
/** @returns true if a slot was available and claimed. */
|
|
87
|
+
acquire() {
|
|
88
|
+
if (this.used >= this.max)
|
|
89
|
+
return false;
|
|
90
|
+
this.used++;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
release() {
|
|
94
|
+
if (this.used > 0)
|
|
95
|
+
this.used--;
|
|
96
|
+
}
|
|
97
|
+
get inUse() {
|
|
98
|
+
return this.used;
|
|
99
|
+
}
|
|
100
|
+
get available() {
|
|
101
|
+
return this.max - this.used;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let state = null;
|
|
105
|
+
/**
|
|
106
|
+
* A5 — canvas↔event clock alignment (VERIFIED, no runtime change needed).
|
|
107
|
+
*
|
|
108
|
+
* Canvas SFCV frame `tMs` is raw `Date.now()` (this `now`). The question was
|
|
109
|
+
* whether that aligns with the timeline the player reconstructs from the rrweb
|
|
110
|
+
* event stream — specifically `getMetaData().startTime`, which the player
|
|
111
|
+
* derives from `events[0].timestamp`.
|
|
112
|
+
*
|
|
113
|
+
* Traced through the recorder: every rrweb event's `timestamp` is OVERWRITTEN
|
|
114
|
+
* at emit time by rrweb's `wrapEvent`/emit (`@sailfish-rrweb/rrweb-record-only`,
|
|
115
|
+
* `R.timestamp = Bt()` where `let Bt = Date.now`). Our own SailfishCustom canvas
|
|
116
|
+
* index events (`op:"start"`/`mode`/`masked`/`stop`/`taint`) go through that
|
|
117
|
+
* same emit path, and the surrounding DOM/snapshot/perf/console events in
|
|
118
|
+
* recording.tsx all stamp `timestamp: Date.now()` too. So the player's
|
|
119
|
+
* `startTime` is in raw client `Date.now()` epoch-ms — the SAME clock and the
|
|
120
|
+
* SAME `Date.now()` source as canvas `tMs`.
|
|
121
|
+
*
|
|
122
|
+
* The `serverEstimated`/`clockSync` envelope is an ADDITIVE per-event field used
|
|
123
|
+
* for cross-device server-side ordering; it never replaces `timestamp`, and the
|
|
124
|
+
* player's canvas/DOM alignment math uses `timestamp`. Therefore canvas frames
|
|
125
|
+
* and DOM replay share one monotonic-enough wall clock and align without a frame
|
|
126
|
+
* header change. (If the player ever switches to anchoring on `serverEstimated`,
|
|
127
|
+
* the SFCV header would need `mono`+`off` so it can re-derive — flagged, not
|
|
128
|
+
* needed today.) See tests/canvasClockAlignment.test.ts for the guard.
|
|
129
|
+
*/
|
|
130
|
+
const now = () => Date.now();
|
|
131
|
+
const encodeImage = (b) => encodeBitmap(b, 0.85, true);
|
|
132
|
+
function noteOnce(m) {
|
|
133
|
+
if (!state || state.notes.has(m))
|
|
134
|
+
return;
|
|
135
|
+
state.notes.add(m);
|
|
136
|
+
try {
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.debug?.("[Sailfish][canvas]", m);
|
|
139
|
+
}
|
|
140
|
+
catch { /* never break capture */ }
|
|
141
|
+
}
|
|
142
|
+
function emitEvent(payload) {
|
|
143
|
+
if (!state)
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
state.deps.addSailfishEvent(payload);
|
|
147
|
+
}
|
|
148
|
+
catch { /* never break the host app */ }
|
|
149
|
+
}
|
|
150
|
+
// ─── Frame emission ──────────────────────────────────────────────────────────
|
|
151
|
+
function sendFrame(ctl, kind, meta, payload,
|
|
152
|
+
// BUGFIX (Phase 0.5 E2E, found on /charts): callers whose payload is
|
|
153
|
+
// produced ASYNCHRONOUSLY after capture (keyframes: createImageBitmap +
|
|
154
|
+
// worker webp encode, ~30ms) must pass the CAPTURE-time timestamp.
|
|
155
|
+
// Stamping Date.now() at send time made the player anchor keyframe pixels
|
|
156
|
+
// ~30ms late, silently dropping every op recorded in that window when
|
|
157
|
+
// replay resumed from the keyframe (interaction redraws were lost).
|
|
158
|
+
tMs = Date.now()) {
|
|
159
|
+
const s = state;
|
|
160
|
+
if (!s)
|
|
161
|
+
return;
|
|
162
|
+
const payloadLen = payload == null ? 0 : payload.byteLength;
|
|
163
|
+
if (payloadLen > MAX_FRAME_BYTES) {
|
|
164
|
+
noteOnce(`oversize ${kind} frame dropped (${payloadLen}B > 8MiB)`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// [audit B3] Leaky-bucket byte budget. Decay the recent-bytes counter, then gate
|
|
168
|
+
// this frame. Over budget → DROP it (and pause), but keep decaying every call so
|
|
169
|
+
// a quiet period drains the bucket and sending RESUMES automatically — no
|
|
170
|
+
// permanent session-wide kill. (Decay runs even while paused because the capture
|
|
171
|
+
// pump keeps calling sendFrame; dropped frames don't accrue bytes.)
|
|
172
|
+
const budget = s.budgets.sessionByteBudget;
|
|
173
|
+
if (s.lastBudgetTickMs > 0) {
|
|
174
|
+
s.totalBytes = decayBytes(s.totalBytes, budget, BUDGET_WINDOW_MS, tMs - s.lastBudgetTickMs);
|
|
175
|
+
}
|
|
176
|
+
// [audit B3-followup] Never let the tick REGRESS. A keyframe's sendFrame carries
|
|
177
|
+
// its capture-instant `tMs` but emits AFTER an async encode, so it can land with
|
|
178
|
+
// an older tMs than a command/video frame that already advanced the tick. A bare
|
|
179
|
+
// assignment would move the tick backwards, and the NEXT frame would then see an
|
|
180
|
+
// inflated elapsed and over-drain the bucket (weakening the cap). Monotonic max
|
|
181
|
+
// keeps decay honest; the older frame itself already decayed by ≤0 (no-op).
|
|
182
|
+
s.lastBudgetTickMs = Math.max(s.lastBudgetTickMs, tMs);
|
|
183
|
+
if (s.totalBytes > budget) {
|
|
184
|
+
if (!s.budgetExceeded) {
|
|
185
|
+
s.budgetExceeded = true;
|
|
186
|
+
noteOnce("per-session canvas byte budget exceeded — capture paused (auto-resumes as it drains)");
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (s.budgetExceeded)
|
|
191
|
+
s.budgetExceeded = false; // drained back under budget → resume
|
|
192
|
+
const ids = s.deps.getContextIds();
|
|
193
|
+
const buf = buildFrame({
|
|
194
|
+
v: 1,
|
|
195
|
+
kind,
|
|
196
|
+
sessionId: ids.session_id ?? "",
|
|
197
|
+
pageVisitUuid: ids.page_visit_uuid ?? "",
|
|
198
|
+
canvasId: ctl.canvasId,
|
|
199
|
+
seq: ctl.seq++,
|
|
200
|
+
tMs,
|
|
201
|
+
meta,
|
|
202
|
+
}, payload);
|
|
203
|
+
try {
|
|
204
|
+
s.deps.sendBinary(buf);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// [audit B3] Accrue into the leaky bucket. The gate above already decayed it and
|
|
210
|
+
// confirmed we were under budget; this frame's bytes may push it over, which
|
|
211
|
+
// pauses the NEXT frame until the bucket drains — transient backpressure, not a
|
|
212
|
+
// permanent stop.
|
|
213
|
+
s.totalBytes += buf.byteLength;
|
|
214
|
+
}
|
|
215
|
+
// ─── Same-task pump (guardrail #6) ───────────────────────────────────────────
|
|
216
|
+
function markDirty(ctl) {
|
|
217
|
+
ctl.dirty = true;
|
|
218
|
+
ctl.everDirty = true;
|
|
219
|
+
if (!ctl.pumpScheduled) {
|
|
220
|
+
ctl.pumpScheduled = true;
|
|
221
|
+
queueMicrotask(() => pumpCanvas(ctl));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function pumpCanvas(ctl) {
|
|
225
|
+
ctl.pumpScheduled = false;
|
|
226
|
+
if (!state || ctl.stopped || !ctl.dirty)
|
|
227
|
+
return;
|
|
228
|
+
// A10: while the GPU context is lost, the canvas pixels are blank/garbage and
|
|
229
|
+
// the command stream references dead resources — capture nothing until it is
|
|
230
|
+
// restored.
|
|
231
|
+
if (ctl.contextLost) {
|
|
232
|
+
ctl.dirty = false;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
ctl.dirty = false;
|
|
236
|
+
const t = now();
|
|
237
|
+
// A24: detect a mid-session resize same-task with the render. For command/
|
|
238
|
+
// keyframe mode this emits a `resize` index event + forces a keyframe; for
|
|
239
|
+
// video mode VideoCapture.afterRender drives its own encoder restart, so we
|
|
240
|
+
// only keep lastW/lastH in sync here (no resize event for video).
|
|
241
|
+
maybeEmitResize(ctl, t);
|
|
242
|
+
// maskCanvasDraws / redact text: the app masked a draw. In command mode the
|
|
243
|
+
// keyframe capturer region-blanks the masked bboxes (recorder.maskedRegions),
|
|
244
|
+
// so keyframes are KEPT and pixel-perfect outside the masked regions — no
|
|
245
|
+
// teardown. Only a canvas already DEMOTED to video needs hard suppression here:
|
|
246
|
+
// video frames can't be cheaply region-blanked, and its command stream is a
|
|
247
|
+
// DiscardSink (no safe fallback). (A command-mode masked canvas never demotes —
|
|
248
|
+
// maybeDemote vetoes hasMaskedDraws.)
|
|
249
|
+
// [audit C-NEW-1] A masked op could not be region-bounded (degenerate
|
|
250
|
+
// transform / ctx.fill(path2d) / zero-area paint) — its pixels can't be safely
|
|
251
|
+
// blanked, so suppress pixel keyframes entirely (command-only replay) rather
|
|
252
|
+
// than leak them or blank the whole canvas.
|
|
253
|
+
if (ctl.recorder2d?.keyframeUnsafe && !ctl.pixelSuppressed) {
|
|
254
|
+
suppressPixelCapture(ctl);
|
|
255
|
+
}
|
|
256
|
+
if (ctl.recorder2d?.hasMaskedDraws && !ctl.pixelSuppressed && ctl.mode === "video") {
|
|
257
|
+
suppressPixelCapture(ctl);
|
|
258
|
+
}
|
|
259
|
+
if (ctl.mode === "video") {
|
|
260
|
+
// A41: a MediaRecorder-backed video controller self-paces from the
|
|
261
|
+
// captureStream track, so its afterRender is a no-op; a WebCodecs controller
|
|
262
|
+
// grabs a frame here. Exactly one backend is set.
|
|
263
|
+
ctl.video?.afterRender(t);
|
|
264
|
+
ctl.mediaRecorder?.afterRender(t);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
ctl.keyframes?.afterRender(t);
|
|
268
|
+
}
|
|
269
|
+
maybeDemote(ctl);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* maskCanvasDraws — tear down pixel capture for a canvas whose recorder reported
|
|
273
|
+
* masked draws. Keyframes/video would otherwise capture the (real, painted)
|
|
274
|
+
* pixels of the sanitized region. The sanitized command stream remains the
|
|
275
|
+
* replay source; for a vector 2D canvas this is loss-free. One-shot.
|
|
276
|
+
*/
|
|
277
|
+
function suppressPixelCapture(ctl) {
|
|
278
|
+
ctl.pixelSuppressed = true;
|
|
279
|
+
ctl.keyframes = null;
|
|
280
|
+
if (ctl.video) {
|
|
281
|
+
void ctl.video.stop();
|
|
282
|
+
ctl.video = null;
|
|
283
|
+
releaseVideoSlot(ctl);
|
|
284
|
+
}
|
|
285
|
+
if (ctl.mediaRecorder) {
|
|
286
|
+
try {
|
|
287
|
+
void ctl.mediaRecorder.stop();
|
|
288
|
+
}
|
|
289
|
+
catch { /* already gone */ }
|
|
290
|
+
ctl.mediaRecorder = null;
|
|
291
|
+
}
|
|
292
|
+
if (ctl.mode === "video") {
|
|
293
|
+
// Rare: a canvas already demoted to video before the first maskCanvasDraws.
|
|
294
|
+
// Its command recorder is a DiscardSink, so there is no safe command stream
|
|
295
|
+
// to fall back to — stop capturing rather than risk leaking masked pixels.
|
|
296
|
+
ctl.mode = "none";
|
|
297
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: "maskCanvasDraws-pixel-suppressed" });
|
|
298
|
+
}
|
|
299
|
+
noteOnce("maskCanvasDraws used — pixel capture (keyframes/video) suppressed; sanitized command-only replay");
|
|
300
|
+
}
|
|
301
|
+
// ─── Command pipeline ────────────────────────────────────────────────────────
|
|
302
|
+
function flushBatch(ctl, t) {
|
|
303
|
+
if (!ctl.encoder || ctl.encoder.ops === 0)
|
|
304
|
+
return;
|
|
305
|
+
const raw = ctl.encoder.takeBatch();
|
|
306
|
+
const z = deflate(raw);
|
|
307
|
+
// chain emission so command frames keep batch order even if deflates
|
|
308
|
+
// resolve out of order (seq is assigned at emission time)
|
|
309
|
+
ctl.flushChain = ctl.flushChain
|
|
310
|
+
.then(async () => {
|
|
311
|
+
const bytes = await z;
|
|
312
|
+
ctl.governor.addBytes(bytes.length);
|
|
313
|
+
sendFrame(ctl, "commands", { codec: "sfc-bin-1", compression: "deflate" }, bytes);
|
|
314
|
+
maybeDemote(ctl);
|
|
315
|
+
})
|
|
316
|
+
.catch(() => { });
|
|
317
|
+
void t;
|
|
318
|
+
}
|
|
319
|
+
function makeCommandSink(ctl) {
|
|
320
|
+
return {
|
|
321
|
+
onOp(op) {
|
|
322
|
+
// A10: drop ops issued while the GPU context is lost — they reference
|
|
323
|
+
// dead resources and would corrupt the command stream.
|
|
324
|
+
if (ctl.contextLost)
|
|
325
|
+
return;
|
|
326
|
+
ctl.encoder.encode(op);
|
|
327
|
+
ctl.governor.addOp(op.t);
|
|
328
|
+
if (ctl.batchStart === 0)
|
|
329
|
+
ctl.batchStart = op.t;
|
|
330
|
+
if (op.t - ctl.batchStart >= BATCH_MS) {
|
|
331
|
+
flushBatch(ctl, op.t);
|
|
332
|
+
ctl.batchStart = op.t;
|
|
333
|
+
}
|
|
334
|
+
else if (ctl.flushTimer === null) {
|
|
335
|
+
// BUGFIX (Phase 0.5 E2E, found on /idle): batches flushed only when a
|
|
336
|
+
// LATER op crossed the 1s boundary — a canvas that draws sparsely
|
|
337
|
+
// (one repaint per click, then silence) kept its final ops in the
|
|
338
|
+
// encoder forever and replay never saw the last interaction. A timer
|
|
339
|
+
// backstop flushes pending ops even when no further draw arrives.
|
|
340
|
+
ctl.flushTimer = setTimeout(() => {
|
|
341
|
+
ctl.flushTimer = null;
|
|
342
|
+
if (!ctl.stopped) {
|
|
343
|
+
flushBatch(ctl, now());
|
|
344
|
+
ctl.batchStart = 0;
|
|
345
|
+
}
|
|
346
|
+
}, BATCH_MS + 100);
|
|
347
|
+
}
|
|
348
|
+
markDirty(ctl);
|
|
349
|
+
},
|
|
350
|
+
onAssetBytes() { },
|
|
351
|
+
onAssetData(info, payload) {
|
|
352
|
+
sendFrame(ctl, "asset", { assetId: info.id, format: info.format, w: info.w, h: info.h }, payload);
|
|
353
|
+
},
|
|
354
|
+
onVideoSource() {
|
|
355
|
+
ctl.governor.addVideoDraw();
|
|
356
|
+
},
|
|
357
|
+
onTaint(t) { emitTaint(ctl, t); },
|
|
358
|
+
note(m) { noteOnce(m); },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// ─── A40: structured tainted-asset marker ────────────────────────────────────
|
|
362
|
+
/**
|
|
363
|
+
* A cross-origin/tainted image source was drawn and could not be read back, so
|
|
364
|
+
* its pixels never reach the player. Emit a per-canvas index event (deduped to
|
|
365
|
+
* at most once per canvas) so the player can render a placeholder instead of a
|
|
366
|
+
* silent blank. Mirrors the op:"masked" emission path. The `t` is the capture
|
|
367
|
+
* timestamp (same clock as every other op for this canvas).
|
|
368
|
+
*/
|
|
369
|
+
function emitTaint(ctl, t) {
|
|
370
|
+
if (ctl.taintEmitted)
|
|
371
|
+
return;
|
|
372
|
+
ctl.taintEmitted = true;
|
|
373
|
+
emitEvent({ op: "taint", canvasId: ctl.canvasId, t });
|
|
374
|
+
}
|
|
375
|
+
// ─── Governor demotion (Phase 2, contract §5) ────────────────────────────────
|
|
376
|
+
function maybeDemote(ctl) {
|
|
377
|
+
if (ctl.mode !== "commands" || ctl.demoted || ctl.stopped || ctl.worker)
|
|
378
|
+
return;
|
|
379
|
+
// A13: a "redact" canvas must NEVER demote to video — video capture would
|
|
380
|
+
// ship the rendered (un-redacted) text PIXELS. Stay command-only forever; the
|
|
381
|
+
// text-redacted command stream is the only safe representation.
|
|
382
|
+
if (ctl.privacyMode === "redact")
|
|
383
|
+
return;
|
|
384
|
+
// maskCanvasDraws: a canvas with masked draws must stay command-only — video
|
|
385
|
+
// would capture the masked pixels. (pixelSuppressed is set the same frame.)
|
|
386
|
+
if (ctl.recorder2d?.hasMaskedDraws)
|
|
387
|
+
return;
|
|
388
|
+
const reason = ctl.governor.shouldDemote();
|
|
389
|
+
if (reason)
|
|
390
|
+
demote(ctl, reason);
|
|
391
|
+
}
|
|
392
|
+
function demote(ctl, reason) {
|
|
393
|
+
ctl.demoted = true;
|
|
394
|
+
flushBatch(ctl, now());
|
|
395
|
+
// Bridge the demotion gap (Phase 0.5 E2E, found on /2d-stress): the video
|
|
396
|
+
// encoder takes hundreds of ms to produce its first keyframe, leaving a
|
|
397
|
+
// replay hole between the last command batch and the first video frame.
|
|
398
|
+
// A keyframe at the boundary anchors it. demote() runs from the render
|
|
399
|
+
// pump or the flush chain — 2D buffers persist across tasks, and 2D is the
|
|
400
|
+
// only kind the byte/ops governor demotes in practice (GL/GPU default to
|
|
401
|
+
// keyframe pacing already).
|
|
402
|
+
ctl.keyframes?.takeKeyframe(now());
|
|
403
|
+
// Detach the command stream but keep the patch recorder attached with a
|
|
404
|
+
// DiscardSink: ops are dropped before serialization (zero encode cost) and
|
|
405
|
+
// only drive the dirty flag that paces video/keyframe capture.
|
|
406
|
+
const discard = new DiscardSink(() => markDirty(ctl));
|
|
407
|
+
if (ctl.recorder2d)
|
|
408
|
+
ctl.recorder2d.swapSink(discard);
|
|
409
|
+
if (ctl.recorderGL)
|
|
410
|
+
ctl.recorderGL.swapSink(discard);
|
|
411
|
+
if (ctl.recorderGPU)
|
|
412
|
+
swapGPUSink(discard);
|
|
413
|
+
// A30: on iOS-Safari WebCodecs is unreliable/absent — when the device profile
|
|
414
|
+
// prefers it, try the MediaRecorder backend (A41) FIRST and only fall through
|
|
415
|
+
// to the WebCodecs path if MediaRecorder can't start.
|
|
416
|
+
if (state?.budgets.preferMediaRecorder && tryStartMediaRecorder(ctl, reason)) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (VideoCapture.supported()) {
|
|
420
|
+
// A25: only demote to a hardware encoder if a global slot is free. When the
|
|
421
|
+
// concurrent-encoder cap is reached, stay on the KEYFRAME tier (degrade,
|
|
422
|
+
// don't fail) — the command stream is already detached (DiscardSink above)
|
|
423
|
+
// and keyframes keep replay alive without consuming a codec session.
|
|
424
|
+
if (!tryAcquireVideoSlot(ctl)) {
|
|
425
|
+
ctl.mode = "none";
|
|
426
|
+
noteOnce(`video-encoder cap (${state?.budgets.maxConcurrentVideo ?? MAX_CONCURRENT_VIDEO_CANVASES}) reached — canvas kept on keyframes`);
|
|
427
|
+
emitEvent({
|
|
428
|
+
op: "mode",
|
|
429
|
+
canvasId: ctl.canvasId,
|
|
430
|
+
mode: "none",
|
|
431
|
+
reason: `${reason} (video-encoder cap reached — keyframes only)`,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
ctl.mode = "video";
|
|
436
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "video", reason });
|
|
437
|
+
const video = makeVideo(ctl, false);
|
|
438
|
+
ctl.video = video;
|
|
439
|
+
void video.start().then((ok) => {
|
|
440
|
+
if (!ok && !ctl.stopped) {
|
|
441
|
+
ctl.video = null;
|
|
442
|
+
releaseVideoSlot(ctl); // A25: hand the slot back on start failure
|
|
443
|
+
ctl.mode = "none"; // keyframes-only fallback
|
|
444
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: "video-start-failed" });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
else if (tryStartMediaRecorder(ctl, reason)) {
|
|
449
|
+
// A41: No WebCodecs but MediaRecorder + captureStream are available
|
|
450
|
+
// (Safari < 26) — capture via MediaRecorder instead of keyframe-only.
|
|
451
|
+
// tryStartMediaRecorder set mode/video index event + acquired the slot.
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
// No WebCodecs and no usable MediaRecorder (or the cap is reached):
|
|
455
|
+
// keyframes-only mode.
|
|
456
|
+
ctl.mode = "none";
|
|
457
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: `${reason} (no WebCodecs — keyframes only)` });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// ─── Capture builders ────────────────────────────────────────────────────────
|
|
461
|
+
function makeKeyframes(ctl) {
|
|
462
|
+
return new KeyframeCapturer(ctl.canvas, () => (ctl.recorder2d && !ctl.demoted ? ctl.recorder2d.snapshotState() : null),
|
|
463
|
+
// kf.tMs (capture instant) — NOT send time; see sendFrame's tMs note.
|
|
464
|
+
(kf) => sendFrame(ctl, "keyframe", { format: "webp", w: kf.w, h: kf.h, state: kf.state }, kf.bytes, kf.tMs), noteOnce,
|
|
465
|
+
// Region-blank source: the 2D recorder's accumulated masked-content bboxes
|
|
466
|
+
// (maskCanvasDraws / redact-mode text). Empty for GL/GPU (no per-op masking).
|
|
467
|
+
() => ctl.recorder2d?.maskedRegions ?? []);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* A27 — if this canvas pre-existed the SDK (present at the initial sweep), take
|
|
471
|
+
* a single ground-truth keyframe of its already-painted pixels so static
|
|
472
|
+
* content has an initial anchor even if the canvas never redraws. Consumes the
|
|
473
|
+
* preExisting marker so a re-tracked canvas (e.g. after context-loss reattach)
|
|
474
|
+
* does not re-shoot it. No-op for canvases discovered after the sweep, for
|
|
475
|
+
* controllers with no keyframe capturer (video/none/worker), when the canvas
|
|
476
|
+
* has a zero-size / unreadable context, or when pre-attach masking occurred (the
|
|
477
|
+
* shot could leak un-blanked masked pixels — A27 safety). Never throws.
|
|
478
|
+
*/
|
|
479
|
+
function maybeInitialKeyframe(ctl) {
|
|
480
|
+
const s = state;
|
|
481
|
+
if (!s || !ctl.keyframes)
|
|
482
|
+
return;
|
|
483
|
+
if (wasPreAttachMaskUsed(ctl.canvas))
|
|
484
|
+
return; // could leak pre-attach masked pixels [audit A27/H2]
|
|
485
|
+
if (!s.preExisting.has(ctl.canvas))
|
|
486
|
+
return;
|
|
487
|
+
s.preExisting.delete(ctl.canvas);
|
|
488
|
+
try {
|
|
489
|
+
ctl.keyframes.initialKeyframe(now());
|
|
490
|
+
}
|
|
491
|
+
catch { /* never break the host app */ }
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A25 — try to claim one of the global video-encoder slots for this controller.
|
|
495
|
+
* Returns true and marks the controller if a slot was free; false (leaving the
|
|
496
|
+
* controller on the keyframe tier) when the per-session cap is reached.
|
|
497
|
+
* Idempotent for an already-holding controller (re-claim is a no-op true).
|
|
498
|
+
*/
|
|
499
|
+
function tryAcquireVideoSlot(ctl) {
|
|
500
|
+
const s = state;
|
|
501
|
+
if (!s)
|
|
502
|
+
return false;
|
|
503
|
+
if (ctl.hasVideoSlot)
|
|
504
|
+
return true;
|
|
505
|
+
if (!s.videoSlots.acquire())
|
|
506
|
+
return false;
|
|
507
|
+
ctl.hasVideoSlot = true;
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
/** A25 — release this controller's video slot back to the pool. Idempotent:
|
|
511
|
+
* only releases when the controller actually held a slot, so a teardown after
|
|
512
|
+
* a stop (or a double teardown) never under-counts the pool. */
|
|
513
|
+
function releaseVideoSlot(ctl) {
|
|
514
|
+
if (!ctl.hasVideoSlot)
|
|
515
|
+
return;
|
|
516
|
+
ctl.hasVideoSlot = false;
|
|
517
|
+
state?.videoSlots.release();
|
|
518
|
+
}
|
|
519
|
+
function makeVideo(ctl, intervalPaced) {
|
|
520
|
+
return new VideoCapture(ctl.canvas, now, {
|
|
521
|
+
onInit: (codec, description) => sendFrame(ctl, "video-init", { codec, description: description ? bytesToBase64(description) : null }, null),
|
|
522
|
+
onChunk: (key, timestampUs, data) => sendFrame(ctl, "video-chunk", { key, timestampUs }, data),
|
|
523
|
+
note: noteOnce,
|
|
524
|
+
// A11: pause frame production while the transport buffer is saturated.
|
|
525
|
+
isTransportSaturated: state?.deps.isTransportSaturated
|
|
526
|
+
? () => !!state?.deps.isTransportSaturated?.()
|
|
527
|
+
: undefined,
|
|
528
|
+
}, { intervalPaced });
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* A41 — build a MediaRecorder fallback capturer wired to the SFCV frame
|
|
532
|
+
* pipeline. The init frame carries `codec:"mediarecorder"` + the recorder's
|
|
533
|
+
* negotiated `mimeType` (no binary payload); each chunk frame carries
|
|
534
|
+
* `codec:"mediarecorder"` + a monotonic `seq` with one MediaRecorder Blob's
|
|
535
|
+
* bytes as payload (first webm chunk holds the init segment — order preserved
|
|
536
|
+
* by the capturer's emission chain). Matches the player-side contract exactly.
|
|
537
|
+
*/
|
|
538
|
+
function makeMediaRecorder(ctl) {
|
|
539
|
+
return new MediaRecorderCapture(ctl.canvas, {
|
|
540
|
+
onInit: (mimeType, w, h) => sendFrame(ctl, "video-init", { codec: "mediarecorder", mimeType, w, h }, null),
|
|
541
|
+
onChunk: (seq, data) => sendFrame(ctl, "video-chunk", { codec: "mediarecorder", seq }, data),
|
|
542
|
+
note: noteOnce,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* A41 — try to start MediaRecorder fallback video for a controller that would
|
|
547
|
+
* otherwise degrade to keyframe-only (WebCodecs absent). Acquires an A25 video
|
|
548
|
+
* slot first (MediaRecorder is a real encoder backend too). Returns true if the
|
|
549
|
+
* recorder started; false leaves the controller untouched for the caller to
|
|
550
|
+
* fall back to keyframe-only. Emits the op:"mode" video index event on success.
|
|
551
|
+
*/
|
|
552
|
+
function tryStartMediaRecorder(ctl, reason) {
|
|
553
|
+
if (!MediaRecorderCapture.supported())
|
|
554
|
+
return false;
|
|
555
|
+
if (!tryAcquireVideoSlot(ctl)) {
|
|
556
|
+
noteOnce(`video-encoder cap (${state?.budgets.maxConcurrentVideo ?? MAX_CONCURRENT_VIDEO_CANVASES}) reached — canvas kept on keyframes`);
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
const mr = makeMediaRecorder(ctl);
|
|
560
|
+
ctl.mediaRecorder = mr;
|
|
561
|
+
ctl.mode = "video";
|
|
562
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "video", reason: `${reason} (MediaRecorder fallback)` });
|
|
563
|
+
void mr.start().then((ok) => {
|
|
564
|
+
if (!ok && !ctl.stopped) {
|
|
565
|
+
ctl.mediaRecorder = null;
|
|
566
|
+
releaseVideoSlot(ctl);
|
|
567
|
+
ctl.mode = "none"; // keyframes-only fallback
|
|
568
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: "mediarecorder-start-failed" });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
function baseController(canvas, canvasId, contextType, mode, privacyMode = "normal") {
|
|
574
|
+
return {
|
|
575
|
+
canvas,
|
|
576
|
+
canvasId,
|
|
577
|
+
contextType,
|
|
578
|
+
mode,
|
|
579
|
+
privacyMode,
|
|
580
|
+
seq: 0,
|
|
581
|
+
dirty: false,
|
|
582
|
+
everDirty: false,
|
|
583
|
+
pumpScheduled: false,
|
|
584
|
+
flushTimer: null,
|
|
585
|
+
stopped: false,
|
|
586
|
+
demoted: false,
|
|
587
|
+
hasVideoSlot: false,
|
|
588
|
+
contextLost: false,
|
|
589
|
+
taintEmitted: false,
|
|
590
|
+
lastW: canvas.width,
|
|
591
|
+
lastH: canvas.height,
|
|
592
|
+
removeContextLossListeners: null,
|
|
593
|
+
governor: new CanvasGovernor(now, state?.budgets?.governor),
|
|
594
|
+
encoder: null,
|
|
595
|
+
batchStart: 0,
|
|
596
|
+
flushChain: Promise.resolve(),
|
|
597
|
+
keyframes: null,
|
|
598
|
+
video: null,
|
|
599
|
+
mediaRecorder: null,
|
|
600
|
+
recorder2d: null,
|
|
601
|
+
recorderGL: null,
|
|
602
|
+
recorderGPU: null,
|
|
603
|
+
worker: null,
|
|
604
|
+
workerDone: null,
|
|
605
|
+
removeWorkerListener: null,
|
|
606
|
+
pixelSuppressed: false,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function dpr() {
|
|
610
|
+
return typeof window !== "undefined" ? window.devicePixelRatio : 1;
|
|
611
|
+
}
|
|
612
|
+
function emitStart(ctl) {
|
|
613
|
+
ctl.lastW = ctl.canvas.width;
|
|
614
|
+
ctl.lastH = ctl.canvas.height;
|
|
615
|
+
emitEvent({
|
|
616
|
+
op: "start",
|
|
617
|
+
canvasId: ctl.canvasId,
|
|
618
|
+
contextType: ctl.contextType,
|
|
619
|
+
mode: ctl.mode,
|
|
620
|
+
w: ctl.canvas.width,
|
|
621
|
+
h: ctl.canvas.height,
|
|
622
|
+
dpr: dpr(),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* A24 (command/keyframe mode): the canvas was resized mid-session. Video-mode
|
|
627
|
+
* controllers handle this inside VideoCapture (encoder restart); for command/
|
|
628
|
+
* keyframe streams the player learns the new size from a `resize` index event
|
|
629
|
+
* (new dims + DPR) plus a forced keyframe at the new size. Cheap and idempotent
|
|
630
|
+
* — only fires when the dimensions actually changed.
|
|
631
|
+
*/
|
|
632
|
+
function maybeEmitResize(ctl, t) {
|
|
633
|
+
const w = ctl.canvas.width;
|
|
634
|
+
const h = ctl.canvas.height;
|
|
635
|
+
if (w === ctl.lastW && h === ctl.lastH)
|
|
636
|
+
return;
|
|
637
|
+
ctl.lastW = w;
|
|
638
|
+
ctl.lastH = h;
|
|
639
|
+
if (ctl.mode === "video")
|
|
640
|
+
return; // handled by VideoCapture.requestRestart
|
|
641
|
+
emitEvent({ op: "resize", canvasId: ctl.canvasId, w, h, dpr: dpr() });
|
|
642
|
+
// [audit B1] A masked draw THIS frame may have an UNCOMPUTABLE bbox
|
|
643
|
+
// (keyframeUnsafe: ctx.fill(Path2D) / degenerate CTM / zero-area) — its pixels
|
|
644
|
+
// can't be region-blanked. The resize keyframe below would ship them in full,
|
|
645
|
+
// and the resetMaskedRegions() that follows clears the keyframeUnsafe latch
|
|
646
|
+
// BEFORE pumpCanvas's own suppression check (which runs AFTER this function) can
|
|
647
|
+
// act on it — so the leak would also disable all future suppression. Evaluate
|
|
648
|
+
// suppression HERE, first: tear down pixel capture and skip the leaking keyframe.
|
|
649
|
+
// (The computable-region case is still handled by takeKeyframe blanking
|
|
650
|
+
// maskedRegions below. [audit H-NEW-1])
|
|
651
|
+
if (ctl.recorder2d?.keyframeUnsafe && !ctl.pixelSuppressed) {
|
|
652
|
+
suppressPixelCapture(ctl);
|
|
653
|
+
}
|
|
654
|
+
// Force a fresh keyframe so replay re-anchors at the new size — UNLESS pixel
|
|
655
|
+
// capture is now suppressed (command-only replay). Take it BEFORE resetting
|
|
656
|
+
// masked regions: a masked draw may have happened THIS frame after the resize
|
|
657
|
+
// (the `canvas.width=` clear-then-redraw idiom), and its region must still blank
|
|
658
|
+
// this keyframe — resetting first would leak it. [audit H-NEW-1]
|
|
659
|
+
if (!ctl.pixelSuppressed)
|
|
660
|
+
ctl.keyframes?.takeKeyframe(t);
|
|
661
|
+
// Now drop the (old-size) accumulated regions so they don't over-blank or
|
|
662
|
+
// accumulate forever on a canvas that "clears" via resize. [audit H2]
|
|
663
|
+
ctl.recorder2d?.resetMaskedRegions();
|
|
664
|
+
}
|
|
665
|
+
// ─── Canvas discovery & routing ──────────────────────────────────────────────
|
|
666
|
+
function isMasked(canvas, maskSelector) {
|
|
667
|
+
try {
|
|
668
|
+
return !!canvas.closest(maskSelector);
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* A13 — project the recorder settings onto the classifier's privacy-settings
|
|
676
|
+
* shape. Empty-string selectors (the backend's "rule disabled" sentinel) are
|
|
677
|
+
* coerced to undefined so closestSafe/matchesSelectorSafe treat them as "no
|
|
678
|
+
* rule" rather than matching the empty selector.
|
|
679
|
+
*/
|
|
680
|
+
/**
|
|
681
|
+
* [audit B7] Return the NAMES of configured privacy selectors that are malformed
|
|
682
|
+
* (throw in `document.querySelector`). The matchers (closestSafe/
|
|
683
|
+
* matchesSelectorSafe) fail SAFE — a selector that throws in `el.matches()`/
|
|
684
|
+
* `el.closest()` simply doesn't match, so a typo in a block/mask/unmask selector
|
|
685
|
+
* silently DISABLES that rule (under-masking) with no signal. Surfacing the bad
|
|
686
|
+
* selector names in the recorder notes warns the operator without changing the
|
|
687
|
+
* fail-safe runtime behavior. SSR-guarded (no document on the server → []).
|
|
688
|
+
* Pure + exported so it's testable without the module's session state.
|
|
689
|
+
*/
|
|
690
|
+
export function collectMalformedSelectors(settings) {
|
|
691
|
+
if (typeof document === "undefined")
|
|
692
|
+
return [];
|
|
693
|
+
const named = [
|
|
694
|
+
["maskTextSelector", settings.maskTextSelector],
|
|
695
|
+
["blockSelector", settings.blockSelector],
|
|
696
|
+
["unmaskSelector", settings.unmaskSelector],
|
|
697
|
+
];
|
|
698
|
+
const bad = [];
|
|
699
|
+
for (const [name, sel] of named) {
|
|
700
|
+
if (!sel)
|
|
701
|
+
continue;
|
|
702
|
+
try {
|
|
703
|
+
document.querySelector(sel);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
bad.push(name);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return bad;
|
|
710
|
+
}
|
|
711
|
+
function derivePrivacySettings(settings) {
|
|
712
|
+
const nz = (s) => (s && s.length > 0 ? s : undefined);
|
|
713
|
+
return {
|
|
714
|
+
maskCanvasClass: settings.maskCanvasClass,
|
|
715
|
+
maskTextClass: nz(settings.maskTextClass),
|
|
716
|
+
maskTextSelector: nz(settings.maskTextSelector),
|
|
717
|
+
blockSelector: nz(settings.blockSelector),
|
|
718
|
+
unmaskSelector: nz(settings.unmaskSelector),
|
|
719
|
+
maskCanvasText: settings.maskCanvasText,
|
|
720
|
+
recordSsn: settings.recordSsn,
|
|
721
|
+
recordCreditCardInfo: settings.recordCreditCardInfo,
|
|
722
|
+
recordDob: settings.recordDob,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/** A13 — the content-detection flags used for normal-mode drawn-text redaction. */
|
|
726
|
+
function deriveContentFlags(settings) {
|
|
727
|
+
return {
|
|
728
|
+
recordSsn: settings.recordSsn,
|
|
729
|
+
recordCreditCardInfo: settings.recordCreditCardInfo,
|
|
730
|
+
recordDob: settings.recordDob,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* A13 — classify a canvas's privacy mode against the active privacy framework.
|
|
735
|
+
* Thin wrapper over classifyCanvasPrivacy so call sites read cleanly.
|
|
736
|
+
*/
|
|
737
|
+
function classify(canvas) {
|
|
738
|
+
if (!state)
|
|
739
|
+
return "normal";
|
|
740
|
+
return classifyCanvasPrivacy(canvas, state.privacySettings);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* A13 — true when at least one content-detection flag is OFF (privacy intent:
|
|
744
|
+
* "do not record this PII type"), so normal-mode canvases should still scan
|
|
745
|
+
* drawn text. When every flag is on (or unset), no normal-mode redaction is
|
|
746
|
+
* needed and the controller is built WITHOUT a textRedactor (zero hot-path cost).
|
|
747
|
+
*/
|
|
748
|
+
function contentDetectionActive() {
|
|
749
|
+
const f = state?.contentFlags;
|
|
750
|
+
if (!f)
|
|
751
|
+
return false;
|
|
752
|
+
return (f.recordCreditCardInfo === false ||
|
|
753
|
+
f.recordSsn === false ||
|
|
754
|
+
f.recordDob === false);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* A13 — build the text redactor installed on a controller's ContextRecorder.
|
|
758
|
+
* "redact" canvases get a full mask of every drawn string; "normal" canvases
|
|
759
|
+
* get content-detection redaction ONLY when a flag is off (else undefined so
|
|
760
|
+
* the recorder's hot path is untouched). "suppress" never reaches here.
|
|
761
|
+
*/
|
|
762
|
+
function makeTextRedactor(mode) {
|
|
763
|
+
if (mode === "redact") {
|
|
764
|
+
return (text) => redactCanvasText(text, "redact", {});
|
|
765
|
+
}
|
|
766
|
+
if (mode === "normal" && contentDetectionActive()) {
|
|
767
|
+
const flags = state.contentFlags;
|
|
768
|
+
return (text) => redactCanvasText(text, "normal", flags);
|
|
769
|
+
}
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* A13 — emit the op:"masked" index event for a suppressed canvas, carrying a
|
|
774
|
+
* `reason` ("block"|"mask") where determinable so the player and debugging
|
|
775
|
+
* can tell a block selector apart from the legacy mask-canvas class.
|
|
776
|
+
*/
|
|
777
|
+
function emitMasked(canvas, canvasId) {
|
|
778
|
+
const reason = state
|
|
779
|
+
? suppressReason(canvas, state.privacySettings)
|
|
780
|
+
: undefined;
|
|
781
|
+
const payload = { op: "masked", canvasId };
|
|
782
|
+
if (reason)
|
|
783
|
+
payload.reason = reason;
|
|
784
|
+
emitEvent(payload);
|
|
785
|
+
// A13: remember this canvas as suppressed so A6 re-eval can RE-ARM it if the
|
|
786
|
+
// privacy rule is later removed (normal/redact mid-session).
|
|
787
|
+
state?.suppressed.add(canvas);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* A13 — a "redact" canvas whose text lives in PIXELS (WebGL / WebGPU draw glyph
|
|
791
|
+
* geometry; worker / OffscreenCanvas serializes its own batches with no DOM to
|
|
792
|
+
* classify) CANNOT have its text redacted via 2D fillText arg masking. For those
|
|
793
|
+
* we ESCALATE redact → suppress: record nothing rather than leak rendered text
|
|
794
|
+
* pixels. Returns the effective mode to build the controller in.
|
|
795
|
+
*/
|
|
796
|
+
function escalateRedactIfPixelText(mode, contextType) {
|
|
797
|
+
if (mode !== "redact")
|
|
798
|
+
return mode;
|
|
799
|
+
if (contextType === "webgl" ||
|
|
800
|
+
contextType === "webgl2" ||
|
|
801
|
+
contextType === "experimental-webgl" ||
|
|
802
|
+
contextType === "webgpu" ||
|
|
803
|
+
contextType === "worker") {
|
|
804
|
+
noteOnce(`redact canvas with pixel-only text (${contextType}) — escalated to suppress`);
|
|
805
|
+
return "suppress";
|
|
806
|
+
}
|
|
807
|
+
return mode;
|
|
808
|
+
}
|
|
809
|
+
/** Resolve the rrweb mirror id, retrying briefly (new nodes get ids once the
|
|
810
|
+
* mutation that added them is serialized). */
|
|
811
|
+
function resolveCanvasId(canvas) {
|
|
812
|
+
return new Promise((resolve) => {
|
|
813
|
+
let tries = 0;
|
|
814
|
+
const attempt = () => {
|
|
815
|
+
if (!state || state.stopped)
|
|
816
|
+
return resolve(null);
|
|
817
|
+
let id = -1;
|
|
818
|
+
try {
|
|
819
|
+
id = state.deps.mirror.getId(canvas);
|
|
820
|
+
}
|
|
821
|
+
catch { /* mirror unavailable */ }
|
|
822
|
+
if (typeof id === "number" && id >= 0)
|
|
823
|
+
return resolve(String(id));
|
|
824
|
+
if (++tries > 30) {
|
|
825
|
+
noteOnce("canvas never received an rrweb mirror id — not recorded");
|
|
826
|
+
return resolve(null);
|
|
827
|
+
}
|
|
828
|
+
setTimeout(attempt, 300);
|
|
829
|
+
};
|
|
830
|
+
attempt();
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* A13 — re-arm a canvas whose privacy classification changed in a way that
|
|
835
|
+
* requires a fresh controller (suppress→normal/redact, or normal↔redact). Drops
|
|
836
|
+
* the `handled`/`suppressed` markers so the next `considerCanvas` re-discovers
|
|
837
|
+
* and rebuilds it in the new mode. Safe to call for a connected canvas only.
|
|
838
|
+
*/
|
|
839
|
+
function rearmCanvas(canvas) {
|
|
840
|
+
const s = state;
|
|
841
|
+
if (!s || s.stopped)
|
|
842
|
+
return;
|
|
843
|
+
s.handled.delete(canvas);
|
|
844
|
+
s.suppressed.delete(canvas);
|
|
845
|
+
// Re-discover via the normal path (resolves context + classifies + builds).
|
|
846
|
+
considerCanvas(canvas);
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* A6 + A13 — dynamic privacy re-evaluation. Privacy selectors (mask-canvas,
|
|
850
|
+
* block, maskText, unmask) may apply to a canvas OR any
|
|
851
|
+
* ancestor MID-SESSION (a privacy toggle adds/removes a class, an unmask region
|
|
852
|
+
* is added). This re-classifies every TRACKED canvas AND every
|
|
853
|
+
* SUPPRESSED canvas after a relevant mutation and applies any transition:
|
|
854
|
+
*
|
|
855
|
+
* tracked → suppress : teardown, emit op:"masked", remember as suppressed
|
|
856
|
+
* tracked normal ↔ redact : teardown + RE-ARM (rebuild in the new mode — the
|
|
857
|
+
* recorder's redactor + pixel-capture config differ)
|
|
858
|
+
* suppressed → normal/redact : RE-ARM (re-discover + start recording) — fixes
|
|
859
|
+
* the one-way freeze where an unmasked canvas stayed
|
|
860
|
+
* permanently off.
|
|
861
|
+
*
|
|
862
|
+
* The previous A6 implementation only handled tracked→suppress and left a
|
|
863
|
+
* canvas permanently `handled`; A13 makes every transition bidirectional.
|
|
864
|
+
*/
|
|
865
|
+
function reevaluateAllMasking() {
|
|
866
|
+
const s = state;
|
|
867
|
+
if (!s || s.stopped)
|
|
868
|
+
return;
|
|
869
|
+
// 1. Tracked canvases: reclassify and apply transitions. Snapshot first —
|
|
870
|
+
// teardown/rearm mutate s.controllers.
|
|
871
|
+
for (const ctl of [...s.controllers.values()]) {
|
|
872
|
+
if (ctl.stopped)
|
|
873
|
+
continue;
|
|
874
|
+
const canvas = ctl.canvas;
|
|
875
|
+
const nextMode = classifyCanvasPrivacy(canvas, s.privacySettings);
|
|
876
|
+
if (nextMode === "suppress") {
|
|
877
|
+
const canvasId = ctl.canvasId;
|
|
878
|
+
teardownController(ctl);
|
|
879
|
+
s.handled.add(canvas);
|
|
880
|
+
emitMasked(canvas, canvasId); // records in suppressed + emits reason
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (nextMode !== ctl.privacyMode) {
|
|
884
|
+
// normal ↔ redact transition: the recorder's text redactor and pixel
|
|
885
|
+
// capture (keyframes/video, demote eligibility) all differ between modes.
|
|
886
|
+
// Tear down and rebuild fresh in the new mode rather than mutate a live
|
|
887
|
+
// recorder in place (simpler + correct: a redact→normal canvas re-enables
|
|
888
|
+
// keyframes, a normal→redact canvas suppresses them and re-installs the
|
|
889
|
+
// full-mask redactor).
|
|
890
|
+
teardownController(ctl);
|
|
891
|
+
if (canvas.isConnected)
|
|
892
|
+
rearmCanvas(canvas);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// 2. Suppressed canvases: re-arm any that are no longer suppressed (the
|
|
896
|
+
// privacy rule was removed mid-session). Snapshot the set — rearmCanvas
|
|
897
|
+
// mutates it.
|
|
898
|
+
for (const canvas of [...s.suppressed]) {
|
|
899
|
+
if (!canvas.isConnected) {
|
|
900
|
+
s.suppressed.delete(canvas);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const nextMode = classifyCanvasPrivacy(canvas, s.privacySettings);
|
|
904
|
+
if (nextMode !== "suppress") {
|
|
905
|
+
rearmCanvas(canvas);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* A22 — a canvas left the DOM. If it was tracked, tear down its controller so
|
|
911
|
+
* the recorder, video-encoder worker, and keyframe scratch canvas are released
|
|
912
|
+
* instead of leaking for the rest of the session. A move (remove+add in the
|
|
913
|
+
* same batch) leaves the canvas still connected — skip those so a relocated
|
|
914
|
+
* canvas keeps recording. A genuinely removed canvas is also un-`handled` so a
|
|
915
|
+
* later re-attach re-discovers it fresh (consistent with the disconnected-
|
|
916
|
+
* canvas policy in considerCanvas).
|
|
917
|
+
*/
|
|
918
|
+
function onCanvasRemoved(canvas) {
|
|
919
|
+
const s = state;
|
|
920
|
+
if (!s || s.stopped)
|
|
921
|
+
return;
|
|
922
|
+
if (canvas.isConnected)
|
|
923
|
+
return; // moved within the DOM, not removed
|
|
924
|
+
const ctl = s.controllers.get(canvas);
|
|
925
|
+
if (ctl) {
|
|
926
|
+
// Best-effort terminal flush is intentionally skipped: a removed canvas's
|
|
927
|
+
// GPU/2D buffers are gone, so flushing would emit garbage. We still emit
|
|
928
|
+
// op:"stop" so the player knows the stream ended here.
|
|
929
|
+
const canvasId = ctl.canvasId;
|
|
930
|
+
teardownController(ctl);
|
|
931
|
+
emitEvent({ op: "stop", canvasId });
|
|
932
|
+
}
|
|
933
|
+
// Allow re-discovery if it comes back.
|
|
934
|
+
s.handled.delete(canvas);
|
|
935
|
+
s.suppressed.delete(canvas); // A13: no longer a candidate for re-arm
|
|
936
|
+
}
|
|
937
|
+
function considerCanvas(canvas) {
|
|
938
|
+
const s = state;
|
|
939
|
+
if (!s || s.stopped || s.handled.has(canvas))
|
|
940
|
+
return;
|
|
941
|
+
// Invisible canvases record NOTHING (Phase 0.5 E2E, found on /editor):
|
|
942
|
+
// canvases outside the document — Konva hit canvases, framework buffer
|
|
943
|
+
// canvases — are never user-visible, never receive an rrweb mirror id
|
|
944
|
+
// (resolveCanvasId would spin for 9s), and recording them wastes customer
|
|
945
|
+
// bytes. Their auto recorder is released so a constantly-redrawing hit
|
|
946
|
+
// canvas cannot grow an unbounded BufferSink. Deliberately NOT marked
|
|
947
|
+
// handled: if the canvas is attached to the DOM later, the
|
|
948
|
+
// MutationObserver re-considers it (pre-attach pixels arrive via the
|
|
949
|
+
// first keyframe after attach).
|
|
950
|
+
if (!canvas.isConnected) {
|
|
951
|
+
releaseAutoRecorder(canvas);
|
|
952
|
+
noteOnce("disconnected canvas skipped — recorded only if attached to the document");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
// A13: classify against the full privacy framework (unmask > block/
|
|
956
|
+
// mask-canvas > maskText > normal). "suppress" tears down here with an
|
|
957
|
+
// op:"masked" index event (reason where determinable).
|
|
958
|
+
const mode = classify(canvas);
|
|
959
|
+
if (mode === "suppress") {
|
|
960
|
+
s.handled.add(canvas);
|
|
961
|
+
void resolveCanvasId(canvas).then((id) => {
|
|
962
|
+
if (id != null)
|
|
963
|
+
emitMasked(canvas, id);
|
|
964
|
+
});
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const worker = getWorkerForCanvas(canvas);
|
|
968
|
+
const info = getContextInfo(canvas);
|
|
969
|
+
// No known context yet — wait for the getContext auto-attach path, a
|
|
970
|
+
// worker association, or first untracked-context activity.
|
|
971
|
+
if (!worker && !info)
|
|
972
|
+
return;
|
|
973
|
+
s.handled.add(canvas);
|
|
974
|
+
void resolveCanvasId(canvas).then((canvasId) => {
|
|
975
|
+
if (canvasId == null || !state || state.stopped)
|
|
976
|
+
return;
|
|
977
|
+
if (worker) {
|
|
978
|
+
// A13: a worker/OffscreenCanvas serializes its own batches with no DOM —
|
|
979
|
+
// reliable worker-side text redaction + keyframe suppression is hard, so a
|
|
980
|
+
// worker canvas that classifies "redact" is ESCALATED to suppress (record
|
|
981
|
+
// nothing). "normal" worker canvases record as today.
|
|
982
|
+
if (mode === "redact") {
|
|
983
|
+
noteOnce("redact worker/offscreen canvas — escalated to suppress (no DOM to redact)");
|
|
984
|
+
emitMasked(canvas, canvasId);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
void buildWorkerController(canvas, canvasId, worker);
|
|
988
|
+
}
|
|
989
|
+
else if (info.type === "2d") {
|
|
990
|
+
build2DController(canvas, canvasId, null, mode);
|
|
991
|
+
}
|
|
992
|
+
else if (info.type === "webgl" || info.type === "webgl2" || info.type === "experimental-webgl") {
|
|
993
|
+
// A13: GL text is rendered glyph geometry (pixels), not redactable
|
|
994
|
+
// fillText args — a "redact" GL canvas escalates to suppress.
|
|
995
|
+
const eff = escalateRedactIfPixelText(mode, info.type);
|
|
996
|
+
if (eff === "suppress") {
|
|
997
|
+
emitMasked(canvas, canvasId);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
buildGLController(canvas, canvasId, info.type, null, eff);
|
|
1001
|
+
}
|
|
1002
|
+
else if (info.type === "webgpu") {
|
|
1003
|
+
// A13: WebGPU text is also pixels — "redact" escalates to suppress.
|
|
1004
|
+
const eff = escalateRedactIfPixelText(mode, "webgpu");
|
|
1005
|
+
if (eff === "suppress") {
|
|
1006
|
+
emitMasked(canvas, canvasId);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
buildGPUController(canvas, canvasId, eff);
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
// bitmaprenderer etc. — no command hooks, no dirty signal: not captured.
|
|
1013
|
+
const ctl = baseController(canvas, canvasId, "unknown", "none");
|
|
1014
|
+
state.controllers.set(canvas, ctl);
|
|
1015
|
+
emitStart(ctl);
|
|
1016
|
+
noteOnce(`unsupported context type "${info.type}" — not captured`);
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
function build2DController(canvas, canvasId, preAttached, privacyMode = "normal") {
|
|
1021
|
+
const s = state;
|
|
1022
|
+
if (!s || s.stopped)
|
|
1023
|
+
return;
|
|
1024
|
+
const ctl = baseController(canvas, canvasId, "2d", "commands", privacyMode);
|
|
1025
|
+
ctl.encoder = new CommandEncoder();
|
|
1026
|
+
const sink = makeCommandSink(ctl);
|
|
1027
|
+
let rec = preAttached;
|
|
1028
|
+
if (rec) {
|
|
1029
|
+
rec.swapSink(sink); // drains the temporary BufferSink
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
rec = attachRecorder(canvas, sink, now, "cas", encodeImage, { retainAssets: false });
|
|
1033
|
+
}
|
|
1034
|
+
if (!rec) {
|
|
1035
|
+
ctl.mode = "none";
|
|
1036
|
+
noteOnce("canvas has no readable 2D context — nothing recorded");
|
|
1037
|
+
}
|
|
1038
|
+
// A13: install the privacy text redactor on the recorder. "redact" masks all
|
|
1039
|
+
// drawn text; "normal" content-detects when a record* flag is off; otherwise
|
|
1040
|
+
// undefined (hot path untouched). P1: the resolved global redaction style drives
|
|
1041
|
+
// how masked text/shapes are baked (per-call maskCanvasDraws overrides it).
|
|
1042
|
+
if (rec) {
|
|
1043
|
+
rec.textRedactor = makeTextRedactor(privacyMode);
|
|
1044
|
+
rec.redactStyle = s.redactStyle;
|
|
1045
|
+
}
|
|
1046
|
+
ctl.recorder2d = rec;
|
|
1047
|
+
// P1: a "redact" 2D canvas now KEEPS keyframes — the masked TEXT regions are
|
|
1048
|
+
// blanked on every keyframe (region-blanking via recorder.maskedRegions), so
|
|
1049
|
+
// real text pixels never ship while the rest of the canvas stays pixel-perfect.
|
|
1050
|
+
// It still never demotes to video (maybeDemote vetoes redact) because video
|
|
1051
|
+
// frames can't be region-blanked. (GL/GPU/worker redact canvases — whose text
|
|
1052
|
+
// is pixels — escalate to suppress upstream in escalateRedactIfPixelText.)
|
|
1053
|
+
ctl.keyframes = makeKeyframes(ctl);
|
|
1054
|
+
s.controllers.set(canvas, ctl);
|
|
1055
|
+
emitStart(ctl);
|
|
1056
|
+
maybeInitialKeyframe(ctl); // A27 (no-op when keyframes suppressed)
|
|
1057
|
+
}
|
|
1058
|
+
function buildGLController(canvas, canvasId, rawType, preAttachedCtx, privacyMode = "normal") {
|
|
1059
|
+
const s = state;
|
|
1060
|
+
if (!s || s.stopped)
|
|
1061
|
+
return;
|
|
1062
|
+
const contextType = rawType === "experimental-webgl" ? "webgl" : rawType;
|
|
1063
|
+
// A13: a "redact" GL canvas escalates to suppress upstream (GL text is
|
|
1064
|
+
// pixels). If one slips through, escalate here too — never build a redact GL
|
|
1065
|
+
// controller (it could not redact glyph geometry).
|
|
1066
|
+
if (escalateRedactIfPixelText(privacyMode, contextType) === "suppress") {
|
|
1067
|
+
emitMasked(canvas, canvasId);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const ctl = baseController(canvas, canvasId, contextType, "commands", privacyMode);
|
|
1071
|
+
ctl.encoder = new CommandEncoder();
|
|
1072
|
+
const sink = makeCommandSink(ctl);
|
|
1073
|
+
let rec;
|
|
1074
|
+
if (preAttachedCtx) {
|
|
1075
|
+
rec = attachGLRecorderToContext(preAttachedCtx, sink, now);
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
rec = attachGLRecorder(canvas, rawType, sink, now);
|
|
1079
|
+
}
|
|
1080
|
+
if (!rec) {
|
|
1081
|
+
ctl.mode = "none";
|
|
1082
|
+
noteOnce("GL context not reachable — nothing recorded");
|
|
1083
|
+
}
|
|
1084
|
+
ctl.recorderGL = rec;
|
|
1085
|
+
ctl.keyframes = makeKeyframes(ctl);
|
|
1086
|
+
installGLContextLossHandling(ctl); // A10
|
|
1087
|
+
s.controllers.set(canvas, ctl);
|
|
1088
|
+
emitStart(ctl);
|
|
1089
|
+
maybeInitialKeyframe(ctl); // A27
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* A10 (WebGL) — context-loss recovery. A GPU reset / driver crash / tab
|
|
1093
|
+
* backgrounding fires `webglcontextlost` on the canvas; without handling, the
|
|
1094
|
+
* recorder keeps emitting ops that reference dead GL objects (garbage replay)
|
|
1095
|
+
* and never re-keys after restore. We:
|
|
1096
|
+
* - on lost: preventDefault (REQUIRED for the browser to later fire
|
|
1097
|
+
* `webglcontextrestored`), mark the controller contextLost (pump + command
|
|
1098
|
+
* sink drop everything), flush the pending batch, emit op:"context-lost".
|
|
1099
|
+
* - on restored: attach a FRESH GL recorder to the restored context (interned
|
|
1100
|
+
* object ids reset — the app re-creates its GL resources after restore and
|
|
1101
|
+
* those creation calls are captured anew), clear contextLost, emit
|
|
1102
|
+
* op:"context-restored", and force a keyframe so the stream re-anchors.
|
|
1103
|
+
*/
|
|
1104
|
+
function installGLContextLossHandling(ctl) {
|
|
1105
|
+
const canvas = ctl.canvas;
|
|
1106
|
+
const onLost = (e) => {
|
|
1107
|
+
// Without preventDefault the context is NOT restorable (spec).
|
|
1108
|
+
try {
|
|
1109
|
+
e.preventDefault();
|
|
1110
|
+
}
|
|
1111
|
+
catch { /* best effort */ }
|
|
1112
|
+
if (ctl.stopped || ctl.contextLost)
|
|
1113
|
+
return;
|
|
1114
|
+
ctl.contextLost = true;
|
|
1115
|
+
try {
|
|
1116
|
+
flushBatch(ctl, now());
|
|
1117
|
+
}
|
|
1118
|
+
catch { /* best effort */ }
|
|
1119
|
+
emitEvent({ op: "context-lost", canvasId: ctl.canvasId });
|
|
1120
|
+
};
|
|
1121
|
+
const onRestored = () => {
|
|
1122
|
+
if (ctl.stopped || !ctl.contextLost)
|
|
1123
|
+
return;
|
|
1124
|
+
// Re-attach a fresh recorder to the restored context.
|
|
1125
|
+
try {
|
|
1126
|
+
if (ctl.recorderGL?.rawCtx)
|
|
1127
|
+
detachGLRecorderFromContext(ctl.recorderGL.rawCtx);
|
|
1128
|
+
}
|
|
1129
|
+
catch { /* best effort */ }
|
|
1130
|
+
const rawType = ctl.contextType === "webgl2" ? "webgl2" : "webgl";
|
|
1131
|
+
const rec = attachGLRecorder(canvas, rawType, makeCommandSink(ctl), now);
|
|
1132
|
+
ctl.recorderGL = rec;
|
|
1133
|
+
ctl.contextLost = false;
|
|
1134
|
+
emitEvent({ op: "context-restored", canvasId: ctl.canvasId, w: canvas.width, h: canvas.height, dpr: dpr() });
|
|
1135
|
+
// Force a fresh keyframe once the context is live again so replay re-keys.
|
|
1136
|
+
ctl.everDirty = true;
|
|
1137
|
+
markDirty(ctl);
|
|
1138
|
+
ctl.keyframes?.onInteraction(now()); // request a near-term keyframe in-task
|
|
1139
|
+
};
|
|
1140
|
+
canvas.addEventListener("webglcontextlost", onLost, false);
|
|
1141
|
+
canvas.addEventListener("webglcontextrestored", onRestored, false);
|
|
1142
|
+
ctl.removeContextLossListeners = () => {
|
|
1143
|
+
canvas.removeEventListener("webglcontextlost", onLost, false);
|
|
1144
|
+
canvas.removeEventListener("webglcontextrestored", onRestored, false);
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
function buildGPUController(canvas, canvasId, privacyMode = "normal") {
|
|
1148
|
+
const s = state;
|
|
1149
|
+
if (!s || s.stopped)
|
|
1150
|
+
return;
|
|
1151
|
+
// A13: WebGPU text is pixels — a "redact" GPU canvas escalates to suppress.
|
|
1152
|
+
if (escalateRedactIfPixelText(privacyMode, "webgpu") === "suppress") {
|
|
1153
|
+
emitMasked(canvas, canvasId);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const ctl = baseController(canvas, canvasId, "webgpu", "commands", privacyMode);
|
|
1157
|
+
ctl.encoder = new CommandEncoder();
|
|
1158
|
+
const rec = attachGPURecorder(canvas, makeCommandSink(ctl), now);
|
|
1159
|
+
if (!rec) {
|
|
1160
|
+
// GPU stream is page-global (one canvas per page in this increment), or
|
|
1161
|
+
// the pipeline predates recording. Timer-paced readback of a GPU canvas
|
|
1162
|
+
// is exactly the guardrail-#6 hazard, so there is no safe fallback here.
|
|
1163
|
+
ctl.mode = "none";
|
|
1164
|
+
noteOnce("WebGPU recorder not armed for this canvas — nothing recorded");
|
|
1165
|
+
}
|
|
1166
|
+
ctl.recorderGPU = rec;
|
|
1167
|
+
ctl.keyframes = makeKeyframes(ctl);
|
|
1168
|
+
installGPUContextLossHandling(ctl); // A10
|
|
1169
|
+
s.controllers.set(canvas, ctl);
|
|
1170
|
+
emitStart(ctl);
|
|
1171
|
+
maybeInitialKeyframe(ctl); // A27
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* A10 (WebGPU) — device-loss recovery. WebGPU has no contextlost event; the
|
|
1175
|
+
* device's `lost` promise is watched in patchWebGPU and reported here. On loss
|
|
1176
|
+
* we mark the controller contextLost (pump + command sink drop everything) and
|
|
1177
|
+
* emit op:"context-lost"; when the app acquires a NEW device the notifier fires
|
|
1178
|
+
* with lost=false and we clear the state, emit op:"context-restored", and force
|
|
1179
|
+
* a keyframe so replay re-anchors. The GPU stream is page-global in this
|
|
1180
|
+
* increment, so a single controller owns the notifier.
|
|
1181
|
+
*/
|
|
1182
|
+
function installGPUContextLossHandling(ctl) {
|
|
1183
|
+
setGPUDeviceLostNotifier((lost) => {
|
|
1184
|
+
if (ctl.stopped)
|
|
1185
|
+
return;
|
|
1186
|
+
if (lost) {
|
|
1187
|
+
if (ctl.contextLost)
|
|
1188
|
+
return;
|
|
1189
|
+
ctl.contextLost = true;
|
|
1190
|
+
try {
|
|
1191
|
+
flushBatch(ctl, now());
|
|
1192
|
+
}
|
|
1193
|
+
catch { /* best effort */ }
|
|
1194
|
+
emitEvent({ op: "context-lost", canvasId: ctl.canvasId });
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
if (!ctl.contextLost)
|
|
1198
|
+
return;
|
|
1199
|
+
ctl.contextLost = false;
|
|
1200
|
+
emitEvent({
|
|
1201
|
+
op: "context-restored",
|
|
1202
|
+
canvasId: ctl.canvasId,
|
|
1203
|
+
w: ctl.canvas.width,
|
|
1204
|
+
h: ctl.canvas.height,
|
|
1205
|
+
dpr: dpr(),
|
|
1206
|
+
});
|
|
1207
|
+
ctl.everDirty = true;
|
|
1208
|
+
markDirty(ctl);
|
|
1209
|
+
ctl.keyframes?.onInteraction(now());
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
ctl.removeContextLossListeners = () => setGPUDeviceLostNotifier(null);
|
|
1213
|
+
}
|
|
1214
|
+
async function buildWorkerController(canvas, canvasId, worker) {
|
|
1215
|
+
const hasShim = await pingWorkerShim(worker);
|
|
1216
|
+
const s = state;
|
|
1217
|
+
if (!s || s.stopped)
|
|
1218
|
+
return;
|
|
1219
|
+
if (hasShim) {
|
|
1220
|
+
const ctl = baseController(canvas, canvasId, "worker", "commands");
|
|
1221
|
+
ctl.worker = worker;
|
|
1222
|
+
let resolveDone;
|
|
1223
|
+
ctl.workerDone = new Promise((r) => { resolveDone = r; });
|
|
1224
|
+
const onMsg = (e) => {
|
|
1225
|
+
const m = e.data;
|
|
1226
|
+
if (m?.type === "sf-record-done")
|
|
1227
|
+
resolveDone(m);
|
|
1228
|
+
else if (m?.type === "sf-record-noctx") {
|
|
1229
|
+
// Shim present but the worker has no 2D context to record (GL/GPU
|
|
1230
|
+
// worker canvases): fall back to interval-paced video of the
|
|
1231
|
+
// placeholder, exactly like the no-shim path (contract §5).
|
|
1232
|
+
if (!ctl.stopped && ctl.mode === "commands" && !ctl.video && !ctl.mediaRecorder) {
|
|
1233
|
+
// A25: respect the global encoder cap — a worker placeholder canvas
|
|
1234
|
+
// also spins a real VideoEncoder, so degrade to keyframe-only when no
|
|
1235
|
+
// slot is free.
|
|
1236
|
+
const canVideo = VideoCapture.supported() && tryAcquireVideoSlot(ctl);
|
|
1237
|
+
if (VideoCapture.supported() && !canVideo) {
|
|
1238
|
+
noteOnce(`video-encoder cap (${state?.budgets.maxConcurrentVideo ?? MAX_CONCURRENT_VIDEO_CANVASES}) reached — worker canvas kept on keyframes`);
|
|
1239
|
+
}
|
|
1240
|
+
if (canVideo) {
|
|
1241
|
+
ctl.mode = "video";
|
|
1242
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "video", reason: "worker-no-2d-context" });
|
|
1243
|
+
const video = makeVideo(ctl, true);
|
|
1244
|
+
ctl.video = video;
|
|
1245
|
+
void video.start().then((ok) => {
|
|
1246
|
+
if (!ok && !ctl.stopped) {
|
|
1247
|
+
ctl.video = null;
|
|
1248
|
+
releaseVideoSlot(ctl); // A25
|
|
1249
|
+
ctl.mode = "none";
|
|
1250
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: "video-start-failed" });
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
else if (!VideoCapture.supported() && tryStartMediaRecorder(ctl, "worker-no-2d-context")) {
|
|
1255
|
+
// A41: WebCodecs absent — MediaRecorder fallback for the worker
|
|
1256
|
+
// placeholder. tryStartMediaRecorder set mode/index event + slot.
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
ctl.mode = "none";
|
|
1260
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: "worker-no-2d-context" });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
else if (m?.type === "sf-record-batch") {
|
|
1265
|
+
// live-streamed worker command batch (already deflated worker-side)
|
|
1266
|
+
sendFrame(ctl, "commands", { codec: "sfc-bin-1", compression: "deflate" }, new Uint8Array(m.deflated), m.tMs);
|
|
1267
|
+
ctl.governor.addBytes(m.deflated.byteLength);
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
worker.addEventListener("message", onMsg);
|
|
1271
|
+
ctl.removeWorkerListener = () => worker.removeEventListener("message", onMsg);
|
|
1272
|
+
try {
|
|
1273
|
+
// A13: forward the data-sensitivity flags so the worker redacts CC/SSN/DOB
|
|
1274
|
+
// in its own drawn-text args (selector suppression was already applied
|
|
1275
|
+
// main-thread; a worker reaching here is "normal" mode). The worker
|
|
1276
|
+
// installs a text redactor only when a flag is actually off.
|
|
1277
|
+
worker.postMessage({ type: "sf-record-start", contentFlags: s.contentFlags });
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
noteOnce("worker postMessage failed — worker canvas not recorded");
|
|
1281
|
+
}
|
|
1282
|
+
s.controllers.set(canvas, ctl);
|
|
1283
|
+
emitStart(ctl);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
// No shim in the worker: fall back to interval-paced video capture of the
|
|
1287
|
+
// placeholder (its committed frame persists like an image — safe to read
|
|
1288
|
+
// outside the rendering task).
|
|
1289
|
+
const ctl = baseController(canvas, canvasId, "worker", "none");
|
|
1290
|
+
s.controllers.set(canvas, ctl);
|
|
1291
|
+
// A25: a no-shim worker placeholder still drives a real VideoEncoder, so it
|
|
1292
|
+
// must hold a global slot. When WebCodecs is unavailable OR the cap is reached
|
|
1293
|
+
// there is no command/keyframe fallback for a worker placeholder (no draw
|
|
1294
|
+
// hooks, no readable main-thread context).
|
|
1295
|
+
if (VideoCapture.supported() && tryAcquireVideoSlot(ctl)) {
|
|
1296
|
+
ctl.mode = "video";
|
|
1297
|
+
const video = makeVideo(ctl, true);
|
|
1298
|
+
ctl.video = video;
|
|
1299
|
+
void video.start().then((ok) => {
|
|
1300
|
+
if (!ok && !ctl.stopped) {
|
|
1301
|
+
ctl.video = null;
|
|
1302
|
+
releaseVideoSlot(ctl); // A25
|
|
1303
|
+
ctl.mode = "none";
|
|
1304
|
+
emitEvent({ op: "mode", canvasId: ctl.canvasId, mode: "none", reason: "video-start-failed" });
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
else if (VideoCapture.supported()) {
|
|
1309
|
+
noteOnce(`video-encoder cap (${state?.budgets.maxConcurrentVideo ?? MAX_CONCURRENT_VIDEO_CANVASES}) reached — worker placeholder canvas not captured`);
|
|
1310
|
+
}
|
|
1311
|
+
else if (tryStartMediaRecorder(ctl, "worker-no-shim")) {
|
|
1312
|
+
// A41: WebCodecs absent (Safari < 26) but MediaRecorder + captureStream
|
|
1313
|
+
// available — capture the worker placeholder via MediaRecorder instead.
|
|
1314
|
+
// tryStartMediaRecorder set mode/index event + slot.
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
noteOnce("worker canvas without shim and no usable video backend — not captured");
|
|
1318
|
+
}
|
|
1319
|
+
emitStart(ctl);
|
|
1320
|
+
}
|
|
1321
|
+
// ─── Untracked-context discovery (contexts created before hooks installed) ──
|
|
1322
|
+
function onUntracked2D(ctx) {
|
|
1323
|
+
const s = state;
|
|
1324
|
+
if (!s || s.stopped || s.seenContexts.has(ctx))
|
|
1325
|
+
return;
|
|
1326
|
+
s.seenContexts.add(ctx);
|
|
1327
|
+
const canvas = ctx.canvas;
|
|
1328
|
+
if (!(canvas instanceof HTMLCanvasElement))
|
|
1329
|
+
return; // OffscreenCanvas buffer ctx
|
|
1330
|
+
if (!canvas.isConnected)
|
|
1331
|
+
return; // framework buffer canvas — content arrives via per-draw assets
|
|
1332
|
+
if (s.handled.has(canvas))
|
|
1333
|
+
return;
|
|
1334
|
+
// A13: classify against the privacy framework. "suppress" tears down here.
|
|
1335
|
+
const mode = classify(canvas);
|
|
1336
|
+
if (mode === "suppress") {
|
|
1337
|
+
s.handled.add(canvas);
|
|
1338
|
+
void resolveCanvasId(canvas).then((id) => {
|
|
1339
|
+
if (id != null)
|
|
1340
|
+
emitMasked(canvas, id);
|
|
1341
|
+
});
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
s.handled.add(canvas);
|
|
1345
|
+
// Buffer from this moment; the controller swaps the real sink in once the
|
|
1346
|
+
// mirror id resolves.
|
|
1347
|
+
const rec = attachRecorderToContext(ctx, new BufferSink(), now, "cas", encodeImage, { retainAssets: false });
|
|
1348
|
+
void resolveCanvasId(canvas).then((canvasId) => {
|
|
1349
|
+
if (canvasId == null || !state || state.stopped) {
|
|
1350
|
+
detachRecorderFromContext(ctx);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
build2DController(canvas, canvasId, rec, mode);
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
function onUntrackedGL(ctx) {
|
|
1357
|
+
const s = state;
|
|
1358
|
+
if (!s || s.stopped || s.seenContexts.has(ctx))
|
|
1359
|
+
return;
|
|
1360
|
+
s.seenContexts.add(ctx);
|
|
1361
|
+
const canvas = ctx.canvas;
|
|
1362
|
+
if (!(canvas instanceof HTMLCanvasElement))
|
|
1363
|
+
return;
|
|
1364
|
+
if (!canvas.isConnected)
|
|
1365
|
+
return;
|
|
1366
|
+
if (s.handled.has(canvas))
|
|
1367
|
+
return;
|
|
1368
|
+
// A13: classify. "suppress" (block/mask) AND "redact" both tear down a
|
|
1369
|
+
// GL canvas — GL text is pixels, so redact escalates to suppress here too.
|
|
1370
|
+
const mode = classify(canvas);
|
|
1371
|
+
const rawTypeEarly = typeof WebGL2RenderingContext !== "undefined" && ctx instanceof WebGL2RenderingContext
|
|
1372
|
+
? "webgl2"
|
|
1373
|
+
: "webgl";
|
|
1374
|
+
if (mode === "suppress" || escalateRedactIfPixelText(mode, rawTypeEarly) === "suppress") {
|
|
1375
|
+
s.handled.add(canvas);
|
|
1376
|
+
void resolveCanvasId(canvas).then((id) => {
|
|
1377
|
+
if (id != null)
|
|
1378
|
+
emitMasked(canvas, id);
|
|
1379
|
+
});
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
s.handled.add(canvas);
|
|
1383
|
+
const rawType = rawTypeEarly;
|
|
1384
|
+
const buffer = new BufferSink();
|
|
1385
|
+
const rec = attachGLRecorderToContext(ctx, buffer, now);
|
|
1386
|
+
void resolveCanvasId(canvas).then((canvasId) => {
|
|
1387
|
+
if (canvasId == null || !state || state.stopped) {
|
|
1388
|
+
detachGLRecorderFromContext(ctx);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const s2 = state;
|
|
1392
|
+
const ctl = baseController(canvas, canvasId, rawType, "commands", mode);
|
|
1393
|
+
ctl.encoder = new CommandEncoder();
|
|
1394
|
+
rec.swapSink(makeCommandSink(ctl));
|
|
1395
|
+
ctl.recorderGL = rec;
|
|
1396
|
+
ctl.keyframes = makeKeyframes(ctl);
|
|
1397
|
+
installGLContextLossHandling(ctl); // A10
|
|
1398
|
+
s2.controllers.set(canvas, ctl);
|
|
1399
|
+
emitStart(ctl);
|
|
1400
|
+
maybeInitialKeyframe(ctl); // A27
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
|
1404
|
+
export function startCanvasRecording(deps) {
|
|
1405
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
1406
|
+
return; // SSR
|
|
1407
|
+
if (state)
|
|
1408
|
+
return;
|
|
1409
|
+
const maskClass = deps.settings.maskCanvasClass || DEFAULT_MASK_CLASS;
|
|
1410
|
+
// A30: resolve device-class budgets once (SSR-safe; desktop profile under SSR).
|
|
1411
|
+
const budgets = resolveDeviceBudgets();
|
|
1412
|
+
state = {
|
|
1413
|
+
deps,
|
|
1414
|
+
maskSelector: `.${maskClass}, [data-sf-mask]`,
|
|
1415
|
+
privacySettings: derivePrivacySettings(deps.settings),
|
|
1416
|
+
contentFlags: deriveContentFlags(deps.settings),
|
|
1417
|
+
redactStyle: resolveRedactStyle(DEFAULT_REDACT_STYLE, deps.settings.canvasRedactStyle),
|
|
1418
|
+
budgets,
|
|
1419
|
+
controllers: new Map(),
|
|
1420
|
+
handled: new WeakSet(),
|
|
1421
|
+
suppressed: new Set(),
|
|
1422
|
+
preExisting: new WeakSet(),
|
|
1423
|
+
seenContexts: new WeakSet(),
|
|
1424
|
+
observer: null,
|
|
1425
|
+
removeListeners: [],
|
|
1426
|
+
totalBytes: 0,
|
|
1427
|
+
budgetExceeded: false,
|
|
1428
|
+
lastBudgetTickMs: 0,
|
|
1429
|
+
notes: new Set(),
|
|
1430
|
+
stopped: false,
|
|
1431
|
+
videoSlots: new VideoSlotPool(budgets.maxConcurrentVideo),
|
|
1432
|
+
};
|
|
1433
|
+
// [audit B7] Warn (once, after `state` exists) about any malformed privacy
|
|
1434
|
+
// selector — it fails safe (under-masks) at runtime, so without this the
|
|
1435
|
+
// operator gets no signal that a block/mask/unmask rule is silently disabled.
|
|
1436
|
+
for (const name of collectMalformedSelectors(deps.settings)) {
|
|
1437
|
+
noteOnce(`malformed canvas privacy selector "${name}" is invalid and will not match — that privacy rule is disabled`);
|
|
1438
|
+
}
|
|
1439
|
+
// 1. Install + ARM all hooks BEFORE scanning, so contexts created from here
|
|
1440
|
+
// on buffer their creation-time ops (shader compiles, gradients...).
|
|
1441
|
+
installCommandHooks();
|
|
1442
|
+
installGLHooks();
|
|
1443
|
+
installGPUHooks();
|
|
1444
|
+
installWorkerRelay();
|
|
1445
|
+
setAutoAttach({ now, assetMode: "cas", encodeImage, retainAssets: false });
|
|
1446
|
+
setGLAutoAttach({ now });
|
|
1447
|
+
setGPUAutoAttach({ now });
|
|
1448
|
+
setContextCreatedNotifier((canvas) => {
|
|
1449
|
+
// let getContext return (and the auto recorder bind) before routing
|
|
1450
|
+
queueMicrotask(() => considerCanvas(canvas));
|
|
1451
|
+
});
|
|
1452
|
+
setUntracked2DContextNotifier(onUntracked2D);
|
|
1453
|
+
setUntrackedGLContextNotifier(onUntrackedGL);
|
|
1454
|
+
setWorkerAssociationNotifier((canvas) => considerCanvas(canvas));
|
|
1455
|
+
// 1d. Draw-site click-to-code hook (Phase 3b, contract §8). Build-time
|
|
1456
|
+
// stamping rewrites canvas call sites to `__sfds(ctx, id).method(...)`,
|
|
1457
|
+
// whose helper invokes globalThis.__sfDrawSite(ctx, id) — identity
|
|
1458
|
+
// passthrough that tags the recorded op for the matching context. Cheap
|
|
1459
|
+
// (two WeakMap lookups), synchronous, removed at stop. No-op for unrecorded
|
|
1460
|
+
// contexts (incl. WebGPU in this increment).
|
|
1461
|
+
const g = globalThis;
|
|
1462
|
+
g.__sfDrawSite = (o, id) => {
|
|
1463
|
+
if (o && typeof o === "object") {
|
|
1464
|
+
if (!noteCtxPendingSiteId(o, id))
|
|
1465
|
+
noteGLPendingSiteId(o, id);
|
|
1466
|
+
}
|
|
1467
|
+
return o;
|
|
1468
|
+
};
|
|
1469
|
+
// 1e. Framework semantic adapters (Phase 4, contract §9): on pointerdown
|
|
1470
|
+
// over a recorded canvas, the registered framework (Konva/Fabric/Chart.js/
|
|
1471
|
+
// Three/Pixi) resolves the clicked logical object and emits op:"hit". The
|
|
1472
|
+
// customer registers instances via the exported register* helpers.
|
|
1473
|
+
installAdapters({
|
|
1474
|
+
canvasIdFor: (canvas) => state?.controllers.get(canvas)?.canvasId ?? null,
|
|
1475
|
+
emitHit: (payload) => emitEvent(payload),
|
|
1476
|
+
now,
|
|
1477
|
+
});
|
|
1478
|
+
// 2. Interactions: forced keyframes / video boost (dirty-gated downstream).
|
|
1479
|
+
const onInteraction = () => {
|
|
1480
|
+
const s = state;
|
|
1481
|
+
if (!s || s.stopped)
|
|
1482
|
+
return;
|
|
1483
|
+
const t = now();
|
|
1484
|
+
for (const ctl of s.controllers.values()) {
|
|
1485
|
+
if (ctl.stopped)
|
|
1486
|
+
continue;
|
|
1487
|
+
if (ctl.mode === "video") {
|
|
1488
|
+
ctl.video?.onInteraction(t);
|
|
1489
|
+
ctl.mediaRecorder?.onInteraction(t);
|
|
1490
|
+
}
|
|
1491
|
+
else
|
|
1492
|
+
ctl.keyframes?.onInteraction(t);
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
window.addEventListener("pointerdown", onInteraction, { capture: true, passive: true });
|
|
1496
|
+
window.addEventListener("keydown", onInteraction, { capture: true, passive: true });
|
|
1497
|
+
state.removeListeners.push(() => {
|
|
1498
|
+
window.removeEventListener("pointerdown", onInteraction, { capture: true });
|
|
1499
|
+
window.removeEventListener("keydown", onInteraction, { capture: true });
|
|
1500
|
+
});
|
|
1501
|
+
// 3. Discovery: initial scan + MutationObserver for added canvases. Late
|
|
1502
|
+
// context creation is covered by the getContext notifier; contexts that
|
|
1503
|
+
// predate the hooks surface on their first draw (untracked notifiers).
|
|
1504
|
+
const observer = new MutationObserver((muts) => {
|
|
1505
|
+
let maskMayHaveChanged = false;
|
|
1506
|
+
for (const m of muts) {
|
|
1507
|
+
if (m.type === "attributes") {
|
|
1508
|
+
// A6 + A13: a class / data-sf-mask / id / style / data-* change anywhere
|
|
1509
|
+
// may change the privacy classification of a tracked OR suppressed
|
|
1510
|
+
// canvas (on the canvas itself or any ancestor) — the new privacy
|
|
1511
|
+
// selectors (block/maskText/unmask) can key off id, data-*, style, etc.
|
|
1512
|
+
// Defer the re-check to once per batch (it scans only the small
|
|
1513
|
+
// tracked-controller + suppressed sets).
|
|
1514
|
+
maskMayHaveChanged = true;
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
m.addedNodes.forEach((node) => {
|
|
1518
|
+
if (!(node instanceof Element))
|
|
1519
|
+
return;
|
|
1520
|
+
if (node instanceof HTMLCanvasElement)
|
|
1521
|
+
considerCanvas(node);
|
|
1522
|
+
else
|
|
1523
|
+
node.querySelectorAll("canvas").forEach((c) => considerCanvas(c));
|
|
1524
|
+
});
|
|
1525
|
+
// A22: a tracked canvas removed from the DOM must have its controller
|
|
1526
|
+
// torn down (recorder stopped, video worker terminated, scratch canvas
|
|
1527
|
+
// released) or it leaks for the rest of the session. Handle the canvas
|
|
1528
|
+
// itself and any canvases nested inside a removed subtree.
|
|
1529
|
+
m.removedNodes.forEach((node) => {
|
|
1530
|
+
if (!(node instanceof Element))
|
|
1531
|
+
return;
|
|
1532
|
+
if (node instanceof HTMLCanvasElement)
|
|
1533
|
+
onCanvasRemoved(node);
|
|
1534
|
+
else
|
|
1535
|
+
node.querySelectorAll("canvas").forEach((c) => onCanvasRemoved(c));
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
if (maskMayHaveChanged)
|
|
1539
|
+
reevaluateAllMasking();
|
|
1540
|
+
});
|
|
1541
|
+
observer.observe(document.documentElement, {
|
|
1542
|
+
childList: true,
|
|
1543
|
+
subtree: true,
|
|
1544
|
+
// A13: observe ALL attribute mutations subtree-wide (no attributeFilter).
|
|
1545
|
+
// The A6 filter was ["class","data-sf-mask"], but the privacy framework now
|
|
1546
|
+
// honors arbitrary admin selectors (block/maskText/unmask) that may key off
|
|
1547
|
+
// id, style, role, or any data-* attribute — a filtered observer would miss
|
|
1548
|
+
// a privacy toggle implemented as e.g. `data-privacy="on"` or an id swap.
|
|
1549
|
+
// COST: the observer callback fires on every attribute write anywhere in the
|
|
1550
|
+
// document, but the work per batch is gated to a single boolean + one
|
|
1551
|
+
// deferred reevaluateAllMasking() that scans only the small tracked +
|
|
1552
|
+
// suppressed canvas sets (typically 0-handful of canvases). attributeOldValue
|
|
1553
|
+
// is NOT requested, so the records stay minimal. This is the documented
|
|
1554
|
+
// trade-off: correctness for arbitrary selectors over a narrow filter.
|
|
1555
|
+
attributes: true,
|
|
1556
|
+
});
|
|
1557
|
+
state.observer = observer;
|
|
1558
|
+
// A27: mark every canvas present at load as pre-existing BEFORE routing it,
|
|
1559
|
+
// so its controller takes a one-shot keyframe of already-painted pixels even
|
|
1560
|
+
// if it never redraws. Marking happens here (not inside considerCanvas) so it
|
|
1561
|
+
// applies ONLY to the initial sweep — canvases added later go through the
|
|
1562
|
+
// MutationObserver / getContext paths and are covered by live keyframe pacing.
|
|
1563
|
+
document.querySelectorAll("canvas").forEach((c) => {
|
|
1564
|
+
state.preExisting.add(c);
|
|
1565
|
+
considerCanvas(c);
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* A22 / A6: synchronous teardown of one controller WITHOUT a final flush —
|
|
1570
|
+
* used when the canvas was removed from the DOM (A22) or masked mid-session
|
|
1571
|
+
* (A6). Stops the recorder, aborts the video encoder worker (no flush — the
|
|
1572
|
+
* content is gone or must not be recorded), disposes the keyframe scratch
|
|
1573
|
+
* canvas, detaches the patch recorder, removes any worker listener, and drops
|
|
1574
|
+
* the controller from state.controllers. Idempotent and best-effort: every
|
|
1575
|
+
* step is guarded so a teardown failure never breaks the host app.
|
|
1576
|
+
*/
|
|
1577
|
+
function teardownController(ctl) {
|
|
1578
|
+
if (ctl.stopped) {
|
|
1579
|
+
state?.controllers.delete(ctl.canvas);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
ctl.stopped = true;
|
|
1583
|
+
try {
|
|
1584
|
+
if (ctl.flushTimer !== null) {
|
|
1585
|
+
clearTimeout(ctl.flushTimer);
|
|
1586
|
+
ctl.flushTimer = null;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
catch { /* best effort */ }
|
|
1590
|
+
try {
|
|
1591
|
+
ctl.removeContextLossListeners?.();
|
|
1592
|
+
}
|
|
1593
|
+
catch { /* best effort */ }
|
|
1594
|
+
ctl.removeContextLossListeners = null;
|
|
1595
|
+
try {
|
|
1596
|
+
ctl.video?.abort();
|
|
1597
|
+
}
|
|
1598
|
+
catch { /* best effort */ }
|
|
1599
|
+
ctl.video = null;
|
|
1600
|
+
try {
|
|
1601
|
+
ctl.mediaRecorder?.abort();
|
|
1602
|
+
}
|
|
1603
|
+
catch { /* best effort */ } // A41
|
|
1604
|
+
ctl.mediaRecorder = null;
|
|
1605
|
+
releaseVideoSlot(ctl); // A25: free the encoder slot for another canvas
|
|
1606
|
+
try {
|
|
1607
|
+
ctl.keyframes?.dispose();
|
|
1608
|
+
}
|
|
1609
|
+
catch { /* best effort */ }
|
|
1610
|
+
ctl.keyframes = null;
|
|
1611
|
+
// Worker-relay controllers: tell the worker to stop recording (we discard its
|
|
1612
|
+
// result — this is a non-flushing teardown) and drop our message listener.
|
|
1613
|
+
if (ctl.worker) {
|
|
1614
|
+
try {
|
|
1615
|
+
ctl.worker.postMessage({ type: "sf-record-stop" });
|
|
1616
|
+
}
|
|
1617
|
+
catch { /* worker gone */ }
|
|
1618
|
+
}
|
|
1619
|
+
try {
|
|
1620
|
+
ctl.removeWorkerListener?.();
|
|
1621
|
+
}
|
|
1622
|
+
catch { /* best effort */ }
|
|
1623
|
+
ctl.removeWorkerListener = null;
|
|
1624
|
+
try {
|
|
1625
|
+
if (ctl.recorder2d)
|
|
1626
|
+
detachRecorderFromContext(ctl.recorder2d.rawCtx);
|
|
1627
|
+
}
|
|
1628
|
+
catch { /* best effort */ }
|
|
1629
|
+
try {
|
|
1630
|
+
if (ctl.recorderGL?.rawCtx)
|
|
1631
|
+
detachGLRecorderFromContext(ctl.recorderGL.rawCtx);
|
|
1632
|
+
}
|
|
1633
|
+
catch { /* best effort */ }
|
|
1634
|
+
try {
|
|
1635
|
+
ctl.recorderGPU?.detach();
|
|
1636
|
+
}
|
|
1637
|
+
catch { /* best effort */ }
|
|
1638
|
+
state?.controllers.delete(ctl.canvas);
|
|
1639
|
+
}
|
|
1640
|
+
/** [audit C1] Upper bound on a single recorder.settle() at teardown. The 2D CAS
|
|
1641
|
+
* `encodeImage` path awaits an encode-worker reply with no timeout of its own, so
|
|
1642
|
+
* a silently-dropped worker message would otherwise deadlock the clean stop. Match
|
|
1643
|
+
* the existing worker-stop bound (5 s). */
|
|
1644
|
+
const SETTLE_TIMEOUT_MS = 5000;
|
|
1645
|
+
/** [audit C1] Await a settle() but never longer than `ms`, and never reject — a
|
|
1646
|
+
* stuck/failed final encode must not hang or abort the rest of teardown. A late
|
|
1647
|
+
* asset that resolves after the timeout still ships best-effort (or is dropped by
|
|
1648
|
+
* sendFrame's `!state` guard once the session is fully gone). */
|
|
1649
|
+
export function settleOrTimeout(p, ms) {
|
|
1650
|
+
return Promise.race([
|
|
1651
|
+
p.catch(() => { }),
|
|
1652
|
+
new Promise((resolve) => setTimeout(resolve, ms)),
|
|
1653
|
+
]);
|
|
1654
|
+
}
|
|
1655
|
+
async function stopController(ctl, t) {
|
|
1656
|
+
if (ctl.stopped)
|
|
1657
|
+
return;
|
|
1658
|
+
ctl.stopped = true;
|
|
1659
|
+
// A10: drop context-loss listeners (GL element listeners / GPU notifier).
|
|
1660
|
+
try {
|
|
1661
|
+
ctl.removeContextLossListeners?.();
|
|
1662
|
+
}
|
|
1663
|
+
catch { /* best effort */ }
|
|
1664
|
+
ctl.removeContextLossListeners = null;
|
|
1665
|
+
if (ctl.flushTimer !== null) {
|
|
1666
|
+
clearTimeout(ctl.flushTimer);
|
|
1667
|
+
ctl.flushTimer = null;
|
|
1668
|
+
}
|
|
1669
|
+
if (ctl.worker && ctl.workerDone) {
|
|
1670
|
+
try {
|
|
1671
|
+
ctl.worker.postMessage({ type: "sf-record-stop" });
|
|
1672
|
+
}
|
|
1673
|
+
catch { /* worker gone */ }
|
|
1674
|
+
const m = await Promise.race([
|
|
1675
|
+
ctl.workerDone,
|
|
1676
|
+
new Promise((r) => setTimeout(() => r(null), 5000)),
|
|
1677
|
+
]);
|
|
1678
|
+
ctl.removeWorkerListener?.();
|
|
1679
|
+
if (m)
|
|
1680
|
+
await convertWorkerResult(ctl, m);
|
|
1681
|
+
if (ctl.video)
|
|
1682
|
+
await ctl.video.stop(); // worker noctx video fallback
|
|
1683
|
+
if (ctl.mediaRecorder)
|
|
1684
|
+
await ctl.mediaRecorder.stop(); // A41 worker fallback
|
|
1685
|
+
}
|
|
1686
|
+
else {
|
|
1687
|
+
flushBatch(ctl, t);
|
|
1688
|
+
if (ctl.video)
|
|
1689
|
+
await ctl.video.stop();
|
|
1690
|
+
if (ctl.mediaRecorder)
|
|
1691
|
+
await ctl.mediaRecorder.stop(); // A41: flush final chunk
|
|
1692
|
+
if (ctl.keyframes) {
|
|
1693
|
+
if (ctl.everDirty)
|
|
1694
|
+
ctl.keyframes.takeKeyframe(t); // final keyframe
|
|
1695
|
+
await ctl.keyframes.stop(t);
|
|
1696
|
+
}
|
|
1697
|
+
// [audit B12] DETACH BEFORE settle. settle() is async; while it awaits pending
|
|
1698
|
+
// asset encodes the context is still live, so a draw landing in that window
|
|
1699
|
+
// would record a NEW op (never flushed → lost) and push a NEW texture-encode
|
|
1700
|
+
// whose asset could ship AFTER this stop frame (orphan). Detaching first stops
|
|
1701
|
+
// recording, so the subsequent settle only drains already-captured assets.
|
|
1702
|
+
if (ctl.recorder2d) {
|
|
1703
|
+
detachRecorderFromContext(ctl.recorder2d.rawCtx);
|
|
1704
|
+
// [audit C2] A 2D draw can land during the awaited video/keyframe stops above
|
|
1705
|
+
// (after the first flushBatch), recording an op into the encoder that would
|
|
1706
|
+
// otherwise never be flushed. Detach has now stopped new recording, so flush
|
|
1707
|
+
// those trailing ops (no-op when the encoder is empty). Keeps detach AFTER the
|
|
1708
|
+
// final keyframe so masked-region tracking still blanks correctly (B12).
|
|
1709
|
+
flushBatch(ctl, t);
|
|
1710
|
+
// [audit C1] bound settle so a dropped encode-worker message can't deadlock stop.
|
|
1711
|
+
await settleOrTimeout(ctl.recorder2d.settle(), SETTLE_TIMEOUT_MS);
|
|
1712
|
+
}
|
|
1713
|
+
if (ctl.recorderGL) {
|
|
1714
|
+
if (ctl.recorderGL.rawCtx)
|
|
1715
|
+
detachGLRecorderFromContext(ctl.recorderGL.rawCtx);
|
|
1716
|
+
await settleOrTimeout(ctl.recorderGL.settle(), SETTLE_TIMEOUT_MS); // ship pending GL texture assets
|
|
1717
|
+
}
|
|
1718
|
+
ctl.recorderGPU?.detach();
|
|
1719
|
+
await ctl.flushChain;
|
|
1720
|
+
}
|
|
1721
|
+
releaseVideoSlot(ctl); // A25: free the encoder slot at clean stop
|
|
1722
|
+
emitEvent({ op: "stop", canvasId: ctl.canvasId });
|
|
1723
|
+
}
|
|
1724
|
+
async function convertWorkerResult(ctl, m) {
|
|
1725
|
+
for (const note of m.notes ?? [])
|
|
1726
|
+
noteOnce(`worker: ${note}`);
|
|
1727
|
+
for (const batch of m.batches ?? []) {
|
|
1728
|
+
try {
|
|
1729
|
+
const z = await deflate(batch);
|
|
1730
|
+
sendFrame(ctl, "commands", { codec: "sfc-bin-1", compression: "deflate" }, z);
|
|
1731
|
+
}
|
|
1732
|
+
catch { /* drop batch */ }
|
|
1733
|
+
}
|
|
1734
|
+
for (const a of m.assets ?? []) {
|
|
1735
|
+
try {
|
|
1736
|
+
if (a.bitmap) {
|
|
1737
|
+
const w = a.w ?? a.bitmap.width;
|
|
1738
|
+
const h = a.h ?? a.bitmap.height;
|
|
1739
|
+
const r = await encodeBitmap(a.bitmap, 0.85, true); // transfers the bitmap
|
|
1740
|
+
if (r.bytes)
|
|
1741
|
+
sendFrame(ctl, "asset", { assetId: a.id, format: "webp", w, h }, r.bytes);
|
|
1742
|
+
}
|
|
1743
|
+
else if (a.imageData) {
|
|
1744
|
+
const d = a.imageData;
|
|
1745
|
+
const z = await deflate(new Uint8Array(d.data.buffer, d.data.byteOffset, d.data.byteLength));
|
|
1746
|
+
sendFrame(ctl, "asset", { assetId: a.id, format: "imagedata", w: d.width, h: d.height }, z);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
catch { /* drop asset */ }
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
/** Flush + final keyframes + op:"stop" per canvas; idempotent. Prototype
|
|
1753
|
+
* patches stay installed but inert (contract §5). */
|
|
1754
|
+
export async function stopCanvasRecording() {
|
|
1755
|
+
const s = state;
|
|
1756
|
+
if (!s || s.stopped)
|
|
1757
|
+
return;
|
|
1758
|
+
s.stopped = true;
|
|
1759
|
+
// disarm discovery + auto-attach (patches remain, recorders release below)
|
|
1760
|
+
setAutoAttach(null);
|
|
1761
|
+
setGLAutoAttach(null);
|
|
1762
|
+
setGPUAutoAttach(null);
|
|
1763
|
+
setGPUDeviceLostNotifier(null); // A10
|
|
1764
|
+
setContextCreatedNotifier(null);
|
|
1765
|
+
setUntracked2DContextNotifier(null);
|
|
1766
|
+
setUntrackedGLContextNotifier(null);
|
|
1767
|
+
setWorkerAssociationNotifier(null);
|
|
1768
|
+
// remove the draw-site hook so stamped app code resumes identity passthrough
|
|
1769
|
+
delete globalThis.__sfDrawSite;
|
|
1770
|
+
uninstallAdapters();
|
|
1771
|
+
s.observer?.disconnect();
|
|
1772
|
+
for (const remove of s.removeListeners) {
|
|
1773
|
+
try {
|
|
1774
|
+
remove();
|
|
1775
|
+
}
|
|
1776
|
+
catch { /* already removed */ }
|
|
1777
|
+
}
|
|
1778
|
+
const t = now();
|
|
1779
|
+
await Promise.all([...s.controllers.values()].map((ctl) => stopController(ctl, t).catch(() => { })));
|
|
1780
|
+
pruneUnboundRecorders();
|
|
1781
|
+
pruneUnboundGLRecorders();
|
|
1782
|
+
pruneUnboundGPURecorders();
|
|
1783
|
+
terminateImgEncoder();
|
|
1784
|
+
state = null;
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* A23: NON-terminal flush — flush every controller's pending command batch
|
|
1788
|
+
* (the same flush the BATCH_MS timer / flushBatch does) and request each active
|
|
1789
|
+
* video encoder to drain its queued frames. Recording stays armed and resumes
|
|
1790
|
+
* normally if the page returns.
|
|
1791
|
+
*
|
|
1792
|
+
* Wired to visibilitychange:"hidden": on mobile a tab/app switch fires
|
|
1793
|
+
* visibilitychange(hidden) and the OS may kill the tab WITHOUT a reliable
|
|
1794
|
+
* pagehide, losing the last ≤1s command batch and the encoder tail. This pushes
|
|
1795
|
+
* that data out while the page is still alive. Idempotent and safe to call
|
|
1796
|
+
* repeatedly; a no-op when recording is inactive.
|
|
1797
|
+
*/
|
|
1798
|
+
export async function flushAllCanvas() {
|
|
1799
|
+
const s = state;
|
|
1800
|
+
if (!s || s.stopped)
|
|
1801
|
+
return;
|
|
1802
|
+
const t = now();
|
|
1803
|
+
const drains = [];
|
|
1804
|
+
for (const ctl of s.controllers.values()) {
|
|
1805
|
+
if (ctl.stopped)
|
|
1806
|
+
continue;
|
|
1807
|
+
// Flush pending command batch (worker controllers stream their batches
|
|
1808
|
+
// live and have no local encoder buffer, so flushBatch is a no-op there).
|
|
1809
|
+
try {
|
|
1810
|
+
flushBatch(ctl, t);
|
|
1811
|
+
}
|
|
1812
|
+
catch { /* never break the host app */ }
|
|
1813
|
+
// Drain the video encoder tail without tearing it down.
|
|
1814
|
+
if (ctl.video) {
|
|
1815
|
+
drains.push(ctl.video.flush().catch(() => { }));
|
|
1816
|
+
}
|
|
1817
|
+
// A41: drain the MediaRecorder fallback's pending chunk too.
|
|
1818
|
+
if (ctl.mediaRecorder) {
|
|
1819
|
+
drains.push(ctl.mediaRecorder.flush().catch(() => { }));
|
|
1820
|
+
}
|
|
1821
|
+
// Settle the command flush chain so deflated batches reach sendBinary.
|
|
1822
|
+
drains.push(ctl.flushChain.catch(() => { }));
|
|
1823
|
+
}
|
|
1824
|
+
await Promise.all(drains).catch(() => { });
|
|
1825
|
+
}
|
|
1826
|
+
/** Test/diagnostic hook. */
|
|
1827
|
+
export function isCanvasRecordingActive() {
|
|
1828
|
+
return state != null && !state.stopped;
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* A25 — test-only hooks for the concurrent-video-encoder cap. Exposes the real
|
|
1832
|
+
* `demote` decision path and slot accounting so a unit test can drive N+1
|
|
1833
|
+
* demotions deterministically WITHOUT spawning hardware encoders (the test
|
|
1834
|
+
* stubs `VideoCapture`). NOT part of the public SDK surface — tree-shaken from
|
|
1835
|
+
* production bundles since nothing in the app imports it. Each helper is a thin
|
|
1836
|
+
* wrapper over the same internal functions the live discovery path uses, so the
|
|
1837
|
+
* test exercises production behavior, not a parallel reimplementation.
|
|
1838
|
+
*/
|
|
1839
|
+
export const __canvasManagerTestHooks = {
|
|
1840
|
+
/** Spin up a minimal session state (no DOM scan / hooks) for slot testing. */
|
|
1841
|
+
initState(deps) {
|
|
1842
|
+
state = {
|
|
1843
|
+
deps,
|
|
1844
|
+
maskSelector: ".sailfishSanitize, [data-sf-mask]",
|
|
1845
|
+
privacySettings: derivePrivacySettings(deps.settings),
|
|
1846
|
+
contentFlags: deriveContentFlags(deps.settings),
|
|
1847
|
+
redactStyle: resolveRedactStyle(DEFAULT_REDACT_STYLE, deps.settings.canvasRedactStyle),
|
|
1848
|
+
budgets: resolveDeviceBudgets(),
|
|
1849
|
+
controllers: new Map(),
|
|
1850
|
+
handled: new WeakSet(),
|
|
1851
|
+
suppressed: new Set(),
|
|
1852
|
+
preExisting: new WeakSet(),
|
|
1853
|
+
seenContexts: new WeakSet(),
|
|
1854
|
+
observer: null,
|
|
1855
|
+
removeListeners: [],
|
|
1856
|
+
totalBytes: 0,
|
|
1857
|
+
budgetExceeded: false,
|
|
1858
|
+
lastBudgetTickMs: 0,
|
|
1859
|
+
notes: new Set(),
|
|
1860
|
+
stopped: false,
|
|
1861
|
+
videoSlots: new VideoSlotPool(resolveDeviceBudgets().maxConcurrentVideo),
|
|
1862
|
+
};
|
|
1863
|
+
},
|
|
1864
|
+
/** Register a synthetic command-mode 2D controller (no recorder/keyframes). */
|
|
1865
|
+
addController(canvas, canvasId, privacyMode = "normal") {
|
|
1866
|
+
const ctl = baseController(canvas, canvasId, "2d", "commands", privacyMode);
|
|
1867
|
+
state.controllers.set(canvas, ctl);
|
|
1868
|
+
return ctl;
|
|
1869
|
+
},
|
|
1870
|
+
// ── A13 privacy test hooks ──────────────────────────────────────────────
|
|
1871
|
+
/** Classify a canvas against the current session's privacy settings. */
|
|
1872
|
+
classify(canvas) {
|
|
1873
|
+
return classify(canvas);
|
|
1874
|
+
},
|
|
1875
|
+
/** Build the text redactor a controller in `mode` would install. */
|
|
1876
|
+
makeTextRedactor(mode) {
|
|
1877
|
+
return makeTextRedactor(mode);
|
|
1878
|
+
},
|
|
1879
|
+
/** Run the real A6/A13 privacy re-evaluation pass. */
|
|
1880
|
+
reevaluate() {
|
|
1881
|
+
reevaluateAllMasking();
|
|
1882
|
+
},
|
|
1883
|
+
/** Escalation decision for pixel-text contexts (GL/GPU/worker). */
|
|
1884
|
+
escalate(mode, contextType) {
|
|
1885
|
+
return escalateRedactIfPixelText(mode, contextType);
|
|
1886
|
+
},
|
|
1887
|
+
/** Whether a canvas is currently tracked by a controller. */
|
|
1888
|
+
isTracked(canvas) {
|
|
1889
|
+
return !!state?.controllers.get(canvas);
|
|
1890
|
+
},
|
|
1891
|
+
/** Whether a canvas is in the suppressed (re-arm candidate) set. */
|
|
1892
|
+
isSuppressed(canvas) {
|
|
1893
|
+
return !!state?.suppressed.has(canvas);
|
|
1894
|
+
},
|
|
1895
|
+
/** Mark a canvas suppressed (as the discovery path would). */
|
|
1896
|
+
markSuppressed(canvas) {
|
|
1897
|
+
state?.handled.add(canvas);
|
|
1898
|
+
state?.suppressed.add(canvas);
|
|
1899
|
+
},
|
|
1900
|
+
/** Read a controller's current privacy mode + whether keyframes are armed. */
|
|
1901
|
+
controllerPrivacy(ctl) {
|
|
1902
|
+
return { mode: ctl.privacyMode, hasKeyframes: ctl.keyframes != null };
|
|
1903
|
+
},
|
|
1904
|
+
/** Run the real maybeDemote path (respects the redact no-demote rule). */
|
|
1905
|
+
tryDemote(ctl) {
|
|
1906
|
+
maybeDemote(ctl);
|
|
1907
|
+
},
|
|
1908
|
+
/** Run the real maybeEmitResize on a controller — drives the B1 resize +
|
|
1909
|
+
* keyframeUnsafe leak path (suppress-before-keyframe ordering). */
|
|
1910
|
+
emitResize(ctl) {
|
|
1911
|
+
maybeEmitResize(ctl, now());
|
|
1912
|
+
},
|
|
1913
|
+
/**
|
|
1914
|
+
* Drive the REAL build2DController with a fake pre-attached recorder so the
|
|
1915
|
+
* keyframe-suppression + redactor-installation behavior can be asserted
|
|
1916
|
+
* without a live 2D context (jsdom has none). Returns the built controller
|
|
1917
|
+
* plus the redactor the recorder received.
|
|
1918
|
+
*/
|
|
1919
|
+
build2DWithFakeRecorder(canvas, canvasId, privacyMode) {
|
|
1920
|
+
const fakeRec = {
|
|
1921
|
+
swapSink: () => { },
|
|
1922
|
+
textRedactor: undefined,
|
|
1923
|
+
rawCtx: {},
|
|
1924
|
+
};
|
|
1925
|
+
build2DController(canvas, canvasId, fakeRec, privacyMode);
|
|
1926
|
+
return {
|
|
1927
|
+
ctl: state?.controllers.get(canvas),
|
|
1928
|
+
redactor: fakeRec.textRedactor,
|
|
1929
|
+
};
|
|
1930
|
+
},
|
|
1931
|
+
/** Run the real governor-demotion path on a controller. */
|
|
1932
|
+
demote(ctl, reason = "bytes") {
|
|
1933
|
+
demote(ctl, reason);
|
|
1934
|
+
},
|
|
1935
|
+
/** Run the real (non-flushing) teardown, which frees the controller's slot. */
|
|
1936
|
+
teardown(ctl) {
|
|
1937
|
+
teardownController(ctl);
|
|
1938
|
+
},
|
|
1939
|
+
/** Current per-session slot usage. */
|
|
1940
|
+
slotsInUse() {
|
|
1941
|
+
return state?.videoSlots.inUse ?? -1;
|
|
1942
|
+
},
|
|
1943
|
+
controllerHasSlot(ctl) {
|
|
1944
|
+
return ctl.hasVideoSlot;
|
|
1945
|
+
},
|
|
1946
|
+
reset() {
|
|
1947
|
+
state = null;
|
|
1948
|
+
},
|
|
1949
|
+
};
|