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 theCHROME_PATHenvironment 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 inpysdl2-dll, which bundles the SDL2 binaries, so you usually don't need a systemlibsdl2. If you prefer your distro's SDL2, install PySDL2 without the bundled DLL and providelibsdl2yourself.
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. TwoFrameBufferinstances (same process or different processes) with the same name attach to the same data.width, height: int— frame dimensions. The writer'swrite()calls must match exactly.attach: bool—False(default) creates and owns the segments;Trueattaches to segments created elsewhere. Only the owner should callunlink().
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×4uint8RGBA. Read-only view into shared memory unless produced by.copy().width, height: intframe_id: int— unique within oneFrameBuffersession. TwoFrames with the same id are the same underlying frame.timestamp: float—time.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 toEmulation.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]—Noneor anything butFalseenablesclick/key/type/scroll/move/click_element;Falsemakes all of them raiseRuntimeError.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 (seeheader_addbelow).
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 amousePressed+mouseReleasedpair at pixel coordinates.await move(x: float, y: float) -> Noneawait scroll(x: float, y: float, delta_x: float = 0, delta_y: float = 100) -> Noneawait key(key: str, *, kind: str = "keyDown", text: Optional[str] = None) -> None—keyis a DOM key value ("Enter","a", etc.).textdefaults tokeyitself for single printable characters and to""otherwise. Known rough edge: a manualkeyDown/keyUppair for"Enter"doesn't reliably submit forms in practice. Sending"\r"throughtype(), or callingeval()to submit the form directly, both work as documented workarounds (seeexamples/03_multi_step_automation.py).await type(text: str) -> None— insertstextviaInput.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
selectormatches anything, thenthmatch is used as-is (no ancestor redirection: a selector match is exactly what you asked for). Otherwisetextis 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; returnsFalseon timeout rather than raising. - Clicks the resolved element's
getBoundingClientRect()center viaclick(). scroll_into_view=Truere-resolves and callsscrollIntoView({block: 'center', inline: 'center'})first. If the element vanished between find and scroll, returnsFalseinstead of clicking stale coordinates.- Raises
ValueErrorif neitherselectornortextis given; raisesRuntimeErrorif 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 asyncnext_frame(timeout)(i.e. aFrameBuffer).wm_class: str— give each concurrentDisplayClienta distinct value. The KWin and X11 placement backends match windows by this. It's set as both the X11WM_CLASSand 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 ofint(raisesValueErrorotherwise).monitor: int | None— selects howpositionis interpreted.None(default):positionis a global virtual-desktop pixel. Anint:positionis local to that monitor, an index intolist_displays(). See the coordinate model below.always_on_top, borderless— best-effort.always_on_topsilently no-ops if no placement backend supports it on the current session.borderlessis unconditional (SDL_WINDOW_BORDERLESSat 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. Checkis_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).0disables 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):positionis 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 fromlist_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>):positionis local to that monitor's origin, whichDisplayClientadds for you.monitor=0does not guarantee your leftmost or primary monitor — index order is whatever the OS reports. Check eachlist_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>—positionis 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:
- KWin D-Bus scripting — KDE Plasma, X11 or Wayland. Sets geometry and
keepAbovethrough KWin's own scripting interface over the session bus. Requirespydbus+PyGObject(wbb[kde]). On a Wayland session this is the only backend that can position windows. Has no input-transparency mechanism, soclick_throughis a logged no-op here. - X11 / EWMH — non-KDE X11 desktops. Positions via the
_NET_MOVERESIZE_WINDOWclient message (which window managers honor for managed windows, unlike a rawXConfigureWindow), sets_NET_WM_STATE_ABOVE, and does click-through via the Shape extension. Requirespython-xlib(wbb[x11]). Naturally inert under Wayland (no X11 display to open). - 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
pipcommand for your detected session and current interpreter, then makesalways_on_top/position/click_throughsilently 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+DisplayClientwith a filter pipeline (built-incolorize/cropchained with a user-defined vignette).02_monitor_and_react.py: polls frames, computes a pixel-diff fraction over a region, and triggers aclick()plus a saved screenshot on change. Graceful Ctrl-C shutdown via signal handlers.03_1_wait_for_selector.py:wait_for_selector()andclick_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 namedFrameBuffer(attach=False), a second process attaches (attach=True) and displays it. Run asrenderer/viewerfrom two terminals.05_record_to_ffmpeg.py: pipes raw RGBA frames into anffmpegsubprocess at a fixed FPS with real-time pacing.06_combined_scenario.py: oneFrameBuffershared concurrently by a liveDisplayClient, a PNG-snapshot recorder, and a change monitor.07_display_multiple_sites_with_pool.py:BrowserPooldriving several sites at once, composited into a single grid and shown through oneDisplayClient.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 pydbuscan 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 withpython -c "import pydbus, gi"(KWin) orpython -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=Noneplus a target computed fromlist_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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50aa2b405d805d1a8f041553db54cd7216d0213995a0de42dc933f0949b43164
|
|
| MD5 |
269aa66bf5710029edbd197b5d7c9214
|
|
| BLAKE2b-256 |
c4745fc846e54a26562c48aaf521e68f6415e2de512ba0f191613e0a212c8423
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e4a96887f9e20873e70c5c24f0b2df7b4163c165f166dd53d00a29b19f21d56b
|
|
| MD5 |
4557059146aeef2529454c35d8486074
|
|
| BLAKE2b-256 |
8e021a712430f6c999f4c75f05eadb17bf8430d509fd954b026bf53079152991
|