wbb 0.1.0__tar.gz

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.
wbb-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,421 @@
1
+ Metadata-Version: 2.4
2
+ Name: wbb
3
+ Version: 0.1.0
4
+ Summary: Render a live website into an off-screen pixel buffer and expose it as a programmable primitive
5
+ Author-email: Delicious <singularitwenty@proton.me>
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: numpy>=1.24
13
+ Requires-Dist: websockets>=12.0
14
+ Requires-Dist: Pillow>=10.0
15
+ Requires-Dist: aiohttp>=3.9
16
+ Provides-Extra: display
17
+ Requires-Dist: pygame>=2.5; extra == "display"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest; extra == "dev"
20
+ Requires-Dist: pytest-asyncio; extra == "dev"
21
+ Requires-Dist: mypy; extra == "dev"
22
+ Requires-Dist: ruff; extra == "dev"
23
+
24
+ # wbb — WebView Buffer Bridge
25
+
26
+ Renders a live website into an off-screen shared-memory pixel buffer and
27
+ exposes that buffer as a composable, scriptable async Python primitive.
28
+
29
+ ```
30
+ [ headless Chrome ] ──CDP screencast──▶ [ BrowserBridge ]
31
+
32
+ JPEG decode
33
+ RGBA write
34
+
35
+
36
+ [ FrameBuffer / SHM ]
37
+
38
+ zero-copy numpy view
39
+ filter pipeline
40
+
41
+ ┌───────────┴───────────┐
42
+ ▼ ▼
43
+ [ DisplayClient ] [ user script ]
44
+ SDL2 window any consumer
45
+ ```
46
+
47
+ ## Requirements
48
+
49
+ - Python 3.10+
50
+ - Chrome or Chromium installed on the host
51
+ (override path with `CHROME_PATH` env var)
52
+ - `numpy`, `Pillow`, `websockets`, `aiohttp` (auto-installed)
53
+ - `pygame` only if you use `DisplayClient` (`pip install wbb[display]`)
54
+ - `scipy` optional — `filters.blur(radius > 1)` uses it if present and
55
+ falls back to a cheap roll-average otherwise
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install wbb # core
61
+ pip install "wbb[display]" # + pygame for DisplayClient
62
+ ```
63
+
64
+ ## Quick start
65
+
66
+ ```python
67
+ import asyncio
68
+ from wbb import BrowserBridge, FrameBuffer
69
+
70
+ async def main():
71
+ buf = FrameBuffer("my_buf", 1280, 720)
72
+ async with BrowserBridge(buf) as br:
73
+ await br.navigate("https://example.com")
74
+ frame = await buf.next_frame()
75
+ frame.save("screenshot.png")
76
+ del frame # release the zero-copy view before the buffer closes
77
+ buf.close()
78
+ buf.unlink()
79
+
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Public API
86
+
87
+ ### `FrameBuffer`
88
+
89
+ The central primitive. Owns two named shared-memory segments that form a
90
+ double-buffer; a metadata segment tracks which side is current.
91
+
92
+ ```python
93
+ FrameBuffer(name, width, height, *, attach=False)
94
+ ```
95
+
96
+ | Method / property | Description |
97
+ |---|---|
98
+ | `write(rgba)` | Write an H×W×4 uint8 array into the inactive buffer, then flip. Returns the new `frame_id`. Called internally by `BrowserBridge`. |
99
+ | `read()` | Return the latest `Frame` as a zero-copy, read-only view. |
100
+ | `async next_frame(timeout=5.0)` | Wait up to `timeout` seconds for a new frame; on timeout, returns the latest frame via `read()` instead of raising. |
101
+ | `async for frame in buf` | Yield one `Frame` per rendered frame, indefinitely (built on `next_frame`). |
102
+ | `close()` | Release this process's memory mappings. |
103
+ | `unlink()` | Destroy the underlying OS-level segments. Only the creating process should call this. |
104
+ | `with FrameBuffer(...) as buf` | Context manager: `close()`s on exit, and also `unlink()`s if this process created the buffer (`attach=False`). |
105
+
106
+ **Lifetime contract — read this before debugging a hang:**
107
+ `read()` and async iteration return **zero-copy views** into shared
108
+ memory. CPython will not unmap memory while any array still holds a live
109
+ buffer-protocol export on it — the same rule as `mmap` generally. In
110
+ practice: **drop every `Frame` (and anything derived from one, e.g. via
111
+ `.crop()`) before calling `close()`** — reassign the variable, let it go
112
+ out of scope, or `del frame`. If you need the pixel data to outlive the
113
+ buffer, call `frame.copy()` first to detach it entirely. `close()` itself
114
+ is best-effort: if a view is still outstanding it logs and returns rather
115
+ than raising, and the segment is freed once that reference is dropped.
116
+ `unlink()` always succeeds regardless, since it only removes the *name*.
117
+
118
+ **Cross-process attach:**
119
+
120
+ ```python
121
+ # Process A — creates the buffer
122
+ buf = FrameBuffer("shared", 1280, 720, attach=False)
123
+
124
+ # Process B — connects to the same buffer, no BrowserBridge needed
125
+ buf = FrameBuffer("shared", 1280, 720, attach=True)
126
+ frame = buf.read()
127
+ ```
128
+
129
+ ---
130
+
131
+ ### `Frame`
132
+
133
+ A snapshot of one rendered frame.
134
+
135
+ ```python
136
+ Frame(data, width, height, frame_id, timestamp)
137
+ ```
138
+
139
+ | Method | Description |
140
+ |---|---|
141
+ | `data` | `np.ndarray` H×W×4 uint8 RGBA. Read-only view into shared memory. |
142
+ | `copy()` | Return a writable, detached copy of the array. |
143
+ | `crop(x, y, w, h)` | Zero-copy sub-region view. Returns a new `Frame` (same `frame_id`/`timestamp`). |
144
+ | `save(path, format=None)` | Save to file (PNG, JPEG, …); format inferred from extension unless given. |
145
+ | `frame_id` | Monotonically increasing integer, unique within a `FrameBuffer` session. |
146
+ | `timestamp` | `time.monotonic()` at write time. |
147
+
148
+ ---
149
+
150
+ ### `BrowserBridge`
151
+
152
+ Owns the headless Chrome process and CDP connection.
153
+
154
+ ```python
155
+ BrowserBridge(
156
+ buffer,
157
+ *,
158
+ width=1280,
159
+ height=720,
160
+ screencast_quality=80,
161
+ screencast_max_fps=30,
162
+ enable_input=True,
163
+ headless_args=None,
164
+ )
165
+ ```
166
+
167
+ **Lifecycle**
168
+
169
+ ```python
170
+ async with BrowserBridge(buf) as br:
171
+ ...
172
+ # or manually
173
+ br = BrowserBridge(buf)
174
+ await br.start()
175
+ ...
176
+ await br.stop()
177
+ ```
178
+
179
+ `stop()` is a no-op (returns immediately) if `start()` was never called.
180
+
181
+ **Navigation**
182
+
183
+ ```python
184
+ await br.navigate(url) # does not block on load — see note below
185
+ await br.reload(ignore_cache=False)
186
+ await br.set_viewport(width, height)
187
+ result = await br.eval("document.title", await_promise=False)
188
+ found = await br.wait_for_selector("#some-id", timeout=10.0, poll_interval=0.15)
189
+ ```
190
+
191
+ > **Note:** `navigate()` sends `Page.navigate` and returns immediately;
192
+ > it does **not** wait for the load event. Pace multi-step automation
193
+ > with `wait_for_selector()`, `click_element()`'s own polling, or an
194
+ > explicit `asyncio.sleep(...)` between steps — see
195
+ > `examples/03_multi_step_automation.py`.
196
+
197
+ **Input** (requires `enable_input=True`, which is the default; all of
198
+ these raise `RuntimeError` if input was disabled)
199
+
200
+ ```python
201
+ await br.click(x, y, button="left")
202
+ await br.move(x, y)
203
+ await br.scroll(x, y, delta_x=0, delta_y=100)
204
+ await br.key("Enter") # DOM key name; see caveat below
205
+ await br.type("hello") # inserts text via Input.insertText
206
+ found = await br.click_element(selector=None, text=None, nth=0,
207
+ scroll_into_view=False, timeout=5.0,
208
+ poll_interval=0.15, button="left")
209
+ ```
210
+
211
+ > **`key()` is known to be flaky.** It dispatches a raw
212
+ > `Input.dispatchKeyEvent` and Chrome doesn't always treat it as
213
+ > Wikipedia's search box does, for instance — sending `"\r"` as a key
214
+ > reliably submits a form where `"Enter"` does not, in practice. If a
215
+ > key event doesn't do what you expect, try the alternative character,
216
+ > or fall back to `eval()`-driven form submission.
217
+
218
+ `click_element` resolves an element by CSS *selector* (tried first; if it
219
+ matches anything, the *nth* match is used directly — no redirect to a
220
+ clickable ancestor) or, failing that, by case-insensitive *text*
221
+ substring match against an element's own direct text nodes, with each
222
+ text match resolved to its nearest clickable ancestor (`a`, `button`,
223
+ `input`, `select`, `textarea`, `[role="button"]`, `[onclick]`, `summary`,
224
+ `label`) and de-duplicated. It polls every `poll_interval` seconds up to
225
+ `timeout`, then clicks the resolved bounding-box center via the same path
226
+ as `click()`. Returns `False` on timeout rather than raising; raises
227
+ `ValueError` if neither `selector` nor `text` is given.
228
+
229
+ **Frame access**
230
+
231
+ ```python
232
+ frame = br.buffer.read() # latest frame, immediately
233
+ async for frame in br.frames():
234
+ ... # one Frame per screencast push
235
+ ```
236
+
237
+ **Event hooks**
238
+
239
+ ```python
240
+ br.on("frame", lambda frame: ...) # every rendered frame
241
+ br.on("navigate", lambda url: ...) # page navigation
242
+ br.on("load", lambda: ...) # load event fired
243
+ br.on("console", lambda level, args: ...)
244
+ br.on("error", lambda description: ...)
245
+ ```
246
+
247
+ Callbacks may be plain functions or coroutine functions.
248
+
249
+ ---
250
+
251
+ ### `DisplayClient`
252
+
253
+ Optional SDL2 window. Requires `pygame`.
254
+
255
+ ```python
256
+ DisplayClient(
257
+ buffer,
258
+ *,
259
+ title="wbb",
260
+ filters=None, # list of Filter callables
261
+ on_mouse_event=None, # (event_type, x, y, button) -> Any
262
+ on_key_event=None, # (event_type, key_name) -> Any
263
+ window_size=None, # (width, height); defaults to buffer size
264
+ )
265
+ ```
266
+
267
+ `window_size` is useful when a filter changes the frame's output
268
+ dimensions (e.g. a crop) — set it to match so the SDL surface isn't
269
+ mismatched against the buffer's native size.
270
+
271
+ ```python
272
+ # Blocking
273
+ display.run()
274
+
275
+ # Async task (composable with other coroutines)
276
+ task = asyncio.create_task(display.run_async())
277
+ display.stop() # signal shutdown from another coroutine
278
+ await task
279
+ ```
280
+
281
+ Input forwarding to the browser:
282
+
283
+ ```python
284
+ def on_mouse(kind, x, y, button):
285
+ if kind == "down":
286
+ asyncio.create_task(br.click(x, y))
287
+
288
+ display = DisplayClient(buf, on_mouse_event=on_mouse)
289
+ ```
290
+
291
+ ---
292
+
293
+ ### `filters` module
294
+
295
+ Every filter is `(np.ndarray) -> np.ndarray` — plain callables, fully
296
+ composable. Built-ins return factory functions so parameters are explicit:
297
+
298
+ ```python
299
+ from wbb import filters
300
+
301
+ pipeline = [
302
+ filters.crop(0, 0, 640, 360), # top-left quadrant
303
+ filters.scale(1280, 720), # scale back up
304
+ filters.grayscale(),
305
+ filters.blur(radius=2), # uses scipy if installed, else a cheap fallback
306
+ filters.brightness(+20),
307
+ filters.contrast(1.2),
308
+ filters.colorize(r=1.1, g=0.9, b=0.9),
309
+ filters.flip(horizontal=True),
310
+ ]
311
+
312
+ # Combine into one callable
313
+ f = filters.chain(*pipeline)
314
+ result = f(frame.data)
315
+ ```
316
+
317
+ User-defined filters plug in identically:
318
+
319
+ ```python
320
+ def my_filter(frame: np.ndarray) -> np.ndarray:
321
+ return frame.copy() # or any transformation
322
+
323
+ pipeline = [filters.grayscale(), my_filter]
324
+ display = DisplayClient(buf, filters=pipeline)
325
+ ```
326
+
327
+ ---
328
+
329
+ ## Scriptability model
330
+
331
+ A user scenario is a plain `.py` file that imports `wbb` and composes its
332
+ primitives. The library places no constraints on structure or complexity.
333
+
334
+ ### Using the CLI script runner
335
+
336
+ ```bash
337
+ python -m wbb script my_scenario.py --url https://example.com
338
+ ```
339
+
340
+ The script receives pre-initialized objects as module-level names:
341
+ `wbb_buffer`, `wbb_browser`, `wbb_url`, plus all public symbols
342
+ (`BrowserBridge`, `FrameBuffer`, `Frame`, `DisplayClient`, `filters`).
343
+
344
+ `wbb_browser` is **not** started for you — the script owns its lifecycle,
345
+ exactly like every `BrowserBridge` in `examples/` (`async with
346
+ BrowserBridge(...) as br:` or manual `start()`/`stop()`). This avoids
347
+ launching a Chrome process the script may never touch if it builds its
348
+ own objects instead, which is equally valid. If the script never starts
349
+ `wbb_browser`, the runner's cleanup `stop()` call is a safe no-op.
350
+
351
+ If the script defines an `async def main()`, the runner calls it.
352
+ Otherwise the script executes at import time.
353
+
354
+ ### CLI commands
355
+
356
+ ```bash
357
+ # Open a URL in a window (requires pygame)
358
+ python -m wbb display https://example.com
359
+
360
+ # Save a screenshot and exit
361
+ python -m wbb screenshot https://example.com output.png --wait 2.0
362
+
363
+ # Run a user scenario script
364
+ python -m wbb script path/to/scenario.py --url https://example.com \
365
+ --width 1920 --height 1080
366
+ ```
367
+
368
+ Shared flags (`--width`, `--height`, `--buffer-name`) belong to the top-
369
+ level parser and go *before* the subcommand name.
370
+
371
+ ---
372
+
373
+ ## Examples
374
+
375
+ | Script | What it demonstrates |
376
+ |---|---|
377
+ | `initial_test.py` | Minimal smoke test: navigate, grab one frame, save it |
378
+ | `01_display_with_filter.py` | DisplayClient + user-defined vignette filter |
379
+ | `01_1_display_with_filter.py` | Same, with a normalized crop region and a matching `window_size` |
380
+ | `02_monitor_and_react.py` | Pixel diff loop → conditional click + screenshot, with graceful Ctrl-C shutdown |
381
+ | `03_multi_step_automation.py` | Navigate → type → key → `click_element` (selector and text fallback) → screenshot pipeline |
382
+ | `04_cross_process.py` | BrowserBridge in process A, DisplayClient in process B |
383
+ | `05_record_to_ffmpeg.py` | Headless recording via ffmpeg pipe, real-time frame pacing, no window |
384
+ | `06_combined_scenario.py` | Display + record + monitor as concurrent async tasks sharing one buffer |
385
+
386
+ ---
387
+
388
+ ## Architecture notes
389
+
390
+ **No Playwright, Puppeteer, or CEF.** wbb drives Chrome directly via the
391
+ Chrome DevTools Protocol (CDP) WebSocket. This makes it pip-installable
392
+ everywhere and lets it fit inside a single small package.
393
+
394
+ **Screencast, not polling.** `Page.startScreencast` pushes JPEG frames as
395
+ they are rendered. There is no polling loop and no screenshot RPC overhead.
396
+
397
+ **Double-buffered shared memory.** Two segments alternate; readers always
398
+ see a complete frame. The metadata segment (active index + frame_id +
399
+ timestamp) is the only synchronisation point. Cross-process reading
400
+ requires no locking — see `FrameBuffer`'s lifetime contract above for the
401
+ one thing that *does* require care: dropping zero-copy views before
402
+ `close()`.
403
+
404
+ **Decoupled layers.** Render, buffer, and display each run independently.
405
+ A slow display does not drop render frames; a slow render does not block
406
+ the display. User tasks run alongside both without any additional glue.
407
+
408
+ **Resource-tracker hygiene.** `wbb._shm.ShmSegment` wraps
409
+ `multiprocessing.shared_memory.SharedMemory` and immediately unregisters
410
+ attached (non-owning) segments from the process's resource tracker, so
411
+ an attaching/reader process never races the owning process's `unlink()`
412
+ on exit (see `wbb/_shm.py` for the CPython issue this works around).
413
+
414
+ ---
415
+
416
+ ## License
417
+
418
+ MIT
419
+
420
+ ## personal notes
421
+ pip install "setuptools>=68" wheel "numpy>=1.24" "websockets>=12.0" "Pillow>=10.0" "aiohttp>=3.9" "pygame>2.5"