@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,252 @@
1
+ /**
2
+ * WebCodecs video capture, attach side — port of the bench webcodecs strategy
3
+ * (test-applications/canvas-recorder-bench/src/capture/webcodecs.ts).
4
+ * Frames are grabbed IN-TASK with the render (the manager's microtask pump
5
+ * provides the afterRender hook) — the only way to read
6
+ * preserveDrawingBuffer:false WebGL and task-scoped WebGPU textures without
7
+ * buffer-forcing hacks. Adaptive fps + interaction-forced keyframes; the
8
+ * encoder runs in an inline-blob worker; frames travel as transferred
9
+ * VideoFrames; encoded chunks stream back live for SFCV video-chunk frames.
10
+ *
11
+ * `intervalPaced` mode covers worker-rendered placeholder canvases that have
12
+ * no draw hooks: the placeholder's COMMITTED frame persists like an image
13
+ * (no task-scoped readback hazard), so a timer-paced grab is safe there.
14
+ */
15
+ import { VideoEncoderClient } from "./encodeWorker";
16
+ const BASE_FPS = 20;
17
+ const BOOST_FPS = 30;
18
+ /** boost window after an interaction */
19
+ const BOOST_MS = 1_500;
20
+ const KEYFRAME_EVERY_MS = 2_000;
21
+ // Encoder bitrate is per-second (size barely depends on fps) — higher fps
22
+ // cuts replay staleness for moving scenes at near-zero size cost.
23
+ const BITRATE = 1_800_000;
24
+ export class VideoCapture {
25
+ canvas;
26
+ now;
27
+ out;
28
+ opts;
29
+ client = null;
30
+ configured = false;
31
+ failed = false;
32
+ stopped = false;
33
+ lastFrame = -Infinity;
34
+ lastKey = -Infinity;
35
+ boostUntil = -Infinity;
36
+ configuredW = 0;
37
+ configuredH = 0;
38
+ intervalId = null;
39
+ /** A11: dedupe the "throttled" note to once per saturation episode */
40
+ throttledNoted = false;
41
+ /** A24: an encoder restart at the new canvas size is in flight */
42
+ restarting = false;
43
+ /** A24: force the next captured frame to a keyframe (after a restart) */
44
+ forceKeyNext = false;
45
+ static supported() {
46
+ return typeof VideoEncoder === "function" && typeof VideoFrame === "function";
47
+ }
48
+ constructor(canvas, now, out, opts) {
49
+ this.canvas = canvas;
50
+ this.now = now;
51
+ this.out = out;
52
+ this.opts = opts;
53
+ }
54
+ /** Resolves true when the encoder configured; false -> keyframes-only fallback. */
55
+ async start() {
56
+ if (!VideoCapture.supported()) {
57
+ this.failed = true;
58
+ this.out.note("no WebCodecs — video capture unavailable");
59
+ return false;
60
+ }
61
+ const w = this.canvas.width;
62
+ const h = this.canvas.height;
63
+ if (!w || !h) {
64
+ this.failed = true;
65
+ this.out.note("zero-size canvas — video capture unavailable");
66
+ return false;
67
+ }
68
+ this.client = new VideoEncoderClient({
69
+ onDecoderConfig: (codec, description) => this.out.onInit(codec, description),
70
+ onChunk: (data, timestampUs, key) => this.out.onChunk(key, timestampUs, data),
71
+ onError: (m) => this.out.note(m),
72
+ });
73
+ const ok = await this.client.configure({
74
+ width: w,
75
+ height: h,
76
+ bitrate: BITRATE,
77
+ codecs: ["avc1.42001f", "vp8", "vp09.00.10.08"],
78
+ });
79
+ if (!ok) {
80
+ this.failed = true;
81
+ this.out.note("no supported video codec");
82
+ this.client.terminate();
83
+ this.client = null;
84
+ return false;
85
+ }
86
+ this.configured = true;
87
+ this.configuredW = w;
88
+ this.configuredH = h;
89
+ if (this.opts?.intervalPaced) {
90
+ this.intervalId = setInterval(() => this.afterRender(this.now()), 1000 / BASE_FPS);
91
+ }
92
+ return true;
93
+ }
94
+ onInteraction(tMs) {
95
+ this.boostUntil = tMs + BOOST_MS;
96
+ this.lastKey = -Infinity; // force keyframe on next captured frame
97
+ }
98
+ /** Same-task capture: invoked by the manager's microtask pump right after
99
+ * the scene rendered. Dirty-by-construction — only called when a frame was
100
+ * drawn (or on the interval for placeholder canvases). */
101
+ afterRender(t) {
102
+ if (!this.configured || this.failed || this.stopped || !this.client)
103
+ return;
104
+ // A11 backpressure: the encoder can outpace a slow-but-open socket, growing
105
+ // WebSocket.bufferedAmount without bound. While the transport is saturated,
106
+ // SKIP producing a new VideoFrame entirely (no encode, no send). lastFrame/
107
+ // lastKey are left untouched so that as soon as the buffer drains, the next
108
+ // render produces a frame and the keyframe spacing logic re-keys naturally.
109
+ // Never blocks: this is a single cached-number check.
110
+ if (this.out.isTransportSaturated?.()) {
111
+ if (!this.throttledNoted) {
112
+ this.throttledNoted = true;
113
+ this.out.note("transport saturated — video frames throttled");
114
+ }
115
+ return;
116
+ }
117
+ this.throttledNoted = false;
118
+ if (this.canvas.width !== this.configuredW || this.canvas.height !== this.configuredH) {
119
+ // A24: the canvas was resized mid-stream. The encoder is locked to its
120
+ // configured dimensions, so instead of silently dropping every later
121
+ // frame (the old behavior — replay froze at the resize), restart the
122
+ // encoder at the new size. The restart is async; skip frames until it
123
+ // completes, then the next frame is forced to a keyframe.
124
+ this.requestRestart();
125
+ return;
126
+ }
127
+ const fps = t < this.boostUntil ? BOOST_FPS : BASE_FPS;
128
+ if (t - this.lastFrame < 1000 / fps)
129
+ return;
130
+ this.lastFrame = t;
131
+ // First frame after a successful restart forces a keyframe so the new-size
132
+ // stream is independently decodable from its start.
133
+ const forceKey = this.forceKeyNext;
134
+ this.forceKeyNext = false;
135
+ const key = forceKey || t - this.lastKey >= KEYFRAME_EVERY_MS;
136
+ if (key)
137
+ this.lastKey = t;
138
+ try {
139
+ // t is epoch ms; chunk timestamps therefore land in epoch µs
140
+ // (contract §1's clock, µs units for WebCodecs).
141
+ const frame = new VideoFrame(this.canvas, { timestamp: Math.round(t * 1000) });
142
+ this.client.encodeFrame(frame, key);
143
+ }
144
+ catch (e) {
145
+ this.out.note(String(e));
146
+ }
147
+ }
148
+ /**
149
+ * A24: restart the encoder at the canvas's current dimensions after a
150
+ * mid-stream resize. The old encoder is torn down (its locked-size frames are
151
+ * no longer valid) and a fresh one is configured for the new size; the new
152
+ * encoder's first chunk re-emits the decoder config (onInit) so the player
153
+ * re-initializes, and the next captured frame is forced to a keyframe. Skips
154
+ * if already restarting, stopped, or the new size is zero.
155
+ */
156
+ requestRestart() {
157
+ if (this.restarting || this.stopped)
158
+ return;
159
+ const w = this.canvas.width;
160
+ const h = this.canvas.height;
161
+ if (!w || !h)
162
+ return; // transient zero-size during resize — wait
163
+ this.restarting = true;
164
+ const oldClient = this.client;
165
+ this.client = null; // pause frame production until reconfigured
166
+ this.configured = false;
167
+ this.out.note(`canvas resized to ${w}x${h} during video capture — restarting encoder`);
168
+ void (async () => {
169
+ try {
170
+ if (oldClient) {
171
+ try {
172
+ oldClient.terminate();
173
+ }
174
+ catch { /* already gone */ }
175
+ }
176
+ if (this.stopped)
177
+ return;
178
+ const client = new VideoEncoderClient({
179
+ onDecoderConfig: (codec, description) => this.out.onInit(codec, description),
180
+ onChunk: (data, timestampUs, key) => this.out.onChunk(key, timestampUs, data),
181
+ onError: (m) => this.out.note(m),
182
+ });
183
+ const ok = await client.configure({
184
+ width: w,
185
+ height: h,
186
+ bitrate: BITRATE,
187
+ codecs: ["avc1.42001f", "vp8", "vp09.00.10.08"],
188
+ });
189
+ if (this.stopped || !ok) {
190
+ client.terminate();
191
+ if (!ok)
192
+ this.out.note("encoder restart failed after resize — frames dropped until next resize");
193
+ return;
194
+ }
195
+ this.client = client;
196
+ this.configuredW = w;
197
+ this.configuredH = h;
198
+ this.configured = true;
199
+ this.forceKeyNext = true;
200
+ // reset pacing so the very next render produces the keyframe
201
+ this.lastFrame = -Infinity;
202
+ this.lastKey = -Infinity;
203
+ }
204
+ finally {
205
+ this.restarting = false;
206
+ }
207
+ })();
208
+ }
209
+ /** Non-terminal drain (visibilitychange:hidden on mobile, where the OS may
210
+ * kill the tab without a reliable pagehide): push the encoder's queued frames
211
+ * out as chunks WITHOUT tearing down, so capture resumes if the tab returns.
212
+ * No-op once stopped. */
213
+ async flush() {
214
+ if (this.stopped || !this.client)
215
+ return;
216
+ await this.client.flush();
217
+ }
218
+ /**
219
+ * A22 / A6 / A10: immediate teardown WITHOUT flushing — used when the canvas
220
+ * was removed from the DOM, masked mid-session, or its GPU context was lost.
221
+ * Flushing would emit chunks for content that is gone/garbage, so we just
222
+ * terminate the encoder worker and stop. Idempotent.
223
+ */
224
+ abort() {
225
+ if (this.stopped)
226
+ return;
227
+ this.stopped = true;
228
+ if (this.intervalId != null) {
229
+ clearInterval(this.intervalId);
230
+ this.intervalId = null;
231
+ }
232
+ if (this.client) {
233
+ this.client.terminate();
234
+ this.client = null;
235
+ }
236
+ }
237
+ /** Flush the encoder (remaining chunks stream through onChunk) and tear down. */
238
+ async stop() {
239
+ if (this.stopped)
240
+ return;
241
+ this.stopped = true;
242
+ if (this.intervalId != null) {
243
+ clearInterval(this.intervalId);
244
+ this.intervalId = null;
245
+ }
246
+ if (this.client) {
247
+ await this.client.stop();
248
+ this.client.terminate();
249
+ this.client = null;
250
+ }
251
+ }
252
+ }
Binary file
Binary file
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Main-thread registry for canvases whose control was transferred to a
3
+ * worker, plus the AUTO-ASSOCIATION patches the bench left to the build
4
+ * plugin (contract §5):
5
+ *
6
+ * - HTMLCanvasElement.prototype.transferControlToOffscreen is patched to
7
+ * remember which OffscreenCanvas came from which canvas element.
8
+ * - Worker.prototype.postMessage is patched to map canvas -> worker when an
9
+ * OffscreenCanvas from a tracked canvas appears in the transfer list.
10
+ *
11
+ * Worker-side recording additionally requires the customer to import
12
+ * `@sailfish-ai/recorder/canvas-worker` in the worker (the shim). The
13
+ * manager pings the worker (sf-record-ping/pong) to detect the shim; absent
14
+ * a pong, the canvas falls back to video capture of the placeholder.
15
+ *
16
+ * SSR rule: nothing here touches window/document at module scope — patches
17
+ * install inside installWorkerRelay().
18
+ */
19
+ const offscreenToCanvas = new WeakMap();
20
+ const registry = new Map();
21
+ let installed = false;
22
+ let onAssociation = null;
23
+ /** Manager subscription: fires when a canvas->worker link is discovered. */
24
+ export function setWorkerAssociationNotifier(fn) {
25
+ onAssociation = fn;
26
+ }
27
+ export function installWorkerRelay() {
28
+ if (installed)
29
+ return;
30
+ installed = true;
31
+ if (typeof HTMLCanvasElement !== "undefined" &&
32
+ typeof HTMLCanvasElement.prototype.transferControlToOffscreen === "function") {
33
+ const orig = HTMLCanvasElement.prototype.transferControlToOffscreen;
34
+ HTMLCanvasElement.prototype.transferControlToOffscreen = function () {
35
+ const off = orig.call(this);
36
+ offscreenToCanvas.set(off, this);
37
+ return off;
38
+ };
39
+ }
40
+ if (typeof Worker !== "undefined") {
41
+ const origPost = Worker.prototype.postMessage;
42
+ Worker.prototype.postMessage = function (message, transferOrOptions) {
43
+ const transfer = Array.isArray(transferOrOptions)
44
+ ? transferOrOptions
45
+ : transferOrOptions?.transfer;
46
+ if (transfer) {
47
+ for (const t of transfer) {
48
+ const canvas = t && typeof t === "object"
49
+ ? offscreenToCanvas.get(t)
50
+ : undefined;
51
+ if (canvas && !registry.has(canvas)) {
52
+ registry.set(canvas, this);
53
+ onAssociation?.(canvas, this);
54
+ }
55
+ }
56
+ }
57
+ return origPost.apply(this, [message, transferOrOptions]);
58
+ };
59
+ }
60
+ }
61
+ export function registerWorkerCanvas(canvas, worker) {
62
+ registry.set(canvas, worker);
63
+ onAssociation?.(canvas, worker);
64
+ }
65
+ export function getWorkerForCanvas(canvas) {
66
+ return registry.get(canvas);
67
+ }
68
+ export function unregisterWorkerCanvas(canvas) {
69
+ registry.delete(canvas);
70
+ }
71
+ /**
72
+ * Detect the worker-side shim: resolves true iff the worker answers
73
+ * sf-record-ping with sf-record-pong within `timeoutMs`.
74
+ */
75
+ export function pingWorkerShim(worker, timeoutMs = 500) {
76
+ return new Promise((resolve) => {
77
+ let settled = false;
78
+ const finish = (ok) => {
79
+ if (settled)
80
+ return;
81
+ settled = true;
82
+ clearTimeout(tid);
83
+ worker.removeEventListener("message", onMsg);
84
+ resolve(ok);
85
+ };
86
+ const onMsg = (e) => {
87
+ if (e.data?.type === "sf-record-pong")
88
+ finish(true);
89
+ };
90
+ const tid = setTimeout(() => finish(false), timeoutMs);
91
+ worker.addEventListener("message", onMsg);
92
+ try {
93
+ worker.postMessage({ type: "sf-record-ping" });
94
+ }
95
+ catch {
96
+ finish(false);
97
+ }
98
+ });
99
+ }
Binary file
Binary file
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Worker-side 2D command recorder — port of the bench tier-2 worker module
3
+ * (test-applications/canvas-recorder-bench/src/capture/workerRecorder.ts).
4
+ * Customers import this as `@sailfish-ai/recorder/canvas-worker` inside any
5
+ * worker that renders to a transferred OffscreenCanvas (a later build plugin
6
+ * will inject it automatically). It patches
7
+ * OffscreenCanvasRenderingContext2D, records the command stream with the
8
+ * shared binary codec, and relays batches/assets/sizes to the main thread on
9
+ * demand.
10
+ *
11
+ * Message protocol (main -> worker):
12
+ * {type:'sf-record-ping'} -> {type:'sf-record-pong'} (shim detection)
13
+ * {type:'sf-record-start'} / {type:'sf-record-stop'}
14
+ * Worker -> main:
15
+ * {type:'sf-record-done', batches, chunks, cpuMs, notes, assets}
16
+ *
17
+ * SDK adaptations: the recorder clock defaults to epoch ms (contract §1 —
18
+ * setWorkerNow remains available for custom clocks); the OffscreenCanvas
19
+ * getContext is patched so the rendered context registers itself (the bench
20
+ * required an explicit registerWorkerCanvasContext call); the module
21
+ * auto-installs when imported inside a worker scope. Only ONE OffscreenCanvas
22
+ * per worker is recorded in this increment (bench behavior).
23
+ */
24
+ import { attachRecorderToContext, patch2DProto, } from "./patch2d";
25
+ import { CommandEncoder, deflate } from "./codec";
26
+ import { redactSensitiveContent } from "./piiDetect";
27
+ let patched = false;
28
+ let installedListener = false;
29
+ let registeredCtx = null;
30
+ /** recorder clock — epoch ms by default (contract §1, rrweb-aligned) */
31
+ let workerNow = () => Date.now();
32
+ // A13: content-detection flags pushed in by the main thread at sf-record-start.
33
+ // A worker/OffscreenCanvas serializes its own commands with no DOM, so the main
34
+ // thread can only forward the data-sensitivity flags; selector-based suppression
35
+ // (block/maskText) is decided main-thread BEFORE the worker is started, so
36
+ // any worker that reaches here is "normal" mode and only needs CC/SSN/DOB
37
+ // content redaction on its drawn-text args.
38
+ let contentFlags = null;
39
+ /** True when at least one record* flag is explicitly false (redaction needed). */
40
+ function flagsRequireRedaction(f) {
41
+ return (!!f &&
42
+ (f.recordCreditCardInfo === false ||
43
+ f.recordSsn === false ||
44
+ f.recordDob === false));
45
+ }
46
+ let recorder = null;
47
+ let encoder = null;
48
+ let rawBatches = [];
49
+ let chunks = [];
50
+ let notes = [];
51
+ let encodeCpu = 0;
52
+ let batchStart = 0;
53
+ let pending = [];
54
+ /** Optional: explicitly register the OffscreenCanvas 2D context to record
55
+ * (the getContext patch normally does this automatically). */
56
+ export function registerWorkerCanvasContext(ctx) {
57
+ registeredCtx = ctx;
58
+ }
59
+ /** Optional: keep the recorder clock in sync with a custom render time. */
60
+ export function setWorkerNow(now) {
61
+ workerNow = now;
62
+ }
63
+ async function encodeBitmapLocal(bitmap) {
64
+ const off = new OffscreenCanvas(bitmap.width, bitmap.height);
65
+ off.getContext("2d").drawImage(bitmap, 0, 0);
66
+ bitmap.close();
67
+ const blob = await off.convertToBlob({ type: "image/webp", quality: 0.85 });
68
+ return { size: blob.size };
69
+ }
70
+ function flushBatch(t) {
71
+ if (!encoder || encoder.ops === 0)
72
+ return;
73
+ const raw = encoder.takeBatch();
74
+ // STREAM batches live (Phase 0.5 E2E fix): the bench protocol accumulated
75
+ // until sf-record-stop, which meant a live session produced ZERO frames
76
+ // until teardown — and none at all if the page never cleanly stopped.
77
+ // Each ~1s batch is deflated and posted immediately; sf-record-done now
78
+ // carries only assets/notes/cpu (batches: [] kept for shape compat).
79
+ pending.push(deflate(raw).then((z) => {
80
+ chunks.push({ tMs: t, bytes: z.length });
81
+ const buf = z.buffer.slice(z.byteOffset, z.byteOffset + z.byteLength);
82
+ self.postMessage({ type: "sf-record-batch", deflated: buf, tMs: t }, [buf]);
83
+ }));
84
+ }
85
+ function ensurePatched() {
86
+ if (patched)
87
+ return;
88
+ patched = true;
89
+ const proto = globalThis
90
+ .OffscreenCanvasRenderingContext2D?.prototype;
91
+ if (proto)
92
+ patch2DProto(proto);
93
+ // Auto-register: the first OffscreenCanvas 2D context created in this
94
+ // worker becomes the recorded context (bench required an explicit call).
95
+ const OffscreenCtor = globalThis.OffscreenCanvas;
96
+ if (OffscreenCtor) {
97
+ const origGetContext = OffscreenCtor.prototype.getContext;
98
+ OffscreenCtor.prototype.getContext = function (type, attrs) {
99
+ const result = origGetContext.call(this, type, attrs);
100
+ if (type === "2d" && result && !registeredCtx) {
101
+ registeredCtx = result;
102
+ }
103
+ return result;
104
+ };
105
+ }
106
+ }
107
+ function start(flags) {
108
+ ensurePatched();
109
+ contentFlags = flags ?? null;
110
+ encoder = new CommandEncoder();
111
+ rawBatches = [];
112
+ chunks = [];
113
+ notes = [];
114
+ encodeCpu = 0;
115
+ batchStart = 0;
116
+ pending = [];
117
+ if (registeredCtx) {
118
+ attachNow();
119
+ }
120
+ else {
121
+ // No 2D OffscreenCanvas context in this worker (e.g. it renders WebGL —
122
+ // the shim records 2D only). Tell the main thread so it can fall back to
123
+ // video capture of the placeholder instead of waiting on command batches
124
+ // that will never come. Recording stays armed: if a 2D context appears
125
+ // later the getContext patch registers it and stop() captures the tail.
126
+ notes.push("sf-record-start: no OffscreenCanvas 2D context registered yet");
127
+ self.postMessage({ type: "sf-record-noctx" });
128
+ }
129
+ }
130
+ function attachNow() {
131
+ if (!registeredCtx || !encoder)
132
+ return;
133
+ recorder = attachRecorderToContext(registeredCtx, {
134
+ onOp(op) {
135
+ const t0 = performance.now();
136
+ encoder.encode(op);
137
+ encodeCpu += performance.now() - t0;
138
+ if (op.t - batchStart >= 1000) {
139
+ flushBatch(op.t);
140
+ batchStart = op.t;
141
+ }
142
+ },
143
+ onAssetBytes(_id, bytes, t) {
144
+ chunks.push({ tMs: t, bytes });
145
+ },
146
+ note(m) { if (!notes.includes(m))
147
+ notes.push(m); },
148
+ }, () => workerNow(), "cas", encodeBitmapLocal, { retainAssets: true });
149
+ // A13: normal-mode worker canvases still get content-detection redaction of
150
+ // drawn-text args (CC/SSN/DOB) when the account opts those types out. Selector
151
+ // suppression (block/maskText) was already decided main-thread, so the
152
+ // worker only needs the content layer. Skip installing the redactor entirely
153
+ // when no flag requires it (keeps the hot path allocation-free).
154
+ if (recorder && flagsRequireRedaction(contentFlags)) {
155
+ const flags = contentFlags;
156
+ recorder.textRedactor = (t) => redactSensitiveContent(t, flags);
157
+ }
158
+ }
159
+ async function stop() {
160
+ // Late registration (context created after start): attach so at least the
161
+ // tail is captured; common case is attach-at-start.
162
+ if (!recorder && registeredCtx && encoder)
163
+ attachNow();
164
+ flushBatch(workerNow());
165
+ await recorder?.settle();
166
+ await Promise.all(pending);
167
+ const assets = [];
168
+ const transfers = [];
169
+ for (const [, a] of recorder?.assets ?? new Map()) {
170
+ const wire = { id: a.id, kind: a.kind, t: a.t, bytes: a.bytes, w: a.w, h: a.h };
171
+ if (a.bitmap) {
172
+ wire.bitmap = a.bitmap;
173
+ transfers.push(a.bitmap);
174
+ }
175
+ if (a.imageData)
176
+ wire.imageData = a.imageData;
177
+ assets.push(wire);
178
+ }
179
+ self.postMessage({
180
+ type: "sf-record-done",
181
+ batches: [],
182
+ chunks,
183
+ cpuMs: (recorder?.cpuMs ?? 0) + encodeCpu,
184
+ notes,
185
+ assets,
186
+ }, transfers);
187
+ recorder = null;
188
+ }
189
+ /** Install the relay message listener (call once at worker startup —
190
+ * importing this module inside a worker does it automatically). */
191
+ export function installWorkerRecorder() {
192
+ if (installedListener)
193
+ return;
194
+ installedListener = true;
195
+ // BUGFIX (Phase 0.5 E2E, found on /worker-2d): patch getContext AT INSTALL,
196
+ // not lazily at sf-record-start. The worker handles the app's init message
197
+ // (creating its OffscreenCanvas 2D context) BEFORE the main thread's
198
+ // shim-ping/record-start round trip arrives — a lazily-installed patch
199
+ // missed the registration, the recorder never attached, and the canvas
200
+ // recorded zero ops. installWorkerRecorder only runs inside worker scopes
201
+ // (auto-install below is guarded), so this stays SSR/main-thread safe.
202
+ ensurePatched();
203
+ self.addEventListener("message", (e) => {
204
+ const msg = e.data;
205
+ if (msg?.type === "sf-record-ping") {
206
+ self.postMessage({ type: "sf-record-pong" });
207
+ }
208
+ else if (msg?.type === "sf-record-start") {
209
+ start(msg.contentFlags);
210
+ }
211
+ else if (msg?.type === "sf-record-stop")
212
+ void stop();
213
+ });
214
+ }
215
+ // Auto-install when imported inside a (dedicated) worker scope. The guards
216
+ // keep this module side-effect-free on the main thread and under SSR.
217
+ if (typeof self !== "undefined" &&
218
+ typeof window === "undefined" &&
219
+ typeof document === "undefined" &&
220
+ typeof self.postMessage === "function") {
221
+ try {
222
+ installWorkerRecorder();
223
+ }
224
+ catch {
225
+ /* never break the host worker */
226
+ }
227
+ }
Binary file
Binary file
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const e = require("./chunks/patch2d-9voowhJ_.js");
4
+ let t = false, s = false, n = null, workerNow = () => Date.now(), o = null;
5
+ let r = null, a = null, c = [], i = [], f = 0, d = 0, l = [];
6
+ async function encodeBitmapLocal(e2) {
7
+ const t2 = new OffscreenCanvas(e2.width, e2.height);
8
+ t2.getContext("2d").drawImage(e2, 0, 0), e2.close();
9
+ return { size: (await t2.convertToBlob({ type: "image/webp", quality: 0.85 })).size };
10
+ }
11
+ function flushBatch(t2) {
12
+ if (!a || 0 === a.ops) return;
13
+ const s2 = a.takeBatch();
14
+ l.push(e.deflate(s2).then((e2) => {
15
+ c.push({ tMs: t2, bytes: e2.length });
16
+ const s3 = e2.buffer.slice(e2.byteOffset, e2.byteOffset + e2.byteLength);
17
+ self.postMessage({ type: "sf-record-batch", deflated: s3, tMs: t2 }, [s3]);
18
+ }));
19
+ }
20
+ function ensurePatched() {
21
+ var _a;
22
+ if (t) return;
23
+ t = true;
24
+ const s2 = (_a = globalThis.OffscreenCanvasRenderingContext2D) == null ? void 0 : _a.prototype;
25
+ s2 && e.patch2DProto(s2);
26
+ const o2 = globalThis.OffscreenCanvas;
27
+ if (o2) {
28
+ const e2 = o2.prototype.getContext;
29
+ o2.prototype.getContext = function(t2, s3) {
30
+ const o3 = e2.call(this, t2, s3);
31
+ return "2d" === t2 && o3 && !n && (n = o3), o3;
32
+ };
33
+ }
34
+ }
35
+ function attachNow() {
36
+ if (n && a && (r = e.attachRecorderToContext(n, { onOp(e2) {
37
+ const t2 = performance.now();
38
+ a.encode(e2), f += performance.now() - t2, e2.t - d >= 1e3 && (flushBatch(e2.t), d = e2.t);
39
+ }, onAssetBytes(e2, t2, s2) {
40
+ c.push({ tMs: s2, bytes: t2 });
41
+ }, note(e2) {
42
+ i.includes(e2) || i.push(e2);
43
+ } }, () => workerNow(), "cas", encodeBitmapLocal, { retainAssets: true }), r && (function flagsRequireRedaction(e2) {
44
+ return !!e2 && (false === e2.recordCreditCardInfo || false === e2.recordSsn || false === e2.recordDob);
45
+ })(o))) {
46
+ const t2 = o;
47
+ r.textRedactor = (s2) => e.redactSensitiveContent(s2, t2);
48
+ }
49
+ }
50
+ function installWorkerRecorder() {
51
+ s || (s = true, ensurePatched(), self.addEventListener("message", (t2) => {
52
+ const s2 = t2.data;
53
+ "sf-record-ping" === (s2 == null ? void 0 : s2.type) ? self.postMessage({ type: "sf-record-pong" }) : "sf-record-start" === (s2 == null ? void 0 : s2.type) ? (function start(t3) {
54
+ ensurePatched(), o = t3 ?? null, a = new e.CommandEncoder(), c = [], i = [], f = 0, d = 0, l = [], n ? attachNow() : (i.push("sf-record-start: no OffscreenCanvas 2D context registered yet"), self.postMessage({ type: "sf-record-noctx" }));
55
+ })(s2.contentFlags) : "sf-record-stop" === (s2 == null ? void 0 : s2.type) && (async function stop() {
56
+ !r && n && a && attachNow(), flushBatch(workerNow()), await (r == null ? void 0 : r.settle()), await Promise.all(l);
57
+ const e2 = [], t3 = [];
58
+ for (const [, s3] of (r == null ? void 0 : r.assets) ?? /* @__PURE__ */ new Map()) {
59
+ const n2 = { id: s3.id, kind: s3.kind, t: s3.t, bytes: s3.bytes, w: s3.w, h: s3.h };
60
+ s3.bitmap && (n2.bitmap = s3.bitmap, t3.push(s3.bitmap)), s3.imageData && (n2.imageData = s3.imageData), e2.push(n2);
61
+ }
62
+ self.postMessage({ type: "sf-record-done", batches: [], chunks: c, cpuMs: ((r == null ? void 0 : r.cpuMs) ?? 0) + f, notes: i, assets: e2 }, t3), r = null;
63
+ })();
64
+ }));
65
+ }
66
+ if ("undefined" != typeof self && "undefined" == typeof window && "undefined" == typeof document && "function" == typeof self.postMessage) try {
67
+ installWorkerRecorder();
68
+ } catch {
69
+ }
70
+ exports.installWorkerRecorder = installWorkerRecorder, exports.registerWorkerCanvasContext = function registerWorkerCanvasContext(e2) {
71
+ n = e2;
72
+ }, exports.setWorkerNow = function setWorkerNow(e2) {
73
+ workerNow = e2;
74
+ };
Binary file
Binary file