Crawl a website and capture full-page screenshots (PNG / real WebP / JPEG) and optional multi-quality videos for every reachable internal route β driven by Playwright, orchestrated by Effect.
π API docs Β· π·οΈ Releases Β· π Issues
bun add -g @elysiumoss/ui-capture
bunx playwright install chromium
ui-capture https://example.com --max-depth 1 --video
Outputs land in ./ui-captures/ with REPORT.md, capture-report.json, and per-route screenshots / videos at every configured viewport.
flowchart LR
Seed["Seed URL"] --> Discover["Link discovery
(anchors + framework globals)"]
Discover --> Filter["Asset + host filter"]
Filter --> Queue["Bounded queue
(routeConcurrency)"]
Queue --> Workers["Worker pool
(per-context Page)"]
Workers --> Warmup["Warm-up scroll
(triggers lazy-loads)"]
Warmup --> Shots["PNG screenshot
(buffer in memory)"]
Shots --> Pipe["ffmpeg -f image2pipe
libwebp β real WebP"]
Shots --> Jpg["Playwright JPEG"]
Workers --> Video["Optional video
recordVideo + ffmpeg transcodes"]
Workers --> Report["capture-report.json + REPORT.md"]
sequenceDiagram
autonumber
participant W as Worker
participant Q as Queue
participant P as Page
participant FS as Filesystem
participant FF as ffmpeg
W->>Q: take task
Q-->>W: RouteTask{url, depth}
W->>P: page.goto(url, "networkidle")
P-->>W: load complete
W->>P: prepareForLinkDiscovery + extractLinks
P-->>W: internal URLs
W->>P: warm-up scroll (topβbottomβtop)
loop each viewport
W->>P: setViewportSize(w, h)
W->>P: screenshot(png) β Buffer
W->>FS: write PNG (latest + history)
W->>FF: pipe PNG β libwebp
FF-->>FS: real WebP
W->>P: screenshot(jpeg)
W->>FS: write JPEG
end
W->>Q: schedule discovered links
W->>Q: markTaskComplete
stateDiagram-v2
[*] --> Booting
Booting --> Idle: context + page acquired
Idle --> Processing: take(RouteTask)
Processing --> Idle: capturePage done
markTaskComplete
Processing --> Idle: failure caught
(Effect.catchAll)
Idle --> ShuttingDown: take(ShutdownSignal)
Processing --> ShuttingDown: pendingTasks==0
signalShutdown
ShuttingDown --> [*]: release: context.close
flowchart LR
argv["process.argv.slice(2)"] --> parse["parseCliArgs
(BOOLEAN_FLAGS aware)"]
parse --> build["buildInvocation
(URL check + list/integer parsers)"]
build --> over["CaptureConfigOverrides"]
over --> live["CaptureConfigLive(overrides)
= Layer.succeed(CaptureConfigTag, β¦)"]
live --> svc["UICaptureService.Default
(consumes CaptureConfigTag)"]
svc --> cap["service.captureWebsite(url)"]
.webp-extension.
One PNG capture per page is piped through ffmpeg --libwebp for a real WebP, plus Playwright's native JPEG.
Three formats, one screenshot.__NEXT_DATA__, __NUXT__, __SAPPER__), then drops anything that looks like a static asset (.css, .js, .svg, .webmanifest, fonts, media, archives).recordVideo master at 1Γ, plus ffmpeg --libvpx-vp9 transcodes at 0.75Γ and 0.5Γ scale.bunx playwright install chromium)ffmpeg on PATH (or pass --ffmpeg /path/to/binary)# Global CLI
bun add -g @elysiumoss/ui-capture
# Or as a project dependency
bun add @elysiumoss/ui-capture
# or
npm i @elysiumoss/ui-capture
Then once per machine:
bunx playwright install chromium
Usage: ui-capture <url> [options]
Arguments:
<url> Starting URL to crawl
Options:
--output-dir <path> Output directory (default: ./ui-captures)
--max-depth <n> Maximum crawl depth (default: 2)
--wait <ms> Per-page wait after networkidle (default: 2000)
--concurrency <n> Parallel route workers (default: 2)
--include-subdomains Crawl subdomains of the starting host
--allowed-hosts <a,b,...> Extra allowed hostnames (comma separated)
--viewports <spec,spec> Viewport specs as name:WIDTHxHEIGHT
(default: desktop:1920x1080,tablet:768x1024,mobile:375x667)
--hide <sel,sel,...> CSS selectors to hide before screenshotting
--menu-selectors <sel,...> Selectors to click before link discovery
--video Capture videos in addition to screenshots
--video-duration <ms> Video duration when --video (default: 10000)
--no-interactions Disable scripted scrolling during video
--no-warmup Skip the pre-screenshot warm-up scroll
--ffmpeg <path> ffmpeg binary path (default: ffmpeg)
--help Show this message
Examples:
ui-capture https://example.com
ui-capture https://example.com --video --max-depth 1 --concurrency 4
ui-capture https://example.com --viewports desktop:1920x1080,mobile:390x844
ui-capture https://example.com --hide ".cookie-banner,#chat-widget"
import { Effect } from "effect";
import {
CaptureConfigLive,
UICaptureService,
} from "@elysiumoss/ui-capture";
const program = Effect.gen(function* () {
const service = yield* UICaptureService;
return yield* service.captureWebsite("https://example.com");
}).pipe(
Effect.provide(UICaptureService.Default),
Effect.provide(
CaptureConfigLive({
outputDir: "./ui-captures",
maxDepth: 1,
captureVideo: true,
viewports: [
{ name: "desktop", width: 1920, height: 1080 },
{ name: "mobile", width: 390, height: 844 },
],
}),
),
);
const results = await Effect.runPromise(program);
console.log(`Captured ${results.size} routes`);
import { Effect } from "effect";
import {
CaptureConfigLive,
UICaptureService,
} from "@elysiumoss/ui-capture";
const sites = [
{ url: "https://example.com", outputDir: "./out/example" },
{ url: "https://acme.test", outputDir: "./out/acme" },
];
for (const site of sites) {
const program = Effect.gen(function* () {
const svc = yield* UICaptureService;
return yield* svc.captureWebsite(site.url);
}).pipe(
Effect.provide(UICaptureService.Default),
Effect.provide(
CaptureConfigLive({ outputDir: site.outputDir, maxDepth: 1 }),
),
);
await Effect.runPromise(program);
}
import { Effect } from "effect";
import {
BrowserError,
CaptureConfigLive,
CaptureError,
FileSystemError,
UICaptureService,
} from "@elysiumoss/ui-capture";
const program = Effect.gen(function* () {
const svc = yield* UICaptureService;
return yield* svc.captureWebsite("https://example.com");
}).pipe(
Effect.catchTag("BrowserError", (e: BrowserError) =>
Effect.logError(`browser failed: ${e.message}`).pipe(Effect.as(null)),
),
Effect.catchTag("CaptureError", (e: CaptureError) =>
Effect.logError(`capture failed at ${e.url}: ${e.message}`).pipe(
Effect.as(null),
),
),
Effect.catchTag("FileSystemError", (e: FileSystemError) =>
Effect.logError(`fs failed at ${e.path} (${e.operation})`).pipe(
Effect.as(null),
),
),
Effect.provide(UICaptureService.Default),
Effect.provide(CaptureConfigLive({ outputDir: "./out" })),
);
classDiagram
class TaggedError {
<>
+_tag: string
}
class BrowserError {
+_tag: "BrowserError"
+message: string
+cause: unknown
}
class CaptureError {
+_tag: "CaptureError"
+url: string
+message: string
+cause: unknown
}
class FileSystemError {
+_tag: "FileSystemError"
+path: string
+operation: string
+cause: unknown
}
TaggedError <|-- BrowserError
TaggedError <|-- CaptureError
TaggedError <|-- FileSystemError
All three errors are S.TaggedError subclasses, so they discriminate cleanly under Effect.catchTag / Effect.catchTags.
CaptureConfig fields and the matching CLI flag:
| Field | CLI flag | Type | Default | Notes |
|---|---|---|---|---|
outputDir |
--output-dir |
string |
"ui-captures" |
Resolved against cwd when set via CLI. |
maxDepth |
--max-depth |
int β₯ 0 |
2 |
0 captures only the seed URL. |
waitTime |
--wait |
int β₯ 0 (ms) |
2000 |
Settle time after networkidle. |
routeConcurrency |
--concurrency |
int β₯ 1 |
2 |
Worker pool size; each holds its own browser context. |
includeSubdomains |
--include-subdomains |
boolean |
false |
Subdomain match excludes bare TLDs (no leak across .com). |
allowedHosts |
--allowed-hosts |
string[] |
[] |
Extra hostnames in addition to the seed host. |
viewports |
--viewports |
ViewportConfig[] |
desktop / tablet / mobile defaults | Each viewport produces its own PNG/WebP/JPEG triple. |
captureVideo |
--video |
boolean |
false |
Video adds 5β15 s per route Γ per viewport. |
videoOptions.duration |
--video-duration |
int β₯ 1 (ms) |
10000 |
Total recorded duration for the master capture. |
videoOptions.interactions |
--no-interactions (Β¬) |
boolean |
true |
Auto-scrolls during recording so dynamic content shows. |
warmupScroll |
--no-warmup (Β¬) |
boolean |
true |
Topβbottomβtop scroll before each shot to trigger lazy loads. |
screenshotHideSelectors |
--hide |
string[] (CSS selectors) |
[] |
Hidden via injected visibility:hidden style during capture. |
menuInteractionSelectors |
--menu-selectors |
string[] |
[] |
Clicked before link discovery for collapsed nav menus. |
ffmpegPath |
--ffmpeg |
string |
"ffmpeg" |
Absolute path or anything on PATH. |
(Β¬) means the CLI flag negates the default β e.g. --no-warmup sets warmupScroll: false.
ui-captures/
βββ REPORT.md
βββ capture-report.json
βββ <route-slug>/
βββ screenshots/
β βββ png/
β β βββ desktop_1920x1080_latest.png
β β βββ history/desktop_1920x1080_<timestamp>.png
β βββ webp/ # real WebP via libwebp
β β βββ desktop_1920x1080_latest.webp
β β βββ history/...
β βββ jpg/
β βββ desktop_1920x1080_latest.jpg
β βββ history/...
βββ videos/ # only when --video
βββ high-quality/desktop_1920x1080_<ts>.webm # 1.0Γ scale, master
βββ medium-quality/... # 0.75Γ scale, ffmpeg transcode
βββ low-quality/... # 0.5Γ scale, ffmpeg transcode
The <route-slug> is the URL pathname slugified to filesystem-friendly characters; / becomes root.
capture-report.json shapetype CaptureReport = {
timestamp: string; // ISO-8601
totalRoutes: number;
successfulCaptures: number;
failedCaptures: number;
viewports: { name: string; width: number; height: number }[];
results: Array<{
url: string;
route: string; // route-slug
screenshots: string[]; // viewport names that produced a triple
hasVideo: boolean;
error?: string;
}>;
};
__NEXT_DATA__ don't poison the crawl queue.--no-warmup if it interferes with state-machine sites.Effect.acquireUseRelease releases, so partial failures don't leak Chromium processes.provenance: true β npm publishes are signed with GitHub Actions OIDC; verify with npm audit signatures.The repo ships with two suites:
bun run test β 43 tests across argument parsing, schema defaults / overrides, host-filter, URL utils, and CLI β config wiring.
Runs in under a second.bun run test:integration β spins up a localhost HTML fixture, drives the full Effect pipeline through one viewport, and asserts that PNG / WebP / JPEG / REPORT.md / capture-report.json all land.
Gated by RUN_INTEGRATION=1 so it only runs when explicitly requested.--stitched)PRs welcome.
Please run bun run lint && bun run test --run before opening one.
MIT β see LICENSE.md.