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 +421 -0
- wbb-0.1.0/README.md +398 -0
- wbb-0.1.0/pyproject.toml +45 -0
- wbb-0.1.0/setup.cfg +4 -0
- wbb-0.1.0/wbb/__init__.py +27 -0
- wbb-0.1.0/wbb/__main__.py +165 -0
- wbb-0.1.0/wbb/_shm.py +89 -0
- wbb-0.1.0/wbb/browser.py +673 -0
- wbb-0.1.0/wbb/buffer.py +245 -0
- wbb-0.1.0/wbb/display.py +162 -0
- wbb-0.1.0/wbb/filters.py +218 -0
- wbb-0.1.0/wbb/frame.py +89 -0
- wbb-0.1.0/wbb.egg-info/PKG-INFO +421 -0
- wbb-0.1.0/wbb.egg-info/SOURCES.txt +16 -0
- wbb-0.1.0/wbb.egg-info/dependency_links.txt +1 -0
- wbb-0.1.0/wbb.egg-info/entry_points.txt +2 -0
- wbb-0.1.0/wbb.egg-info/requires.txt +13 -0
- wbb-0.1.0/wbb.egg-info/top_level.txt +1 -0
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"
|