@humanjs/playwright 0.2.0 → 0.4.0
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 +231 -4
- package/dist/index.cjs +1013 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +478 -4
- package/dist/index.d.ts +478 -4
- package/dist/index.js +978 -39
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,89 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var core = require('@humanjs/core');
|
|
4
|
+
var child_process = require('child_process');
|
|
5
|
+
var fs = require('fs');
|
|
6
|
+
var promises = require('fs/promises');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var ffmpegStatic = require('ffmpeg-static');
|
|
9
|
+
var os = require('os');
|
|
10
|
+
var playwright = require('playwright');
|
|
11
|
+
|
|
12
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
13
|
+
|
|
14
|
+
var ffmpegStatic__default = /*#__PURE__*/_interopDefault(ffmpegStatic);
|
|
4
15
|
|
|
5
16
|
// src/index.ts
|
|
17
|
+
|
|
18
|
+
// src/internal/timing.ts
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
21
|
+
}
|
|
22
|
+
function speedModeFactor(speed) {
|
|
23
|
+
switch (speed) {
|
|
24
|
+
case "fast":
|
|
25
|
+
return 0.5;
|
|
26
|
+
case "instant":
|
|
27
|
+
return 0;
|
|
28
|
+
default:
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function computeDwellTime(meanMs, jitter, personality, speed, rng) {
|
|
33
|
+
if (meanMs <= 0) return 0;
|
|
34
|
+
const jitterMag = meanMs * jitter;
|
|
35
|
+
const offset = rng.nextFloat(-jitterMag, jitterMag);
|
|
36
|
+
return Math.max(0, (meanMs + offset) * personality.speed * speedModeFactor(speed));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/keyboard/index.ts
|
|
40
|
+
async function executeType(target, value, ctx) {
|
|
41
|
+
const locator = typeof target === "string" ? ctx.page.locator(target) : target;
|
|
42
|
+
if (value.length === 0) {
|
|
43
|
+
return { characters: 0, typos: 0, corrections: 0 };
|
|
44
|
+
}
|
|
45
|
+
if (ctx.speed === "instant") {
|
|
46
|
+
await locator.pressSequentially(value, { delay: 0 });
|
|
47
|
+
return { characters: value.length, typos: 0, corrections: 0 };
|
|
48
|
+
}
|
|
49
|
+
await locator.focus();
|
|
50
|
+
const plan = core.planTypeKeystrokes(value, ctx.personality.typing, ctx.rng, {
|
|
51
|
+
personalitySpeed: ctx.personality.speed,
|
|
52
|
+
speedFactor: speedModeFactor(ctx.speed)
|
|
53
|
+
});
|
|
54
|
+
let typos = 0;
|
|
55
|
+
let corrections = 0;
|
|
56
|
+
for (const step of plan) {
|
|
57
|
+
if (step.delayBeforeMs > 0) await sleep(step.delayBeforeMs);
|
|
58
|
+
await dispatchKey(ctx.page, step.key);
|
|
59
|
+
if (step.isTypo) typos++;
|
|
60
|
+
if (step.isCorrection) corrections++;
|
|
61
|
+
}
|
|
62
|
+
return { characters: value.length, typos, corrections };
|
|
63
|
+
}
|
|
64
|
+
async function dispatchKey(page, key) {
|
|
65
|
+
if (key.length > 1 || key.charCodeAt(0) < 128) {
|
|
66
|
+
await page.keyboard.press(key);
|
|
67
|
+
} else {
|
|
68
|
+
await page.keyboard.insertText(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/internal/mouse-walk.ts
|
|
73
|
+
async function walkMouseAlongPath(page, path, durationMs) {
|
|
74
|
+
if (path.length === 0) return;
|
|
75
|
+
const stepDelayMs = path.length > 1 && durationMs > 0 ? durationMs / (path.length - 1) : 0;
|
|
76
|
+
for (let i = 0; i < path.length; i++) {
|
|
77
|
+
const point = path[i];
|
|
78
|
+
if (!point) continue;
|
|
79
|
+
await page.mouse.move(point.x, point.y);
|
|
80
|
+
if (i < path.length - 1 && stepDelayMs > 0) {
|
|
81
|
+
await sleep(stepDelayMs);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/mouse/index.ts
|
|
6
87
|
async function executeClick(target, ctx) {
|
|
7
88
|
const locator = typeof target === "string" ? ctx.page.locator(target) : target;
|
|
8
89
|
if (ctx.speed === "instant") {
|
|
@@ -24,7 +105,8 @@ async function executeClick(target, ctx) {
|
|
|
24
105
|
curvature: ctx.personality.mouse.curvature
|
|
25
106
|
});
|
|
26
107
|
const path = core.humanizePath(rawPath, ctx.rng);
|
|
27
|
-
|
|
108
|
+
const travelMs = computeTravelTime(path, ctx.personality, ctx.speed, ctx.rng);
|
|
109
|
+
await walkMouseAlongPath(ctx.page, path, travelMs);
|
|
28
110
|
const preClickMs = computeDwellTime(
|
|
29
111
|
ctx.personality.dwell.preClickMs,
|
|
30
112
|
ctx.personality.dwell.preClickJitter,
|
|
@@ -52,19 +134,6 @@ function pickClickPoint(box, rng) {
|
|
|
52
134
|
const y = clamp(cy + rng.nextGaussian(0, box.height / 8), box.y, box.y + box.height);
|
|
53
135
|
return { x, y };
|
|
54
136
|
}
|
|
55
|
-
async function walkMouseAlongPath(page, path, personality, rng, speed) {
|
|
56
|
-
if (path.length === 0) return;
|
|
57
|
-
const totalTimeMs = computeTravelTime(path, personality, speed, rng);
|
|
58
|
-
const stepDelayMs = path.length > 1 ? totalTimeMs / (path.length - 1) : 0;
|
|
59
|
-
for (let i = 0; i < path.length; i++) {
|
|
60
|
-
const point = path[i];
|
|
61
|
-
if (!point) continue;
|
|
62
|
-
await page.mouse.move(point.x, point.y);
|
|
63
|
-
if (i < path.length - 1 && stepDelayMs > 0) {
|
|
64
|
-
await sleep(stepDelayMs);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
137
|
function computeTravelTime(path, personality, speed, rng) {
|
|
69
138
|
let distance = 0;
|
|
70
139
|
for (let i = 1; i < path.length; i++) {
|
|
@@ -79,31 +148,779 @@ function computeTravelTime(path, personality, speed, rng) {
|
|
|
79
148
|
const total = (baseTime + jitter) * personality.speed * speedModeFactor(speed);
|
|
80
149
|
return Math.max(0, total);
|
|
81
150
|
}
|
|
82
|
-
function computeDwellTime(meanMs, jitter, personality, speed, rng) {
|
|
83
|
-
if (meanMs <= 0) return 0;
|
|
84
|
-
const jitterMag = meanMs * jitter;
|
|
85
|
-
const offset = rng.nextFloat(-jitterMag, jitterMag);
|
|
86
|
-
return Math.max(0, (meanMs + offset) * personality.speed * speedModeFactor(speed));
|
|
87
|
-
}
|
|
88
|
-
function speedModeFactor(speed) {
|
|
89
|
-
switch (speed) {
|
|
90
|
-
case "fast":
|
|
91
|
-
return 0.5;
|
|
92
|
-
case "instant":
|
|
93
|
-
return 0;
|
|
94
|
-
default:
|
|
95
|
-
return 1;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
151
|
function describeTarget(target) {
|
|
99
152
|
return typeof target === "string" ? target : target.toString?.() ?? "locator";
|
|
100
153
|
}
|
|
101
154
|
function clamp(value, min, max) {
|
|
102
155
|
return value < min ? min : value > max ? max : value;
|
|
103
156
|
}
|
|
104
|
-
function
|
|
105
|
-
|
|
157
|
+
async function executeRead(target, ctx, options = {}) {
|
|
158
|
+
let words = 0;
|
|
159
|
+
let locator;
|
|
160
|
+
if (typeof target === "string") {
|
|
161
|
+
locator = ctx.page.locator(target);
|
|
162
|
+
} else if ("words" in target) {
|
|
163
|
+
words = target.words;
|
|
164
|
+
} else if ("text" in target) {
|
|
165
|
+
words = core.countWords(target.text);
|
|
166
|
+
} else {
|
|
167
|
+
locator = target;
|
|
168
|
+
}
|
|
169
|
+
let autoDetectedKind;
|
|
170
|
+
if (locator) {
|
|
171
|
+
if (options.scrollIntoView) {
|
|
172
|
+
await locator.scrollIntoViewIfNeeded();
|
|
173
|
+
}
|
|
174
|
+
const text = await locator.innerText().catch(() => "");
|
|
175
|
+
words = core.countWords(text);
|
|
176
|
+
if (options.kind === void 0) {
|
|
177
|
+
autoDetectedKind = await detectKindFromTag(locator);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const kind = options.kind ?? autoDetectedKind ?? "prose";
|
|
181
|
+
const durationMs = core.computeReadingDwellMs(words, ctx.personality.reading, ctx.rng, {
|
|
182
|
+
kind,
|
|
183
|
+
wpmMultiplier: options.wpmMultiplier,
|
|
184
|
+
personalitySpeed: ctx.personality.speed,
|
|
185
|
+
speedFactor: speedModeFactor(ctx.speed)
|
|
186
|
+
});
|
|
187
|
+
const withMotion = options.withMotion ?? true;
|
|
188
|
+
if (withMotion && locator && durationMs > 0) {
|
|
189
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
190
|
+
if (box) {
|
|
191
|
+
const lineRects = await getLineRects(locator).catch(() => []);
|
|
192
|
+
const path = core.planReadingScan(box, ctx.rng, {
|
|
193
|
+
start: ctx.getMousePosition(),
|
|
194
|
+
lineRects: lineRects.length > 0 ? lineRects : void 0
|
|
195
|
+
});
|
|
196
|
+
await walkMouseAlongPath(ctx.page, path, durationMs);
|
|
197
|
+
const final = path[path.length - 1];
|
|
198
|
+
if (final) ctx.setMousePosition(final);
|
|
199
|
+
return { words, durationMs, kind };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (durationMs > 0) await sleep(durationMs);
|
|
203
|
+
return { words, durationMs, kind };
|
|
204
|
+
}
|
|
205
|
+
async function getLineRects(locator) {
|
|
206
|
+
const result = await locator.evaluate((el) => {
|
|
207
|
+
const walker = el.ownerDocument.createTreeWalker(el, 4);
|
|
208
|
+
const rects = [];
|
|
209
|
+
let node = walker.nextNode();
|
|
210
|
+
while (node) {
|
|
211
|
+
const text = node.textContent ?? "";
|
|
212
|
+
if (text.trim().length > 0) {
|
|
213
|
+
const range = el.ownerDocument.createRange();
|
|
214
|
+
range.selectNodeContents(node);
|
|
215
|
+
for (const r of Array.from(range.getClientRects())) {
|
|
216
|
+
if (r.width > 0 && r.height > 0) {
|
|
217
|
+
rects.push({ x: r.x, y: r.y, width: r.width, height: r.height });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
node = walker.nextNode();
|
|
222
|
+
}
|
|
223
|
+
rects.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
224
|
+
const merged = [];
|
|
225
|
+
for (const r of rects) {
|
|
226
|
+
const last = merged[merged.length - 1];
|
|
227
|
+
if (last && Math.abs(last.y - r.y) < 1 && r.x - (last.x + last.width) < 6) {
|
|
228
|
+
const right = Math.max(last.x + last.width, r.x + r.width);
|
|
229
|
+
const bottom = Math.max(last.y + last.height, r.y + r.height);
|
|
230
|
+
last.width = right - last.x;
|
|
231
|
+
last.height = bottom - last.y;
|
|
232
|
+
} else {
|
|
233
|
+
merged.push({ ...r });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return merged;
|
|
237
|
+
});
|
|
238
|
+
if (!Array.isArray(result)) return [];
|
|
239
|
+
return result.filter(
|
|
240
|
+
(r) => r != null && typeof r === "object" && typeof r.x === "number" && typeof r.y === "number" && typeof r.width === "number" && typeof r.height === "number"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
async function detectKindFromTag(locator) {
|
|
244
|
+
const tag = await locator.evaluate((el) => el.tagName?.toLowerCase() ?? "").catch(() => "");
|
|
245
|
+
if (tag === "pre" || tag === "code") return "code";
|
|
246
|
+
return void 0;
|
|
247
|
+
}
|
|
248
|
+
var pendingFrameCleanups = /* @__PURE__ */ new Set();
|
|
249
|
+
var exitHandlerInstalled = false;
|
|
250
|
+
function ensureExitHandler() {
|
|
251
|
+
if (exitHandlerInstalled) return;
|
|
252
|
+
exitHandlerInstalled = true;
|
|
253
|
+
process.on("exit", () => {
|
|
254
|
+
for (const dir of pendingFrameCleanups) {
|
|
255
|
+
try {
|
|
256
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
pendingFrameCleanups.clear();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
var FFMPEG_PATH = ffmpegStatic__default.default;
|
|
264
|
+
var QUALITY_PRESETS = {
|
|
265
|
+
fast: {
|
|
266
|
+
captureFormat: "jpeg",
|
|
267
|
+
captureJpegQuality: 85,
|
|
268
|
+
captureFps: 24,
|
|
269
|
+
crf: 23,
|
|
270
|
+
preset: "fast"
|
|
271
|
+
},
|
|
272
|
+
standard: {
|
|
273
|
+
captureFormat: "jpeg",
|
|
274
|
+
captureJpegQuality: 90,
|
|
275
|
+
captureFps: 30,
|
|
276
|
+
crf: 20,
|
|
277
|
+
preset: "fast"
|
|
278
|
+
},
|
|
279
|
+
high: {
|
|
280
|
+
captureFormat: "jpeg",
|
|
281
|
+
captureJpegQuality: 95,
|
|
282
|
+
captureFps: 30,
|
|
283
|
+
crf: 18,
|
|
284
|
+
preset: "slow",
|
|
285
|
+
// 'animation' suits screen content (large solid regions, sharp edges)
|
|
286
|
+
// better than 'film' which is tuned for live-action grain.
|
|
287
|
+
tune: "animation"
|
|
288
|
+
},
|
|
289
|
+
lossless: {
|
|
290
|
+
// PNG capture for perceptually lossless source frames. Temp files are
|
|
291
|
+
// 10-20× larger than JPEG; output mp4 still benefits from the extra
|
|
292
|
+
// headroom (no JPEG artifacts to preserve).
|
|
293
|
+
captureFormat: "png",
|
|
294
|
+
captureJpegQuality: 100,
|
|
295
|
+
captureFps: 30,
|
|
296
|
+
crf: 12,
|
|
297
|
+
preset: "veryslow",
|
|
298
|
+
tune: "animation"
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
function getCaptureSettingsForQuality(quality) {
|
|
302
|
+
const preset = QUALITY_PRESETS[quality];
|
|
303
|
+
return {
|
|
304
|
+
format: preset.captureFormat,
|
|
305
|
+
quality: preset.captureJpegQuality,
|
|
306
|
+
fps: preset.captureFps
|
|
307
|
+
};
|
|
106
308
|
}
|
|
309
|
+
var Recording = class {
|
|
310
|
+
#capture;
|
|
311
|
+
#windowStartMs;
|
|
312
|
+
#windowEndMs;
|
|
313
|
+
#timelineSource;
|
|
314
|
+
// Frames live on disk until `dispose()` is called. Exporters
|
|
315
|
+
// (`toVideo`, `toGif`) are repeatable and interleavable — they read the
|
|
316
|
+
// same frame source, they don't consume it.
|
|
317
|
+
#disposed = false;
|
|
318
|
+
constructor(capture, windowStartMs, windowEndMs, timelineSource) {
|
|
319
|
+
this.#capture = capture;
|
|
320
|
+
this.#windowStartMs = windowStartMs;
|
|
321
|
+
this.#windowEndMs = windowEndMs;
|
|
322
|
+
this.#timelineSource = timelineSource;
|
|
323
|
+
if (capture !== null) {
|
|
324
|
+
pendingFrameCleanups.add(capture.dir);
|
|
325
|
+
ensureExitHandler();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Wall-clock duration of the recorded window. */
|
|
329
|
+
get durationMs() {
|
|
330
|
+
return this.#windowEndMs - this.#windowStartMs;
|
|
331
|
+
}
|
|
332
|
+
/** True if frames were captured during this recording. */
|
|
333
|
+
get hasVideo() {
|
|
334
|
+
return this.#capture !== null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The structured action timeline of this recording — same data that
|
|
338
|
+
* `toTimeline()` writes to disk.
|
|
339
|
+
*/
|
|
340
|
+
get timeline() {
|
|
341
|
+
return {
|
|
342
|
+
version: 1,
|
|
343
|
+
personality: this.#timelineSource.personality,
|
|
344
|
+
seed: this.#timelineSource.seed,
|
|
345
|
+
speed: this.#timelineSource.speed,
|
|
346
|
+
durationMs: this.durationMs,
|
|
347
|
+
events: this.#timelineSource.events
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Assembles the captured frames into a video at `outputPath`. The output
|
|
352
|
+
* format is inferred from the extension — `.mp4` (H.264, re-encoded
|
|
353
|
+
* with the configured quality) or `.webm` (VP9).
|
|
354
|
+
*
|
|
355
|
+
* Repeatable and interleavable with `toGif()` — the frame source is read,
|
|
356
|
+
* not consumed. Frames live until you call `dispose()` (or `await using`
|
|
357
|
+
* goes out of scope, or the process exits and the OS reaps `tmpdir`).
|
|
358
|
+
*
|
|
359
|
+
* @returns the resolved output path.
|
|
360
|
+
*/
|
|
361
|
+
async toVideo(outputPath, options = {}) {
|
|
362
|
+
if (this.#disposed) {
|
|
363
|
+
throw new Error("Recording.toVideo() called after dispose() \u2014 the source frames are gone.");
|
|
364
|
+
}
|
|
365
|
+
if (this.#capture === null) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
"Recording.toVideo() requires video capture, which was disabled for this recording. Call `human.record(cb)` (default captures video) or pass `output` to @humanjs/recorder's `record()`. `toTimeline()` and `.timeline` work without capture."
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const preset = QUALITY_PRESETS[options.quality ?? "high"];
|
|
371
|
+
const crf = options.crf ?? preset.crf;
|
|
372
|
+
const ffmpegPreset = options.preset ?? preset.preset;
|
|
373
|
+
const tune = options.tune ?? preset.tune;
|
|
374
|
+
const { dir, frames, startedAtMs, stoppedAtMs } = this.#capture;
|
|
375
|
+
if (frames.length === 0) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
"No frames were captured. The recording window may have been too short, or the page may not have rendered any frames before the callback completed."
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
await promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
381
|
+
const ext = path.extname(outputPath).toLowerCase();
|
|
382
|
+
if (ext !== ".mp4" && ext !== ".webm") {
|
|
383
|
+
throw new Error(`Unsupported output extension: ${ext || "(none)"}. Use .mp4 or .webm.`);
|
|
384
|
+
}
|
|
385
|
+
const concatPath = `${dir}/concat.txt`;
|
|
386
|
+
const concatBody = buildConcatFile(frames, stoppedAtMs - startedAtMs);
|
|
387
|
+
await promises.writeFile(concatPath, concatBody, "utf8");
|
|
388
|
+
const args = ["-y", "-f", "concat", "-safe", "0", "-i", concatPath, "-vsync", "vfr"];
|
|
389
|
+
if (ext === ".mp4") {
|
|
390
|
+
args.push(
|
|
391
|
+
"-c:v",
|
|
392
|
+
"libx264",
|
|
393
|
+
"-pix_fmt",
|
|
394
|
+
"yuv420p",
|
|
395
|
+
"-crf",
|
|
396
|
+
String(crf),
|
|
397
|
+
"-preset",
|
|
398
|
+
ffmpegPreset
|
|
399
|
+
);
|
|
400
|
+
if (tune) args.push("-tune", tune);
|
|
401
|
+
args.push("-movflags", "+faststart");
|
|
402
|
+
} else {
|
|
403
|
+
args.push(
|
|
404
|
+
"-c:v",
|
|
405
|
+
"libvpx-vp9",
|
|
406
|
+
"-pix_fmt",
|
|
407
|
+
"yuv420p",
|
|
408
|
+
"-crf",
|
|
409
|
+
String(crf),
|
|
410
|
+
"-b:v",
|
|
411
|
+
"0",
|
|
412
|
+
"-deadline",
|
|
413
|
+
ffmpegPreset === "fast" || ffmpegPreset === "veryfast" ? "realtime" : "good"
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
args.push(outputPath);
|
|
417
|
+
await runFfmpeg(args);
|
|
418
|
+
return outputPath;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Assembles the captured frames into an animated GIF at `outputPath`.
|
|
422
|
+
* Optimized for embedding in READMEs, PRs, Slack, and docs — uses a
|
|
423
|
+
* per-recording palette (`palettegen` + `paletteuse`) with Bayer dithering
|
|
424
|
+
* so gradients stay smooth without exploding the file size.
|
|
425
|
+
*
|
|
426
|
+
* Repeatable and interleavable with `toVideo()` — call them in any order,
|
|
427
|
+
* any number of times. Frames live until you call `dispose()`.
|
|
428
|
+
*
|
|
429
|
+
* @returns the resolved output path.
|
|
430
|
+
*/
|
|
431
|
+
async toGif(outputPath, options = {}) {
|
|
432
|
+
if (this.#disposed) {
|
|
433
|
+
throw new Error("Recording.toGif() called after dispose() \u2014 the source frames are gone.");
|
|
434
|
+
}
|
|
435
|
+
if (this.#capture === null) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
"Recording.toGif() requires video capture, which was disabled for this recording. Call `human.record(cb)` (default captures video) or pass `output` to @humanjs/recorder's `record()`. `toTimeline()` and `.timeline` work without capture."
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const fps = options.fps ?? 15;
|
|
441
|
+
const width = options.width;
|
|
442
|
+
const { dir, frames, startedAtMs, stoppedAtMs } = this.#capture;
|
|
443
|
+
if (frames.length === 0) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
"No frames were captured. The recording window may have been too short, or the page may not have rendered any frames before the callback completed."
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
await promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
449
|
+
const ext = path.extname(outputPath).toLowerCase();
|
|
450
|
+
if (ext !== ".gif") {
|
|
451
|
+
throw new Error(`Unsupported output extension: ${ext || "(none)"}. Use .gif.`);
|
|
452
|
+
}
|
|
453
|
+
const concatPath = `${dir}/concat.txt`;
|
|
454
|
+
const concatBody = buildConcatFile(frames, stoppedAtMs - startedAtMs);
|
|
455
|
+
await promises.writeFile(concatPath, concatBody, "utf8");
|
|
456
|
+
const filterSteps = [`fps=${fps}`];
|
|
457
|
+
if (width !== void 0) {
|
|
458
|
+
filterSteps.push(`scale=${width}:-1:flags=lanczos`);
|
|
459
|
+
}
|
|
460
|
+
const preFilter = filterSteps.join(",");
|
|
461
|
+
const filterComplex = `${preFilter},split [a][b]; [a] palettegen=stats_mode=diff [p]; [b][p] paletteuse=dither=bayer:bayer_scale=5`;
|
|
462
|
+
const args = [
|
|
463
|
+
"-y",
|
|
464
|
+
"-f",
|
|
465
|
+
"concat",
|
|
466
|
+
"-safe",
|
|
467
|
+
"0",
|
|
468
|
+
"-i",
|
|
469
|
+
concatPath,
|
|
470
|
+
"-filter_complex",
|
|
471
|
+
filterComplex,
|
|
472
|
+
"-loop",
|
|
473
|
+
"0",
|
|
474
|
+
outputPath
|
|
475
|
+
];
|
|
476
|
+
await runFfmpeg(args);
|
|
477
|
+
return outputPath;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Writes the structured action timeline to `outputPath` as JSON.
|
|
481
|
+
* Independent of `toVideo()` / `toGif()` — call before, after, in between,
|
|
482
|
+
* or instead. Safe to call multiple times. Unaffected by `dispose()`
|
|
483
|
+
* (the timeline lives in memory, not in the captured-frames temp dir).
|
|
484
|
+
*
|
|
485
|
+
* @returns the resolved output path.
|
|
486
|
+
*/
|
|
487
|
+
async toTimeline(outputPath) {
|
|
488
|
+
await promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
489
|
+
await promises.writeFile(outputPath, `${JSON.stringify(this.timeline, null, 2)}
|
|
490
|
+
`, "utf8");
|
|
491
|
+
return outputPath;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Releases the captured-frames temp directory. After this call, `toVideo()`
|
|
495
|
+
* and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
|
|
496
|
+
* still work because those don't depend on the frames.
|
|
497
|
+
*
|
|
498
|
+
* **Optional.** A process-exit handler also sweeps any un-disposed frame
|
|
499
|
+
* dirs, so casual scripts can skip this entirely. Call it explicitly when
|
|
500
|
+
* you want to release frames proactively (long-running services, batch
|
|
501
|
+
* jobs, or anywhere you want predictable disk usage).
|
|
502
|
+
*
|
|
503
|
+
* Idempotent. Safe to call on a Recording that never had a capture
|
|
504
|
+
* (timeline-only mode) — no-op there.
|
|
505
|
+
*
|
|
506
|
+
* Also wired to `Symbol.asyncDispose`, so the explicit-resource-management
|
|
507
|
+
* `await using` syntax (TypeScript ≥ 5.2 / Node ≥ 20.4) works:
|
|
508
|
+
*
|
|
509
|
+
* ```ts
|
|
510
|
+
* await using rec = await human.record(fn);
|
|
511
|
+
* await rec.toVideo('demo.mp4');
|
|
512
|
+
* await rec.toGif('demo.gif');
|
|
513
|
+
* // frames cleaned up automatically when `rec` goes out of scope
|
|
514
|
+
* ```
|
|
515
|
+
*/
|
|
516
|
+
async dispose() {
|
|
517
|
+
if (this.#disposed) return;
|
|
518
|
+
if (this.#capture !== null) {
|
|
519
|
+
await this.#capture.cleanup();
|
|
520
|
+
pendingFrameCleanups.delete(this.#capture.dir);
|
|
521
|
+
}
|
|
522
|
+
this.#disposed = true;
|
|
523
|
+
}
|
|
524
|
+
async [Symbol.asyncDispose]() {
|
|
525
|
+
await this.dispose();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
function buildConcatFile(frames, totalMs) {
|
|
529
|
+
const lines = [];
|
|
530
|
+
for (let i = 0; i < frames.length; i++) {
|
|
531
|
+
const frame = frames[i];
|
|
532
|
+
const next = frames[i + 1];
|
|
533
|
+
const nextTMs = next ? next.tMs : totalMs;
|
|
534
|
+
const durationS = Math.max(1e-3, (nextTMs - frame.tMs) / 1e3);
|
|
535
|
+
lines.push(`file '${frame.path.replaceAll("'", "'\\''")}'`);
|
|
536
|
+
lines.push(`duration ${durationS.toFixed(6)}`);
|
|
537
|
+
}
|
|
538
|
+
const last = frames[frames.length - 1];
|
|
539
|
+
if (last) {
|
|
540
|
+
lines.push(`file '${last.path.replaceAll("'", "'\\''")}'`);
|
|
541
|
+
}
|
|
542
|
+
return `${lines.join("\n")}
|
|
543
|
+
`;
|
|
544
|
+
}
|
|
545
|
+
function runFfmpeg(args) {
|
|
546
|
+
if (!FFMPEG_PATH) {
|
|
547
|
+
return Promise.reject(
|
|
548
|
+
new Error(
|
|
549
|
+
"ffmpeg-static did not bundle a binary for this platform. Install system ffmpeg and set FFMPEG_PATH, or run on a supported platform."
|
|
550
|
+
)
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
const proc = child_process.spawn(FFMPEG_PATH, [...args]);
|
|
555
|
+
let stderr = "";
|
|
556
|
+
proc.stderr?.on("data", (chunk) => {
|
|
557
|
+
stderr += chunk.toString();
|
|
558
|
+
});
|
|
559
|
+
proc.on("error", reject);
|
|
560
|
+
proc.on("close", (code) => {
|
|
561
|
+
if (code === 0) resolve();
|
|
562
|
+
else reject(new Error(`ffmpeg exited with code ${code}
|
|
563
|
+
${stderr.trim()}`));
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
async function startCapture(page, options = {}) {
|
|
568
|
+
const format = options.format ?? "jpeg";
|
|
569
|
+
const quality = options.quality ?? 95;
|
|
570
|
+
const fps = Math.max(1, Math.min(60, options.fps ?? 30));
|
|
571
|
+
const intervalMs = 1e3 / fps;
|
|
572
|
+
const dir = await promises.mkdtemp(path.join(os.tmpdir(), "humanjs-capture-"));
|
|
573
|
+
const frames = [];
|
|
574
|
+
const ext = format === "png" ? "png" : "jpg";
|
|
575
|
+
let stopped = false;
|
|
576
|
+
let frameIndex = 0;
|
|
577
|
+
const writes = [];
|
|
578
|
+
const startedAtMs = Date.now();
|
|
579
|
+
const captureLoop = async () => {
|
|
580
|
+
while (!stopped) {
|
|
581
|
+
const loopStart = Date.now();
|
|
582
|
+
try {
|
|
583
|
+
const buf = await page.screenshot({
|
|
584
|
+
type: format,
|
|
585
|
+
quality: format === "jpeg" ? quality : void 0
|
|
586
|
+
});
|
|
587
|
+
if (stopped) return;
|
|
588
|
+
const idx = frameIndex++;
|
|
589
|
+
const path$1 = path.join(dir, `frame_${String(idx).padStart(6, "0")}.${ext}`);
|
|
590
|
+
const tMs = loopStart - startedAtMs;
|
|
591
|
+
writes.push(
|
|
592
|
+
promises.writeFile(path$1, buf).then(
|
|
593
|
+
() => {
|
|
594
|
+
frames.push({ path: path$1, tMs });
|
|
595
|
+
},
|
|
596
|
+
(err) => {
|
|
597
|
+
console.warn(`humanjs capture: write failed for frame ${idx}:`, err);
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (stopped) return;
|
|
603
|
+
console.warn("humanjs capture: screenshot failed, stopping loop:", err);
|
|
604
|
+
stopped = true;
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const elapsed = Date.now() - loopStart;
|
|
608
|
+
const wait = intervalMs - elapsed;
|
|
609
|
+
if (wait > 0) await core.sleep(wait);
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
const loopPromise = captureLoop();
|
|
613
|
+
const finish = async () => {
|
|
614
|
+
stopped = true;
|
|
615
|
+
await loopPromise;
|
|
616
|
+
await Promise.allSettled(writes);
|
|
617
|
+
};
|
|
618
|
+
return {
|
|
619
|
+
async stop() {
|
|
620
|
+
await finish();
|
|
621
|
+
const stoppedAtMs = Date.now();
|
|
622
|
+
return {
|
|
623
|
+
dir,
|
|
624
|
+
frames: [...frames].sort((a, b) => a.tMs - b.tMs),
|
|
625
|
+
startedAtMs,
|
|
626
|
+
stoppedAtMs,
|
|
627
|
+
format,
|
|
628
|
+
fps,
|
|
629
|
+
cleanup: () => promises.rm(dir, { recursive: true, force: true }).then(() => void 0)
|
|
630
|
+
};
|
|
631
|
+
},
|
|
632
|
+
async abort() {
|
|
633
|
+
await finish();
|
|
634
|
+
await promises.rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
var RESERVED_TARGETS = /* @__PURE__ */ new Set(["natural", "end", "top"]);
|
|
639
|
+
async function executeScroll(target, ctx, options = {}) {
|
|
640
|
+
const { page, personality, rng, speed } = ctx;
|
|
641
|
+
const speedFactor = speedModeFactor(speed);
|
|
642
|
+
const axis = options.axis ?? "y";
|
|
643
|
+
const container = resolveWithin(options.within, ctx);
|
|
644
|
+
const geom = container ? await readContainerGeometry(container, axis) : await readWindowGeometry(page, axis);
|
|
645
|
+
if (!geom) {
|
|
646
|
+
return { from: 0, to: 0, distance: 0, durationMs: 0 };
|
|
647
|
+
}
|
|
648
|
+
const from = geom.current;
|
|
649
|
+
const targetPos = await resolveTarget(target, ctx, geom, container, axis, options.block);
|
|
650
|
+
const to = clamp2(targetPos, 0, Math.max(0, geom.total - geom.viewport));
|
|
651
|
+
const distance = to - from;
|
|
652
|
+
if (distance === 0) {
|
|
653
|
+
return { from, to, distance: 0, durationMs: 0 };
|
|
654
|
+
}
|
|
655
|
+
if (speed === "instant") {
|
|
656
|
+
if (container) {
|
|
657
|
+
await container.evaluate(
|
|
658
|
+
(el, args) => {
|
|
659
|
+
const a = args;
|
|
660
|
+
if (a.axis === "x") el.scrollTo(a.pos, el.scrollTop);
|
|
661
|
+
else el.scrollTo(el.scrollLeft, a.pos);
|
|
662
|
+
},
|
|
663
|
+
{ axis, pos: to }
|
|
664
|
+
);
|
|
665
|
+
} else {
|
|
666
|
+
await page.evaluate(
|
|
667
|
+
(args) => {
|
|
668
|
+
if (args.axis === "x") window.scrollTo(args.pos, window.scrollY);
|
|
669
|
+
else window.scrollTo(window.scrollX, args.pos);
|
|
670
|
+
},
|
|
671
|
+
{ axis, pos: to }
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
return { from, to, distance, durationMs: 0 };
|
|
675
|
+
}
|
|
676
|
+
const segments = core.planScroll(from, to, personality.scroll, rng, {
|
|
677
|
+
forceOvershoot: options.overshoot,
|
|
678
|
+
withPauses: options.withPauses,
|
|
679
|
+
personalitySpeed: personality.speed,
|
|
680
|
+
speedFactor
|
|
681
|
+
});
|
|
682
|
+
if (container && geom.hover) {
|
|
683
|
+
await page.mouse.move(geom.hover.x, geom.hover.y);
|
|
684
|
+
}
|
|
685
|
+
const startedAt = Date.now();
|
|
686
|
+
await walkSegments(page, segments, axis, container);
|
|
687
|
+
const durationMs = Date.now() - startedAt;
|
|
688
|
+
return { from, to, distance, durationMs };
|
|
689
|
+
}
|
|
690
|
+
function resolveWithin(within, ctx) {
|
|
691
|
+
if (!within) return null;
|
|
692
|
+
return typeof within === "string" ? ctx.page.locator(within) : within;
|
|
693
|
+
}
|
|
694
|
+
async function readWindowGeometry(page, axis) {
|
|
695
|
+
const g = await page.evaluate((a) => {
|
|
696
|
+
if (a === "x") {
|
|
697
|
+
return {
|
|
698
|
+
current: window.scrollX,
|
|
699
|
+
viewport: window.innerWidth,
|
|
700
|
+
total: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0)
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
current: window.scrollY,
|
|
705
|
+
viewport: window.innerHeight,
|
|
706
|
+
total: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0)
|
|
707
|
+
};
|
|
708
|
+
}, axis);
|
|
709
|
+
return { current: g.current, viewport: g.viewport, total: g.total };
|
|
710
|
+
}
|
|
711
|
+
async function readContainerGeometry(container, axis) {
|
|
712
|
+
return container.evaluate((el, a) => {
|
|
713
|
+
const rect = el.getBoundingClientRect();
|
|
714
|
+
const isX = a === "x";
|
|
715
|
+
return {
|
|
716
|
+
current: isX ? el.scrollLeft : el.scrollTop,
|
|
717
|
+
viewport: isX ? el.clientWidth : el.clientHeight,
|
|
718
|
+
total: isX ? el.scrollWidth : el.scrollHeight,
|
|
719
|
+
hover: {
|
|
720
|
+
x: rect.left + rect.width / 2,
|
|
721
|
+
y: rect.top + rect.height / 2
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}, axis).catch(() => null);
|
|
725
|
+
}
|
|
726
|
+
async function resolveTarget(target, ctx, geom, container, axis, block = "start") {
|
|
727
|
+
if (target === void 0 || target === "natural") return geom.current + geom.viewport;
|
|
728
|
+
if (target === "end") return geom.total;
|
|
729
|
+
if (target === "top") return 0;
|
|
730
|
+
if (typeof target === "object" && "by" in target) return geom.current + target.by;
|
|
731
|
+
if (typeof target === "object" && "to" in target) return target.to;
|
|
732
|
+
const elementLocator = typeof target === "string" && !RESERVED_TARGETS.has(target) ? ctx.page.locator(target) : typeof target === "string" ? null : target;
|
|
733
|
+
if (!elementLocator) return geom.current + geom.viewport;
|
|
734
|
+
return container ? resolveElementWithinContainer(elementLocator, container, geom, axis, block) : resolveElementInWindow(elementLocator, geom, axis, block);
|
|
735
|
+
}
|
|
736
|
+
async function resolveElementInWindow(elementLocator, geom, axis, block) {
|
|
737
|
+
const rect = await elementLocator.boundingBox().catch(() => null);
|
|
738
|
+
if (!rect) return geom.current;
|
|
739
|
+
const relStart = axis === "x" ? rect.x : rect.y;
|
|
740
|
+
const length = axis === "x" ? rect.width : rect.height;
|
|
741
|
+
const absoluteStart = geom.current + relStart;
|
|
742
|
+
const absoluteEnd = absoluteStart + length;
|
|
743
|
+
if (block === "start") return absoluteStart;
|
|
744
|
+
if (block === "end") return absoluteEnd - geom.viewport;
|
|
745
|
+
if (block === "nearest") {
|
|
746
|
+
if (relStart >= 0 && relStart + length <= geom.viewport) return geom.current;
|
|
747
|
+
if (relStart < 0) return absoluteStart;
|
|
748
|
+
return absoluteEnd - geom.viewport;
|
|
749
|
+
}
|
|
750
|
+
return absoluteStart - (geom.viewport - length) / 2;
|
|
751
|
+
}
|
|
752
|
+
async function resolveElementWithinContainer(elementLocator, container, geom, axis, block) {
|
|
753
|
+
const rects = await container.evaluate(
|
|
754
|
+
(containerEl, args) => {
|
|
755
|
+
const elementEl = args.sel ? document.querySelector(args.sel) : null;
|
|
756
|
+
const targetEl = elementEl ?? containerEl.querySelector(":scope > *");
|
|
757
|
+
if (!targetEl) return null;
|
|
758
|
+
const cRect = containerEl.getBoundingClientRect();
|
|
759
|
+
const eRect = targetEl.getBoundingClientRect();
|
|
760
|
+
return args.axis === "x" ? { relStart: eRect.left - cRect.left, length: eRect.width } : { relStart: eRect.top - cRect.top, length: eRect.height };
|
|
761
|
+
},
|
|
762
|
+
{ sel: await locatorSelector(elementLocator), axis }
|
|
763
|
+
).catch(() => null);
|
|
764
|
+
if (!rects) return geom.current;
|
|
765
|
+
const offsetStart = rects.relStart + geom.current;
|
|
766
|
+
const offsetEnd = offsetStart + rects.length;
|
|
767
|
+
if (block === "start") return offsetStart;
|
|
768
|
+
if (block === "end") return offsetEnd - geom.viewport;
|
|
769
|
+
if (block === "nearest") {
|
|
770
|
+
if (rects.relStart >= 0 && rects.relStart + rects.length <= geom.viewport) {
|
|
771
|
+
return geom.current;
|
|
772
|
+
}
|
|
773
|
+
if (rects.relStart < 0) return offsetStart;
|
|
774
|
+
return offsetEnd - geom.viewport;
|
|
775
|
+
}
|
|
776
|
+
return offsetStart - (geom.viewport - rects.length) / 2;
|
|
777
|
+
}
|
|
778
|
+
async function locatorSelector(locator) {
|
|
779
|
+
const s = locator.toString?.();
|
|
780
|
+
if (typeof s !== "string") return null;
|
|
781
|
+
const match = /locator\(['"](.+?)['"]/.exec(s);
|
|
782
|
+
if (!match) return null;
|
|
783
|
+
const raw = match[1] ?? "";
|
|
784
|
+
const eq = raw.indexOf("=");
|
|
785
|
+
return eq > 0 && /^[a-z]+$/.test(raw.slice(0, eq)) ? raw.slice(eq + 1) : raw;
|
|
786
|
+
}
|
|
787
|
+
async function walkSegments(page, segments, axis, container) {
|
|
788
|
+
for (const segment of segments) {
|
|
789
|
+
if (segment.delayBeforeMs > 0) await sleep(segment.delayBeforeMs);
|
|
790
|
+
if (segment.delta === 0) continue;
|
|
791
|
+
if (container) {
|
|
792
|
+
await container.evaluate(
|
|
793
|
+
(el, args) => {
|
|
794
|
+
const a = args;
|
|
795
|
+
if (a.axis === "x") el.scrollLeft += a.delta;
|
|
796
|
+
else el.scrollTop += a.delta;
|
|
797
|
+
},
|
|
798
|
+
{ axis, delta: segment.delta }
|
|
799
|
+
);
|
|
800
|
+
} else if (axis === "x") {
|
|
801
|
+
await page.mouse.wheel(segment.delta, 0);
|
|
802
|
+
} else {
|
|
803
|
+
await page.mouse.wheel(0, segment.delta);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function clamp2(value, min, max) {
|
|
808
|
+
return value < min ? min : value > max ? max : value;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/mouse-helper/index.ts
|
|
812
|
+
var CURSOR_PATH = "M 0 0 L 16 6 L 8 9.5 L 5 19 Z";
|
|
813
|
+
var INSTALLED_FLAG = /* @__PURE__ */ Symbol.for("@humanjs/playwright:mouse-helper:installed");
|
|
814
|
+
async function installMouseHelper(target, options = {}) {
|
|
815
|
+
const tagged = target;
|
|
816
|
+
if (tagged[INSTALLED_FLAG]) return;
|
|
817
|
+
tagged[INSTALLED_FLAG] = true;
|
|
818
|
+
const config = {
|
|
819
|
+
color: options.color ?? "#f5a55c",
|
|
820
|
+
stroke: "#020203",
|
|
821
|
+
size: options.size ?? 22,
|
|
822
|
+
showClicks: options.showClicks ?? true,
|
|
823
|
+
haloOpacity: options.haloOpacity ?? 0.18,
|
|
824
|
+
path: CURSOR_PATH
|
|
825
|
+
};
|
|
826
|
+
await target.addInitScript(installScript, config);
|
|
827
|
+
const attachPageHooks = (page) => {
|
|
828
|
+
page.on("domcontentloaded", () => {
|
|
829
|
+
page.evaluate(installScript, config).catch(() => void 0);
|
|
830
|
+
});
|
|
831
|
+
};
|
|
832
|
+
const pages = "pages" in target ? target.pages() : [target];
|
|
833
|
+
for (const page of pages) attachPageHooks(page);
|
|
834
|
+
if ("on" in target && "newPage" in target) {
|
|
835
|
+
target.on("page", attachPageHooks);
|
|
836
|
+
}
|
|
837
|
+
await Promise.all(
|
|
838
|
+
pages.map((page) => page.evaluate(installScript, config).catch(() => void 0))
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
function installScript(config) {
|
|
842
|
+
if (document.querySelector("[data-humanjs-cursor]")) return;
|
|
843
|
+
const attach = () => {
|
|
844
|
+
const cursor = document.createElement("div");
|
|
845
|
+
cursor.setAttribute("aria-hidden", "true");
|
|
846
|
+
cursor.setAttribute("data-humanjs-cursor", "true");
|
|
847
|
+
cursor.style.cssText = [
|
|
848
|
+
"position: fixed",
|
|
849
|
+
"left: 0",
|
|
850
|
+
"top: 0",
|
|
851
|
+
`width: ${config.size}px`,
|
|
852
|
+
`height: ${config.size + 4}px`,
|
|
853
|
+
"pointer-events: none",
|
|
854
|
+
"z-index: 2147483647",
|
|
855
|
+
// Start visible at (0, 0) so the cursor is on screen from the moment
|
|
856
|
+
// the page loads — without this the helper looks like nothing happened
|
|
857
|
+
// until the first mousemove arrives.
|
|
858
|
+
"opacity: 1",
|
|
859
|
+
"transform: translate(0px, 0px)",
|
|
860
|
+
// CSS interpolates between successive `mousemove` updates so the
|
|
861
|
+
// cursor reads as continuous motion instead of discrete hops. Slightly
|
|
862
|
+
// longer than the path-walker's typical step interval (~30–80ms) so
|
|
863
|
+
// each tween is still settling when the next move lands → no pauses.
|
|
864
|
+
"transition: transform 110ms ease-out, opacity 0.18s ease-out",
|
|
865
|
+
"will-change: transform"
|
|
866
|
+
].join("; ");
|
|
867
|
+
const haloRadius = Math.round(config.size * 0.6);
|
|
868
|
+
cursor.innerHTML = `
|
|
869
|
+
<svg width="${config.size}" height="${config.size + 4}" viewBox="0 0 22 24" style="overflow: visible;">
|
|
870
|
+
<circle cx="0" cy="0" r="${haloRadius}" fill="${config.color}" opacity="${config.haloOpacity}" />
|
|
871
|
+
<path d="${config.path}" fill="${config.color}" stroke="${config.stroke}" stroke-width="0.7" stroke-linejoin="round" />
|
|
872
|
+
</svg>
|
|
873
|
+
`;
|
|
874
|
+
document.body.appendChild(cursor);
|
|
875
|
+
let lastX = 0;
|
|
876
|
+
let lastY = 0;
|
|
877
|
+
const onMove = (e) => {
|
|
878
|
+
lastX = e.clientX;
|
|
879
|
+
lastY = e.clientY;
|
|
880
|
+
cursor.style.transform = `translate(${lastX}px, ${lastY}px)`;
|
|
881
|
+
cursor.style.opacity = "1";
|
|
882
|
+
};
|
|
883
|
+
window.addEventListener("mousemove", onMove, { capture: true, passive: true });
|
|
884
|
+
document.addEventListener("mousemove", onMove, { capture: true, passive: true });
|
|
885
|
+
document.addEventListener(
|
|
886
|
+
"mouseleave",
|
|
887
|
+
() => {
|
|
888
|
+
cursor.style.opacity = "0";
|
|
889
|
+
},
|
|
890
|
+
{ capture: true, passive: true }
|
|
891
|
+
);
|
|
892
|
+
if (config.showClicks) {
|
|
893
|
+
const styleEl = document.createElement("style");
|
|
894
|
+
styleEl.textContent = "@keyframes humanjs-ripple { 0% { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; } }";
|
|
895
|
+
document.head.appendChild(styleEl);
|
|
896
|
+
window.addEventListener(
|
|
897
|
+
"mousedown",
|
|
898
|
+
() => {
|
|
899
|
+
const ripple = document.createElement("div");
|
|
900
|
+
ripple.style.cssText = [
|
|
901
|
+
"position: fixed",
|
|
902
|
+
`left: ${lastX}px`,
|
|
903
|
+
`top: ${lastY}px`,
|
|
904
|
+
"width: 28px",
|
|
905
|
+
"height: 28px",
|
|
906
|
+
"border-radius: 50%",
|
|
907
|
+
`border: 1.5px solid ${config.color}`,
|
|
908
|
+
"pointer-events: none",
|
|
909
|
+
"z-index: 2147483646",
|
|
910
|
+
"animation: humanjs-ripple 0.45s ease-out forwards"
|
|
911
|
+
].join("; ");
|
|
912
|
+
document.body.appendChild(ripple);
|
|
913
|
+
window.setTimeout(() => ripple.remove(), 500);
|
|
914
|
+
},
|
|
915
|
+
{ capture: true, passive: true }
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
if (document.body) attach();
|
|
920
|
+
else document.addEventListener("DOMContentLoaded", attach, { once: true });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/index.ts
|
|
107
924
|
async function createHuman(page, options = {}) {
|
|
108
925
|
const personality = core.resolvePersonality(options.personality ?? "careful");
|
|
109
926
|
const rng = core.createRng(options.seed);
|
|
@@ -113,6 +930,9 @@ async function createHuman(page, options = {}) {
|
|
|
113
930
|
for (const plugin of plugins) {
|
|
114
931
|
await plugin.install?.(context);
|
|
115
932
|
}
|
|
933
|
+
let hasRecorded = false;
|
|
934
|
+
let activeRecordingEvents = null;
|
|
935
|
+
let activeRecordingStartMs = 0;
|
|
116
936
|
async function performAction(action, actionFn) {
|
|
117
937
|
for (const plugin of plugins) {
|
|
118
938
|
await plugin.beforeAction?.(action);
|
|
@@ -120,15 +940,30 @@ async function createHuman(page, options = {}) {
|
|
|
120
940
|
const startedAt = Date.now();
|
|
121
941
|
try {
|
|
122
942
|
const value = await actionFn();
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
943
|
+
const durationMs = Date.now() - startedAt;
|
|
944
|
+
const result = { type: action.type, durationMs };
|
|
945
|
+
if (activeRecordingEvents !== null && action.type !== "record") {
|
|
946
|
+
activeRecordingEvents.push({
|
|
947
|
+
type: action.type,
|
|
948
|
+
params: action.params ?? {},
|
|
949
|
+
tMs: startedAt - activeRecordingStartMs,
|
|
950
|
+
durationMs
|
|
951
|
+
});
|
|
952
|
+
}
|
|
127
953
|
for (const plugin of plugins) {
|
|
128
954
|
await plugin.afterAction?.(action, result);
|
|
129
955
|
}
|
|
130
956
|
return value;
|
|
131
957
|
} catch (error) {
|
|
958
|
+
if (activeRecordingEvents !== null && action.type !== "record") {
|
|
959
|
+
activeRecordingEvents.push({
|
|
960
|
+
type: action.type,
|
|
961
|
+
params: action.params ?? {},
|
|
962
|
+
tMs: startedAt - activeRecordingStartMs,
|
|
963
|
+
durationMs: Date.now() - startedAt,
|
|
964
|
+
error: error instanceof Error ? error.message : String(error)
|
|
965
|
+
});
|
|
966
|
+
}
|
|
132
967
|
for (const plugin of plugins) {
|
|
133
968
|
await plugin.onError?.(action, error);
|
|
134
969
|
}
|
|
@@ -158,9 +993,117 @@ async function createHuman(page, options = {}) {
|
|
|
158
993
|
}
|
|
159
994
|
});
|
|
160
995
|
});
|
|
996
|
+
},
|
|
997
|
+
async type(target, value) {
|
|
998
|
+
const description = typeof target === "string" ? target : target.toString?.() ?? "locator";
|
|
999
|
+
await performAction(
|
|
1000
|
+
{ type: "type", params: { target: description, length: value.length } },
|
|
1001
|
+
async () => {
|
|
1002
|
+
await executeType(target, value, { page, personality, rng, speed });
|
|
1003
|
+
}
|
|
1004
|
+
);
|
|
1005
|
+
},
|
|
1006
|
+
async read(target, options2) {
|
|
1007
|
+
const description = describeReadTarget(target);
|
|
1008
|
+
return performAction(
|
|
1009
|
+
{
|
|
1010
|
+
type: "read",
|
|
1011
|
+
params: {
|
|
1012
|
+
target: description,
|
|
1013
|
+
kind: options2?.kind
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
() => executeRead(
|
|
1017
|
+
target,
|
|
1018
|
+
{
|
|
1019
|
+
page,
|
|
1020
|
+
personality,
|
|
1021
|
+
rng,
|
|
1022
|
+
speed,
|
|
1023
|
+
// Read shares the session's tracked cursor position so an eye
|
|
1024
|
+
// scan starts from where the last click left off, and the next
|
|
1025
|
+
// click starts from where the scan ended.
|
|
1026
|
+
getMousePosition: () => lastMousePosition,
|
|
1027
|
+
setMousePosition: (point) => {
|
|
1028
|
+
lastMousePosition = point;
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
options2
|
|
1032
|
+
)
|
|
1033
|
+
);
|
|
1034
|
+
},
|
|
1035
|
+
async scroll(target, options2) {
|
|
1036
|
+
const description = describeScrollTarget(target);
|
|
1037
|
+
return performAction(
|
|
1038
|
+
{
|
|
1039
|
+
type: "scroll",
|
|
1040
|
+
params: { target: description }
|
|
1041
|
+
},
|
|
1042
|
+
() => executeScroll(target, { page, personality, rng, speed }, options2)
|
|
1043
|
+
);
|
|
1044
|
+
},
|
|
1045
|
+
async sleep(ms) {
|
|
1046
|
+
await performAction({ type: "sleep", params: { ms } }, () => core.sleep(ms));
|
|
1047
|
+
},
|
|
1048
|
+
async record(optionsOrFn, maybeFn) {
|
|
1049
|
+
const [recordOptions, fn] = typeof optionsOrFn === "function" ? [{}, optionsOrFn] : [optionsOrFn, maybeFn];
|
|
1050
|
+
if (hasRecorded) {
|
|
1051
|
+
throw new Error(
|
|
1052
|
+
"human.record() can only be called once per session. Create a new browser context (and a new human session) to record a separate clip."
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
hasRecorded = true;
|
|
1056
|
+
const captureEnabled = recordOptions.video !== false;
|
|
1057
|
+
const captureQuality = recordOptions.quality ?? "high";
|
|
1058
|
+
let captureSession = null;
|
|
1059
|
+
if (captureEnabled) {
|
|
1060
|
+
const { format, quality, fps } = getCaptureSettingsForQuality(captureQuality);
|
|
1061
|
+
captureSession = await startCapture(page, { format, quality, fps });
|
|
1062
|
+
}
|
|
1063
|
+
const events = [];
|
|
1064
|
+
const windowStartMs = Date.now();
|
|
1065
|
+
activeRecordingEvents = events;
|
|
1066
|
+
activeRecordingStartMs = windowStartMs;
|
|
1067
|
+
let windowEndMs = windowStartMs;
|
|
1068
|
+
try {
|
|
1069
|
+
await performAction({ type: "record", params: {} }, async () => {
|
|
1070
|
+
try {
|
|
1071
|
+
await fn();
|
|
1072
|
+
} finally {
|
|
1073
|
+
windowEndMs = Date.now();
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
if (captureSession) await captureSession.abort();
|
|
1078
|
+
throw error;
|
|
1079
|
+
} finally {
|
|
1080
|
+
activeRecordingEvents = null;
|
|
1081
|
+
}
|
|
1082
|
+
const captureResult = captureSession ? await captureSession.stop() : null;
|
|
1083
|
+
return new Recording(captureResult, windowStartMs, windowEndMs, {
|
|
1084
|
+
personality: personality.name,
|
|
1085
|
+
seed: options.seed === void 0 ? null : String(options.seed),
|
|
1086
|
+
speed,
|
|
1087
|
+
events
|
|
1088
|
+
});
|
|
161
1089
|
}
|
|
162
1090
|
};
|
|
163
1091
|
}
|
|
1092
|
+
function describeScrollTarget(target) {
|
|
1093
|
+
if (target === void 0) return "natural";
|
|
1094
|
+
if (typeof target === "string") return target;
|
|
1095
|
+
if ("by" in target) return `by:${target.by}`;
|
|
1096
|
+
if ("to" in target) return `to:${target.to}`;
|
|
1097
|
+
return target.toString?.() ?? "locator";
|
|
1098
|
+
}
|
|
1099
|
+
function describeReadTarget(target) {
|
|
1100
|
+
if (typeof target === "string") return target;
|
|
1101
|
+
if ("words" in target && typeof target.words === "number") return `${target.words} words`;
|
|
1102
|
+
if ("text" in target && typeof target.text === "string") {
|
|
1103
|
+
return `text:${target.text.length} chars`;
|
|
1104
|
+
}
|
|
1105
|
+
return target.toString?.() ?? "locator";
|
|
1106
|
+
}
|
|
164
1107
|
|
|
165
1108
|
Object.defineProperty(exports, "applyMicroJitter", {
|
|
166
1109
|
enumerable: true,
|
|
@@ -182,6 +1125,14 @@ Object.defineProperty(exports, "careful", {
|
|
|
182
1125
|
enumerable: true,
|
|
183
1126
|
get: function () { return core.careful; }
|
|
184
1127
|
});
|
|
1128
|
+
Object.defineProperty(exports, "computeReadingDwellMs", {
|
|
1129
|
+
enumerable: true,
|
|
1130
|
+
get: function () { return core.computeReadingDwellMs; }
|
|
1131
|
+
});
|
|
1132
|
+
Object.defineProperty(exports, "countWords", {
|
|
1133
|
+
enumerable: true,
|
|
1134
|
+
get: function () { return core.countWords; }
|
|
1135
|
+
});
|
|
185
1136
|
Object.defineProperty(exports, "createRng", {
|
|
186
1137
|
enumerable: true,
|
|
187
1138
|
get: function () { return core.createRng; }
|
|
@@ -198,6 +1149,14 @@ Object.defineProperty(exports, "humanizePath", {
|
|
|
198
1149
|
enumerable: true,
|
|
199
1150
|
get: function () { return core.humanizePath; }
|
|
200
1151
|
});
|
|
1152
|
+
Object.defineProperty(exports, "planScroll", {
|
|
1153
|
+
enumerable: true,
|
|
1154
|
+
get: function () { return core.planScroll; }
|
|
1155
|
+
});
|
|
1156
|
+
Object.defineProperty(exports, "planTypeKeystrokes", {
|
|
1157
|
+
enumerable: true,
|
|
1158
|
+
get: function () { return core.planTypeKeystrokes; }
|
|
1159
|
+
});
|
|
201
1160
|
Object.defineProperty(exports, "precise", {
|
|
202
1161
|
enumerable: true,
|
|
203
1162
|
get: function () { return core.precise; }
|
|
@@ -206,6 +1165,24 @@ Object.defineProperty(exports, "resolvePersonality", {
|
|
|
206
1165
|
enumerable: true,
|
|
207
1166
|
get: function () { return core.resolvePersonality; }
|
|
208
1167
|
});
|
|
1168
|
+
Object.defineProperty(exports, "sleep", {
|
|
1169
|
+
enumerable: true,
|
|
1170
|
+
get: function () { return core.sleep; }
|
|
1171
|
+
});
|
|
1172
|
+
Object.defineProperty(exports, "chromium", {
|
|
1173
|
+
enumerable: true,
|
|
1174
|
+
get: function () { return playwright.chromium; }
|
|
1175
|
+
});
|
|
1176
|
+
Object.defineProperty(exports, "firefox", {
|
|
1177
|
+
enumerable: true,
|
|
1178
|
+
get: function () { return playwright.firefox; }
|
|
1179
|
+
});
|
|
1180
|
+
Object.defineProperty(exports, "webkit", {
|
|
1181
|
+
enumerable: true,
|
|
1182
|
+
get: function () { return playwright.webkit; }
|
|
1183
|
+
});
|
|
1184
|
+
exports.Recording = Recording;
|
|
209
1185
|
exports.createHuman = createHuman;
|
|
1186
|
+
exports.installMouseHelper = installMouseHelper;
|
|
210
1187
|
//# sourceMappingURL=index.cjs.map
|
|
211
1188
|
//# sourceMappingURL=index.cjs.map
|