@humanjs/playwright 0.3.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 +91 -5
- package/dist/index.cjs +501 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +243 -30
- package/dist/index.d.ts +243 -30
- package/dist/index.js +483 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -75,15 +75,16 @@ await human.read('ul.changelog', { kind: 'scan' }); // explicit skim
|
|
|
75
75
|
|
|
76
76
|
Explicit `kind` always wins over auto-detection.
|
|
77
77
|
|
|
78
|
-
**
|
|
78
|
+
**Eye-scan cursor motion** runs during the dwell by default:
|
|
79
79
|
|
|
80
80
|
```ts
|
|
81
|
-
await human.read('article'
|
|
81
|
+
await human.read('article'); // motion: on
|
|
82
|
+
await human.read('article', { withMotion: false }); // motion: off
|
|
82
83
|
```
|
|
83
84
|
|
|
84
|
-
The cursor walks a humanized L→R sweep through every line of rendered text and emits a small return-saccade between lines — same `mousemove` events a real reader would dispatch (so reading-time tooltip / hover handlers fire).
|
|
85
|
+
The cursor walks a humanized L→R sweep through every line of rendered text and emits a small return-saccade between lines — same `mousemove` events a real reader would dispatch (so reading-time tooltip / hover handlers fire). Pass `{ withMotion: false }` when you only care about the temporal pattern (typical AI-agent use case).
|
|
85
86
|
|
|
86
|
-
For demos and screen recordings, pair
|
|
87
|
+
For demos and screen recordings, pair it with `installMouseHelper(page)` to render a visible cursor that follows the synthetic motion:
|
|
87
88
|
|
|
88
89
|
```ts
|
|
89
90
|
import { createHuman, installMouseHelper } from '@humanjs/playwright';
|
|
@@ -93,7 +94,7 @@ await page.goto('https://example.com/article');
|
|
|
93
94
|
await installMouseHelper(page);
|
|
94
95
|
|
|
95
96
|
const human = await createHuman(page, { personality: 'careful' });
|
|
96
|
-
await human.read('article'
|
|
97
|
+
await human.read('article');
|
|
97
98
|
```
|
|
98
99
|
|
|
99
100
|
**Returns** a `ReadResult`:
|
|
@@ -171,6 +172,91 @@ In `speed: 'instant'`, the page jumps directly via `window.scrollTo` — no whee
|
|
|
171
172
|
|
|
172
173
|
See [humanjs.dev](https://humanjs.dev) for the full feature set and personality reference.
|
|
173
174
|
|
|
175
|
+
### Recording
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { chromium, createHuman, installMouseHelper } from '@humanjs/playwright';
|
|
179
|
+
|
|
180
|
+
const browser = await chromium.launch({ headless: false });
|
|
181
|
+
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
|
182
|
+
const page = await context.newPage();
|
|
183
|
+
|
|
184
|
+
// Visible cursor overlay so the recorded video shows mouse motion.
|
|
185
|
+
await installMouseHelper(context);
|
|
186
|
+
|
|
187
|
+
const human = await createHuman(page);
|
|
188
|
+
|
|
189
|
+
const rec = await human.record(async () => {
|
|
190
|
+
await human.click('#login');
|
|
191
|
+
await human.type('#email', 'demo@humanjs.dev');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await rec.toVideo('demo.mp4');
|
|
195
|
+
await rec.toTimeline('demo.json');
|
|
196
|
+
await browser.close();
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`human.record(cb)` polls `page.screenshot()` at the target FPS, writes each frame to a temp directory, then assembles them via ffmpeg when you call an exporter:
|
|
200
|
+
|
|
201
|
+
- `rec.toVideo(path)` — `.mp4` (H.264 / yuv420p) or `.webm` (VP9)
|
|
202
|
+
- `rec.toGif(path, { fps?, width? })` — palette-optimized animated GIF (`palettegen` + `paletteuse`, Bayer dither). Defaults to 15 fps, source viewport size.
|
|
203
|
+
|
|
204
|
+
Both exporters are **repeatable and interleavable** — they read the captured frames, they don't consume them. Want an mp4 for the landing page *and* a GIF for the README from the same recording? Just call both:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const rec = await human.record(fn);
|
|
208
|
+
await rec.toVideo('demo.mp4');
|
|
209
|
+
await rec.toGif('demo.gif', { width: 720 });
|
|
210
|
+
// No explicit cleanup needed for one-shot scripts — see below.
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Captured frames live in a temp directory under `os.tmpdir()`. Cleanup happens automatically at process exit (a single `process.on('exit')` handler sweeps any un-disposed frame dirs), so casual scripts don't have to think about it. For long-running services, batch jobs, or anywhere you want predictable disk usage, release them proactively:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
await rec.dispose(); // explicit, idempotent
|
|
217
|
+
// or with TS ≥ 5.2 / Node ≥ 20.4:
|
|
218
|
+
await using rec = await human.record(fn); // auto-disposes at scope exit
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The same `Recording` exposes a **structured action timeline** of everything that happened during the callback:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
await rec.toTimeline('session.json'); // → JSON on disk
|
|
225
|
+
const timeline = rec.timeline; // → in-memory object
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The shape (`Timeline` with `personality`, `seed`, `speed`, `durationMs`, and an `events` array of `{ type, params, tMs, durationMs }`) is intended for observability pipelines, replay infrastructure, analytics, and debugger UIs. `toTimeline()` doesn't touch the browser context — call it before or after `toVideo()`, multiple times, in any order.
|
|
229
|
+
|
|
230
|
+
**Quality presets** trade off file size, encoding time, and visual fidelity. Defaults to `'high'`:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
await rec.toVideo('demo.mp4', { quality: 'high' });
|
|
234
|
+
// 'fast' — JPEG q=85, CRF 23, preset fast (iteration)
|
|
235
|
+
// 'standard' — JPEG q=90, CRF 20, preset fast (balanced)
|
|
236
|
+
// 'high' — JPEG q=95, CRF 18, preset slow, animation (DEFAULT)
|
|
237
|
+
// 'lossless' — PNG capture, CRF 12, preset veryslow (archival)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Individual ffmpeg knobs (`crf`, `preset`, `tune`) can override the preset for fine-grained control.
|
|
241
|
+
|
|
242
|
+
**Timeline-only mode** — skip the capture overhead entirely when you only need the action timeline:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
const rec = await human.record({ video: false }, async () => {
|
|
246
|
+
await human.click('#login');
|
|
247
|
+
});
|
|
248
|
+
await rec.toTimeline('session.json'); // works
|
|
249
|
+
// rec.toVideo('demo.mp4') // throws with a clear message
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Lifecycle notes**:
|
|
253
|
+
|
|
254
|
+
- Each session can produce **one** recording. `human.record()` throws if called twice on the same session — open a new context (and a new human) to record a separate clip.
|
|
255
|
+
- `Recording.toVideo()` / `Recording.toGif()` are repeatable and interleavable. Frames live until `rec.dispose()` (or `await using` goes out of scope, or the process exits — a sweep-on-exit handler covers forgotten disposes).
|
|
256
|
+
- For a one-call API that owns the entire lifecycle (launch → record → close), use [`@humanjs/recorder`](../recorder)'s `record(options, fn)` instead.
|
|
257
|
+
|
|
258
|
+
Every recording is a regular plugin action — `beforeAction` and `afterAction` observe `{ type: 'record' }` exactly like `'click'` or `'scroll'`.
|
|
259
|
+
|
|
174
260
|
## License
|
|
175
261
|
|
|
176
262
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
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
|
|
6
17
|
|
|
@@ -173,7 +184,8 @@ async function executeRead(target, ctx, options = {}) {
|
|
|
173
184
|
personalitySpeed: ctx.personality.speed,
|
|
174
185
|
speedFactor: speedModeFactor(ctx.speed)
|
|
175
186
|
});
|
|
176
|
-
|
|
187
|
+
const withMotion = options.withMotion ?? true;
|
|
188
|
+
if (withMotion && locator && durationMs > 0) {
|
|
177
189
|
const box = await locator.boundingBox().catch(() => null);
|
|
178
190
|
if (box) {
|
|
179
191
|
const lineRects = await getLineRects(locator).catch(() => []);
|
|
@@ -233,6 +245,396 @@ async function detectKindFromTag(locator) {
|
|
|
233
245
|
if (tag === "pre" || tag === "code") return "code";
|
|
234
246
|
return void 0;
|
|
235
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
|
+
};
|
|
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
|
+
}
|
|
236
638
|
var RESERVED_TARGETS = /* @__PURE__ */ new Set(["natural", "end", "top"]);
|
|
237
639
|
async function executeScroll(target, ctx, options = {}) {
|
|
238
640
|
const { page, personality, rng, speed } = ctx;
|
|
@@ -408,7 +810,11 @@ function clamp2(value, min, max) {
|
|
|
408
810
|
|
|
409
811
|
// src/mouse-helper/index.ts
|
|
410
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");
|
|
411
814
|
async function installMouseHelper(target, options = {}) {
|
|
815
|
+
const tagged = target;
|
|
816
|
+
if (tagged[INSTALLED_FLAG]) return;
|
|
817
|
+
tagged[INSTALLED_FLAG] = true;
|
|
412
818
|
const config = {
|
|
413
819
|
color: options.color ?? "#f5a55c",
|
|
414
820
|
stroke: "#020203",
|
|
@@ -418,16 +824,22 @@ async function installMouseHelper(target, options = {}) {
|
|
|
418
824
|
path: CURSOR_PATH
|
|
419
825
|
};
|
|
420
826
|
await target.addInitScript(installScript, config);
|
|
827
|
+
const attachPageHooks = (page) => {
|
|
828
|
+
page.on("domcontentloaded", () => {
|
|
829
|
+
page.evaluate(installScript, config).catch(() => void 0);
|
|
830
|
+
});
|
|
831
|
+
};
|
|
421
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
|
+
}
|
|
422
837
|
await Promise.all(
|
|
423
838
|
pages.map((page) => page.evaluate(installScript, config).catch(() => void 0))
|
|
424
839
|
);
|
|
425
840
|
}
|
|
426
841
|
function installScript(config) {
|
|
427
|
-
|
|
428
|
-
const w = window;
|
|
429
|
-
if (w[guardKey]) return;
|
|
430
|
-
w[guardKey] = true;
|
|
842
|
+
if (document.querySelector("[data-humanjs-cursor]")) return;
|
|
431
843
|
const attach = () => {
|
|
432
844
|
const cursor = document.createElement("div");
|
|
433
845
|
cursor.setAttribute("aria-hidden", "true");
|
|
@@ -518,6 +930,9 @@ async function createHuman(page, options = {}) {
|
|
|
518
930
|
for (const plugin of plugins) {
|
|
519
931
|
await plugin.install?.(context);
|
|
520
932
|
}
|
|
933
|
+
let hasRecorded = false;
|
|
934
|
+
let activeRecordingEvents = null;
|
|
935
|
+
let activeRecordingStartMs = 0;
|
|
521
936
|
async function performAction(action, actionFn) {
|
|
522
937
|
for (const plugin of plugins) {
|
|
523
938
|
await plugin.beforeAction?.(action);
|
|
@@ -525,15 +940,30 @@ async function createHuman(page, options = {}) {
|
|
|
525
940
|
const startedAt = Date.now();
|
|
526
941
|
try {
|
|
527
942
|
const value = await actionFn();
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
}
|
|
532
953
|
for (const plugin of plugins) {
|
|
533
954
|
await plugin.afterAction?.(action, result);
|
|
534
955
|
}
|
|
535
956
|
return value;
|
|
536
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
|
+
}
|
|
537
967
|
for (const plugin of plugins) {
|
|
538
968
|
await plugin.onError?.(action, error);
|
|
539
969
|
}
|
|
@@ -611,6 +1041,51 @@ async function createHuman(page, options = {}) {
|
|
|
611
1041
|
},
|
|
612
1042
|
() => executeScroll(target, { page, personality, rng, speed }, options2)
|
|
613
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
|
+
});
|
|
614
1089
|
}
|
|
615
1090
|
};
|
|
616
1091
|
}
|
|
@@ -690,6 +1165,23 @@ Object.defineProperty(exports, "resolvePersonality", {
|
|
|
690
1165
|
enumerable: true,
|
|
691
1166
|
get: function () { return core.resolvePersonality; }
|
|
692
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;
|
|
693
1185
|
exports.createHuman = createHuman;
|
|
694
1186
|
exports.installMouseHelper = installMouseHelper;
|
|
695
1187
|
//# sourceMappingURL=index.cjs.map
|