@sailfish-ai/recorder 1.12.5 → 1.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +8 -0
  2. package/dist/canvas/adapters.js +382 -0
  3. package/dist/canvas/adapters.js.br +0 -0
  4. package/dist/canvas/adapters.js.gz +0 -0
  5. package/dist/canvas/canvasManager.js +1949 -0
  6. package/dist/canvas/canvasManager.js.br +0 -0
  7. package/dist/canvas/canvasManager.js.gz +0 -0
  8. package/dist/canvas/canvasPrivacy.js +233 -0
  9. package/dist/canvas/canvasPrivacy.js.br +0 -0
  10. package/dist/canvas/canvasPrivacy.js.gz +0 -0
  11. package/dist/canvas/codec.js +314 -0
  12. package/dist/canvas/codec.js.br +0 -0
  13. package/dist/canvas/codec.js.gz +0 -0
  14. package/dist/canvas/deviceClass.js +67 -0
  15. package/dist/canvas/deviceClass.js.br +0 -0
  16. package/dist/canvas/deviceClass.js.gz +0 -0
  17. package/dist/canvas/encodeWorker.js +284 -0
  18. package/dist/canvas/encodeWorker.js.br +0 -0
  19. package/dist/canvas/encodeWorker.js.gz +0 -0
  20. package/dist/canvas/governor.js +79 -0
  21. package/dist/canvas/governor.js.br +0 -0
  22. package/dist/canvas/governor.js.gz +0 -0
  23. package/dist/canvas/keyframes.js +183 -0
  24. package/dist/canvas/keyframes.js.br +0 -0
  25. package/dist/canvas/keyframes.js.gz +0 -0
  26. package/dist/canvas/mediaRecorderCapture.js +257 -0
  27. package/dist/canvas/mediaRecorderCapture.js.br +0 -0
  28. package/dist/canvas/mediaRecorderCapture.js.gz +0 -0
  29. package/dist/canvas/objectLog.js +150 -0
  30. package/dist/canvas/objectLog.js.br +0 -0
  31. package/dist/canvas/objectLog.js.gz +0 -0
  32. package/dist/canvas/patch2d.js +1302 -0
  33. package/dist/canvas/patch2d.js.br +0 -0
  34. package/dist/canvas/patch2d.js.gz +0 -0
  35. package/dist/canvas/patchGL.js +371 -0
  36. package/dist/canvas/patchGL.js.br +0 -0
  37. package/dist/canvas/patchGL.js.gz +0 -0
  38. package/dist/canvas/patchWebGPU.js +361 -0
  39. package/dist/canvas/patchWebGPU.js.br +0 -0
  40. package/dist/canvas/patchWebGPU.js.gz +0 -0
  41. package/dist/canvas/piiDetect.js +155 -0
  42. package/dist/canvas/piiDetect.js.br +0 -0
  43. package/dist/canvas/piiDetect.js.gz +0 -0
  44. package/dist/canvas/protocol.js +122 -0
  45. package/dist/canvas/protocol.js.br +0 -0
  46. package/dist/canvas/protocol.js.gz +0 -0
  47. package/dist/canvas/videoCapture.js +252 -0
  48. package/dist/canvas/videoCapture.js.br +0 -0
  49. package/dist/canvas/videoCapture.js.gz +0 -0
  50. package/dist/canvas/workerRelay.js +99 -0
  51. package/dist/canvas/workerRelay.js.br +0 -0
  52. package/dist/canvas/workerRelay.js.gz +0 -0
  53. package/dist/canvas/workerShim.js +227 -0
  54. package/dist/canvas/workerShim.js.br +0 -0
  55. package/dist/canvas/workerShim.js.gz +0 -0
  56. package/dist/canvas-worker.cjs +74 -0
  57. package/dist/canvas-worker.cjs.br +0 -0
  58. package/dist/canvas-worker.cjs.gz +0 -0
  59. package/dist/canvas-worker.js +78 -0
  60. package/dist/canvas-worker.js.br +0 -0
  61. package/dist/canvas-worker.js.gz +0 -0
  62. package/dist/canvasFrameStore.js +198 -0
  63. package/dist/canvasFrameStore.js.br +0 -0
  64. package/dist/canvasFrameStore.js.gz +0 -0
  65. package/dist/chunks/canvasManager-BYn3_oyo.js +1440 -0
  66. package/dist/chunks/canvasManager-BYn3_oyo.js.br +0 -0
  67. package/dist/chunks/canvasManager-BYn3_oyo.js.gz +0 -0
  68. package/dist/chunks/canvasManager-CoX5072G.js +1456 -0
  69. package/dist/chunks/canvasManager-CoX5072G.js.br +0 -0
  70. package/dist/chunks/canvasManager-CoX5072G.js.gz +0 -0
  71. package/dist/chunks/{chunkSerializer-Cd8y9tID.js → chunkSerializer-C_K0Wyk0.js} +1 -1
  72. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.br +0 -0
  73. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.gz +0 -0
  74. package/dist/chunks/{chunkSerializer-pkpgT-FH.js → chunkSerializer-CghPitF4.js} +1 -1
  75. package/dist/chunks/chunkSerializer-CghPitF4.js.br +0 -0
  76. package/dist/chunks/chunkSerializer-CghPitF4.js.gz +0 -0
  77. package/dist/chunks/{index-Bvd8oTpV.js → index-BIj4l3CV.js} +1025 -655
  78. package/dist/chunks/index-BIj4l3CV.js.br +0 -0
  79. package/dist/chunks/index-BIj4l3CV.js.gz +0 -0
  80. package/dist/chunks/{index-CXvHbLRT.js → index-DIYomvdD.js} +1115 -724
  81. package/dist/chunks/index-DIYomvdD.js.br +0 -0
  82. package/dist/chunks/index-DIYomvdD.js.gz +0 -0
  83. package/dist/chunks/patch2d-9voowhJ_.js +816 -0
  84. package/dist/chunks/patch2d-9voowhJ_.js.br +0 -0
  85. package/dist/chunks/patch2d-9voowhJ_.js.gz +0 -0
  86. package/dist/chunks/patch2d-PQxcaE6r.js +886 -0
  87. package/dist/chunks/patch2d-PQxcaE6r.js.br +0 -0
  88. package/dist/chunks/patch2d-PQxcaE6r.js.gz +0 -0
  89. package/dist/graphql.js +12 -1
  90. package/dist/graphql.js.br +0 -0
  91. package/dist/graphql.js.gz +0 -0
  92. package/dist/index.js +144 -2
  93. package/dist/index.js.br +0 -0
  94. package/dist/index.js.gz +0 -0
  95. package/dist/recorder.cjs +2 -2
  96. package/dist/recorder.cjs.br +0 -0
  97. package/dist/recorder.cjs.gz +0 -0
  98. package/dist/recorder.js +52 -41
  99. package/dist/recorder.js.br +0 -0
  100. package/dist/recorder.js.gz +0 -0
  101. package/dist/recorder.umd.cjs +4842 -2215
  102. package/dist/recorder.umd.cjs.br +0 -0
  103. package/dist/recorder.umd.cjs.gz +0 -0
  104. package/dist/recording.js +119 -10
  105. package/dist/recording.js.br +0 -0
  106. package/dist/recording.js.gz +0 -0
  107. package/dist/types/canvas/adapters.d.ts +71 -0
  108. package/dist/types/canvas/canvasManager.d.ts +273 -0
  109. package/dist/types/canvas/canvasPrivacy.d.ts +184 -0
  110. package/dist/types/canvas/codec.d.ts +27 -0
  111. package/dist/types/canvas/deviceClass.d.ts +40 -0
  112. package/dist/types/canvas/encodeWorker.d.ts +54 -0
  113. package/dist/types/canvas/governor.d.ts +44 -0
  114. package/dist/types/canvas/keyframes.d.ts +81 -0
  115. package/dist/types/canvas/mediaRecorderCapture.d.ts +91 -0
  116. package/dist/types/canvas/objectLog.d.ts +75 -0
  117. package/dist/types/canvas/patch2d.d.ts +339 -0
  118. package/dist/types/canvas/patchGL.d.ts +74 -0
  119. package/dist/types/canvas/patchWebGPU.d.ts +37 -0
  120. package/dist/types/canvas/piiDetect.d.ts +60 -0
  121. package/dist/types/canvas/protocol.d.ts +176 -0
  122. package/dist/types/canvas/videoCapture.d.ts +67 -0
  123. package/dist/types/canvas/workerRelay.d.ts +29 -0
  124. package/dist/types/canvas/workerShim.d.ts +10 -0
  125. package/dist/types/canvasFrameStore.d.ts +23 -0
  126. package/dist/types/graphql.d.ts +1 -1
  127. package/dist/types/index.d.ts +99 -1
  128. package/dist/types/types.d.ts +40 -0
  129. package/dist/types/websocket.d.ts +19 -0
  130. package/dist/websocket.js +210 -0
  131. package/dist/websocket.js.br +0 -0
  132. package/dist/websocket.js.gz +0 -0
  133. package/package.json +7 -1
  134. package/dist/chunks/chunkSerializer-Cd8y9tID.js.br +0 -0
  135. package/dist/chunks/chunkSerializer-Cd8y9tID.js.gz +0 -0
  136. package/dist/chunks/chunkSerializer-pkpgT-FH.js.br +0 -0
  137. package/dist/chunks/chunkSerializer-pkpgT-FH.js.gz +0 -0
  138. package/dist/chunks/index-Bvd8oTpV.js.br +0 -0
  139. package/dist/chunks/index-Bvd8oTpV.js.gz +0 -0
  140. package/dist/chunks/index-CXvHbLRT.js.br +0 -0
  141. package/dist/chunks/index-CXvHbLRT.js.gz +0 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Inline-blob encode workers (webp image encoder + WebCodecs video encoder)
3
+ * and their promise/event clients. Ports of the bench workers
4
+ * (test-applications/canvas-recorder-bench/src/workers/{imgEncoder,videoEncoder}.worker.ts)
5
+ * as TEMPLATE-STRING sources so the vite lib build emits NO separate worker
6
+ * files — same precedent as WS_WORKER_CODE in src/websocket.tsx. Unlike the
7
+ * WS worker, this code may use modern JS: every browser with WebCodecs /
8
+ * OffscreenCanvas supports ES2020.
9
+ *
10
+ * SDK adaptation to the bench video worker: encoded chunk DATA is posted
11
+ * LIVE (transferred) instead of accumulated until stop, and the decoder
12
+ * config is posted once as `decoder-config` — the manager streams video-chunk
13
+ * / video-init SFCV frames as they are produced.
14
+ *
15
+ * SSR rule: workers are created lazily inside functions, never at module scope.
16
+ */
17
+ // ─── Image encoder: ImageBitmap -> webp bytes ────────────────────────────────
18
+ const IMG_ENCODER_CODE = `
19
+ self.onmessage = async (e) => {
20
+ const { id, bitmap, quality, wantBytes } = e.data;
21
+ try {
22
+ const off = new OffscreenCanvas(bitmap.width, bitmap.height);
23
+ const ctx = off.getContext('2d');
24
+ ctx.drawImage(bitmap, 0, 0);
25
+ bitmap.close();
26
+ const blob = await off.convertToBlob({ type: 'image/webp', quality });
27
+ if (wantBytes) {
28
+ const buf = await blob.arrayBuffer();
29
+ self.postMessage({ id, size: buf.byteLength, bytes: buf }, [buf]);
30
+ } else {
31
+ self.postMessage({ id, size: blob.size });
32
+ }
33
+ } catch (err) {
34
+ self.postMessage({ id, size: 0, error: String(err) });
35
+ }
36
+ };
37
+ `;
38
+ // ─── Video encoder: transferred VideoFrames -> encoded chunks (live) ─────────
39
+ const VIDEO_ENCODER_CODE = `
40
+ let encoder = null;
41
+ let decoderConfig = null;
42
+ let sentConfig = false;
43
+
44
+ async function pickCodec(codecs, width, height, bitrate) {
45
+ for (const codec of codecs) {
46
+ try {
47
+ const support = await VideoEncoder.isConfigSupported({
48
+ codec, width, height, bitrate, framerate: 30,
49
+ ...(codec.startsWith('avc1') ? { avc: { format: 'avc' } } : {}),
50
+ });
51
+ if (support.supported) return codec;
52
+ } catch (e) { /* try next */ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ self.onmessage = async (e) => {
58
+ const msg = e.data;
59
+ if (msg.type === 'config') {
60
+ const codec = await pickCodec(msg.codecs, msg.width, msg.height, msg.bitrate);
61
+ if (!codec) {
62
+ self.postMessage({ type: 'config-failed' });
63
+ return;
64
+ }
65
+ encoder = new VideoEncoder({
66
+ output: (chunk, meta) => {
67
+ if (meta && meta.decoderConfig && !decoderConfig) {
68
+ const desc = meta.decoderConfig.description;
69
+ decoderConfig = {
70
+ codec: meta.decoderConfig.codec,
71
+ description: desc
72
+ ? (desc instanceof ArrayBuffer
73
+ ? desc.slice(0)
74
+ : desc.buffer.slice(desc.byteOffset, desc.byteOffset + desc.byteLength))
75
+ : null,
76
+ };
77
+ }
78
+ if (decoderConfig && !sentConfig) {
79
+ sentConfig = true;
80
+ self.postMessage({
81
+ type: 'decoder-config',
82
+ codec: decoderConfig.codec,
83
+ description: decoderConfig.description,
84
+ });
85
+ }
86
+ const data = new ArrayBuffer(chunk.byteLength);
87
+ chunk.copyTo(data);
88
+ self.postMessage({
89
+ type: 'chunk',
90
+ data,
91
+ timestampUs: chunk.timestamp,
92
+ key: chunk.type === 'key',
93
+ }, [data]);
94
+ },
95
+ error: (err) => self.postMessage({ type: 'error', message: String(err) }),
96
+ });
97
+ encoder.configure({
98
+ codec,
99
+ width: msg.width,
100
+ height: msg.height,
101
+ bitrate: msg.bitrate,
102
+ framerate: 30,
103
+ latencyMode: 'realtime',
104
+ ...(codec.startsWith('avc1') ? { avc: { format: 'avc' } } : {}),
105
+ });
106
+ self.postMessage({ type: 'configured', codec });
107
+ return;
108
+ }
109
+ if (msg.type === 'frame') {
110
+ try {
111
+ if (encoder) encoder.encode(msg.frame, { keyFrame: msg.key });
112
+ } catch (err) {
113
+ self.postMessage({ type: 'error', message: String(err) });
114
+ } finally {
115
+ msg.frame.close();
116
+ }
117
+ return;
118
+ }
119
+ if (msg.type === 'flush') {
120
+ // Non-terminal drain (visibilitychange:hidden): push queued frames out as
121
+ // chunks WITHOUT closing the encoder, so capture can resume if the tab
122
+ // returns. Chunks stream through the existing output handler above.
123
+ try {
124
+ if (encoder) await encoder.flush();
125
+ } catch (e) { /* flush may fail when nothing was encoded */ }
126
+ self.postMessage({ type: 'flushed' });
127
+ return;
128
+ }
129
+ if (msg.type === 'stop') {
130
+ try {
131
+ if (encoder) await encoder.flush();
132
+ } catch (e) { /* flush may fail when nothing was encoded */ }
133
+ self.postMessage({ type: 'done' });
134
+ if (encoder) encoder.close();
135
+ encoder = null;
136
+ }
137
+ };
138
+ `;
139
+ function createBlobWorker(code) {
140
+ const blob = new Blob([code], { type: "application/javascript" });
141
+ const url = URL.createObjectURL(blob);
142
+ const w = new Worker(url);
143
+ URL.revokeObjectURL(url); // Worker keeps the blob reference; safe to revoke
144
+ return w;
145
+ }
146
+ let imgWorker = null;
147
+ let nextId = 1;
148
+ const pendingEncodes = new Map();
149
+ function getImgWorker() {
150
+ if (!imgWorker) {
151
+ imgWorker = createBlobWorker(IMG_ENCODER_CODE);
152
+ imgWorker.onmessage = (e) => {
153
+ const resolve = pendingEncodes.get(e.data.id);
154
+ if (resolve) {
155
+ pendingEncodes.delete(e.data.id);
156
+ resolve({ size: e.data.size, bytes: e.data.bytes });
157
+ }
158
+ };
159
+ }
160
+ return imgWorker;
161
+ }
162
+ /** Encode a bitmap to webp in the worker. TRANSFERS (detaches) the bitmap. */
163
+ export function encodeBitmap(bitmap, quality, wantBytes = false) {
164
+ const id = nextId++;
165
+ return new Promise((resolve) => {
166
+ pendingEncodes.set(id, resolve);
167
+ getImgWorker().postMessage({ id, bitmap, quality, wantBytes }, [bitmap]);
168
+ });
169
+ }
170
+ /** Tear down the shared image-encoder worker (manager stop path). */
171
+ export function terminateImgEncoder() {
172
+ if (imgWorker) {
173
+ imgWorker.terminate();
174
+ imgWorker = null;
175
+ }
176
+ // resolve any stragglers so awaiting callers do not hang
177
+ for (const [id, resolve] of pendingEncodes) {
178
+ pendingEncodes.delete(id);
179
+ resolve({ size: 0 });
180
+ }
181
+ }
182
+ export class VideoEncoderClient {
183
+ events;
184
+ worker;
185
+ configured = false;
186
+ doneResolve = null;
187
+ flushResolve = null;
188
+ configResolve = null;
189
+ constructor(events) {
190
+ this.events = events;
191
+ this.worker = createBlobWorker(VIDEO_ENCODER_CODE);
192
+ this.worker.onmessage = (e) => {
193
+ const m = e.data;
194
+ switch (m.type) {
195
+ case "configured":
196
+ this.configured = true;
197
+ this.configResolve?.(true);
198
+ this.configResolve = null;
199
+ break;
200
+ case "config-failed":
201
+ this.configResolve?.(false);
202
+ this.configResolve = null;
203
+ break;
204
+ case "decoder-config":
205
+ this.events.onDecoderConfig(m.codec, m.description ?? null);
206
+ break;
207
+ case "chunk":
208
+ this.events.onChunk(m.data, m.timestampUs, m.key);
209
+ break;
210
+ case "error":
211
+ this.events.onError(String(m.message));
212
+ break;
213
+ case "done":
214
+ this.doneResolve?.();
215
+ this.doneResolve = null;
216
+ break;
217
+ case "flushed":
218
+ this.flushResolve?.();
219
+ this.flushResolve = null;
220
+ break;
221
+ }
222
+ };
223
+ }
224
+ get isConfigured() {
225
+ return this.configured;
226
+ }
227
+ configure(cfg) {
228
+ return new Promise((resolve) => {
229
+ this.configResolve = resolve;
230
+ this.worker.postMessage({ type: "config", ...cfg });
231
+ });
232
+ }
233
+ /** TRANSFERS (closes) the frame. */
234
+ encodeFrame(frame, key) {
235
+ this.worker.postMessage({ type: "frame", frame, key }, [frame]);
236
+ }
237
+ /** Non-terminal drain: flush queued frames out as chunks WITHOUT closing the
238
+ * encoder, so capture can resume. Resolves once the worker acks (or times
239
+ * out). Safe to call repeatedly. */
240
+ flush(timeoutMs = 3000) {
241
+ return new Promise((resolve) => {
242
+ const tid = setTimeout(() => {
243
+ this.flushResolve = null;
244
+ resolve();
245
+ }, timeoutMs);
246
+ this.flushResolve = () => {
247
+ clearTimeout(tid);
248
+ resolve();
249
+ };
250
+ try {
251
+ this.worker.postMessage({ type: "flush" });
252
+ }
253
+ catch {
254
+ clearTimeout(tid);
255
+ this.flushResolve = null;
256
+ resolve();
257
+ }
258
+ });
259
+ }
260
+ /** Flush the encoder; resolves once all chunks have been delivered. */
261
+ stop(timeoutMs = 3000) {
262
+ return new Promise((resolve) => {
263
+ const tid = setTimeout(() => {
264
+ this.doneResolve = null;
265
+ resolve();
266
+ }, timeoutMs);
267
+ this.doneResolve = () => {
268
+ clearTimeout(tid);
269
+ resolve();
270
+ };
271
+ try {
272
+ this.worker.postMessage({ type: "stop" });
273
+ }
274
+ catch {
275
+ clearTimeout(tid);
276
+ this.doneResolve = null;
277
+ resolve();
278
+ }
279
+ });
280
+ }
281
+ terminate() {
282
+ this.worker.terminate();
283
+ }
284
+ }
Binary file
Binary file
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Phase 2 live-demotion governor (contract §5): per-canvas counters that
3
+ * decide when a command stream is too heavy and the canvas should demote to
4
+ * video capture.
5
+ *
6
+ * - POST-deflate command bytes: demote when the trailing 3s window carries
7
+ * more than 512 KB/s (i.e. > 1536 KB total in the window).
8
+ * - dynamic VIDEO-source drawImage: demote when more than 2 video-element
9
+ * draws land within any trailing 1s window.
10
+ * - ops per frame: demote when a single frame (ops sharing one timestamp)
11
+ * exceeds 5000 ops.
12
+ */
13
+ export const DEFAULT_GOVERNOR_THRESHOLDS = {
14
+ maxBytesPerSec: 512 * 1024,
15
+ sustainMs: 3_000,
16
+ maxVideoDrawsPerSec: 2,
17
+ maxOpsPerFrame: 5_000,
18
+ };
19
+ export class CanvasGovernor {
20
+ now;
21
+ thresholds;
22
+ byteSamples = [];
23
+ videoDraws = [];
24
+ frameT = NaN;
25
+ frameOps = 0;
26
+ opsExceeded = false;
27
+ constructor(now, thresholds) {
28
+ this.now = now;
29
+ this.thresholds = { ...DEFAULT_GOVERNOR_THRESHOLDS, ...thresholds };
30
+ }
31
+ /** Record post-deflate bytes for a flushed command batch. */
32
+ addBytes(n) {
33
+ const t = this.now();
34
+ this.byteSamples.push({ t, n });
35
+ this.pruneBytes(t);
36
+ }
37
+ /** Record one dynamic video-source drawImage capture. */
38
+ addVideoDraw() {
39
+ const t = this.now();
40
+ this.videoDraws.push(t);
41
+ this.pruneVideo(t);
42
+ }
43
+ /** Record one recorded op (tMs = the op's frame timestamp). */
44
+ addOp(tMs) {
45
+ if (tMs !== this.frameT) {
46
+ this.frameT = tMs;
47
+ this.frameOps = 0;
48
+ }
49
+ if (++this.frameOps > this.thresholds.maxOpsPerFrame)
50
+ this.opsExceeded = true;
51
+ }
52
+ /** Returns the demotion reason once a threshold trips, else null. */
53
+ shouldDemote() {
54
+ if (this.opsExceeded)
55
+ return "ops";
56
+ const t = this.now();
57
+ this.pruneVideo(t);
58
+ if (this.videoDraws.length > this.thresholds.maxVideoDrawsPerSec)
59
+ return "video-source";
60
+ this.pruneBytes(t);
61
+ let total = 0;
62
+ for (const s of this.byteSamples)
63
+ total += s.n;
64
+ const budget = (this.thresholds.maxBytesPerSec * this.thresholds.sustainMs) / 1000;
65
+ if (total > budget)
66
+ return "bytes";
67
+ return null;
68
+ }
69
+ pruneBytes(t) {
70
+ const cutoff = t - this.thresholds.sustainMs;
71
+ while (this.byteSamples.length > 0 && this.byteSamples[0].t < cutoff)
72
+ this.byteSamples.shift();
73
+ }
74
+ pruneVideo(t) {
75
+ const cutoff = t - 1000;
76
+ while (this.videoDraws.length > 0 && this.videoDraws[0] < cutoff)
77
+ this.videoDraws.shift();
78
+ }
79
+ }
Binary file
Binary file
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Periodic pixel keyframes captured same-task — port of the bench hybrid
3
+ * strategy's takeKeyframe (test-applications/canvas-recorder-bench/src/
4
+ * capture/hybrid.ts). Keyframes are the bit-exact ground truth and the seek
5
+ * anchor; for 2D they also enable replay-from-keyframe (state snapshot rides
6
+ * the SFCV keyframe meta).
7
+ */
8
+ import { REDACTION_COLOR } from "./canvasPrivacy";
9
+ import { encodeBitmap } from "./encodeWorker";
10
+ import { ignore2DContext } from "./patch2d";
11
+ const KEYFRAME_INTERVAL_MS = 4_000;
12
+ /** min spacing for interaction-triggered keyframes */
13
+ const INTERACTION_KEYFRAME_GAP_MS = 250; // tight anchors near interactions — GPU streams replay from keyframes
14
+ export class KeyframeCapturer {
15
+ canvas;
16
+ snapshotState;
17
+ emit;
18
+ note;
19
+ getMaskedRegions;
20
+ scratch = null;
21
+ scratchCtx = null;
22
+ lastKeyframe = -Infinity;
23
+ /** has the canvas actually rendered since the last keyframe? */
24
+ dirtySinceKeyframe = false;
25
+ interactionRequested = false;
26
+ pending = [];
27
+ stopped = false;
28
+ constructor(canvas, snapshotState, emit, note,
29
+ /**
30
+ * Masked-region source (maskCanvasDraws / redact-mode text). Each device-space
31
+ * bbox is filled with REDACTION_COLOR on the scratch copy BEFORE the keyframe
32
+ * is encoded, so the real pixels of masked content never ship — replay then
33
+ * draws the baked redaction (recolored shapes / asterisks / bars) from the
34
+ * command stream on top of the blanked keyframe. Defaults to none (GL/other
35
+ * sources have no per-op masking).
36
+ */
37
+ getMaskedRegions = () => []) {
38
+ this.canvas = canvas;
39
+ this.snapshotState = snapshotState;
40
+ this.emit = emit;
41
+ this.note = note;
42
+ this.getMaskedRegions = getMaskedRegions;
43
+ }
44
+ onInteraction(tMs) {
45
+ // BUGFIX (Phase 0.5 E2E, found on /webgl1 — guardrail #6 third strike):
46
+ // this runs in the POINTER-EVENT task. Copying pixels here reads a
47
+ // cleared buffer on preserveDrawingBuffer:false GL/WebGPU canvases
48
+ // (blank ~1KB keyframes), and on 2D it raced the app's click-handler
49
+ // redraw (the earlier tMs-1 backdating workaround). Instead, REQUEST a
50
+ // keyframe: the next afterRender pump runs in the render task, so the
51
+ // copy is buffer-valid and its pixels already include the interaction's
52
+ // redraw ops — the keyframe invariant holds with no backdating. Canvases
53
+ // that don't redraw on interaction take no keyframe (dirty-gating kept:
54
+ // afterRender only runs after draws).
55
+ if (tMs - this.lastKeyframe >= INTERACTION_KEYFRAME_GAP_MS) {
56
+ this.interactionRequested = true;
57
+ }
58
+ }
59
+ /**
60
+ * A27 — one-shot keyframe for a canvas that was already painted BEFORE the
61
+ * SDK loaded and may never redraw (a static pre-existing canvas). The normal
62
+ * keyframe pacing is dirty-gated (afterRender only runs after a draw), so a
63
+ * canvas that is painted once and then idle would never emit a keyframe and
64
+ * would replay blank. This forces a single ground-truth keyframe of the
65
+ * canvas's current pixels, independent of the dirty flag or the interval.
66
+ *
67
+ * Unlike afterRender this is NOT same-task with a render — but a STATIC
68
+ * pre-existing 2D canvas's committed pixels persist outside any rendering
69
+ * task (the guardrail-#6 hazard only affects pdb:false WebGL / task-scoped
70
+ * WebGPU textures, which are not what A27 targets). takeKeyframe's
71
+ * drawImage(canvas) copy throws on a zero-size canvas (caught -> no emit) and
72
+ * a masked/tainted canvas never reaches here (the manager only builds a
73
+ * controller for unmasked canvases, and a tainted readback simply fails the
74
+ * encode). Returns true if a keyframe copy was kicked off.
75
+ */
76
+ initialKeyframe(tMs) {
77
+ if (this.stopped)
78
+ return false;
79
+ if (!this.canvas.width || !this.canvas.height)
80
+ return false; // zero-size — skip
81
+ const before = this.pending.length;
82
+ this.takeKeyframe(tMs);
83
+ return this.pending.length > before;
84
+ }
85
+ /** Same-task keyframes: GPU-canvas-safe and never captures mid-draw. */
86
+ afterRender(tMs) {
87
+ this.dirtySinceKeyframe = true;
88
+ if (this.interactionRequested) {
89
+ this.interactionRequested = false;
90
+ if (tMs - this.lastKeyframe >= INTERACTION_KEYFRAME_GAP_MS) {
91
+ this.takeKeyframe(tMs);
92
+ return;
93
+ }
94
+ }
95
+ if (tMs - this.lastKeyframe >= KEYFRAME_INTERVAL_MS)
96
+ this.takeKeyframe(tMs);
97
+ }
98
+ takeKeyframe(t) {
99
+ if (this.stopped)
100
+ return;
101
+ this.lastKeyframe = t;
102
+ this.dirtySinceKeyframe = false;
103
+ const state = this.snapshotState();
104
+ // Keyframe pixels MUST be copied synchronously in the rendering task
105
+ // (guardrail #6): createImageBitmap(canvas) defers its copy past the task
106
+ // boundary, where WebGPU textures have expired and pdb:false WebGL
107
+ // buffers are cleared — producing blank keyframes. drawImage to a scratch
108
+ // canvas is the in-task copy; the bitmap is then safely taken from it.
109
+ if (!this.scratch) {
110
+ this.scratch = document.createElement("canvas");
111
+ this.scratchCtx = this.scratch.getContext("2d");
112
+ if (this.scratchCtx)
113
+ ignore2DContext(this.scratchCtx); // our own context — never record it
114
+ }
115
+ const scratch = this.scratch;
116
+ const scratchCtx = this.scratchCtx;
117
+ if (!scratchCtx)
118
+ return;
119
+ if (scratch.width !== this.canvas.width || scratch.height !== this.canvas.height) {
120
+ scratch.width = this.canvas.width;
121
+ scratch.height = this.canvas.height;
122
+ }
123
+ else {
124
+ scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
125
+ }
126
+ try {
127
+ scratchCtx.drawImage(this.canvas, 0, 0);
128
+ }
129
+ catch {
130
+ return; // zero-size canvas
131
+ }
132
+ // Region-blank: paint over the bbox of every masked draw so the keyframe
133
+ // ships NO real pixels of masked content. Regions are device-space (the
134
+ // scratch is canvas-sized), so reset the transform before filling.
135
+ const regions = this.getMaskedRegions();
136
+ if (regions.length > 0) {
137
+ scratchCtx.save();
138
+ scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
139
+ scratchCtx.fillStyle = REDACTION_COLOR;
140
+ for (const r of regions)
141
+ scratchCtx.fillRect(r.x, r.y, r.w, r.h);
142
+ scratchCtx.restore();
143
+ }
144
+ const w = scratch.width;
145
+ const h = scratch.height;
146
+ const p = createImageBitmap(scratch)
147
+ .then(async (bitmap) => {
148
+ // encodeBitmap TRANSFERS the bitmap to the worker (detaching it) —
149
+ // it is consumed here, never reused.
150
+ const r = await encodeBitmap(bitmap, 0.9, true);
151
+ if (r.bytes && r.bytes.byteLength > 0) {
152
+ this.emit({ tMs: t, w, h, state, bytes: r.bytes });
153
+ }
154
+ })
155
+ .catch((e) => this.note(`keyframe encode failed: ${e.message}`));
156
+ this.pending.push(p);
157
+ }
158
+ /** Final keyframe (if dirty) + settle outstanding encodes. */
159
+ async stop(finalT) {
160
+ if (this.dirtySinceKeyframe)
161
+ this.takeKeyframe(finalT);
162
+ this.stopped = true;
163
+ await Promise.all(this.pending);
164
+ }
165
+ /**
166
+ * A22 / A6: immediate disposal with NO final keyframe — used when the canvas
167
+ * was removed from the DOM or masked mid-session (its pixels are gone or must
168
+ * not be recorded). Releases the scratch keyframe canvas so it can be GC'd.
169
+ */
170
+ dispose() {
171
+ this.stopped = true;
172
+ // Drop the scratch canvas (sizing it to 0 frees its backing store promptly).
173
+ if (this.scratch) {
174
+ try {
175
+ this.scratch.width = 0;
176
+ this.scratch.height = 0;
177
+ }
178
+ catch { /* already detached */ }
179
+ }
180
+ this.scratch = null;
181
+ this.scratchCtx = null;
182
+ }
183
+ }
Binary file
Binary file