Skip to content

feat(cli): add compare — side-by-side against plain Playwright - #126

Open
totigm wants to merge 1 commit into
feat/cli-replayfrom
feat/cli-compare
Open

totigm wants to merge 1 commit into
feat/cli-replayfrom
feat/cli-compare

Conversation

@totigm

@totigm totigm commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Stacked on #125, which is stacked on #118. Base is feat/cli-replay, so the diff shows only the compare work.

CLAUDE.md names a robotic-vs-humanized side-by-side as the hero asset every marketing surface links to. Until now only this repo could produce one, against a synthetic page. This produces one of your site — the version anybody would actually share.

npx @humanjs/cli compare https://your-app.com --record before-after.mp4
recording the Playwright lane (speed: instant)…
recording the HumanJS lane (careful)…
combining…

  before-after.mp4
  Playwright 1.9s · HumanJS 8.6s (4.4× longer, and that is the point)

The robotic lane is real

It is speed: 'instant' — the documented mode that bypasses humanization and runs as plain Playwright. Not a caricature, not a simulation.

That matters, because the obvious alternative was the approach in examples/compare-demo.ts: two DOM cursors driven by bezierPath + humanizePath with the travel-time math copied into the demo, under a comment that reads "Mirrors the travel-time math in @humanjs/playwright's mouse module." Copied math drifts, and a comparison built on a reimplementation proves nothing about the real thing. Here both lanes run the identical tour through the real API — demo's tour is exported and reused, not duplicated — so the only variable on screen is the motion.

The duration gap is the message

The lanes never last the same time. The shorter one is extended with tpad=stop_mode=clone, so the robotic side holds on its final frame while the other is still working, instead of the stack cutting to whichever ended first.

The pad arithmetic is unit-tested in both directions: getting it backwards pads the lane that was already longest, which silently produces a video that says nothing.

Two things that would have broken on someone else's machine

Lane labels are CSS, not ffmpeg. drawtext needs a font file present on the host — the classic works-here-fails-there dependency. body::before via addStyleTag uses fonts the browser already has, and it is injected through a new afterLoad hook on tour so the shared script stays shared.

The scale filter lives inside the graph. The first run died with -vf/-af/-filter and -filter_complex cannot be used together for the same stream. h264 needs even dimensions and two stacked lanes can land on an odd width, so the scale is folded into the complex graph after hstack. There is a test asserting -vf is never passed.

Verification

95 unit tests in @humanjs/cli: pad direction both ways, no-pad when equal, seconds-not-milliseconds, arg shape, CSS escaping of quotes in a label, and the click-through/z-index guarantees.

End to end against https://example.com: a 2560×800, 8.64s, 49 KB mp4. A frame pulled from the middle confirms it — the Playwright cursor is parked and frozen on its target while the HumanJS cursor is still mid-flight across the heading, each under its own label.

Whole suite: lint, typecheck (11/11), test (10/10), build (8/8), check:exports (15/15).

CI will not run here for the same reason as #125 — ci.yml triggers on pull_request: branches: [main], and this targets a branch. It runs for real once the stack lands.

CLAUDE.md names a robotic-vs-humanized side-by-side as the hero asset
every marketing surface links to, and until now only this repo could
make one, against a synthetic page. This makes one of the user's own
site, which is the version anybody would actually share.

The robotic lane is not a simulation: it is speed: 'instant', the
documented mode that bypasses humanization and runs as plain Playwright.
Both lanes execute the identical tour -- demo's tour is exported rather
than copied -- so the only variable on screen is the motion, and no
timing math is reimplemented to fake the difference.

The lanes rarely last the same time. The shorter one holds its final
frame while the other keeps moving, so the asymmetry reads as the point
instead of as a glitch.

Lane labels go through CSS body::before rather than ffmpeg drawtext,
which needs a font file on the host and fails differently everywhere.
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
humanjs Ready Ready Preview Sep 7, 2026 5:42am UTC

This branch was successfully deployed

1 active deployment
Preview — 9033dd0f Deployed Sep 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant