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