Skip to main content

Render a live website into an off-screen pixel buffer and expose it as a programmable primitive

Project description

wbb — WebView Buffer Bridge

Render a live website into an off-screen pixel buffer and expose it as a programmable primitive.

wbb drives headless Chrome over the Chrome DevTools Protocol (CDP), decodes its screencast stream into RGBA frames, and writes those frames into a POSIX shared-memory double-buffer. Any process, in the same script or a separate one, can attach to that buffer: read frames with zero-copy views, display them in a window, record them to disk or ffmpeg, or run a filter pipeline over them. Mouse, keyboard, and DOM-query input can be forwarded back into the page, so the buffer is a full interactive surface, not just a screenshot source. The point is to get pixels out of a real, JS-executing browser into your own process at low latency, without polling screenshot RPCs and without tying the renderer to the same process as the consumer.

Installation

pip install wbb

System requirements:

  • Chrome or Chromium on PATH (checked names: google-chrome, google-chrome-stable, chromium, chromium-browser, chrome; common macOS/Windows install paths are also checked). Override with the CHROME_PATH environment variable.
  • ffmpeg, only if you pipe frames to it yourself (see examples/05_record_to_ffmpeg.py). Not a library dependency.

Optional extras

Each extra maps to a specific capability. The display window and the placement backends are optional and platform-specific, so pick the one that matches your desktop.

pip install 'wbb[display]'    # PySDL2 + pysdl2-dll — the SDL2 DisplayClient window
pip install 'wbb[kde]'        # pydbus + PyGObject — KWin placement backend (KDE, X11 OR Wayland)
pip install 'wbb[x11]'        # python-xlib — EWMH placement backend (non-KDE X11 desktops)
pip install 'wbb[fast-jpeg]'  # PyTurboJPEG — much faster screencast JPEG decode
pip install 'wbb[all]'        # display + both placement backends + fast-jpeg
pip install 'wbb[dev]'        # pytest, mypy, ruff

Quote the brackets ('wbb[kde]') — some shells (zsh) treat them as globs otherwise.

Which placement extra do I need? Positioning and always-on-top require a backend that matches your live session:

Session Extra Backend
KDE Plasma (X11 or Wayland) wbb[kde] KWin D-Bus scripting
GNOME/X11, XFCE, i3, other X11 wbb[x11] X11 / EWMH
GNOME Wayland, other non-KDE Wayland (none available) no-op — see below
Not sure wbb[all] best available

If no matching backend is installed (or available), DisplayClient still runs — it just falls back to a plain, compositor-placed window and logs one warning at startup telling you exactly which package to install, with a copy-pasteable command targeting your current interpreter (so it works inside a venv).

Wayland reality check. Wayland has no protocol for a client to set its own absolute position or stay-on-top. On KDE Wayland the only mechanism is the KWin scripting backend (wbb[kde]). On non-KDE Wayland (e.g. GNOME) there is currently no positioning backend at all — run under an X11/XWayland session if you need precise placement there.

Note on the SDL2 system library. pip install 'wbb[display]' pulls in pysdl2-dll, which bundles the SDL2 binaries, so you usually don't need a system libsdl2. If you prefer your distro's SDL2, install PySDL2 without the bundled DLL and provide libsdl2 yourself.

Quickstart

import asyncio
from wbb import BrowserBridge, FrameBuffer

async def main():
    buf = FrameBuffer("my_buf", 1280, 720)
    async with BrowserBridge(buf) as br:
        await br.navigate("https://example.com")
        frame = await buf.next_frame()
        frame.save("screenshot.png")
    buf.close()
    buf.unlink()

asyncio.run(main())

Features

  • CDP screencast capture, no screenshot polling
  • Shared-memory double-buffer with zero-copy frame reads
  • Cross-process display: render in one process, view in another
  • Composable filter pipelines (crop, scale, color, blur, custom), with adjacent color filters fused into a single lookup table
  • SDL2 window with always-on-top, absolute position, and click-through, where the desktop supports it
  • DOM-aware interaction: wait_for_selector, click_element (selector or text fallback)
  • Browser process pooling for reuse across multiple sites
  • Frame recording to PNG sequences or an ffmpeg pipe
  • Per-bridge extra HTTP header management (add/remove by group)
  • Event hooks: frame, navigate, load, console, error

Documentation

wbb.buffer

class FrameBuffer(name, width, height, *, attach=False)

Shared, mutable RGBA pixel buffer backed by two POSIX shared-memory segments (<name>_a, <name>_b) plus a small metadata segment that tracks which one is current. The writer alternates buffers and flips a pointer after each write, so a reader never sees a torn frame.

  • Parameters
    • name: str — shared-memory identifier. Two FrameBuffer instances (same process or different processes) with the same name attach to the same data.
    • width, height: int — frame dimensions. The writer's write() calls must match exactly.
    • attach: boolFalse (default) creates and owns the segments; True attaches to segments created elsewhere. Only the owner should call unlink().
buf = FrameBuffer("ex01", 1280, 720)          # creates segments
viewer_buf = FrameBuffer("ex01", 1280, 720, attach=True)  # attaches, e.g. in another process
FrameBuffer.write(rgba: np.ndarray) -> int

Writer-side. Copies an H×W×4 uint8 RGBA array into the inactive buffer, then flips the active pointer. Returns the new monotonically increasing frame_id. Raises ValueError if rgba.shape != (height, width, 4). Called internally by BrowserBridge. You generally don't call this yourself unless compositing frames manually (see examples/07_display_multiple_sites_with_pool.py).

FrameBuffer.read() -> Frame

Returns the latest frame as a zero-copy view into shared memory. The view goes stale the instant the writer flips again. It doesn't update in place; it just silently starts showing old pixels once a newer frame exists elsewhere. Call frame.copy() if you need to keep it past the next write.

frame = buf.read()
frame.save("latest.png")
del frame  # release the view
async FrameBuffer.next_frame(timeout: float = 5.0) -> Frame

Blocks, without busy-polling (backed by a threading.Condition), until a frame newer than the last one observed arrives, or timeout seconds pass, then returns read(). On timeout it does not raise or return None. It just returns whatever is currently committed, so repeated calls under a slow or idle screencast are harmless.

The blocking wait runs on a small dedicated thread pool owned by the buffer, not asyncio's shared default executor. This matters under load: the default executor is also where BrowserBridge runs JPEG decode, so parking readers there could starve the decoder when many consumers wait at once. The dedicated pool keeps the two from competing.

frame = await buf.next_frame(2)  # wait up to 2s for a new frame
async for frame in buf: (FrameBuffer.__aiter__)

Infinite async iterator, equivalent to calling next_frame() in a loop with the default timeout. Used by consumers like examples/06_combined_scenario.py's record_task/monitor_task.

FrameBuffer.close() -> None

Releases this process's own memory mappings, wakes any parked next_frame() waiter, and shuts down the buffer's wait pool. Drop every Frame (and any array derived from one, e.g. via .crop()) before calling this. CPython won't unmap memory while a buffer-protocol export is still live on it, the same rule mmap follows generally. If a view is still outstanding, close() logs a debug message and returns instead of raising. The segment releases once you drop the last reference and GC runs.

FrameBuffer.unlink() -> None

Destroys the underlying named shared-memory segments. Only the creator (attach=False) should call this. Safe to call even if close() couldn't fully release a mapping: unlink() only removes the name so no new process can attach. This process's own mapping is freed by the OS at exit regardless.

Context manager

with FrameBuffer(...) as buf: calls close() on exit, and unlink() too if the buffer wasn't created with attach=True.

wbb.frame

class Frame (frozen dataclass)

A snapshot of one rendered frame.

  • Fields
    • data: np.ndarray — H×W×4 uint8 RGBA. Read-only view into shared memory unless produced by .copy().
    • width, height: int
    • frame_id: int — unique within one FrameBuffer session. Two Frames with the same id are the same underlying frame.
    • timestamp: floattime.monotonic() at write time.
Frame.copy() -> np.ndarray

Detaches the data from shared memory entirely. A writable array, safe to keep past FrameBuffer.close().

Frame.crop(x, y, w, h) -> Frame

Returns a new Frame whose data is a zero-copy sub-view. frame_id/timestamp are inherited from the source. Still pins shared memory, same lifecycle rule as the parent.

Frame.save(path, *, format=None) -> None

Writes the frame to disk via Pillow. Format is inferred from the extension unless given explicitly (e.g. "PNG", "JPEG").

frame = await buf.next_frame()
frame.crop(0, 0, 640, 360).save("corner.png")

wbb.browser

class BrowserBridge(buffer, *, width=1280, height=720, screencast_quality=80, screencast_max_fps=30, enable_input=None, headless_args=None, extra_headers=None)

Owns one headless Chrome process and its CDP WebSocket connection. Frames arrive via Page.startScreencast push, no polling. Each JPEG payload is decoded to RGBA off the event loop and written into buffer. With PyTurboJPEG installed, decode goes straight to RGBA in a single libjpeg-turbo pass (no intermediate channel-shuffle copy); without it, Pillow is the fallback.

  • Parameters
    • buffer: FrameBuffer — destination for decoded frames.
    • width, height: int — viewport size, also passed to Emulation.setDeviceMetricsOverride.
    • screencast_quality: int — JPEG quality 1-100 for the CDP stream.
    • screencast_max_fps: int — upper bound Chrome is asked to push at.
    • enable_input: Optional[bool]None or anything but False enables click/key/type/scroll/move/click_element; False makes all of them raise RuntimeError.
    • headless_args: list[str] | None — extra Chrome CLI flags, appended after the built-ins.
    • extra_headers: dict[str, str] | None — seeds an initial header group (see header_add below).
async with BrowserBridge(buf, width=1280, height=720, enable_input=True) as br:
    await br.navigate("https://example.com")
await BrowserBridge.start() / await BrowserBridge.stop()

Manual lifecycle pair behind the async with context manager. start() launches Chrome, attaches a flattened CDP session to its about:blank page target, enables Page/Runtime/Network, pushes any seeded headers, sets the viewport, and starts the screencast. stop() stops the screencast, drains any in-flight frame decode/write tasks, cancels the receive loop, closes the socket, and terminates (then kills, if unresponsive after 5s) the Chrome process.

await BrowserBridge.navigate(url: str) -> None

Sends Page.navigate. Does not wait for load itself. Use wait_for_selector, the "load" event hook, or your own asyncio.sleep/polling, depending on what "loaded" means for your page.

await BrowserBridge.reload(*, ignore_cache: bool = False) -> None
await BrowserBridge.eval(expression: str, *, await_promise: bool = False) -> Any

Evaluates expression via Runtime.evaluate with returnByValue=True and returns the resulting JSON-compatible value.

await BrowserBridge.wait_for_selector(selector: str, *, timeout: float = 10.0) -> bool

Polls via a MutationObserver (not a busy loop) until document.querySelector(selector) matches or timeout elapses. Returns whether it matched.

found = await br.wait_for_selector('i[data-jsl10n="search-input-button"]')
await BrowserBridge.set_viewport(width: int, height: int) -> None

Updates width/height and re-issues Emulation.setDeviceMetricsOverride. Does not resize the screencast's maxWidth/maxHeight. Restart the bridge if you need that to change too.

Extra HTTP headers: header_add, header_remove, header_remove_all, headers_current

CDP's Network.setExtraHTTPHeaders replaces the entire header set on every call. There's no incremental add/remove at the protocol level. BrowserBridge works around this by tracking headers as named groups and re-merging plus re-sending the full set on every mutation.

  • await header_add(headers: dict[str, str]) -> str — registers a group, pushes the merged set, returns an opaque group id.
  • await header_remove(group_id: str) -> None — removes one group by id (others unaffected), re-pushes. No-op on an unknown id.
  • await header_remove_all() -> None — clears every group, pushes an empty header set.
  • headers_current() -> dict[str, str] — local merged state, no CDP round-trip.
gid = await br.header_add({"X-Trace-Id": "abc123"})
...
await br.header_remove(gid)
Input: click, move, scroll, key, type

All raise RuntimeError if the bridge was constructed with enable_input=False.

  • await click(x: float, y: float, *, button: str = "left") -> None — dispatches a mousePressed+mouseReleased pair at pixel coordinates.
  • await move(x: float, y: float) -> None
  • await scroll(x: float, y: float, delta_x: float = 0, delta_y: float = 100) -> None
  • await key(key: str, *, kind: str = "keyDown", text: Optional[str] = None) -> Nonekey is a DOM key value ("Enter", "a", etc.). text defaults to key itself for single printable characters and to "" otherwise. Known rough edge: a manual keyDown/keyUp pair for "Enter" doesn't reliably submit forms in practice. Sending "\r" through type(), or calling eval() to submit the form directly, both work as documented workarounds (see examples/03_multi_step_automation.py).
  • await type(text: str) -> None — inserts text via Input.insertText, as if pasted or typed.
await BrowserBridge.click_element(selector=None, *, text=None, nth=0, scroll_into_view=False, timeout=5.0, poll_interval=0.15, button="left") -> bool

Clicks an element resolved by selector and/or text instead of a hardcoded pixel position.

  • Resolution order: if selector matches anything, the nth match is used as-is (no ancestor redirection: a selector match is exactly what you asked for). Otherwise text is matched case-insensitively against each element's own direct text-node content, and each match is redirected to its nearest clickable ancestor (a, button, input, select, textarea, [role="button"], [onclick], summary, label, or itself), then de-duplicated.
  • Polls (timeout/poll_interval) until something resolves; returns False on timeout rather than raising.
  • Clicks the resolved element's getBoundingClientRect() center via click().
  • scroll_into_view=True re-resolves and calls scrollIntoView({block: 'center', inline: 'center'}) first. If the element vanished between find and scroll, returns False instead of clicking stale coordinates.
  • Raises ValueError if neither selector nor text is given; raises RuntimeError if input is disabled.
await br.click_element("button.pure-button.pure-button-primary-progressive")
await br.click_element(text="Deutsch")  # text fallback, no selector
BrowserBridge.on(event: str, cb) -> None

Registers a callback for "frame", "navigate", "load", "console", or "error". cb may be sync or a coroutine function; both are awaited correctly.

br.on("load", lambda: print("loaded"))
br.on("navigate", lambda url: print(f"[nav] {url}"))
async BrowserBridge.frames() -> AsyncIterator[Frame]

Thin pass-through async iterator over the underlying FrameBuffer.

BrowserBridge.buffer -> FrameBuffer

The buffer this bridge writes into.

wbb.pool

class BrowserPool(max_idle: int = 3) (exported as BrowserPool, defined as ChromePool)

Reuses warm Chrome processes across BrowserBridge instances instead of paying full process-launch cost per site.

What pooling saves is the dominant cost — launching Chrome and completing the CDP attach/enable handshake. What it does not reclaim is renderer memory: Chrome does not shrink its heap back down after a heavy page, so a pool of max_idle warm processes is also a pool of max_idle peak-RSS processes. Use a smaller max_idle (or 0 to disable reuse) if resting memory matters more than launch latency. acquire is reuse-or-spawn and does not cap concurrent live bridges — max_idle bounds the resting footprint, not the peak.

await BrowserPool.acquire(buffer: FrameBuffer, **kwargs) -> BrowserBridge

Pops an idle bridge if one exists: rebinds it to buffer, applies the requested viewport (if width/height are in kwargs), navigates it to about:blank, and restarts its screencast against the new consumer. Otherwise constructs and starts a fresh BrowserBridge(buffer, **kwargs).

await BrowserPool.release(br: BrowserBridge) -> None

Returns br to the idle pool if there's room under max_idle: it clears the previous owner's event hooks, pauses the screencast (so a parked bridge isn't decoding blank frames into a detached buffer), and navigates to about:blank. If the pool is full, calls br.stop() instead.

pool = BrowserPool(max_idle=3)
br = await pool.acquire(buffer=buf, width=640, height=360)
await br.navigate(url)
...
await pool.release(br)

wbb.filters

Every filter has the signature (np.ndarray) -> np.ndarray over an H×W×4 uint8 RGBA array. Built-ins are pure: they return new arrays or views and never mutate input, so user-defined filters of the same shape plug in without modification.

Function Signature Behavior
crop crop(x, y, width, height) -> Filter Zero-copy sub-region slice.
scale scale(width, height) -> Filter Pillow LANCZOS resize.
flip flip(*, horizontal=False, vertical=False) -> Filter Zero-copy slice-based flip.
grayscale grayscale() -> Filter ITU-R BT.601 luminance; alpha preserved.
colorize colorize(r=1.0, g=1.0, b=1.0, a=1.0) -> Filter Per-channel multiply, clipped to [0, 255].
brightness brightness(delta: int) -> Filter Additive RGB shift; alpha untouched.
contrast contrast(factor: float) -> Filter Scales RGB around midpoint 128.
blur blur(radius: int = 2) -> Filter Separable box blur via scipy.ndimage.uniform_filter; falls back to a 5-tap neighbor average if scipy isn't installed.
compose compose(first, second) -> Filter second(first(x)).
chain chain(*filters) -> Filter Left-to-right composition of any number of filters.
identity identity() -> Filter Pass-through.

Color filter fusion. colorize, brightness, and contrast are per-channel point operations, implemented as 256-entry lookup tables rather than per-frame float math. When you chain (or compose) adjacent color filters, their tables are fused at build time into a single lookup, so a run of color filters costs one table gather per frame regardless of how many you stacked. A non-color filter (crop, scale, blur, grayscale, flip, or a custom one) breaks the run; fusion resumes after it. This is transparent — the chained result is bit-identical to applying the filters one at a time.

# These three fuse into one lookup table:
pipeline = filters.chain(
    filters.colorize(r=0.95, g=0.95, b=1.1),
    filters.contrast(1.1),
    filters.brightness(8),
)

# Here the crop breaks the run; colorize and contrast still fuse with each other:
pipeline = filters.chain(
    filters.colorize(r=1.1),
    filters.contrast(1.2),
    filters.crop(0, 0, 640, 360),
)

wbb.display (SDL2 DisplayClient)

Requires the display extra (PySDL2 + SDL2). Renders a FrameBuffer into a window, running everything (SDL event polling and frame pull) as a single coroutine on the caller's own asyncio loop. No second thread, no second main loop. Filters run off-loop in a thread pool, and frames are uploaded to the GPU via SDL_LockTexture (writing straight into the texture's staging memory).

class DisplayClient(buffer, *, title="wbb", wm_class="wbb-display", filters=None, on_mouse_event=None, on_key_event=None, on_scroll_event=None, window_size=None, position=None, monitor=None, always_on_top=False, borderless=False, click_through=False, max_fps=60.0)

  • Parameters
    • buffer — anything with .width, .height, and an async next_frame(timeout) (i.e. a FrameBuffer).
    • wm_class: str — give each concurrent DisplayClient a distinct value. The KWin and X11 placement backends match windows by this. It's set as both the X11 WM_CLASS and the Wayland app-id, so matching works on either session type.
    • filters: list[Filter] | None — run in a thread-pool executor, not inline on the event loop, so a slow chain (Pillow resize, scipy blur) never stalls CDP receive or input dispatch.
    • position: tuple[int, int] | None — see the coordinate model below. None (default) means no position requested — the compositor places the window naturally. When given, it must be a 2-tuple of int (raises ValueError otherwise).
    • monitor: int | None — selects how position is interpreted. None (default): position is a global virtual-desktop pixel. An int: position is local to that monitor, an index into list_displays(). See the coordinate model below.
    • always_on_top, borderless — best-effort. always_on_top silently no-ops if no placement backend supports it on the current session. borderless is unconditional (SDL_WINDOW_BORDERLESS at window creation) and always works.
    • click_through: bool — real on X11 sessions (via the Shape extension's input region); a clean, logged no-op under KWin's Wayland scripting backend, which has no input-transparency mechanism at all. Check is_click_through_active() to know which happened.
    • max_fps: float — caps how often frames are pushed (gates entry into the filter chain, not just the final present). 0 disables the cap.
display = DisplayClient(buf, title="wbb: filtered view", filters=pipeline, window_size=(640, 360))
await display.run_async()

Coordinate model (read this before setting position)

There is one coordinate space every placement backend actually consumes: SDL's global virtual-desktop space, where each monitor has an origin that may be offset from (0, 0). A monitor to the left of your primary has a negative x; a taller monitor sitting below a shorter one mounted higher has a positive y. list_displays() reports each monitor's real origin and size.

DisplayClient lets you address that space two ways:

  • Global mode (monitor=None, the default): position is an absolute desktop pixel, placeable anywhere across all monitors. This is the most predictable choice on offset multi-monitor layouts — what you pass is exactly what the compositor receives. Compute your target from list_displays():

    from wbb.display import DisplayClient, list_displays
    
    d = list_displays()[0]                      # your chosen monitor
    gx = d.x + 20                               # 20px in from its left edge
    gy = d.y + d.height - win_h - 20            # 20px up from its bottom edge
    display = DisplayClient(buf, position=(gx, gy), monitor=None, window_size=(win_w, win_h))
    
  • Monitor-local mode (monitor=<int>): position is local to that monitor's origin, which DisplayClient adds for you. monitor=0 does not guarantee your leftmost or primary monitor — index order is whatever the OS reports. Check each list_displays() entry's .x/.y/.width/.height.

    # bottom-left corner of monitor index 1, in that monitor's own pixels:
    display = DisplayClient(buf, position=(0, mon1_h - win_h), monitor=1)
    
await DisplayClient.run_async() -> None / DisplayClient.run() -> None

run_async() opens the window and runs until stop() is called, the user closes the window, or next_frame() errors out. run() is a sync wrapper (asyncio.run(self.run_async())) for non-async call sites.

Startup placement retry. A freshly created toplevel isn't guaranteed to have finished its first map/configure round-trip with the compositor the instant SDL_CreateWindow returns, so an early placement request can be superseded by the compositor's own initial placement. To cover this, DisplayClient re-applies always_on_top/position for the first few render-loop iterations rather than relying on a fixed sleep. This costs a handful of extra D-Bus/Xlib round-trips, only during startup.

DisplayClient.stop() -> None

Sets a flag the running run_async() loop checks every iteration.

DisplayClient.set_position(position, position_y=None, *, monitor=...) -> None

Moves the window after startup. Accepts either a single (x, y) tuple or two separate ints:

display.set_position((100, 200))
display.set_position(100, 200)

The monitor argument selects the coordinate mode:

  • Omit it — keep whatever mode is currently in effect (global by default).
  • monitor=None — explicitly use global virtual-desktop coordinates.
  • monitor=<int>position is local to that monitor.

No-op if called before run_async() has created the window. Re-arms the placement retry window to also cover monitor hot-plug or compositor-reset edge cases.

DisplayClient.get_position() -> WindowPosition

Returns the last-requested position in whatever convention was last used (global by default, or monitor-local if an int monitor was set), not necessarily the resolved global coordinate actually sent to the backend. WindowPosition is a frozen dataclass with x, y.

DisplayClient.set_always_on_top(above: bool) -> None
DisplayClient.set_click_through(enabled: bool) -> bool

Returns whether it actually took effect, same contract as the constructor flag.

DisplayClient.is_positionable() -> bool / DisplayClient.is_click_through_active() -> bool

Query the currently active placement backend's real capabilities, post-startup.

wbb.display.list_displays() -> list[DisplayBounds]

Enumerates monitors via SDL_GetDisplayBounds. Safe to call before any window exists. DisplayBounds is a frozen dataclass: index, x, y, width, height. x/y are in SDL's single global virtual-desktop coordinate space (a monitor to the left of your primary can have a negative x), which is also what every placement backend expects.

Placement backends

wbb.display.placement (not typically used directly) selects a backend in priority order and keeps the first that activates:

  1. KWin D-Bus scripting — KDE Plasma, X11 or Wayland. Sets geometry and keepAbove through KWin's own scripting interface over the session bus. Requires pydbus + PyGObject (wbb[kde]). On a Wayland session this is the only backend that can position windows. Has no input-transparency mechanism, so click_through is a logged no-op here.
  2. X11 / EWMH — non-KDE X11 desktops. Positions via the _NET_MOVERESIZE_WINDOW client message (which window managers honor for managed windows, unlike a raw XConfigureWindow), sets _NET_WM_STATE_ABOVE, and does click-through via the Shape extension. Requires python-xlib (wbb[x11]). Naturally inert under Wayland (no X11 display to open).
  3. No-op fallback — anything else (non-KDE Wayland, macOS, Windows, or a KDE box with the deps missing). Logs one warning at startup that names the exact package and pip command for your detected session and current interpreter, then makes always_on_top/position/click_through silently do nothing.

Each backend is required to fail cleanly rather than raise; an unexpected exception is caught and treated as a declined activation.

Examples

The examples/ directory in the source repository is the practical reference for composing the primitives above:

  • 01_display_with_filter.py: BrowserBridge + DisplayClient with a filter pipeline (built-in colorize/crop chained with a user-defined vignette).
  • 02_monitor_and_react.py: polls frames, computes a pixel-diff fraction over a region, and triggers a click() plus a saved screenshot on change. Graceful Ctrl-C shutdown via signal handlers.
  • 03_1_wait_for_selector.py: wait_for_selector() and click_element() in place of fixed sleeps.
  • 03_multi_step_automation.py: a full navigate, type, click, eval, screenshot pipeline, including selector and text-fallback clicking on the same page.
  • 04_cross_process.py: one process renders into a named FrameBuffer (attach=False), a second process attaches (attach=True) and displays it. Run as renderer/viewer from two terminals.
  • 05_record_to_ffmpeg.py: pipes raw RGBA frames into an ffmpeg subprocess at a fixed FPS with real-time pacing.
  • 06_combined_scenario.py: one FrameBuffer shared concurrently by a live DisplayClient, a PNG-snapshot recorder, and a change monitor.
  • 07_display_multiple_sites_with_pool.py: BrowserPool driving several sites at once, composited into a single grid and shown through one DisplayClient.
  • 08_clip_display_to_element.py: crops the display to a single page element's bounding box.

Troubleshooting placement

If always_on_top/set_position() seem to do nothing, the startup log says why and what to install — read that first. Common cases:

  • "no backend available … Wayland session": you're on non-KDE Wayland, where client-side positioning isn't possible. On KDE Wayland, install wbb[kde]. Elsewhere, use an X11/XWayland session.
  • Installed the dep but still no effect, inside a venv: make sure it landed in this environment. pip install pydbus can go to your user site (~/.local) where the venv won't see it. The startup warning prints a command using your current interpreter — use that exact line. Verify with python -c "import pydbus, gi" (KWin) or python -c "import Xlib" (X11) from the same Python that runs your app.
  • Window won't move past a monitor edge on KDE: make sure you're passing a global coordinate that actually falls inside a monitor (monitor=None plus a target computed from list_displays()), not a value that lands off all displays.

License

MIT. See LICENSE.

Contributing

Issues and pull requests welcome. For non-trivial changes, open an issue first to discuss the approach.

Maintainer notes (publishing)

pip install "setuptools>=68" wheel "numpy>=1.24" "websockets>=12.0" "Pillow>=10.0" "aiohttp>=3.9" "PyGObject>=3.50" "PyTurboJPEG>=1.7"
rm -rf dist/ build/ *.egg-info
# bump version in pyproject.toml  (keep code + tag in sync — a mismatched
# install where pip metadata and the on-disk __version__ disagree is a
# real source of "my fix isn't taking" confusion)
python -m build
twine check dist/*
twine upload dist/*
git tag v0.1.3.4
git push origin v0.1.3.4

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

wbb-0.1.3.4.tar.gz (89.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

wbb-0.1.3.4-py3-none-any.whl (73.3 kB view details)

Uploaded Python 3

File details

Details for the file wbb-0.1.3.4.tar.gz.

File metadata

  • Download URL: wbb-0.1.3.4.tar.gz
  • Upload date:
  • Size: 89.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for wbb-0.1.3.4.tar.gz
Algorithm Hash digest
SHA256 50aa2b405d805d1a8f041553db54cd7216d0213995a0de42dc933f0949b43164
MD5 269aa66bf5710029edbd197b5d7c9214
BLAKE2b-256 c4745fc846e54a26562c48aaf521e68f6415e2de512ba0f191613e0a212c8423

See more details on using hashes here.

File details

Details for the file wbb-0.1.3.4-py3-none-any.whl.

File metadata

  • Download URL: wbb-0.1.3.4-py3-none-any.whl
  • Upload date:
  • Size: 73.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for wbb-0.1.3.4-py3-none-any.whl
Algorithm Hash digest
SHA256 e4a96887f9e20873e70c5c24f0b2df7b4163c165f166dd53d00a29b19f21d56b
MD5 4557059146aeef2529454c35d8486074
BLAKE2b-256 8e021a712430f6c999f4c75f05eadb17bf8430d509fd954b026bf53079152991

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page