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