@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
|
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
|