@sailfish-ai/recorder 1.12.4 → 1.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +8 -0
  2. package/dist/canvas/adapters.js +382 -0
  3. package/dist/canvas/adapters.js.br +0 -0
  4. package/dist/canvas/adapters.js.gz +0 -0
  5. package/dist/canvas/canvasManager.js +1949 -0
  6. package/dist/canvas/canvasManager.js.br +0 -0
  7. package/dist/canvas/canvasManager.js.gz +0 -0
  8. package/dist/canvas/canvasPrivacy.js +233 -0
  9. package/dist/canvas/canvasPrivacy.js.br +0 -0
  10. package/dist/canvas/canvasPrivacy.js.gz +0 -0
  11. package/dist/canvas/codec.js +314 -0
  12. package/dist/canvas/codec.js.br +0 -0
  13. package/dist/canvas/codec.js.gz +0 -0
  14. package/dist/canvas/deviceClass.js +67 -0
  15. package/dist/canvas/deviceClass.js.br +0 -0
  16. package/dist/canvas/deviceClass.js.gz +0 -0
  17. package/dist/canvas/encodeWorker.js +284 -0
  18. package/dist/canvas/encodeWorker.js.br +0 -0
  19. package/dist/canvas/encodeWorker.js.gz +0 -0
  20. package/dist/canvas/governor.js +79 -0
  21. package/dist/canvas/governor.js.br +0 -0
  22. package/dist/canvas/governor.js.gz +0 -0
  23. package/dist/canvas/keyframes.js +183 -0
  24. package/dist/canvas/keyframes.js.br +0 -0
  25. package/dist/canvas/keyframes.js.gz +0 -0
  26. package/dist/canvas/mediaRecorderCapture.js +257 -0
  27. package/dist/canvas/mediaRecorderCapture.js.br +0 -0
  28. package/dist/canvas/mediaRecorderCapture.js.gz +0 -0
  29. package/dist/canvas/objectLog.js +150 -0
  30. package/dist/canvas/objectLog.js.br +0 -0
  31. package/dist/canvas/objectLog.js.gz +0 -0
  32. package/dist/canvas/patch2d.js +1302 -0
  33. package/dist/canvas/patch2d.js.br +0 -0
  34. package/dist/canvas/patch2d.js.gz +0 -0
  35. package/dist/canvas/patchGL.js +371 -0
  36. package/dist/canvas/patchGL.js.br +0 -0
  37. package/dist/canvas/patchGL.js.gz +0 -0
  38. package/dist/canvas/patchWebGPU.js +361 -0
  39. package/dist/canvas/patchWebGPU.js.br +0 -0
  40. package/dist/canvas/patchWebGPU.js.gz +0 -0
  41. package/dist/canvas/piiDetect.js +155 -0
  42. package/dist/canvas/piiDetect.js.br +0 -0
  43. package/dist/canvas/piiDetect.js.gz +0 -0
  44. package/dist/canvas/protocol.js +122 -0
  45. package/dist/canvas/protocol.js.br +0 -0
  46. package/dist/canvas/protocol.js.gz +0 -0
  47. package/dist/canvas/videoCapture.js +252 -0
  48. package/dist/canvas/videoCapture.js.br +0 -0
  49. package/dist/canvas/videoCapture.js.gz +0 -0
  50. package/dist/canvas/workerRelay.js +99 -0
  51. package/dist/canvas/workerRelay.js.br +0 -0
  52. package/dist/canvas/workerRelay.js.gz +0 -0
  53. package/dist/canvas/workerShim.js +227 -0
  54. package/dist/canvas/workerShim.js.br +0 -0
  55. package/dist/canvas/workerShim.js.gz +0 -0
  56. package/dist/canvas-worker.cjs +74 -0
  57. package/dist/canvas-worker.cjs.br +0 -0
  58. package/dist/canvas-worker.cjs.gz +0 -0
  59. package/dist/canvas-worker.js +78 -0
  60. package/dist/canvas-worker.js.br +0 -0
  61. package/dist/canvas-worker.js.gz +0 -0
  62. package/dist/canvasFrameStore.js +198 -0
  63. package/dist/canvasFrameStore.js.br +0 -0
  64. package/dist/canvasFrameStore.js.gz +0 -0
  65. package/dist/chunks/canvasManager-BYn3_oyo.js +1440 -0
  66. package/dist/chunks/canvasManager-BYn3_oyo.js.br +0 -0
  67. package/dist/chunks/canvasManager-BYn3_oyo.js.gz +0 -0
  68. package/dist/chunks/canvasManager-CoX5072G.js +1456 -0
  69. package/dist/chunks/canvasManager-CoX5072G.js.br +0 -0
  70. package/dist/chunks/canvasManager-CoX5072G.js.gz +0 -0
  71. package/dist/chunks/{chunkSerializer-FQtY90Av.js → chunkSerializer-C_K0Wyk0.js} +1 -1
  72. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.br +0 -0
  73. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.gz +0 -0
  74. package/dist/chunks/{chunkSerializer-DDukZpgl.js → chunkSerializer-CghPitF4.js} +1 -1
  75. package/dist/chunks/chunkSerializer-CghPitF4.js.br +0 -0
  76. package/dist/chunks/chunkSerializer-CghPitF4.js.gz +0 -0
  77. package/dist/chunks/{index-C-qbsfKe.js → index-BIj4l3CV.js} +1028 -650
  78. package/dist/chunks/index-BIj4l3CV.js.br +0 -0
  79. package/dist/chunks/index-BIj4l3CV.js.gz +0 -0
  80. package/dist/chunks/{index-D6axlCRu.js → index-DIYomvdD.js} +1119 -720
  81. package/dist/chunks/index-DIYomvdD.js.br +0 -0
  82. package/dist/chunks/index-DIYomvdD.js.gz +0 -0
  83. package/dist/chunks/patch2d-9voowhJ_.js +816 -0
  84. package/dist/chunks/patch2d-9voowhJ_.js.br +0 -0
  85. package/dist/chunks/patch2d-9voowhJ_.js.gz +0 -0
  86. package/dist/chunks/patch2d-PQxcaE6r.js +886 -0
  87. package/dist/chunks/patch2d-PQxcaE6r.js.br +0 -0
  88. package/dist/chunks/patch2d-PQxcaE6r.js.gz +0 -0
  89. package/dist/graphql.js +12 -1
  90. package/dist/graphql.js.br +0 -0
  91. package/dist/graphql.js.gz +0 -0
  92. package/dist/inAppReportIssueModal/index.js +48 -0
  93. package/dist/inAppReportIssueModal/index.js.br +0 -0
  94. package/dist/inAppReportIssueModal/index.js.gz +0 -0
  95. package/dist/index.js +144 -2
  96. package/dist/index.js.br +0 -0
  97. package/dist/index.js.gz +0 -0
  98. package/dist/recorder.cjs +2 -2
  99. package/dist/recorder.cjs.br +0 -0
  100. package/dist/recorder.cjs.gz +0 -0
  101. package/dist/recorder.js +52 -41
  102. package/dist/recorder.js.br +0 -0
  103. package/dist/recorder.js.gz +0 -0
  104. package/dist/recorder.umd.cjs +4847 -2212
  105. package/dist/recorder.umd.cjs.br +0 -0
  106. package/dist/recorder.umd.cjs.gz +0 -0
  107. package/dist/recording.js +119 -10
  108. package/dist/recording.js.br +0 -0
  109. package/dist/recording.js.gz +0 -0
  110. package/dist/types/canvas/adapters.d.ts +71 -0
  111. package/dist/types/canvas/canvasManager.d.ts +273 -0
  112. package/dist/types/canvas/canvasPrivacy.d.ts +184 -0
  113. package/dist/types/canvas/codec.d.ts +27 -0
  114. package/dist/types/canvas/deviceClass.d.ts +40 -0
  115. package/dist/types/canvas/encodeWorker.d.ts +54 -0
  116. package/dist/types/canvas/governor.d.ts +44 -0
  117. package/dist/types/canvas/keyframes.d.ts +81 -0
  118. package/dist/types/canvas/mediaRecorderCapture.d.ts +91 -0
  119. package/dist/types/canvas/objectLog.d.ts +75 -0
  120. package/dist/types/canvas/patch2d.d.ts +339 -0
  121. package/dist/types/canvas/patchGL.d.ts +74 -0
  122. package/dist/types/canvas/patchWebGPU.d.ts +37 -0
  123. package/dist/types/canvas/piiDetect.d.ts +60 -0
  124. package/dist/types/canvas/protocol.d.ts +176 -0
  125. package/dist/types/canvas/videoCapture.d.ts +67 -0
  126. package/dist/types/canvas/workerRelay.d.ts +29 -0
  127. package/dist/types/canvas/workerShim.d.ts +10 -0
  128. package/dist/types/canvasFrameStore.d.ts +23 -0
  129. package/dist/types/graphql.d.ts +1 -1
  130. package/dist/types/index.d.ts +99 -1
  131. package/dist/types/types.d.ts +40 -0
  132. package/dist/types/websocket.d.ts +19 -0
  133. package/dist/websocket.js +210 -0
  134. package/dist/websocket.js.br +0 -0
  135. package/dist/websocket.js.gz +0 -0
  136. package/package.json +7 -1
  137. package/dist/chunks/chunkSerializer-DDukZpgl.js.br +0 -0
  138. package/dist/chunks/chunkSerializer-DDukZpgl.js.gz +0 -0
  139. package/dist/chunks/chunkSerializer-FQtY90Av.js.br +0 -0
  140. package/dist/chunks/chunkSerializer-FQtY90Av.js.gz +0 -0
  141. package/dist/chunks/index-C-qbsfKe.js.br +0 -0
  142. package/dist/chunks/index-C-qbsfKe.js.gz +0 -0
  143. package/dist/chunks/index-D6axlCRu.js.br +0 -0
  144. package/dist/chunks/index-D6axlCRu.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
+ };