effect-browser-human-strokes 0.0.0-reserved.0 → 0.3.0-beta.10

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/LICENSE-data ADDED
@@ -0,0 +1,28 @@
1
+ BOUN Mouse Dynamics Dataset — derived stroke recordings
2
+
3
+ Original authors: Metehan Yıldırım, Arjen Aykan Kılıç, Emin Anarım
4
+ Dataset version: 2, published 26 March 2021
5
+ Source: https://data.mendeley.com/datasets/w6cxr8yc7p/2
6
+ DOI: https://doi.org/10.17632/w6cxr8yc7p.2
7
+
8
+ The data in data/strokes.bin.br is licensed under the Creative Commons Attribution
9
+ 4.0 International License (CC BY 4.0).
10
+ License: https://creativecommons.org/licenses/by/4.0/
11
+ Legal code: https://creativecommons.org/licenses/by/4.0/legalcode
12
+
13
+ You may share and adapt the data, including commercially, subject to the license,
14
+ including appropriate credit, a link to the license, and an indication of changes.
15
+ No endorsement by the original authors is implied.
16
+
17
+ Modifications in this distribution:
18
+ - Selected browsing click strokes from three source participants.
19
+ - Trimmed the initial two percent of traveled distance and repeated ending positions.
20
+ - Excluded strokes shorter than 30 pixels, with fewer than four retained samples,
21
+ with nonpositive duration, or with duration of 5 seconds or more.
22
+ - Made coordinates and times relative to each first retained sample.
23
+ - Preserved all retained sample times and integer coordinates exactly using a
24
+ BOUN v1 binary delta format with 1/4096-millisecond residuals and Brotli compression.
25
+ - Omitted participant identifiers and unrelated records.
26
+
27
+ The software implementing the codec and motion layer is separately licensed under
28
+ the MIT License in LICENSE. This notice applies to the bundled data.
package/README.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # effect-browser-human-strokes
2
2
 
3
- Namespace-only bootstrap for npm trusted publishing from [mannyc2/effect-agent-browserbase](https://github.com/mannyc2/effect-agent-browserbase).
3
+ Optional recorded pointer motion for `effect-browser`: a pointer planner, for a presenter, over a
4
+ bundled library of 32,130 human strokes. It does not change typing, pauses or input policy.
4
5
 
5
- This package contains no SDK, executable code, scripts, data, or dependencies. Wait for the qualified SDK release before installing.
6
+ ```sh
7
+ npm install effect-browser-human-strokes@beta effect-browser@beta effect playwright-core
8
+ ```
9
+
10
+ ```ts
11
+ import { Effect } from "effect";
12
+ import * as HumanStrokes from "effect-browser-human-strokes";
13
+ import { Browser } from "effect-browser/Browser";
14
+ import * as Chromium from "effect-browser/Chromium";
15
+ import * as Presentation from "effect-browser/Presentation";
16
+
17
+ const program = Effect.gen(function* () {
18
+ const presenter = yield* Presentation.make({ motion: yield* HumanStrokes.motion });
19
+ const page = yield* (yield* Browser).newPage("https://example.com");
20
+
21
+ yield* presenter.view(page).hover({ x: 400, y: 300 });
22
+ }).pipe(Effect.scoped, Effect.provide(Chromium.layer()));
23
+ ```
24
+
25
+ `HumanStrokes.motion` reads, decompresses and validates its fixed package asset, and is the
26
+ planner as a value, which a presenter takes: nothing else reads it, so it cannot be misplaced.
27
+ Loading failures are typed `DataError` values. There is no download, file-path option or configuration
28
+ step. Decompression runs on Node's thread pool rather than the event loop, and the built planner
29
+ keeps only the validated payload. Each plan decodes only its selected stroke. The single Brotli
30
+ asset is 3,152,464 bytes; the validated payload is 10,411,952 bytes. The core browser package
31
+ does not include this data.
32
+
33
+ Selection draws uniformly among strokes whose endpoint distances differ by less than 0.15 in
34
+ absolute log ratio, or chooses the nearest absolute distance when none qualify. The selected
35
+ geometry is rotated, scaled and randomly mirrored, with an exact final destination. Distances
36
+ below two pixels use an immediate endpoint. Original sample times, including fractional
37
+ milliseconds and equal-time samples, are retained. No resampling or time scaling is applied.
38
+
39
+ The library contains 2,604,684 points, with at most 1,604 samples and less than 5,000 milliseconds
40
+ per stroke. Humanization describes presentation behavior; this package makes no identity,
41
+ anti-detection or universal realism claim.
42
+
43
+ ## Data attribution
44
+
45
+ The bundled data derives from the **BOUN Mouse Dynamics Dataset**, version 2, by
46
+ **Metehan Yıldırım, Arjen Aykan Kılıç and Emin Anarım**, published 26 March 2021:
47
+
48
+ - Dataset and license: https://data.mendeley.com/datasets/w6cxr8yc7p/2
49
+ - DOI: https://doi.org/10.17632/w6cxr8yc7p.2
50
+ - License: Creative Commons Attribution 4.0 International,
51
+ https://creativecommons.org/licenses/by/4.0/
52
+
53
+ Changes: browsing click strokes from three source participants were selected; the initial
54
+ two percent of traveled distance and repeated ending positions were trimmed; strokes shorter
55
+ than 30 pixels or lasting 5 seconds or more were excluded. Coordinates and times were made
56
+ relative to the first retained sample. Integer-coordinate deltas and exact 1/4096-millisecond
57
+ time residuals were encoded and Brotli-compressed. Participant identifiers and unrelated
58
+ records are not included. These are transformed recordings, not original dataset files.
59
+
60
+ Code is MIT-licensed. The derived data remains CC BY 4.0; see `LICENSE-data`.
Binary file
@@ -0,0 +1,19 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+ import * as Motion from "effect-browser/Motion";
3
+ //#region src/internal/strokes.d.ts
4
+ declare const DataError_base: Schema.Class<DataError, Schema.TaggedStruct<"HumanStrokesDataError", {
5
+ readonly reason: Schema.Literals<readonly ["Read", "Compression", "Format"]>;
6
+ readonly detail: Schema.String;
7
+ }>, import("effect/Cause").YieldableError>;
8
+ /** The fixed bundled recording could not be read or validated. */
9
+ export declare class DataError extends DataError_base {}
10
+ //#endregion
11
+ //#region src/HumanStrokes.d.ts
12
+ /**
13
+ * The recorded strokes as a pointer planner, for a presenter:
14
+ * `Presentation.make({ motion: yield* HumanStrokes.motion })`. Loading reads, decompresses and
15
+ * validates the package's own asset, with no network or path options, and fails `DataError`.
16
+ */
17
+ export declare const motion: Effect.Effect<Motion.Service, DataError>;
18
+ //#endregion
19
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,218 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { Effect, Random, Result, Schema } from "effect";
3
+ import { promisify } from "node:util";
4
+ import { brotliDecompress } from "node:zlib";
5
+ import * as Motion from "effect-browser/Motion";
6
+ //#region src/internal/strokes.ts
7
+ /** The fixed bundled recording could not be read or validated. */
8
+ var DataError = class extends Schema.TaggedError()("HumanStrokesDataError", {
9
+ reason: Schema.Literals([
10
+ "Read",
11
+ "Compression",
12
+ "Format"
13
+ ]),
14
+ detail: Schema.String
15
+ }) {};
16
+ const rawBytes = 10411952;
17
+ const strokeCount = 32130;
18
+ const ticks = 4096;
19
+ const decompress = promisify(brotliDecompress);
20
+ /** Inflate on the thread pool, bounded to the payload size, without blocking the event loop. */
21
+ const inflate = (compressed) => Effect.tryPromise({
22
+ try: () => decompress(compressed, { maxOutputLength: rawBytes }),
23
+ catch: () => new DataError({
24
+ reason: "Compression",
25
+ detail: "The bundled human-stroke asset could not be decompressed."
26
+ })
27
+ });
28
+ var Reader = class {
29
+ offset;
30
+ invalid = false;
31
+ bytes;
32
+ constructor(bytes, offset = 0) {
33
+ this.bytes = bytes;
34
+ this.offset = offset;
35
+ }
36
+ unsigned() {
37
+ let value = 0;
38
+ let multiplier = 1;
39
+ for (let byteIndex = 0; byteIndex < 5; byteIndex++) {
40
+ const byte = this.bytes[this.offset++];
41
+ if (byte === void 0) {
42
+ this.invalid = true;
43
+ return 0;
44
+ }
45
+ value += (byte & 127) * multiplier;
46
+ if (byte < 128) {
47
+ if (value > 4294967295 || byteIndex > 0 && byte === 0) this.invalid = true;
48
+ return value;
49
+ }
50
+ multiplier *= 128;
51
+ }
52
+ this.invalid = true;
53
+ return 0;
54
+ }
55
+ signed() {
56
+ const value = this.unsigned();
57
+ return value % 2 === 0 ? value / 2 : -(value + 1) / 2;
58
+ }
59
+ };
60
+ const bad = (detail) => Result.fail(new DataError({
61
+ reason: "Format",
62
+ detail
63
+ }));
64
+ /** Validate each bounded record once, retaining only offsets and selection metadata. */
65
+ const index = (bytes) => {
66
+ if (bytes.length !== 10411952 || bytes[0] !== 66 || bytes[1] !== 79 || bytes[2] !== 85 || bytes[3] !== 78 || bytes[4] !== 1) return bad("The bundled stroke header or byte count is invalid.");
67
+ const reader = new Reader(bytes, 5);
68
+ if (reader.unsigned() !== ticks || reader.unsigned() !== 32130) return bad("The bundled stroke precision or record count is invalid.");
69
+ const records = [];
70
+ let points = 0;
71
+ for (let ordinal = 0; ordinal < strokeCount; ordinal++) {
72
+ const length = reader.unsigned() + 1;
73
+ if (reader.invalid || length < 4 || length > Motion.maximumSamples) return bad("A stroke exceeds the supported sample count.");
74
+ points += length;
75
+ if (points > 2604684) return bad("The bundled point count is invalid.");
76
+ const offset = reader.offset;
77
+ const times = new Float64Array(length);
78
+ let whole = 0;
79
+ for (let sample = 1; sample < length; sample++) {
80
+ whole += reader.signed();
81
+ times[sample] = whole;
82
+ }
83
+ let dx = 0;
84
+ let dy = 0;
85
+ for (let sample = 1; sample < length; sample++) dx += reader.signed();
86
+ for (let sample = 1; sample < length; sample++) dy += reader.signed();
87
+ let previous = 0;
88
+ for (let sample = 1; sample < length; sample++) {
89
+ const residual = reader.signed();
90
+ const time = (times[sample] ?? 0) + residual / ticks;
91
+ if (Math.abs(residual) > ticks / 2 || time < previous || time > Motion.maximumDurationMillis) return bad("A stroke has invalid or decreasing sample times.");
92
+ previous = time;
93
+ }
94
+ const distance = Math.hypot(dx, dy);
95
+ if (reader.invalid || distance < 30 || previous <= 0) return bad("A stroke has an invalid displacement or duration.");
96
+ records.push({
97
+ offset,
98
+ length,
99
+ dx,
100
+ dy,
101
+ distance,
102
+ duration: previous
103
+ });
104
+ }
105
+ const first = records[0];
106
+ if (reader.invalid || reader.offset !== bytes.length || points !== 2604684 || first === void 0) return bad("The bundled stroke payload has an invalid extent.");
107
+ return Result.succeed({
108
+ bytes,
109
+ records,
110
+ first,
111
+ points
112
+ });
113
+ };
114
+ /** The index establishes these record boundaries; only the selected coordinates are expanded. */
115
+ const decode = (data, record) => {
116
+ const reader = new Reader(data.bytes, record.offset);
117
+ const times = new Float64Array(record.length);
118
+ const xs = new Float64Array(record.length);
119
+ const ys = new Float64Array(record.length);
120
+ let value = 0;
121
+ for (let sample = 1; sample < record.length; sample++) {
122
+ value += reader.signed();
123
+ times[sample] = value;
124
+ }
125
+ value = 0;
126
+ for (let sample = 1; sample < record.length; sample++) {
127
+ value += reader.signed();
128
+ xs[sample] = value;
129
+ }
130
+ value = 0;
131
+ for (let sample = 1; sample < record.length; sample++) {
132
+ value += reader.signed();
133
+ ys[sample] = value;
134
+ }
135
+ const samples = [{
136
+ x: 0,
137
+ y: 0,
138
+ afterMillis: 0
139
+ }];
140
+ for (let sample = 1; sample < record.length; sample++) samples.push({
141
+ x: xs[sample] ?? 0,
142
+ y: ys[sample] ?? 0,
143
+ afterMillis: (times[sample] ?? 0) + reader.signed() / ticks
144
+ });
145
+ return samples;
146
+ };
147
+ /** Match the measured selection rule: random nearby distance, otherwise nearest absolute distance. */
148
+ const select = (data, distance, choice) => {
149
+ const candidates = [];
150
+ let closest = data.first;
151
+ for (const record of data.records) {
152
+ if (Math.abs(Math.log(record.distance / distance)) < .15) candidates.push(record);
153
+ if (record.distance <= distance && closest.distance <= distance ? record.distance > closest.distance : record.distance >= distance && closest.distance >= distance ? record.distance < closest.distance : Math.abs(record.distance - distance) < Math.abs(closest.distance - distance)) closest = record;
154
+ }
155
+ return candidates[Math.floor(choice * candidates.length)] ?? closest;
156
+ };
157
+ /** Rotate/scale the recorded geometry, optionally mirroring across its own movement axis. */
158
+ const retarget = (data, record, from, to, mirror) => {
159
+ const source = decode(data, record);
160
+ const sourceX = record.dx / record.distance;
161
+ const sourceY = record.dy / record.distance;
162
+ const scale = Math.max(1, Math.abs(from.x), Math.abs(from.y), Math.abs(to.x), Math.abs(to.y));
163
+ const startX = from.x / scale;
164
+ const startY = from.y / scale;
165
+ const deltaX = to.x / scale - startX;
166
+ const deltaY = to.y / scale - startY;
167
+ const coordinate = (value) => Math.round(Math.max(-Number.MAX_VALUE, Math.min(Number.MAX_VALUE, value * scale)));
168
+ return source.map((sample, ordinal) => {
169
+ if (ordinal === 0) return {
170
+ ...from,
171
+ afterMillis: sample.afterMillis
172
+ };
173
+ if (ordinal === source.length - 1) return {
174
+ ...to,
175
+ afterMillis: sample.afterMillis
176
+ };
177
+ const along = (sample.x * sourceX + sample.y * sourceY) / record.distance;
178
+ const lateral = (-sample.x * sourceY + sample.y * sourceX) / record.distance * (mirror ? -1 : 1);
179
+ return {
180
+ x: coordinate(startX + deltaX * along - deltaY * lateral),
181
+ y: coordinate(startY + deltaY * along + deltaX * lateral),
182
+ afterMillis: sample.afterMillis
183
+ };
184
+ });
185
+ };
186
+ //#endregion
187
+ //#region src/HumanStrokes.ts
188
+ const read = Effect.tryPromise({
189
+ try: (signal) => readFile(new URL("../data/strokes.bin.br", import.meta.url), { signal }),
190
+ catch: () => new DataError({
191
+ reason: "Read",
192
+ detail: "The bundled human-stroke asset could not be read."
193
+ })
194
+ });
195
+ /**
196
+ * The recorded strokes as a pointer planner, for a presenter:
197
+ * `Presentation.make({ motion: yield* HumanStrokes.motion })`. Loading reads, decompresses and
198
+ * validates the package's own asset, with no network or path options, and fails `DataError`.
199
+ */
200
+ const motion = Effect.gen(function* () {
201
+ const decoded = index(yield* Effect.flatMap(read, inflate));
202
+ if (Result.isFailure(decoded)) return yield* decoded.failure;
203
+ const data = decoded.success;
204
+ return { plan: Effect.fnUntraced(function* (from, to) {
205
+ const scale = Math.max(1, Math.abs(from.x), Math.abs(from.y), Math.abs(to.x), Math.abs(to.y));
206
+ const distance = Math.hypot(to.x / scale - from.x / scale, to.y / scale - from.y / scale) * scale;
207
+ if (distance < 2) return [{
208
+ ...to,
209
+ afterMillis: 0
210
+ }];
211
+ const record = select(data, distance, yield* Random.next);
212
+ return retarget(data, record, from, to, (yield* Random.next) < .5);
213
+ }) };
214
+ });
215
+ //#endregion
216
+ export { DataError, motion };
217
+
218
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/internal/strokes.ts","../src/HumanStrokes.ts"],"sourcesContent":["import { promisify } from \"node:util\";\nimport { brotliDecompress } from \"node:zlib\";\n\nimport { Effect, Result, Schema } from \"effect\";\nimport * as Motion from \"effect-browser/Motion\";\n\n/** The fixed bundled recording could not be read or validated. */\nexport class DataError extends Schema.TaggedError<DataError>()(\"HumanStrokesDataError\", {\n reason: Schema.Literals([\"Read\", \"Compression\", \"Format\"]),\n detail: Schema.String,\n}) {}\n\nexport const rawBytes = 10_411_952;\nexport const strokeCount = 32_130;\nexport const pointCount = 2_604_684;\nconst ticks = 4096;\nconst decompress = promisify(brotliDecompress);\n\n/** Inflate on the thread pool, bounded to the payload size, without blocking the event loop. */\nexport const inflate = (compressed: Uint8Array) =>\n Effect.tryPromise({\n try: () => decompress(compressed, { maxOutputLength: rawBytes }),\n catch: () =>\n new DataError({\n reason: \"Compression\",\n detail: \"The bundled human-stroke asset could not be decompressed.\",\n }),\n });\n\nexport interface Record {\n readonly offset: number;\n readonly length: number;\n readonly dx: number;\n readonly dy: number;\n readonly distance: number;\n readonly duration: number;\n}\n\nexport interface Index {\n readonly bytes: Uint8Array;\n readonly records: ReadonlyArray<Record>;\n readonly first: Record;\n readonly points: number;\n}\n\n// The private binary codec keeps original fractional milliseconds. Rounding them to integer\n// milliseconds would change 880,905 sample times, despite saving less than a megabyte on disk.\nclass Reader {\n offset: number;\n invalid = false;\n readonly bytes: Uint8Array;\n\n constructor(bytes: Uint8Array, offset = 0) {\n this.bytes = bytes;\n this.offset = offset;\n }\n\n unsigned(): number {\n let value = 0;\n let multiplier = 1;\n\n for (let byteIndex = 0; byteIndex < 5; byteIndex++) {\n const byte = this.bytes[this.offset++];\n\n if (byte === undefined) {\n this.invalid = true;\n\n return 0;\n }\n value += (byte & 127) * multiplier;\n if (byte < 128) {\n if (value > 0xffffffff || (byteIndex > 0 && byte === 0)) this.invalid = true;\n\n return value;\n }\n multiplier *= 128;\n }\n this.invalid = true;\n\n return 0;\n }\n\n signed(): number {\n const value = this.unsigned();\n\n return value % 2 === 0 ? value / 2 : -(value + 1) / 2;\n }\n}\n\nconst bad = (detail: string) => Result.fail(new DataError({ reason: \"Format\", detail }));\n\n/** Validate each bounded record once, retaining only offsets and selection metadata. */\nexport const index = (bytes: Uint8Array): Result.Result<Index, DataError> => {\n if (\n bytes.length !== rawBytes ||\n bytes[0] !== 66 ||\n bytes[1] !== 79 ||\n bytes[2] !== 85 ||\n bytes[3] !== 78 ||\n bytes[4] !== 1\n )\n return bad(\"The bundled stroke header or byte count is invalid.\");\n\n const reader = new Reader(bytes, 5);\n\n if (reader.unsigned() !== ticks || reader.unsigned() !== strokeCount)\n return bad(\"The bundled stroke precision or record count is invalid.\");\n const records: Array<Record> = [];\n let points = 0;\n\n for (let ordinal = 0; ordinal < strokeCount; ordinal++) {\n const intervals = reader.unsigned();\n const length = intervals + 1;\n\n if (reader.invalid || length < 4 || length > Motion.maximumSamples)\n return bad(\"A stroke exceeds the supported sample count.\");\n points += length;\n if (points > pointCount) return bad(\"The bundled point count is invalid.\");\n const offset = reader.offset;\n const times = new Float64Array(length);\n let whole = 0;\n\n for (let sample = 1; sample < length; sample++) {\n whole += reader.signed();\n times[sample] = whole;\n }\n let dx = 0;\n let dy = 0;\n\n for (let sample = 1; sample < length; sample++) dx += reader.signed();\n for (let sample = 1; sample < length; sample++) dy += reader.signed();\n let previous = 0;\n\n for (let sample = 1; sample < length; sample++) {\n const residual = reader.signed();\n const time = (times[sample] ?? 0) + residual / ticks;\n\n if (Math.abs(residual) > ticks / 2 || time < previous || time > Motion.maximumDurationMillis)\n return bad(\"A stroke has invalid or decreasing sample times.\");\n previous = time;\n }\n const distance = Math.hypot(dx, dy);\n\n if (reader.invalid || distance < 30 || previous <= 0)\n return bad(\"A stroke has an invalid displacement or duration.\");\n records.push({ offset, length, dx, dy, distance, duration: previous });\n }\n const first = records[0];\n\n if (\n reader.invalid ||\n reader.offset !== bytes.length ||\n points !== pointCount ||\n first === undefined\n )\n return bad(\"The bundled stroke payload has an invalid extent.\");\n\n return Result.succeed({ bytes, records, first, points });\n};\n\n/** The index establishes these record boundaries; only the selected coordinates are expanded. */\nexport const decode = (data: Index, record: Record): ReadonlyArray<Motion.Sample> => {\n const reader = new Reader(data.bytes, record.offset);\n const times = new Float64Array(record.length);\n const xs = new Float64Array(record.length);\n const ys = new Float64Array(record.length);\n let value = 0;\n\n for (let sample = 1; sample < record.length; sample++) {\n value += reader.signed();\n times[sample] = value;\n }\n value = 0;\n for (let sample = 1; sample < record.length; sample++) {\n value += reader.signed();\n xs[sample] = value;\n }\n value = 0;\n for (let sample = 1; sample < record.length; sample++) {\n value += reader.signed();\n ys[sample] = value;\n }\n const samples: Array<Motion.Sample> = [{ x: 0, y: 0, afterMillis: 0 }];\n\n for (let sample = 1; sample < record.length; sample++)\n samples.push({\n x: xs[sample] ?? 0,\n y: ys[sample] ?? 0,\n afterMillis: (times[sample] ?? 0) + reader.signed() / ticks,\n });\n\n return samples;\n};\n\n/** Match the measured selection rule: random nearby distance, otherwise nearest absolute distance. */\nexport const select = (data: Index, distance: number, choice: number): Record => {\n const candidates: Array<Record> = [];\n let closest = data.first;\n\n for (const record of data.records) {\n if (Math.abs(Math.log(record.distance / distance)) < 0.15) candidates.push(record);\n\n const nearer =\n record.distance <= distance && closest.distance <= distance\n ? record.distance > closest.distance\n : record.distance >= distance && closest.distance >= distance\n ? record.distance < closest.distance\n : Math.abs(record.distance - distance) < Math.abs(closest.distance - distance);\n\n if (nearer) closest = record;\n }\n\n return candidates[Math.floor(choice * candidates.length)] ?? closest;\n};\n\n/** Rotate/scale the recorded geometry, optionally mirroring across its own movement axis. */\nexport const retarget = (\n data: Index,\n record: Record,\n from: Motion.Point,\n to: Motion.Point,\n mirror: boolean,\n): ReadonlyArray<Motion.Sample> => {\n const source = decode(data, record);\n const sourceX = record.dx / record.distance;\n const sourceY = record.dy / record.distance;\n // Normalize before subtraction so large finite coordinates cannot overflow the displacement.\n const scale = Math.max(1, Math.abs(from.x), Math.abs(from.y), Math.abs(to.x), Math.abs(to.y));\n const startX = from.x / scale;\n const startY = from.y / scale;\n const deltaX = to.x / scale - startX;\n const deltaY = to.y / scale - startY;\n\n const coordinate = (value: number) =>\n Math.round(Math.max(-Number.MAX_VALUE, Math.min(Number.MAX_VALUE, value * scale)));\n\n return source.map((sample, ordinal) => {\n if (ordinal === 0) return { ...from, afterMillis: sample.afterMillis };\n if (ordinal === source.length - 1) return { ...to, afterMillis: sample.afterMillis };\n const along = (sample.x * sourceX + sample.y * sourceY) / record.distance;\n\n const lateral =\n ((-sample.x * sourceY + sample.y * sourceX) / record.distance) * (mirror ? -1 : 1);\n\n return {\n x: coordinate(startX + deltaX * along - deltaY * lateral),\n y: coordinate(startY + deltaY * along + deltaX * lateral),\n afterMillis: sample.afterMillis,\n };\n });\n};\n","// This Node-only loader owns one fixed package asset without adding a platform dependency or\n// requiring a caller filesystem service.\n// @effect-diagnostics-next-line nodeBuiltinImport:off -- reads only its own package's asset\nimport { readFile } from \"node:fs/promises\";\n\nimport { Effect, Random, Result } from \"effect\";\nimport type * as Motion from \"effect-browser/Motion\";\n\nimport { DataError, index, inflate, retarget, select } from \"./internal/strokes.ts\";\n\nconst read = Effect.tryPromise({\n try: (signal) => readFile(new URL(\"../data/strokes.bin.br\", import.meta.url), { signal }),\n catch: () =>\n new DataError({\n reason: \"Read\",\n detail: \"The bundled human-stroke asset could not be read.\",\n }),\n});\n\n/**\n * The recorded strokes as a pointer planner, for a presenter:\n * `Presentation.make({ motion: yield* HumanStrokes.motion })`. Loading reads, decompresses and\n * validates the package's own asset, with no network or path options, and fails `DataError`.\n */\nexport const motion: Effect.Effect<Motion.Service, DataError> = Effect.gen(function* () {\n // No binding here holds the compressed asset, so the planner's closure keeps only the payload.\n const decoded = index(yield* Effect.flatMap(read, inflate));\n\n if (Result.isFailure(decoded)) return yield* decoded.failure;\n const data = decoded.success;\n\n const plan: Motion.Service[\"plan\"] = Effect.fnUntraced(function* (from, to) {\n const scale = Math.max(1, Math.abs(from.x), Math.abs(from.y), Math.abs(to.x), Math.abs(to.y));\n\n const distance =\n Math.hypot(to.x / scale - from.x / scale, to.y / scale - from.y / scale) * scale;\n\n if (distance < 2) return [{ ...to, afterMillis: 0 }];\n const record = select(data, distance, yield* Random.next);\n\n return retarget(data, record, from, to, (yield* Random.next) < 0.5);\n });\n\n return { plan } satisfies Motion.Service;\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,17 +1,53 @@
1
1
  {
2
2
  "name": "effect-browser-human-strokes",
3
- "version": "0.0.0-reserved.0",
4
- "description": "Namespace bootstrap only; wait for the first qualified SDK release.",
5
- "license": "MIT",
3
+ "version": "0.3.0-beta.10",
4
+ "description": "Optional recorded human pointer motion for effect-browser.",
5
+ "license": "(MIT AND CC-BY-4.0)",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/mannyc2/effect-agent-browserbase.git"
8
+ "url": "git+https://github.com/mannyc2/effect-agent-browserbase.git",
9
+ "directory": "packages/human-strokes"
9
10
  },
10
11
  "files": [
11
- "README.md"
12
+ "dist",
13
+ "src",
14
+ "data",
15
+ "LICENSE",
16
+ "LICENSE-data"
12
17
  ],
18
+ "type": "module",
19
+ "sideEffects": [],
20
+ "exports": {
21
+ ".": {
22
+ "@effect-browser/source": "./src/index.ts",
23
+ "types": "./dist/index.d.mts",
24
+ "default": "./dist/index.mjs"
25
+ }
26
+ },
13
27
  "publishConfig": {
14
- "access": "public",
15
- "provenance": false
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "vp pack",
32
+ "check": "tsc --noEmit -p tsconfig.json",
33
+ "test": "vp test --run"
34
+ },
35
+ "devDependencies": {
36
+ "@effect/vitest": "4.0.0",
37
+ "@types/node": "26.1.2",
38
+ "effect": "4.0.0",
39
+ "effect-browser": "0.3.0-beta.10",
40
+ "playwright-core": "1.63.0",
41
+ "typescript": "7.0.2",
42
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
43
+ "vite-plus": "1.1.0",
44
+ "vitest": "5.0.3"
45
+ },
46
+ "peerDependencies": {
47
+ "effect": "~4.0.0",
48
+ "effect-browser": "^0.3.0-beta.10"
49
+ },
50
+ "engines": {
51
+ "node": "^22.18.0 || >=24.11.0"
16
52
  }
17
53
  }
@@ -0,0 +1,45 @@
1
+ // This Node-only loader owns one fixed package asset without adding a platform dependency or
2
+ // requiring a caller filesystem service.
3
+ // @effect-diagnostics-next-line nodeBuiltinImport:off -- reads only its own package's asset
4
+ import { readFile } from "node:fs/promises";
5
+
6
+ import { Effect, Random, Result } from "effect";
7
+ import type * as Motion from "effect-browser/Motion";
8
+
9
+ import { DataError, index, inflate, retarget, select } from "./internal/strokes.ts";
10
+
11
+ const read = Effect.tryPromise({
12
+ try: (signal) => readFile(new URL("../data/strokes.bin.br", import.meta.url), { signal }),
13
+ catch: () =>
14
+ new DataError({
15
+ reason: "Read",
16
+ detail: "The bundled human-stroke asset could not be read.",
17
+ }),
18
+ });
19
+
20
+ /**
21
+ * The recorded strokes as a pointer planner, for a presenter:
22
+ * `Presentation.make({ motion: yield* HumanStrokes.motion })`. Loading reads, decompresses and
23
+ * validates the package's own asset, with no network or path options, and fails `DataError`.
24
+ */
25
+ export const motion: Effect.Effect<Motion.Service, DataError> = Effect.gen(function* () {
26
+ // No binding here holds the compressed asset, so the planner's closure keeps only the payload.
27
+ const decoded = index(yield* Effect.flatMap(read, inflate));
28
+
29
+ if (Result.isFailure(decoded)) return yield* decoded.failure;
30
+ const data = decoded.success;
31
+
32
+ const plan: Motion.Service["plan"] = Effect.fnUntraced(function* (from, to) {
33
+ const scale = Math.max(1, Math.abs(from.x), Math.abs(from.y), Math.abs(to.x), Math.abs(to.y));
34
+
35
+ const distance =
36
+ Math.hypot(to.x / scale - from.x / scale, to.y / scale - from.y / scale) * scale;
37
+
38
+ if (distance < 2) return [{ ...to, afterMillis: 0 }];
39
+ const record = select(data, distance, yield* Random.next);
40
+
41
+ return retarget(data, record, from, to, (yield* Random.next) < 0.5);
42
+ });
43
+
44
+ return { plan } satisfies Motion.Service;
45
+ });
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { motion } from "./HumanStrokes.ts";
2
+ export { DataError } from "./internal/strokes.ts";
@@ -0,0 +1,251 @@
1
+ import { promisify } from "node:util";
2
+ import { brotliDecompress } from "node:zlib";
3
+
4
+ import { Effect, Result, Schema } from "effect";
5
+ import * as Motion from "effect-browser/Motion";
6
+
7
+ /** The fixed bundled recording could not be read or validated. */
8
+ export class DataError extends Schema.TaggedError<DataError>()("HumanStrokesDataError", {
9
+ reason: Schema.Literals(["Read", "Compression", "Format"]),
10
+ detail: Schema.String,
11
+ }) {}
12
+
13
+ export const rawBytes = 10_411_952;
14
+ export const strokeCount = 32_130;
15
+ export const pointCount = 2_604_684;
16
+ const ticks = 4096;
17
+ const decompress = promisify(brotliDecompress);
18
+
19
+ /** Inflate on the thread pool, bounded to the payload size, without blocking the event loop. */
20
+ export const inflate = (compressed: Uint8Array) =>
21
+ Effect.tryPromise({
22
+ try: () => decompress(compressed, { maxOutputLength: rawBytes }),
23
+ catch: () =>
24
+ new DataError({
25
+ reason: "Compression",
26
+ detail: "The bundled human-stroke asset could not be decompressed.",
27
+ }),
28
+ });
29
+
30
+ export interface Record {
31
+ readonly offset: number;
32
+ readonly length: number;
33
+ readonly dx: number;
34
+ readonly dy: number;
35
+ readonly distance: number;
36
+ readonly duration: number;
37
+ }
38
+
39
+ export interface Index {
40
+ readonly bytes: Uint8Array;
41
+ readonly records: ReadonlyArray<Record>;
42
+ readonly first: Record;
43
+ readonly points: number;
44
+ }
45
+
46
+ // The private binary codec keeps original fractional milliseconds. Rounding them to integer
47
+ // milliseconds would change 880,905 sample times, despite saving less than a megabyte on disk.
48
+ class Reader {
49
+ offset: number;
50
+ invalid = false;
51
+ readonly bytes: Uint8Array;
52
+
53
+ constructor(bytes: Uint8Array, offset = 0) {
54
+ this.bytes = bytes;
55
+ this.offset = offset;
56
+ }
57
+
58
+ unsigned(): number {
59
+ let value = 0;
60
+ let multiplier = 1;
61
+
62
+ for (let byteIndex = 0; byteIndex < 5; byteIndex++) {
63
+ const byte = this.bytes[this.offset++];
64
+
65
+ if (byte === undefined) {
66
+ this.invalid = true;
67
+
68
+ return 0;
69
+ }
70
+ value += (byte & 127) * multiplier;
71
+ if (byte < 128) {
72
+ if (value > 0xffffffff || (byteIndex > 0 && byte === 0)) this.invalid = true;
73
+
74
+ return value;
75
+ }
76
+ multiplier *= 128;
77
+ }
78
+ this.invalid = true;
79
+
80
+ return 0;
81
+ }
82
+
83
+ signed(): number {
84
+ const value = this.unsigned();
85
+
86
+ return value % 2 === 0 ? value / 2 : -(value + 1) / 2;
87
+ }
88
+ }
89
+
90
+ const bad = (detail: string) => Result.fail(new DataError({ reason: "Format", detail }));
91
+
92
+ /** Validate each bounded record once, retaining only offsets and selection metadata. */
93
+ export const index = (bytes: Uint8Array): Result.Result<Index, DataError> => {
94
+ if (
95
+ bytes.length !== rawBytes ||
96
+ bytes[0] !== 66 ||
97
+ bytes[1] !== 79 ||
98
+ bytes[2] !== 85 ||
99
+ bytes[3] !== 78 ||
100
+ bytes[4] !== 1
101
+ )
102
+ return bad("The bundled stroke header or byte count is invalid.");
103
+
104
+ const reader = new Reader(bytes, 5);
105
+
106
+ if (reader.unsigned() !== ticks || reader.unsigned() !== strokeCount)
107
+ return bad("The bundled stroke precision or record count is invalid.");
108
+ const records: Array<Record> = [];
109
+ let points = 0;
110
+
111
+ for (let ordinal = 0; ordinal < strokeCount; ordinal++) {
112
+ const intervals = reader.unsigned();
113
+ const length = intervals + 1;
114
+
115
+ if (reader.invalid || length < 4 || length > Motion.maximumSamples)
116
+ return bad("A stroke exceeds the supported sample count.");
117
+ points += length;
118
+ if (points > pointCount) return bad("The bundled point count is invalid.");
119
+ const offset = reader.offset;
120
+ const times = new Float64Array(length);
121
+ let whole = 0;
122
+
123
+ for (let sample = 1; sample < length; sample++) {
124
+ whole += reader.signed();
125
+ times[sample] = whole;
126
+ }
127
+ let dx = 0;
128
+ let dy = 0;
129
+
130
+ for (let sample = 1; sample < length; sample++) dx += reader.signed();
131
+ for (let sample = 1; sample < length; sample++) dy += reader.signed();
132
+ let previous = 0;
133
+
134
+ for (let sample = 1; sample < length; sample++) {
135
+ const residual = reader.signed();
136
+ const time = (times[sample] ?? 0) + residual / ticks;
137
+
138
+ if (Math.abs(residual) > ticks / 2 || time < previous || time > Motion.maximumDurationMillis)
139
+ return bad("A stroke has invalid or decreasing sample times.");
140
+ previous = time;
141
+ }
142
+ const distance = Math.hypot(dx, dy);
143
+
144
+ if (reader.invalid || distance < 30 || previous <= 0)
145
+ return bad("A stroke has an invalid displacement or duration.");
146
+ records.push({ offset, length, dx, dy, distance, duration: previous });
147
+ }
148
+ const first = records[0];
149
+
150
+ if (
151
+ reader.invalid ||
152
+ reader.offset !== bytes.length ||
153
+ points !== pointCount ||
154
+ first === undefined
155
+ )
156
+ return bad("The bundled stroke payload has an invalid extent.");
157
+
158
+ return Result.succeed({ bytes, records, first, points });
159
+ };
160
+
161
+ /** The index establishes these record boundaries; only the selected coordinates are expanded. */
162
+ export const decode = (data: Index, record: Record): ReadonlyArray<Motion.Sample> => {
163
+ const reader = new Reader(data.bytes, record.offset);
164
+ const times = new Float64Array(record.length);
165
+ const xs = new Float64Array(record.length);
166
+ const ys = new Float64Array(record.length);
167
+ let value = 0;
168
+
169
+ for (let sample = 1; sample < record.length; sample++) {
170
+ value += reader.signed();
171
+ times[sample] = value;
172
+ }
173
+ value = 0;
174
+ for (let sample = 1; sample < record.length; sample++) {
175
+ value += reader.signed();
176
+ xs[sample] = value;
177
+ }
178
+ value = 0;
179
+ for (let sample = 1; sample < record.length; sample++) {
180
+ value += reader.signed();
181
+ ys[sample] = value;
182
+ }
183
+ const samples: Array<Motion.Sample> = [{ x: 0, y: 0, afterMillis: 0 }];
184
+
185
+ for (let sample = 1; sample < record.length; sample++)
186
+ samples.push({
187
+ x: xs[sample] ?? 0,
188
+ y: ys[sample] ?? 0,
189
+ afterMillis: (times[sample] ?? 0) + reader.signed() / ticks,
190
+ });
191
+
192
+ return samples;
193
+ };
194
+
195
+ /** Match the measured selection rule: random nearby distance, otherwise nearest absolute distance. */
196
+ export const select = (data: Index, distance: number, choice: number): Record => {
197
+ const candidates: Array<Record> = [];
198
+ let closest = data.first;
199
+
200
+ for (const record of data.records) {
201
+ if (Math.abs(Math.log(record.distance / distance)) < 0.15) candidates.push(record);
202
+
203
+ const nearer =
204
+ record.distance <= distance && closest.distance <= distance
205
+ ? record.distance > closest.distance
206
+ : record.distance >= distance && closest.distance >= distance
207
+ ? record.distance < closest.distance
208
+ : Math.abs(record.distance - distance) < Math.abs(closest.distance - distance);
209
+
210
+ if (nearer) closest = record;
211
+ }
212
+
213
+ return candidates[Math.floor(choice * candidates.length)] ?? closest;
214
+ };
215
+
216
+ /** Rotate/scale the recorded geometry, optionally mirroring across its own movement axis. */
217
+ export const retarget = (
218
+ data: Index,
219
+ record: Record,
220
+ from: Motion.Point,
221
+ to: Motion.Point,
222
+ mirror: boolean,
223
+ ): ReadonlyArray<Motion.Sample> => {
224
+ const source = decode(data, record);
225
+ const sourceX = record.dx / record.distance;
226
+ const sourceY = record.dy / record.distance;
227
+ // Normalize before subtraction so large finite coordinates cannot overflow the displacement.
228
+ const scale = Math.max(1, Math.abs(from.x), Math.abs(from.y), Math.abs(to.x), Math.abs(to.y));
229
+ const startX = from.x / scale;
230
+ const startY = from.y / scale;
231
+ const deltaX = to.x / scale - startX;
232
+ const deltaY = to.y / scale - startY;
233
+
234
+ const coordinate = (value: number) =>
235
+ Math.round(Math.max(-Number.MAX_VALUE, Math.min(Number.MAX_VALUE, value * scale)));
236
+
237
+ return source.map((sample, ordinal) => {
238
+ if (ordinal === 0) return { ...from, afterMillis: sample.afterMillis };
239
+ if (ordinal === source.length - 1) return { ...to, afterMillis: sample.afterMillis };
240
+ const along = (sample.x * sourceX + sample.y * sourceY) / record.distance;
241
+
242
+ const lateral =
243
+ ((-sample.x * sourceY + sample.y * sourceX) / record.distance) * (mirror ? -1 : 1);
244
+
245
+ return {
246
+ x: coordinate(startX + deltaX * along - deltaY * lateral),
247
+ y: coordinate(startY + deltaY * along + deltaX * lateral),
248
+ afterMillis: sample.afterMillis,
249
+ };
250
+ });
251
+ };