@sailfish-ai/recorder 1.12.5 → 1.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +8 -0
  2. package/dist/canvas/adapters.js +382 -0
  3. package/dist/canvas/adapters.js.br +0 -0
  4. package/dist/canvas/adapters.js.gz +0 -0
  5. package/dist/canvas/canvasManager.js +1949 -0
  6. package/dist/canvas/canvasManager.js.br +0 -0
  7. package/dist/canvas/canvasManager.js.gz +0 -0
  8. package/dist/canvas/canvasPrivacy.js +233 -0
  9. package/dist/canvas/canvasPrivacy.js.br +0 -0
  10. package/dist/canvas/canvasPrivacy.js.gz +0 -0
  11. package/dist/canvas/codec.js +314 -0
  12. package/dist/canvas/codec.js.br +0 -0
  13. package/dist/canvas/codec.js.gz +0 -0
  14. package/dist/canvas/deviceClass.js +67 -0
  15. package/dist/canvas/deviceClass.js.br +0 -0
  16. package/dist/canvas/deviceClass.js.gz +0 -0
  17. package/dist/canvas/encodeWorker.js +284 -0
  18. package/dist/canvas/encodeWorker.js.br +0 -0
  19. package/dist/canvas/encodeWorker.js.gz +0 -0
  20. package/dist/canvas/governor.js +79 -0
  21. package/dist/canvas/governor.js.br +0 -0
  22. package/dist/canvas/governor.js.gz +0 -0
  23. package/dist/canvas/keyframes.js +183 -0
  24. package/dist/canvas/keyframes.js.br +0 -0
  25. package/dist/canvas/keyframes.js.gz +0 -0
  26. package/dist/canvas/mediaRecorderCapture.js +257 -0
  27. package/dist/canvas/mediaRecorderCapture.js.br +0 -0
  28. package/dist/canvas/mediaRecorderCapture.js.gz +0 -0
  29. package/dist/canvas/objectLog.js +150 -0
  30. package/dist/canvas/objectLog.js.br +0 -0
  31. package/dist/canvas/objectLog.js.gz +0 -0
  32. package/dist/canvas/patch2d.js +1302 -0
  33. package/dist/canvas/patch2d.js.br +0 -0
  34. package/dist/canvas/patch2d.js.gz +0 -0
  35. package/dist/canvas/patchGL.js +371 -0
  36. package/dist/canvas/patchGL.js.br +0 -0
  37. package/dist/canvas/patchGL.js.gz +0 -0
  38. package/dist/canvas/patchWebGPU.js +361 -0
  39. package/dist/canvas/patchWebGPU.js.br +0 -0
  40. package/dist/canvas/patchWebGPU.js.gz +0 -0
  41. package/dist/canvas/piiDetect.js +155 -0
  42. package/dist/canvas/piiDetect.js.br +0 -0
  43. package/dist/canvas/piiDetect.js.gz +0 -0
  44. package/dist/canvas/protocol.js +122 -0
  45. package/dist/canvas/protocol.js.br +0 -0
  46. package/dist/canvas/protocol.js.gz +0 -0
  47. package/dist/canvas/videoCapture.js +252 -0
  48. package/dist/canvas/videoCapture.js.br +0 -0
  49. package/dist/canvas/videoCapture.js.gz +0 -0
  50. package/dist/canvas/workerRelay.js +99 -0
  51. package/dist/canvas/workerRelay.js.br +0 -0
  52. package/dist/canvas/workerRelay.js.gz +0 -0
  53. package/dist/canvas/workerShim.js +227 -0
  54. package/dist/canvas/workerShim.js.br +0 -0
  55. package/dist/canvas/workerShim.js.gz +0 -0
  56. package/dist/canvas-worker.cjs +74 -0
  57. package/dist/canvas-worker.cjs.br +0 -0
  58. package/dist/canvas-worker.cjs.gz +0 -0
  59. package/dist/canvas-worker.js +78 -0
  60. package/dist/canvas-worker.js.br +0 -0
  61. package/dist/canvas-worker.js.gz +0 -0
  62. package/dist/canvasFrameStore.js +198 -0
  63. package/dist/canvasFrameStore.js.br +0 -0
  64. package/dist/canvasFrameStore.js.gz +0 -0
  65. package/dist/chunks/canvasManager-BYn3_oyo.js +1440 -0
  66. package/dist/chunks/canvasManager-BYn3_oyo.js.br +0 -0
  67. package/dist/chunks/canvasManager-BYn3_oyo.js.gz +0 -0
  68. package/dist/chunks/canvasManager-CoX5072G.js +1456 -0
  69. package/dist/chunks/canvasManager-CoX5072G.js.br +0 -0
  70. package/dist/chunks/canvasManager-CoX5072G.js.gz +0 -0
  71. package/dist/chunks/{chunkSerializer-Cd8y9tID.js → chunkSerializer-C_K0Wyk0.js} +1 -1
  72. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.br +0 -0
  73. package/dist/chunks/chunkSerializer-C_K0Wyk0.js.gz +0 -0
  74. package/dist/chunks/{chunkSerializer-pkpgT-FH.js → chunkSerializer-CghPitF4.js} +1 -1
  75. package/dist/chunks/chunkSerializer-CghPitF4.js.br +0 -0
  76. package/dist/chunks/chunkSerializer-CghPitF4.js.gz +0 -0
  77. package/dist/chunks/{index-Bvd8oTpV.js → index-BIj4l3CV.js} +1025 -655
  78. package/dist/chunks/index-BIj4l3CV.js.br +0 -0
  79. package/dist/chunks/index-BIj4l3CV.js.gz +0 -0
  80. package/dist/chunks/{index-CXvHbLRT.js → index-DIYomvdD.js} +1115 -724
  81. package/dist/chunks/index-DIYomvdD.js.br +0 -0
  82. package/dist/chunks/index-DIYomvdD.js.gz +0 -0
  83. package/dist/chunks/patch2d-9voowhJ_.js +816 -0
  84. package/dist/chunks/patch2d-9voowhJ_.js.br +0 -0
  85. package/dist/chunks/patch2d-9voowhJ_.js.gz +0 -0
  86. package/dist/chunks/patch2d-PQxcaE6r.js +886 -0
  87. package/dist/chunks/patch2d-PQxcaE6r.js.br +0 -0
  88. package/dist/chunks/patch2d-PQxcaE6r.js.gz +0 -0
  89. package/dist/graphql.js +12 -1
  90. package/dist/graphql.js.br +0 -0
  91. package/dist/graphql.js.gz +0 -0
  92. package/dist/index.js +144 -2
  93. package/dist/index.js.br +0 -0
  94. package/dist/index.js.gz +0 -0
  95. package/dist/recorder.cjs +2 -2
  96. package/dist/recorder.cjs.br +0 -0
  97. package/dist/recorder.cjs.gz +0 -0
  98. package/dist/recorder.js +52 -41
  99. package/dist/recorder.js.br +0 -0
  100. package/dist/recorder.js.gz +0 -0
  101. package/dist/recorder.umd.cjs +4842 -2215
  102. package/dist/recorder.umd.cjs.br +0 -0
  103. package/dist/recorder.umd.cjs.gz +0 -0
  104. package/dist/recording.js +119 -10
  105. package/dist/recording.js.br +0 -0
  106. package/dist/recording.js.gz +0 -0
  107. package/dist/types/canvas/adapters.d.ts +71 -0
  108. package/dist/types/canvas/canvasManager.d.ts +273 -0
  109. package/dist/types/canvas/canvasPrivacy.d.ts +184 -0
  110. package/dist/types/canvas/codec.d.ts +27 -0
  111. package/dist/types/canvas/deviceClass.d.ts +40 -0
  112. package/dist/types/canvas/encodeWorker.d.ts +54 -0
  113. package/dist/types/canvas/governor.d.ts +44 -0
  114. package/dist/types/canvas/keyframes.d.ts +81 -0
  115. package/dist/types/canvas/mediaRecorderCapture.d.ts +91 -0
  116. package/dist/types/canvas/objectLog.d.ts +75 -0
  117. package/dist/types/canvas/patch2d.d.ts +339 -0
  118. package/dist/types/canvas/patchGL.d.ts +74 -0
  119. package/dist/types/canvas/patchWebGPU.d.ts +37 -0
  120. package/dist/types/canvas/piiDetect.d.ts +60 -0
  121. package/dist/types/canvas/protocol.d.ts +176 -0
  122. package/dist/types/canvas/videoCapture.d.ts +67 -0
  123. package/dist/types/canvas/workerRelay.d.ts +29 -0
  124. package/dist/types/canvas/workerShim.d.ts +10 -0
  125. package/dist/types/canvasFrameStore.d.ts +23 -0
  126. package/dist/types/graphql.d.ts +1 -1
  127. package/dist/types/index.d.ts +99 -1
  128. package/dist/types/types.d.ts +40 -0
  129. package/dist/types/websocket.d.ts +19 -0
  130. package/dist/websocket.js +210 -0
  131. package/dist/websocket.js.br +0 -0
  132. package/dist/websocket.js.gz +0 -0
  133. package/package.json +7 -1
  134. package/dist/chunks/chunkSerializer-Cd8y9tID.js.br +0 -0
  135. package/dist/chunks/chunkSerializer-Cd8y9tID.js.gz +0 -0
  136. package/dist/chunks/chunkSerializer-pkpgT-FH.js.br +0 -0
  137. package/dist/chunks/chunkSerializer-pkpgT-FH.js.gz +0 -0
  138. package/dist/chunks/index-Bvd8oTpV.js.br +0 -0
  139. package/dist/chunks/index-Bvd8oTpV.js.gz +0 -0
  140. package/dist/chunks/index-CXvHbLRT.js.br +0 -0
  141. package/dist/chunks/index-CXvHbLRT.js.gz +0 -0
Binary file
Binary file
@@ -0,0 +1,233 @@
1
+ /**
2
+ * A13 — canvas privacy classification + text redaction. Brings canvas
3
+ * recording under the SAME selector-driven privacy framework rrweb uses for the
4
+ * DOM, plus content-level PII detection. (HIPAA is NOT a canvas suppression
5
+ * signal — a HIPAA account still records canvas; PHI is routed downstream.)
6
+ *
7
+ * Classification yields one of three modes per canvas:
8
+ * "suppress" — record NOTHING (emit op:"masked"). block / mask-canvas.
9
+ * "redact" — record commands but mask ALL drawn text (→ asterisks/bar) and
10
+ * blank the text's pixel region on every kept keyframe, so
11
+ * rendered text never leaves the browser while the rest of the
12
+ * canvas stays pixel-perfect. NON-text shapes/images are NOT
13
+ * masked by redact — use maskCanvasDraws / whole-canvas suppress
14
+ * for those. maskText{Class,Selector} / maskCanvasText.
15
+ * "normal" — record normally, with content-detection defense-in-depth on
16
+ * text args when a record* flag is off.
17
+ *
18
+ * The selector matchers (`closestSafe` / `matchesSelectorSafe`) are reused from
19
+ * privacyMask.ts so a malformed admin selector fails SAFE (treated as no match,
20
+ * never throws into the host app / rrweb serializer).
21
+ *
22
+ * SSR rule: pure functions; the only DOM access is through the passed-in
23
+ * canvas element + the shared safe matchers (which guard their own calls).
24
+ */
25
+ import { closestSafe, matchesSelectorSafe } from "../privacyMask";
26
+ import { redactSensitiveContent, } from "./piiDetect";
27
+ /** True when the app configured ANY DOM text masking (rrweb maskTextSelector or
28
+ * maskTextClass). Drives the mirror-DOM default: a privacy-conscious app that
29
+ * masks DOM text gets canvas text masked too unless it opts out
30
+ * (`maskCanvasText:false`). `derivePrivacySettings` only forwards explicitly-set
31
+ * (non-empty) selector/class values, so any truthy value here is real config. */
32
+ function domTextMaskingConfigured(s) {
33
+ return !!(s.maskTextSelector || s.maskTextClass);
34
+ }
35
+ const DEFAULT_MASK_CLASS = "sailfishSanitize";
36
+ /** The legacy mask-canvas selector: `.${maskCanvasClass}, [data-sf-mask]`. */
37
+ export function maskCanvasSelector(settings) {
38
+ const cls = settings.maskCanvasClass || DEFAULT_MASK_CLASS;
39
+ return `.${cls}, [data-sf-mask]`;
40
+ }
41
+ /**
42
+ * Classify a canvas's privacy mode. PRECEDENCE (highest first):
43
+ *
44
+ * 1. unmaskSelector ancestor/self match ⇒ "normal" (override)
45
+ * 2. blockSelector OR mask-canvas selector match ⇒ "suppress"
46
+ * 3. maskTextClass (.class) OR maskTextSelector ⇒ "redact"
47
+ * 3b. global maskCanvasText === true ⇒ "redact"
48
+ * 3c. mirror-DOM (DOM text masking configured AND
49
+ * maskCanvasText !== false) ⇒ "redact"
50
+ * 4. otherwise ⇒ "normal"
51
+ *
52
+ * NOTE on HIPAA: a HIPAA-enabled account does NOT suppress canvas recording.
53
+ * Canvas still records; PHI separation happens downstream (the SDK's index
54
+ * events ride the rrweb event pipeline, which routes to the `phi.*` sinks via
55
+ * the backend `is_phi` decision, and binary frames store in the company bucket
56
+ * exactly like raw events). Developers prevent specific sensitive canvases from
57
+ * being captured with the mask/block selectors below — HIPAA is a routing flag,
58
+ * not a capture switch.
59
+ *
60
+ * Uses ancestor-aware `closestSafe` for class/block/unmask selectors (a privacy
61
+ * container masks every canvas inside it) and self-or-ancestor for maskText*.
62
+ * Every selector call is wrapped so a malformed admin selector fails SAFE: it
63
+ * simply does not match (a throw would crash the recorder; the explicit
64
+ * mask-canvas / block backstops remain effective).
65
+ */
66
+ export function classifyCanvasPrivacy(canvas, settings) {
67
+ // 1. Unmask override — wins over block/mask.
68
+ if (closestSafe(canvas, settings.unmaskSelector))
69
+ return "normal";
70
+ // 2. Block selector OR the legacy mask-canvas selector ⇒ suppress.
71
+ if (closestSafe(canvas, settings.blockSelector))
72
+ return "suppress";
73
+ if (closestSafe(canvas, maskCanvasSelector(settings)))
74
+ return "suppress";
75
+ // 3. Text-redact tier. Precedence within the tier:
76
+ // a. explicit per-canvas maskText class / selector match — explicit intent,
77
+ // honored even when maskCanvasText is false.
78
+ // b. global maskCanvasText === true — every canvas redacts text.
79
+ // c. mirror-DOM default — DOM text masking configured AND not explicitly
80
+ // disabled (maskCanvasText !== false) ⇒ redact canvas text too.
81
+ if (settings.maskTextClass && closestSafe(canvas, `.${settings.maskTextClass}`)) {
82
+ return "redact";
83
+ }
84
+ if (matchesSelectorSafe(canvas, settings.maskTextSelector) ||
85
+ closestSafe(canvas, settings.maskTextSelector)) {
86
+ return "redact";
87
+ }
88
+ if (settings.maskCanvasText === true)
89
+ return "redact";
90
+ if (settings.maskCanvasText !== false && domTextMaskingConfigured(settings)) {
91
+ return "redact";
92
+ }
93
+ // 4. No privacy rule applies.
94
+ return "normal";
95
+ }
96
+ /**
97
+ * Determine WHY a canvas was suppressed (for the op:"masked" reason field).
98
+ * Only meaningful when `classifyCanvasPrivacy` returned "suppress"; precedence
99
+ * matches the classifier (unmask override, then block, then mask-canvas).
100
+ * Returns undefined for non-suppress canvases.
101
+ */
102
+ export function suppressReason(canvas, settings) {
103
+ if (closestSafe(canvas, settings.unmaskSelector))
104
+ return undefined;
105
+ if (closestSafe(canvas, settings.blockSelector))
106
+ return "block";
107
+ if (closestSafe(canvas, maskCanvasSelector(settings)))
108
+ return "mask";
109
+ return undefined;
110
+ }
111
+ /**
112
+ * Full length-preserving mask: every non-whitespace char → "*", whitespace
113
+ * kept. Mirrors rrweb's maskTextFn (privacyMask.makeMaskTextFn) so a redacted
114
+ * canvas string looks identical to a redacted DOM text node. Surrogate pairs
115
+ * (emoji) and combining marks are each non-whitespace code units, so they
116
+ * become "*" per UTF-16 unit — never throws, never leaks the original glyph.
117
+ */
118
+ export function maskAllText(text) {
119
+ return text.replace(/\S/g, "*");
120
+ }
121
+ /**
122
+ * Redact a single drawn-text argument according to the canvas's privacy mode:
123
+ * - "redact": full mask (maskAllText) — drawn text is never legible.
124
+ * - "normal": content-detection redaction (redactSensitiveContent) gated on
125
+ * the record* flags — defense-in-depth so CC/SSN/DOB that an app
126
+ * paints into a non-masked canvas is still caught.
127
+ * - "suppress": never reaches here (a suppressed canvas records no ops); the
128
+ * input is returned unchanged as a defensive no-op.
129
+ *
130
+ * Non-string input (numbers, x/y coords passed as the 2nd/3rd fillText args)
131
+ * passes through untouched — only the FIRST string arg of fillText/strokeText
132
+ * is the rendered text, and the caller restricts redaction to that arg.
133
+ */
134
+ export function redactCanvasText(text, mode, flags) {
135
+ if (typeof text !== "string")
136
+ return text;
137
+ if (mode === "redact")
138
+ return maskAllText(text);
139
+ if (mode === "normal")
140
+ return redactSensitiveContent(text, flags);
141
+ // mode === "suppress": should never be invoked, return unchanged.
142
+ return text;
143
+ }
144
+ /** Mirror-DOM defaults: text → length-preserving asterisks (looks like a masked
145
+ * DOM text node), shapes → same shape recolored (layout preserved). */
146
+ export const DEFAULT_REDACT_STYLE = { text: "asterisks", shape: "color" };
147
+ /** Dark neutral used everywhere a redaction is painted: recolored shapes + bars
148
+ * in the command stream AND the region-blanked keyframe pixels. A single source
149
+ * of truth keeps command replay and keyframe consistent. */
150
+ export const REDACTION_COLOR = "#1f2433";
151
+ /** Merge a per-call style override onto a base style (per-call wins; omitted
152
+ * fields inherit). `base` defaults to DEFAULT_REDACT_STYLE. Never mutates. */
153
+ export function resolveRedactStyle(base, override) {
154
+ const b = base ?? DEFAULT_REDACT_STYLE;
155
+ if (!override)
156
+ return b;
157
+ return { text: override.text ?? b.text, shape: override.shape ?? b.shape };
158
+ }
159
+ /**
160
+ * Transform the four corners of a LOCAL-space box (x0,y0)-(x1,y1) by a 2D matrix
161
+ * and return its axis-aligned bounding box in DEVICE space. Handles a general
162
+ * CTM (rotation / skew), not just translate+scale — a rotated rect maps to the
163
+ * AABB enclosing all four rotated corners. `pad` (px, device space) is added on
164
+ * every side so a blanked region fully covers antialiased glyph/stroke edges.
165
+ * Returns null when the inputs are non-finite (never throws into the hot path).
166
+ */
167
+ export function transformedBox(x0, y0, x1, y1, m, pad = 1) {
168
+ if (![x0, y0, x1, y1, m.a, m.b, m.c, m.d, m.e, m.f].every(Number.isFinite))
169
+ return null;
170
+ const px = (x, y) => m.a * x + m.c * y + m.e;
171
+ const py = (x, y) => m.b * x + m.d * y + m.f;
172
+ const xs = [px(x0, y0), px(x1, y0), px(x1, y1), px(x0, y1)];
173
+ const ys = [py(x0, y0), py(x1, y0), py(x1, y1), py(x0, y1)];
174
+ const minX = Math.min(...xs), maxX = Math.max(...xs);
175
+ const minY = Math.min(...ys), maxY = Math.max(...ys);
176
+ if (!Number.isFinite(minX) || !Number.isFinite(minY))
177
+ return null;
178
+ return { x: minX - pad, y: minY - pad, w: maxX - minX + 2 * pad, h: maxY - minY + 2 * pad };
179
+ }
180
+ /** Device-space AABB of a local-space rect (x,y,w,h) under transform `m`. */
181
+ export function rectRegion(x, y, w, h, m) {
182
+ return transformedBox(x, y, x + w, y + h, m);
183
+ }
184
+ /**
185
+ * Compute a drawn-text bounding box in LOCAL (pre-transform) space from its
186
+ * measured metrics + alignment. textAlign shifts the box horizontally relative
187
+ * to the draw x (center → centered, right/end → left of x); textBaseline shifts
188
+ * it vertically relative to the draw y (top → below y, middle → centered,
189
+ * bottom → above y, default alphabetic → ascent above / descent below). The
190
+ * caller transforms this box to device space via `transformedBox`. start/end are
191
+ * treated as left/right (ltr assumption) — over-covering on rtl is harmless.
192
+ */
193
+ export function textLocalBox(i) {
194
+ let left = i.x;
195
+ const align = i.align ?? "start";
196
+ if (align === "center")
197
+ left = i.x - i.width / 2;
198
+ else if (align === "right" || align === "end")
199
+ left = i.x - i.width;
200
+ const right = left + i.width;
201
+ const a = i.ascent, d = i.descent, h = a + d;
202
+ let top, bottom;
203
+ switch (i.baseline) {
204
+ case "top":
205
+ case "hanging":
206
+ top = i.y;
207
+ bottom = i.y + h;
208
+ break;
209
+ case "middle":
210
+ top = i.y - h / 2;
211
+ bottom = i.y + h / 2;
212
+ break;
213
+ case "bottom":
214
+ case "ideographic":
215
+ top = i.y - h;
216
+ bottom = i.y;
217
+ break;
218
+ default:
219
+ top = i.y - a;
220
+ bottom = i.y + d;
221
+ break; // alphabetic (default)
222
+ }
223
+ return { x0: left, y0: top, x1: right, y1: bottom };
224
+ }
225
+ /** Device-space AABB of a drawn-text box — measure (text-local) then transform.
226
+ * `pad` (device px) should be font-size-proportional at the call site: the box is
227
+ * built from the glyphs' ADVANCE width, but italic / script / emoji ink overhangs
228
+ * the advance (side bearings), so a 1px pad would under-cover and leak a sliver on
229
+ * the keyframe. Over-covering is privacy-safe. */
230
+ export function textRegion(i, m, pad = 1) {
231
+ const b = textLocalBox(i);
232
+ return transformedBox(b.x0, b.y0, b.x1, b.y1, m, pad);
233
+ }
Binary file
Binary file
@@ -0,0 +1,314 @@
1
+ /** Stable opcode table: union of 2D methods, tracked props, and meta ops. */
2
+ export const OPCODES = [
3
+ "__frame", "__create", "__objcall",
4
+ // methods
5
+ "arc", "arcTo", "beginPath", "bezierCurveTo", "clearRect", "clip", "closePath",
6
+ "drawImage", "ellipse", "fill", "fillRect", "fillText", "lineTo", "moveTo",
7
+ "putImageData", "quadraticCurveTo", "rect", "reset", "resetTransform", "restore",
8
+ "rotate", "roundRect", "save", "scale", "setLineDash", "setTransform", "stroke",
9
+ "strokeRect", "strokeText", "transform", "translate", "drawFocusIfNeeded",
10
+ // tracked props (setter ops)
11
+ "fillStyle", "strokeStyle", "globalAlpha", "globalCompositeOperation",
12
+ "lineWidth", "lineCap", "lineJoin", "miterLimit", "lineDashOffset",
13
+ "font", "textAlign", "textBaseline", "direction",
14
+ "shadowBlur", "shadowColor", "shadowOffsetX", "shadowOffsetY",
15
+ "imageSmoothingEnabled", "imageSmoothingQuality", "filter", "letterSpacing", "wordSpacing",
16
+ // hot WebGL ops (rare ones ride the string-table fallback)
17
+ "useProgram", "bindBuffer", "bindTexture", "bindVertexArray", "bindFramebuffer",
18
+ "bufferData", "bufferSubData", "uniform1f", "uniform1i", "uniform2f", "uniform3f",
19
+ "uniform4f", "uniform1fv", "uniform2fv", "uniform3fv", "uniform4fv",
20
+ "uniformMatrix3fv", "uniformMatrix4fv", "vertexAttribPointer", "enableVertexAttribArray",
21
+ "drawArrays", "drawElements", "drawArraysInstanced", "drawElementsInstanced",
22
+ "enable", "disable", "depthFunc", "depthMask", "blendFunc", "blendFuncSeparate",
23
+ "blendEquation", "cullFace", "frontFace", "viewport", "scissor", "clearColor",
24
+ "clearDepth", "clearStencil", "activeTexture", "texParameteri", "texImage2D",
25
+ "texSubImage2D", "pixelStorei", "colorMask", "stencilFunc", "stencilOp", "stencilMask",
26
+ "polygonOffset",
27
+ // meta: per-op draw-site stamp marker (Phase 3b)
28
+ "__site",
29
+ ];
30
+ const OP_INDEX = new Map(OPCODES.map((o, i) => [o, i]));
31
+ const OP_UNKNOWN = 255;
32
+ const SETTER_BIT = 0x80; // ops fit in 7 bits; high bit marks property-set
33
+ // arg tags
34
+ const T_F64 = 0, T_VARINT = 1, T_STR = 2, T_STR_REF = 3, T_TRUE = 4, T_FALSE = 5, T_NULL = 6, T_ASSET = 7, T_OBJ = 8, T_ARR = 9, T_IMAGEDATA = 10, T_UNSUPPORTED = 11, T_TYPED = 12;
35
+ class ByteWriter {
36
+ buf = new Uint8Array(64 * 1024);
37
+ len = 0;
38
+ view = new DataView(this.buf.buffer);
39
+ ensure(n) {
40
+ if (this.len + n <= this.buf.length)
41
+ return;
42
+ const next = new Uint8Array(Math.max(this.buf.length * 2, this.len + n));
43
+ next.set(this.buf.subarray(0, this.len));
44
+ this.buf = next;
45
+ this.view = new DataView(next.buffer);
46
+ }
47
+ u8(v) { this.ensure(1); this.buf[this.len++] = v & 0xff; }
48
+ varint(v) {
49
+ // zigzag for possibly-negative ints
50
+ let z = v < 0 ? -v * 2 - 1 : v * 2;
51
+ this.ensure(10);
52
+ while (z >= 0x80) {
53
+ this.buf[this.len++] = (z & 0x7f) | 0x80;
54
+ z = Math.floor(z / 128);
55
+ }
56
+ this.buf[this.len++] = z;
57
+ }
58
+ f64(v) { this.ensure(8); this.view.setFloat64(this.len, v, true); this.len += 8; }
59
+ bytes(b) { this.ensure(b.length); this.buf.set(b, this.len); this.len += b.length; }
60
+ take() { const out = this.buf.slice(0, this.len); this.len = 0; return out; }
61
+ get size() { return this.len; }
62
+ }
63
+ class ByteReader {
64
+ buf;
65
+ view;
66
+ pos = 0;
67
+ constructor(buf) {
68
+ this.buf = buf;
69
+ this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
70
+ }
71
+ get done() { return this.pos >= this.buf.length; }
72
+ u8() { return this.buf[this.pos++]; }
73
+ varint() {
74
+ let shift = 1, z = 0, b;
75
+ do {
76
+ b = this.buf[this.pos++];
77
+ z += (b & 0x7f) * shift;
78
+ shift *= 128;
79
+ } while (b & 0x80);
80
+ return z % 2 === 1 ? -(z + 1) / 2 : z / 2;
81
+ }
82
+ f64() { const v = this.view.getFloat64(this.pos, true); this.pos += 8; return v; }
83
+ bytes(n) { const b = this.buf.subarray(this.pos, this.pos + n); this.pos += n; return b; }
84
+ }
85
+ const utf8e = new TextEncoder();
86
+ const utf8d = new TextDecoder();
87
+ export class CommandEncoder {
88
+ w = new ByteWriter();
89
+ strings = new Map();
90
+ lastTUs = 0;
91
+ /** count of ops in current batch */
92
+ ops = 0;
93
+ encode(op) {
94
+ // frame/time delta marker whenever timestamp advances. Deltas are between
95
+ // ROUNDED absolute µs values so rounding error never accumulates.
96
+ const tUs = Math.round(op.t * 1000);
97
+ if (tUs !== this.lastTUs) {
98
+ this.w.u8(OP_INDEX.get("__frame"));
99
+ this.w.varint(tUs - this.lastTUs);
100
+ this.lastTUs = tUs;
101
+ }
102
+ if (op.sfId) {
103
+ this.w.u8(OP_INDEX.get("__site"));
104
+ this.str(op.sfId);
105
+ }
106
+ const code = OP_INDEX.get(op.op);
107
+ if (code === undefined) {
108
+ this.w.u8(OP_UNKNOWN);
109
+ this.str(op.op);
110
+ }
111
+ else {
112
+ this.w.u8(op.setter ? code | SETTER_BIT : code);
113
+ }
114
+ this.w.varint(op.args.length);
115
+ for (const a of op.args)
116
+ this.arg(a);
117
+ this.ops++;
118
+ }
119
+ str(s) {
120
+ const ref = this.strings.get(s);
121
+ if (ref !== undefined) {
122
+ this.w.u8(T_STR_REF);
123
+ this.w.varint(ref);
124
+ return;
125
+ }
126
+ const id = this.strings.size;
127
+ this.strings.set(s, id);
128
+ const b = utf8e.encode(s);
129
+ this.w.u8(T_STR);
130
+ this.w.varint(b.length);
131
+ this.w.bytes(b);
132
+ }
133
+ arg(a) {
134
+ if (a === null) {
135
+ this.w.u8(T_NULL);
136
+ return;
137
+ }
138
+ switch (typeof a) {
139
+ case "number":
140
+ if (Number.isInteger(a) && Math.abs(a) < 2 ** 30) {
141
+ this.w.u8(T_VARINT);
142
+ this.w.varint(a);
143
+ }
144
+ else {
145
+ this.w.u8(T_F64);
146
+ this.w.f64(a);
147
+ }
148
+ return;
149
+ case "string":
150
+ this.str(a);
151
+ return;
152
+ case "boolean":
153
+ this.w.u8(a ? T_TRUE : T_FALSE);
154
+ return;
155
+ }
156
+ if ("$" in a) {
157
+ switch (a.$) {
158
+ case "asset":
159
+ this.w.u8(T_ASSET);
160
+ this.w.varint(a.id);
161
+ return;
162
+ case "obj":
163
+ this.w.u8(T_OBJ);
164
+ this.w.varint(a.id);
165
+ return;
166
+ case "imagedata":
167
+ this.w.u8(T_IMAGEDATA);
168
+ this.w.varint(a.id);
169
+ return;
170
+ case "arr":
171
+ this.w.u8(T_ARR);
172
+ this.w.varint(a.v.length);
173
+ for (const n of a.v)
174
+ this.w.f64(n);
175
+ return;
176
+ case "ta":
177
+ this.w.u8(T_TYPED);
178
+ this.w.u8(a.k);
179
+ this.w.varint(a.b.length);
180
+ this.w.bytes(a.b);
181
+ return;
182
+ case "unsupported":
183
+ this.w.u8(T_UNSUPPORTED);
184
+ this.str(a.kind);
185
+ return;
186
+ }
187
+ }
188
+ this.w.u8(T_NULL);
189
+ }
190
+ /** Flush current batch as raw (uncompressed) bytes. */
191
+ takeBatch() {
192
+ this.ops = 0;
193
+ // [audit B2] Reset the string table + time base so EACH batch is
194
+ // SELF-CONTAINED. Batches are flushed, persisted, and DROPPED independently —
195
+ // both the durable store (canvasFrameStore.pruneToBudget) and the memory ring
196
+ // (websocket pushBinaryRing) evict the OLDEST buffered frames under pressure,
197
+ // i.e. interior command batches. If string-refs or time-deltas spanned
198
+ // batches, one dropped interior batch would desync the string table and clock
199
+ // of every batch after it (wrong colors/fonts, mis-attributed click-to-code,
200
+ // ops anchored to the wrong keyframe). Self-contained batches re-emit a few
201
+ // strings per flush + an absolute time anchor — a small cost for decode that
202
+ // is robust to gaps / loss / reorder. (decodeCommands resets per batch too.)
203
+ this.strings.clear();
204
+ this.lastTUs = 0;
205
+ return this.w.take();
206
+ }
207
+ }
208
+ export function decodeCommands(batches) {
209
+ const ops = [];
210
+ for (const batch of batches) {
211
+ // [audit B2] Each batch is self-contained — its own string table + absolute
212
+ // time anchor — so a dropped/pruned/corrupt batch can't skew the string-refs
213
+ // or timestamps of any other batch. (The encoder resets both per takeBatch.)
214
+ const strings = [];
215
+ let t = 0;
216
+ const r = new ByteReader(batch);
217
+ const readStr = () => {
218
+ const tag = r.u8();
219
+ if (tag === T_STR_REF)
220
+ return strings[r.varint()];
221
+ const len = r.varint();
222
+ const s = utf8d.decode(r.bytes(len));
223
+ strings.push(s);
224
+ return s;
225
+ };
226
+ const readArg = () => {
227
+ const tag = r.u8();
228
+ switch (tag) {
229
+ case T_F64: return r.f64();
230
+ case T_VARINT: return r.varint();
231
+ case T_STR:
232
+ case T_STR_REF: {
233
+ r.pos--;
234
+ return readStr();
235
+ }
236
+ case T_TRUE: return true;
237
+ case T_FALSE: return false;
238
+ case T_NULL: return null;
239
+ case T_ASSET: return { $: "asset", id: r.varint() };
240
+ case T_OBJ: return { $: "obj", id: r.varint() };
241
+ case T_IMAGEDATA: return { $: "imagedata", id: r.varint() };
242
+ case T_ARR: {
243
+ const n = r.varint();
244
+ const v = [];
245
+ for (let i = 0; i < n; i++)
246
+ v.push(r.f64());
247
+ return { $: "arr", v };
248
+ }
249
+ case T_TYPED: {
250
+ const k = r.u8();
251
+ const len = r.varint();
252
+ return { $: "ta", k, b: r.bytes(len).slice(0) };
253
+ }
254
+ case T_UNSUPPORTED: return { $: "unsupported", kind: readStr() };
255
+ default: return null;
256
+ }
257
+ };
258
+ let pendingSfId;
259
+ while (!r.done) {
260
+ const raw = r.u8();
261
+ if ((raw & 0x7f) === OP_INDEX.get("__frame") && !(raw & SETTER_BIT)) {
262
+ t += r.varint() / 1000;
263
+ continue;
264
+ }
265
+ if ((raw & 0x7f) === OP_INDEX.get("__site") && !(raw & SETTER_BIT)) {
266
+ pendingSfId = readStr();
267
+ continue;
268
+ }
269
+ let opName;
270
+ let setter = false;
271
+ if (raw === OP_UNKNOWN) {
272
+ opName = readStr();
273
+ }
274
+ else {
275
+ setter = !!(raw & SETTER_BIT);
276
+ opName = OPCODES[raw & 0x7f] ?? `op${raw & 0x7f}`;
277
+ }
278
+ const argc = r.varint();
279
+ const args = [];
280
+ for (let i = 0; i < argc; i++)
281
+ args.push(readArg());
282
+ const op = { t, op: opName, args };
283
+ if (pendingSfId !== undefined) {
284
+ op.sfId = pendingSfId;
285
+ pendingSfId = undefined;
286
+ }
287
+ if (setter)
288
+ op.setter = true;
289
+ if (opName === "__create")
290
+ op.create = true;
291
+ if (opName === "__objcall")
292
+ op.objcall = true;
293
+ ops.push(op);
294
+ }
295
+ }
296
+ return ops;
297
+ }
298
+ // Port adaptation: the bench piped through Blob.stream(); writing directly to
299
+ // the (De)CompressionStream produces identical bytes and also works in
300
+ // environments whose Blob lacks .stream() (e.g. jsdom). Identical wire format.
301
+ async function pipeThrough(ts, data) {
302
+ const copy = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
303
+ const consumed = new Response(ts.readable).arrayBuffer();
304
+ const writer = ts.writable.getWriter();
305
+ await writer.write(copy);
306
+ await writer.close();
307
+ return new Uint8Array(await consumed);
308
+ }
309
+ export async function deflate(data) {
310
+ return pipeThrough(new CompressionStream("deflate"), data);
311
+ }
312
+ export async function inflate(data) {
313
+ return pipeThrough(new DecompressionStream("deflate"), data);
314
+ }
Binary file
Binary file
@@ -0,0 +1,67 @@
1
+ const MiB = 1024 * 1024;
2
+ /** Desktop default — preserves the pre-A30 budgets exactly. */
3
+ export const DESKTOP_BUDGETS = {
4
+ deviceClass: "desktop",
5
+ sessionByteBudget: 256 * MiB,
6
+ maxConcurrentVideo: 4,
7
+ governor: {},
8
+ preferMediaRecorder: false,
9
+ };
10
+ const MOBILE_BUDGETS = {
11
+ deviceClass: "mobile",
12
+ sessionByteBudget: 96 * MiB,
13
+ maxConcurrentVideo: 2,
14
+ governor: { maxBytesPerSec: 256 * 1024, maxOpsPerFrame: 3_000 },
15
+ preferMediaRecorder: false,
16
+ };
17
+ const MOBILE_LOWEND_BUDGETS = {
18
+ deviceClass: "mobile-lowend",
19
+ sessionByteBudget: 48 * MiB,
20
+ maxConcurrentVideo: 1,
21
+ governor: { maxBytesPerSec: 128 * 1024, maxOpsPerFrame: 2_000, maxVideoDrawsPerSec: 1 },
22
+ preferMediaRecorder: false,
23
+ };
24
+ /** Explicit iOS UA (iPhone / iPad / iPod). WebCodecs is unreliable/absent on
25
+ * older iOS, so these prefer the MediaRecorder backend. */
26
+ function isExplicitIOS(n) {
27
+ return /iPhone|iPad|iPod/.test(n.userAgent ?? "");
28
+ }
29
+ /** iPadOS that reports a desktop "Macintosh" UA but exposes touch points. Treated
30
+ * as a MOBILE device for budgets, but NOT forced onto MediaRecorder: a genuine
31
+ * touch-screen Mac would false-positive this brittle heuristic and has perfectly
32
+ * good WebCodecs, and modern iPadOS WebCodecs is usable too. [audit A30-ipad] */
33
+ function isIPadDesktopUA(n) {
34
+ return /Macintosh/.test(n.userAgent ?? "") && (n.maxTouchPoints ?? 0) > 1;
35
+ }
36
+ function isMobileUA(n) {
37
+ return /Mobi|Android|iPhone|iPad|iPod/.test(n.userAgent ?? "") || isIPadDesktopUA(n);
38
+ }
39
+ /**
40
+ * Resolve device budgets. Pass `nav` in tests; in the browser it defaults to the
41
+ * global `navigator`; under SSR (no navigator) it returns the desktop profile.
42
+ */
43
+ export function resolveDeviceBudgets(nav) {
44
+ const n = nav ?? (typeof navigator !== "undefined" ? navigator : undefined);
45
+ if (!n)
46
+ return DESKTOP_BUDGETS;
47
+ const mem = n.deviceMemory;
48
+ const cores = n.hardwareConcurrency;
49
+ const lowMem = typeof mem === "number" && mem <= 2;
50
+ const lowCores = typeof cores === "number" && cores <= 4;
51
+ const ios = isExplicitIOS(n); // only explicit iOS forces MediaRecorder
52
+ let base;
53
+ if (isMobileUA(n)) {
54
+ // Low-end only when BOTH signals are constrained. A normal phone that reports
55
+ // 4 cores (or no deviceMemory at all, e.g. Safari) must NOT be forced to the
56
+ // harshest 48 MiB / single-encoder tier. [audit A30-cores]
57
+ base = lowMem && lowCores ? MOBILE_LOWEND_BUDGETS : MOBILE_BUDGETS;
58
+ }
59
+ else if (lowMem && lowCores) {
60
+ base = MOBILE_LOWEND_BUDGETS; // constrained desktop / VM / low-end laptop
61
+ }
62
+ else {
63
+ base = DESKTOP_BUDGETS;
64
+ }
65
+ // iOS prefers MediaRecorder regardless of tier (WebCodecs unreliable there).
66
+ return ios ? { ...base, preferMediaRecorder: true } : base;
67
+ }
Binary file
Binary file