kwcapture 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.
- kwcapture-0.1.0/.github/workflows/ci.yml +42 -0
- kwcapture-0.1.0/AGENTS.md +170 -0
- kwcapture-0.1.0/CHANGELOG.md +16 -0
- kwcapture-0.1.0/LICENSE +21 -0
- kwcapture-0.1.0/MANIFEST.in +23 -0
- kwcapture-0.1.0/PKG-INFO +205 -0
- kwcapture-0.1.0/README.md +177 -0
- kwcapture-0.1.0/bench.py +108 -0
- kwcapture-0.1.0/kwcapture/__init__.py +795 -0
- kwcapture-0.1.0/kwcapture/__main__.py +307 -0
- kwcapture-0.1.0/kwcapture/_desktop.py +136 -0
- kwcapture-0.1.0/kwcapture/_native.py +189 -0
- kwcapture-0.1.0/kwcapture/native/include/kwcapture_shm.h +75 -0
- kwcapture-0.1.0/kwcapture/native/kwcapture.c +878 -0
- kwcapture-0.1.0/kwcapture/py.typed +0 -0
- kwcapture-0.1.0/kwcapture.egg-info/PKG-INFO +205 -0
- kwcapture-0.1.0/kwcapture.egg-info/SOURCES.txt +23 -0
- kwcapture-0.1.0/kwcapture.egg-info/dependency_links.txt +1 -0
- kwcapture-0.1.0/kwcapture.egg-info/entry_points.txt +2 -0
- kwcapture-0.1.0/kwcapture.egg-info/requires.txt +7 -0
- kwcapture-0.1.0/kwcapture.egg-info/top_level.txt +1 -0
- kwcapture-0.1.0/pyproject.toml +54 -0
- kwcapture-0.1.0/setup.cfg +4 -0
- kwcapture-0.1.0/setup.py +100 -0
- kwcapture-0.1.0/tests/test_kwcapture.py +187 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: build
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
|
|
18
|
+
- name: Install build dependencies
|
|
19
|
+
run: |
|
|
20
|
+
sudo apt-get update
|
|
21
|
+
sudo apt-get install -y libsystemd-dev libwayland-dev pkg-config
|
|
22
|
+
|
|
23
|
+
- name: Build the wheel
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip build
|
|
26
|
+
python -m build --wheel
|
|
27
|
+
|
|
28
|
+
- name: Install it
|
|
29
|
+
run: |
|
|
30
|
+
python -m pip install --force-reinstall dist/*.whl
|
|
31
|
+
python -c "import kwcapture; print('version', kwcapture.__version__)"
|
|
32
|
+
kwcapture --version
|
|
33
|
+
kwcapture screens || echo "(no Wayland session in CI, as expected)"
|
|
34
|
+
|
|
35
|
+
- name: Check the bundled helper
|
|
36
|
+
run: |
|
|
37
|
+
python - <<'PY'
|
|
38
|
+
from kwcapture import _native
|
|
39
|
+
p = _native.prebuilt_path()
|
|
40
|
+
print("wheel ships the helper:", p, p.is_file())
|
|
41
|
+
assert p.is_file(), "the wheel should contain the compiled helper"
|
|
42
|
+
PY
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# AGENTS.md — fast screen capture on Wayland (KDE Plasma 6 / KWin)
|
|
2
|
+
|
|
3
|
+
Repo: **https://github.com/tjandrasg/kwcapture** (branch `main`, release `v0.1.0` with a
|
|
4
|
+
prebuilt `linux_x86_64` wheel attached) and on PyPI as **`kwcapture`**
|
|
5
|
+
(https://pypi.org/project/kwcapture/) — publish new versions with
|
|
6
|
+
`.venv/bin/python -m twine upload -r pypi dist/*` (credentials in `~/.pypirc`; never
|
|
7
|
+
commit or print them). Author: Tjandra Satria Gunawan <tjandra.satria@sci.ui.ac.id>.
|
|
8
|
+
`origin` is SSH (`git@github.com:tjandrasg/kwcapture.git`). There is no `gh` CLI here, so
|
|
9
|
+
repo/release admin (creating releases, uploading assets, topics) is done with the GitHub
|
|
10
|
+
REST API via `curl` using whatever credential is configured for github.com — never commit
|
|
11
|
+
or print a token.
|
|
12
|
+
|
|
13
|
+
Working dir: `~/way_scr_cap`. **Read this first if you are a fresh session.**
|
|
14
|
+
Status: **done and working** — ~40 fps real Wayland capture, Python API + CLI + tests.
|
|
15
|
+
See `README.md` for user-facing docs; this file is the investigation log + gotchas.
|
|
16
|
+
|
|
17
|
+
## Goal
|
|
18
|
+
Make a **fast** full-screen capture program on Wayland. Original experiments
|
|
19
|
+
(`probe/bench_original.py`, `bench_result.md`): `PIL.ImageGrab` shells out to `spectacle`
|
|
20
|
+
(~2 fps); `mss` is 1100 fps but captures **XWayland** (black for native Wayland windows)
|
|
21
|
+
and dies when `$DISPLAY` is unset. Both unusable.
|
|
22
|
+
|
|
23
|
+
## What we ship (pip-installable package, v0.1.0)
|
|
24
|
+
`make setup` (or just `pip install .`) then `.venv/bin/python tests/test_kwcapture.py`.
|
|
25
|
+
|
|
26
|
+
| file | what |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `kwcapture/native/kwcapture.c` | native helper: one-shot / `--bench` / `--list` / `serve` (daemon + shm ring), sd-bus + wayland-client |
|
|
29
|
+
| `kwcapture/native/include/kwcapture_shm.h` | shm ring protocol (`KWC_HDR_STRUCT_SIZE` = 1776, `_Static_assert`ed) |
|
|
30
|
+
| `kwcapture/__init__.py` | Python API: `Capture`, `grab/latest/shot/shot_jpeg/stats/bench`, ctypes mirror of the header, `to_rgb`/`resize`/`png_bytes`/`jpeg_bytes` |
|
|
31
|
+
| `kwcapture/_native.py` | helper discovery: `$KWCAPTURE_BIN` → `kwcapture/bin/kwcapture` (wheel) → `~/.cache/kwcapture/…` → compile from the shipped source; ELF-arch check |
|
|
32
|
+
| `kwcapture/_desktop.py` | writes/refreshes/prunes the KWin authorisation desktop entry |
|
|
33
|
+
| `kwcapture/__main__.py` | CLI: `doctor [--fix --build] / setup / install-desktop [--uninstall] / screens / grab / demo / bench` (console script `kwcapture`) |
|
|
34
|
+
| `pyproject.toml` + `setup.py` | packaging; `setup.py` compiles the helper during the wheel build (`build_py` → non-pure, `bdist_wheel.get_tag` → `linux_<arch>`), failure is non-fatal (runtime compile) |
|
|
35
|
+
| `tests/test_kwcapture.py` | 27 functional tests, also `pytest tests/` |
|
|
36
|
+
| `bench.py` | comparison table vs `mss` and `PIL.ImageGrab` |
|
|
37
|
+
| `Makefile` | `make` `install-desktop` `setup` `doctor` `test` `bench` `demo` `wheel` `sdist` `dev-install` `clean` |
|
|
38
|
+
| `probe/` | experiments: `globals.c` (dump compositor globals), Gio prototypes, scale/post micro-benchmarks |
|
|
39
|
+
| `AGENTS.md`, `README.md`, `CHANGELOG.md`, `LICENSE` (MIT), `.github/workflows/ci.yml` | docs/CI |
|
|
40
|
+
|
|
41
|
+
Install story: **self-configuring** — first `Capture()` finds/builds the helper, writes the
|
|
42
|
+
desktop entry, retries while KDE's service cache notices it. Verified in a *fresh* venv
|
|
43
|
+
(`pip install dist/*.whl`, numpy only, no desktop entry present): ready in ~150 ms, 37.9 fps,
|
|
44
|
+
numpy-only fallback resize worked. Also verified installing straight from GitHub and from
|
|
45
|
+
the release wheel URL. Published on PyPI: `pip install kwcapture`.
|
|
46
|
+
|
|
47
|
+
## Results (2560x1440@164.69Hz, Plasma 6.6 / kwin 6.6.6, i9-13900K)
|
|
48
|
+
```
|
|
49
|
+
mss (XWayland) 1113.8 fps 0.9 ms <-- BLACK/EMPTY, useless
|
|
50
|
+
PIL.ImageGrab (spectacle) 2.7 fps 357.1 ms
|
|
51
|
+
kwcapture raw BGRA view 38.4 fps 25.8 ms
|
|
52
|
+
kwcapture RGB full res 37.9 fps 26.0 ms
|
|
53
|
+
kwcapture RGB 1280 wide 38.0 fps 26.1 ms
|
|
54
|
+
kwcapture JPEG 1280 35.6 fps 28.0 ms (grab + downscale + encode)
|
|
55
|
+
kwcapture latest() 551704 fps 0.0 ms (view of already-published frame)
|
|
56
|
+
```
|
|
57
|
+
Breakdown at 1440p: KWin grab ≈ 18-25 ms (≈11 ms floor even for a 320x180 area), pipe drain
|
|
58
|
+
≈5 ms (14 MB). Throughput ceiling ≈47 fps (KWin serialises screenshot jobs); `depth=2`
|
|
59
|
+
reaches it, depth 3/4 only add latency (grab_ms 37/58/80 ms). Post-processing: cv2
|
|
60
|
+
`INTER_AREA` 2560→1280 = 0.1 ms, `cvtColor` BGRA2RGB of the small frame ≈3 ms, cv2 JPEG
|
|
61
|
+
1280 = 3.5 ms / 2560 = 4.5 ms, PNG 23 ms (avoid), PIL resize 17 ms (avoid → use cv2).
|
|
62
|
+
Colour fidelity verified vs an independent Spectacle capture: MAD **0.0** (9.7 if R/B swapped).
|
|
63
|
+
|
|
64
|
+
## THE TWO KEY FINDINGS
|
|
65
|
+
|
|
66
|
+
### 1. KWin's D-Bus API `org.kde.KWin.ScreenShot2`
|
|
67
|
+
service `org.kde.KWin.ScreenShot2`, path `/org/kde/KWin/ScreenShot2`, same interface, owned
|
|
68
|
+
by `kwin_wayland`. Methods (all `(…, options a{sv}, pipe h) -> results a{sv}`):
|
|
69
|
+
`CaptureScreen(name s)`, `CaptureActiveScreen`, `CaptureArea(x i,y i,width u,height u)`,
|
|
70
|
+
`CaptureWorkspace`, `CaptureWindow(handle s)`, `CaptureActiveWindow`, `CaptureInteractive(kind u)`.
|
|
71
|
+
* results: `type="raw"`, `format` (QImage::Format; **6 = ARGB32_Premultiplied = BGRA bytes**),
|
|
72
|
+
`width`, `height`, `stride` (bytes/line), `scale` (double), `screen` (e.g. `DP-1`).
|
|
73
|
+
* options: `include-decoration` (def false), `include-shadow` (kwin default **true**),
|
|
74
|
+
`include-cursor` (def false), `native-resolution`, `hide-caller-windows` (def **true** —
|
|
75
|
+
hides the capture tool's own windows, which we want).
|
|
76
|
+
* **KWin replies BEFORE the pixels are written**: `ScreenShotSinkPipe2::flush()` sends the
|
|
77
|
+
method return, then hands the fd to `ScreenShotWriter2` (QThreadPool) which sets
|
|
78
|
+
O_NONBLOCK and `poll(POLLOUT, 60s)`. So EOF on our pipe = frame boundary; read the reply
|
|
79
|
+
first for the geometry, then drain. (A memfd also works but you must poll for the size.)
|
|
80
|
+
* errors: `org.kde.KWin.ScreenShot2.Error.{NoAuthorized,Cancelled,InvalidWindow,
|
|
81
|
+
NoActiveWindow,InvalidArea,InvalidScreen,FileDescriptor}`.
|
|
82
|
+
* Source for reference (branch Plasma/6.6): `src/plugins/screenshot/screenshotdbusinterface2.cpp`
|
|
83
|
+
(`/tmp/sdbus2.cpp` held a copy), `src/utils/serviceutils.h`.
|
|
84
|
+
|
|
85
|
+
### 2. Authorisation (the thing that blocked us first)
|
|
86
|
+
`checkPermissions()` → `KWin::fetchRestrictedDBusInterfacesFromPid(pid)`:
|
|
87
|
+
caller pid (from `GetConnectionUnixProcessID`) → canonical `/proc/<pid>/exe` →
|
|
88
|
+
`KApplicationTrader` finds a **.desktop file whose `Exec=` first token canonicalises to that
|
|
89
|
+
exe** → allowed only if `X-KDE-DBUS-Restricted-Interfaces` contains
|
|
90
|
+
`org.kde.KWin.ScreenShot2`. Spectacle does exactly this.
|
|
91
|
+
⇒ **A Python script can never be authorised**; a native binary + desktop file can.
|
|
92
|
+
Installed by `make install-desktop`:
|
|
93
|
+
`~/.local/share/applications/io.github.kwcapture.desktop` with
|
|
94
|
+
`Exec=<abs path>/bin/kwcapture` + `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`
|
|
95
|
+
(+ `kbuildsycoca6`). **Re-run it after moving/rebuilding the binary.**
|
|
96
|
+
Alternative (NOT used): start the session with `KWIN_SCREENSHOT_NO_PERMISSION_CHECKS=1` in
|
|
97
|
+
*kwin's* environment — disables the check for everyone in the session.
|
|
98
|
+
|
|
99
|
+
## Environment facts
|
|
100
|
+
Ubuntu, Plasma **6.6**, kwin 6.6.6 (`kwin_wayland` + `kwin_wayland_wrapper`), Wayland session,
|
|
101
|
+
single output `DP-1` 2560x1440@164.69Hz scale 1. `$XDG_RUNTIME_DIR=/run/user/1000` (tmpfs 13G).
|
|
102
|
+
`grim` absent; `xdg-desktop-portal` main daemon NOT running (portal/PipeWire route avoided).
|
|
103
|
+
KWin does **not** advertise `ext_image_copy_capture_manager_v1` (checked by dumping the
|
|
104
|
+
Wayland registry — `probe/globals.c`, compiled `probe/globals`); its XML on disk comes from
|
|
105
|
+
wlroots deps only. No `wlr-screencopy` (KWin is not wlroots). XWayland root window is black.
|
|
106
|
+
Toolchain: gcc, `pkg-config libsystemd` (sd-bus 259), `wayland-client`, `wayland-scanner`.
|
|
107
|
+
venv `~/way_scr_cap/.venv` (Python 3.14.4): numpy, pillow, mss, wxPython, dbus-fast, dasbus,
|
|
108
|
+
opencv-python-headless; plus `.pth` → `/usr/lib/python3/dist-packages` so `import gi`
|
|
109
|
+
(PyGObject 3.56 + Gst typelibs) also works inside the venv.
|
|
110
|
+
|
|
111
|
+
## Gotchas (each cost real time — respect them)
|
|
112
|
+
* **D-Bus fd passing**: `busctl`/`dbus-send` can't pass fds. Works with sd-bus
|
|
113
|
+
(`sd_bus_message_append(m,"h",fd)`), `dbus-fast` (`Message(..., unix_fds=[fd])`), or Gio
|
|
114
|
+
(`call_with_unix_fd_list`, **async-only**, arg order
|
|
115
|
+
`bus_name, path, iface, method, params, reply_type, flags, timeout, fd_list, cancellable,
|
|
116
|
+
callback`; `a{sv}` values must each be a `GLib.Variant`).
|
|
117
|
+
* sd-bus: pkg-config name is **libsystemd**, include `<systemd/sd-bus.h>`, and the public
|
|
118
|
+
header has **no `_cleanup_`** macro — call `sd_bus_error_free()` manually.
|
|
119
|
+
* `__atomic_store_n()` does **not** accept `double`; plain-assign it and rely on the release
|
|
120
|
+
store of the sequence counter to order it.
|
|
121
|
+
* **shm request flag needs a wake-up**: the first `serve` implementation polled with a
|
|
122
|
+
250 ms timeout, so on-demand grabs took 273 ms. Fix = a FIFO `<shm>.req` opened O_RDWR by
|
|
123
|
+
the daemon (never EOF, never blocks) and O_WRONLY|O_NONBLOCK by the client; write 1 byte
|
|
124
|
+
per request.
|
|
125
|
+
* Pipes handed to KWin must be **O_NONBLOCK on our read end** in the daemon (else one
|
|
126
|
+
in-flight drain stalls the whole loop and can deadlock at depth>1); handle EOF-before-size
|
|
127
|
+
(set `hdr->error=EIO`, mark done) so the ring cannot wedge.
|
|
128
|
+
* Ring geometry: `KWC_HDR_STRUCT_SIZE` is asserted in **both** C (`_Static_assert`) and
|
|
129
|
+
Python (`assert ctypes.sizeof(_Hdr)`). Bump both if you edit the struct.
|
|
130
|
+
* Keep the ring slot ≥ 5K frames (`5120*2880*4`) so a resolution change doesn't overflow;
|
|
131
|
+
the file is sparse (226 MB apparent → 15 MB resident at 1440p).
|
|
132
|
+
* `np.frombuffer(mmap)` gives a read-only array; `setflags(write=copy)` in `_view()`.
|
|
133
|
+
`mmap.close()` raises `BufferError` while ctypes/numpy hold views (caught in `close()`).
|
|
134
|
+
* Pillow: no `BGRA` mode — use `Image.frombuffer("RGBA", size, buf, "raw", "BGRA", 0, 1)`.
|
|
135
|
+
`Image.fromarray(a, "BGRA")` does not work.
|
|
136
|
+
* `wl_output.geometry` width/height are **millimetres**; pixel size/refresh come from
|
|
137
|
+
`wl_output.mode`. Connector name (`DP-1`) is the `wl_output.name` event → needs binding v4.
|
|
138
|
+
* `pkill -f kwcapture` from a shell command **matches that same shell command** and kills
|
|
139
|
+
itself; use `pkill -f 'bin/kwcaptur[e] serv[e]'` or a unique `--shm` path to match.
|
|
140
|
+
* **The exec_shell tool caps out at 60 s** — `bench.py` (8 cases) and `tests.py` are close
|
|
141
|
+
to that; run them with fewer seconds (`bench.py 1`) or background them.
|
|
142
|
+
* `PIL.ImageGrab` can leave a `spectacle` process holding your stdout pipe open (looks like
|
|
143
|
+
a hang); `bench.py` reaps the ones it started.
|
|
144
|
+
|
|
145
|
+
## Packaging gotchas (v0.1.0)
|
|
146
|
+
* The wheel contains a compiled binary, so it must **not** be tagged `py3-none-any`:
|
|
147
|
+
setting `root_is_pure = False` in `build_py.finalize_options` was NOT enough with the
|
|
148
|
+
PEP 517 flow — override `bdist_wheel.get_tag()` (see `setup.py`) to get
|
|
149
|
+
`py3-none-linux_x86_64`.
|
|
150
|
+
* `_native.binary_matches_arch()` reads the ELF `e_machine` field so a helper that came
|
|
151
|
+
from a wheel built for another CPU is skipped (falls back to compiling here).
|
|
152
|
+
* Do **not** import the `kwcapture` package from `setup.py` (it imports numpy, which is not
|
|
153
|
+
a build dependency) — the compile command is duplicated in `setup.py` on purpose.
|
|
154
|
+
* Use portable `-O2` in `setup.py`; `-march=native` only in the dev `make` build (or
|
|
155
|
+
`KWCAPTURE_NATIVE=1`).
|
|
156
|
+
* KDE picks up a new desktop entry **asynchronously**: right after writing it, KWin can
|
|
157
|
+
still answer `NoAuthorized` for a second or two → `Capture.start()` retries with backoff
|
|
158
|
+
(0.1/0.3/0.6/1.0/1.5 s) and re-runs `kbuildsycoca6` every other attempt.
|
|
159
|
+
* One desktop entry per distinct helper path (`io.github.kwcapture-<sha1(path)[:10]>.desktop`)
|
|
160
|
+
so several venvs coexist; `_desktop.prune()` drops entries whose binary is gone.
|
|
161
|
+
|
|
162
|
+
## Ideas not done yet
|
|
163
|
+
* Per-window capture (`CaptureWindow` + window enumeration; KWin has `org.kde.KWin`/Windows?).
|
|
164
|
+
* Multi-monitor: works via one `Capture` per screen name, but there is no combined-mode helper.
|
|
165
|
+
* Auto-restart the daemon inside `grab()` on `DaemonDead` (currently the caller must
|
|
166
|
+
`restart()`; `kwcapture.grab()` singleton does re-create).
|
|
167
|
+
* Re-size the ring in place on resolution change instead of erroring out.
|
|
168
|
+
* Fractional scaling: `native-resolution` is set; behaviour with scale≠1 untested.
|
|
169
|
+
* If a non-KDE compositor is ever needed: `ext-image-copy-capture-v1`/`wlr-screencopy` with
|
|
170
|
+
the XML in `/usr/share/wayland-protocols/` + `wayland-scanner` (KWin does not implement it yet).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `bin/kwcapture` native helper (sd-bus, single file C): one-shot, `--bench`, `--list`,
|
|
8
|
+
and a `serve` daemon that keeps a D-Bus connection open and publishes frames into a
|
|
9
|
+
shared-memory ring.
|
|
10
|
+
- Python API: `Capture` (`grab` / `shot` / `shot_jpeg` / `latest` / `stats` / `bench`),
|
|
11
|
+
`list_screens()`, zero-copy BGRA views, optional OpenCV-accelerated downscale+encode.
|
|
12
|
+
- Automatic setup on first use: compiles the helper if the wheel did not ship one, and
|
|
13
|
+
writes the `~/.local/share/applications` desktop entry KWin requires to authorise
|
|
14
|
+
`org.kde.KWin.ScreenShot2`.
|
|
15
|
+
- CLI: `kwcapture doctor|setup|install-desktop|screens|grab|demo|bench`.
|
|
16
|
+
- Tested against KDE Plasma 6.6 / kwin 6.6.6 on X86-64; ~40 fps at 2560x1440.
|
kwcapture-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tjandra Satria Gunawan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include LICENSE
|
|
3
|
+
include CHANGELOG.md
|
|
4
|
+
include AGENTS.md
|
|
5
|
+
include pyproject.toml
|
|
6
|
+
include setup.py
|
|
7
|
+
include kwcapture/native/*.c
|
|
8
|
+
include kwcapture/native/include/*.h
|
|
9
|
+
include kwcapture/py.typed
|
|
10
|
+
recursive-include tests *.py
|
|
11
|
+
include bench.py
|
|
12
|
+
include .github/workflows/*.yml
|
|
13
|
+
|
|
14
|
+
prune kwcapture/bin
|
|
15
|
+
prune probe
|
|
16
|
+
global-exclude __pycache__
|
|
17
|
+
global-exclude *.py[cod]
|
|
18
|
+
global-exclude kwcapture/bin/kwcapture
|
|
19
|
+
global-exclude .DS_Store
|
|
20
|
+
prune **/__pycache__
|
|
21
|
+
prune .venv
|
|
22
|
+
exclude prompt.txt
|
|
23
|
+
exclude bench_result.md
|
kwcapture-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kwcapture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast Wayland screen capture for KDE Plasma (KWin): ~40 fps at 1440p with a zero-copy Python API
|
|
5
|
+
Author: Tjandra Satria Gunawan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tjandrasg/kwcapture
|
|
8
|
+
Project-URL: Repository, https://github.com/tjandrasg/kwcapture
|
|
9
|
+
Project-URL: Issues, https://github.com/tjandrasg/kwcapture/issues
|
|
10
|
+
Keywords: wayland,screenshot,screen-capture,kde,plasma,kwin,numpy,computer-use,vision
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: X11 Applications :: KDE
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: C
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Provides-Extra: fast
|
|
24
|
+
Requires-Dist: opencv-python-headless; extra == "fast"
|
|
25
|
+
Provides-Extra: pil
|
|
26
|
+
Requires-Dist: pillow; extra == "pil"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# kwcapture — fast screen capture on Wayland (KDE Plasma)
|
|
30
|
+
|
|
31
|
+
**`PIL.ImageGrab` runs at ~2 fps on Wayland** (it shells out to `spectacle`) and **`mss`
|
|
32
|
+
captures XWayland**, which is black for native Wayland windows. `kwcapture` talks to the
|
|
33
|
+
KWin compositor directly: **~40 fps at 2560×1440**, real pixels, ~26 ms from "give me a
|
|
34
|
+
frame" to a JPEG ready for a vision model.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
method fps median latency
|
|
38
|
+
mss (XWayland) 1113.8 0.9 ms <-- BLACK, useless on Wayland
|
|
39
|
+
PIL.ImageGrab (spectacle) 2.7 357.1 ms
|
|
40
|
+
kwcapture raw BGRA view 38.4 25.8 ms real pixels, zero copy
|
|
41
|
+
kwcapture RGB full res 37.9 26.0 ms
|
|
42
|
+
kwcapture JPEG 1280 wide 35.6 28.0 ms includes downscale + encode
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import kwcapture as K
|
|
47
|
+
|
|
48
|
+
cap = K.Capture() # starts the helper, first frame in ~50 ms
|
|
49
|
+
arr = cap.grab() # (H, W, 4) BGRA view into shared memory
|
|
50
|
+
rgb = cap.shot(width=1280) # (720, 1280, 3) uint8, contiguous
|
|
51
|
+
blob = cap.shot_jpeg(1280, quality=85) # bytes for a vision API
|
|
52
|
+
cap.stats() # {'grab_ms': 20.1, 'total_ms': 27.8, ...}
|
|
53
|
+
cap.close()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install kwcapture # PyPI
|
|
60
|
+
pip install "kwcapture[fast]" # + OpenCV: ~8x faster resize/encode
|
|
61
|
+
|
|
62
|
+
# straight from GitHub (builds the helper for your machine):
|
|
63
|
+
pip install "kwcapture @ git+https://github.com/tjandrasg/kwcapture.git"
|
|
64
|
+
|
|
65
|
+
# or the prebuilt linux x86-64 wheel from the release page (no compiler needed):
|
|
66
|
+
pip install https://github.com/tjandrasg/kwcapture/releases/download/v0.1.0/kwcapture-0.1.0-py3-none-linux_x86_64.whl
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Needs **KDE Plasma with KWin on Wayland** and a C compiler plus `libsystemd`/`wayland-client`
|
|
70
|
+
headers (the wheel builds a small native helper; on Debian/Ubuntu:
|
|
71
|
+
`sudo apt install build-essential libsystemd-dev libwayland-dev`). Only `numpy` is a
|
|
72
|
+
runtime dependency.
|
|
73
|
+
|
|
74
|
+
Then, in the graphical session:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
kwcapture doctor # checks session, helper, KWin authorisation, capture
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The first `Capture()` writes the one file KWin needs to authorise us
|
|
81
|
+
(`~/.local/share/applications/io.github.kwcapture-<hash>.desktop`) — no manual setup step.
|
|
82
|
+
Extras: `pip install "kwcapture[fast]"` (OpenCV: ~8x faster resize/encode) or
|
|
83
|
+
`"kwcapture[pil]"` (Pillow).
|
|
84
|
+
|
|
85
|
+
## Why a native helper (the interesting bit)
|
|
86
|
+
|
|
87
|
+
KWin exports `org.kde.KWin.ScreenShot2` over D-Bus: you hand it a file descriptor and it
|
|
88
|
+
writes raw ARGB32-premultiplied (BGRA in memory) pixels into it. But it **authorises
|
|
89
|
+
callers by `/proc/<pid>/exe`**: that binary must match the `Exec=` of a `.desktop` file
|
|
90
|
+
declaring `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2` — the trick
|
|
91
|
+
Spectacle uses. A Python interpreter can never satisfy that, so:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
kwcapture.py ──poke──▶ helper (serve) ──D-Bus──▶ kwin_wayland
|
|
95
|
+
◀──mmap ring──── ◀──pipe(fd)────────
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The helper keeps one D-Bus connection open and publishes frames into a shared-memory ring,
|
|
99
|
+
so a grab is a byte to a FIFO, a spin on a sequence counter, and a zero-copy numpy view:
|
|
100
|
+
no process per frame, no serialisation, no PNG round trip. KWin sends the reply *before*
|
|
101
|
+
the pixels land, so EOF on the pipe — not the reply — is the frame boundary.
|
|
102
|
+
|
|
103
|
+
## CLI
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
kwcapture doctor [--fix] [--build] # is everything OK? (--fix also authorises)
|
|
107
|
+
kwcapture setup # build the helper + authorise it
|
|
108
|
+
kwcapture install-desktop [--uninstall]# manage the KWin authorisation file
|
|
109
|
+
kwcapture screens # DP-1 2560x1440 @164.69Hz pos 0,0 scale 1
|
|
110
|
+
kwcapture grab -o shot.png --width 1280
|
|
111
|
+
kwcapture grab -o frame.bgra --raw # pure BGRA bytes
|
|
112
|
+
kwcapture demo --frames 60 --save f.png
|
|
113
|
+
kwcapture bench --frames 200
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Python API
|
|
117
|
+
|
|
118
|
+
`Capture(...)` arguments:
|
|
119
|
+
|
|
120
|
+
| argument | meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `screen="DP-1"` | which output (default: active screen) |
|
|
123
|
+
| `area=(x, y, w, h)` | capture a region instead of a whole output |
|
|
124
|
+
| `workspace=True` | capture the entire virtual desktop |
|
|
125
|
+
| `cursor=True` | include the hardware cursor |
|
|
126
|
+
| `decoration=True` | include window decorations and shadows |
|
|
127
|
+
| `slots=4` | ring depth — how many frames until a returned view is overwritten |
|
|
128
|
+
| `depth=2` | requests in flight to KWin (2 is optimal; more adds latency, not fps) |
|
|
129
|
+
| `fps=30` | continuous capture mode; read with `latest()` for zero-latency access |
|
|
130
|
+
| `idle_exit=300` | helper quits when untouched for this many seconds |
|
|
131
|
+
| `binary=…`, `allow_build=…` | override / disable locating-or-building the helper |
|
|
132
|
+
| `install_desktop=False` | don't touch `~/.local/share/applications` |
|
|
133
|
+
| `verbose=True` | report build/authorisation steps |
|
|
134
|
+
|
|
135
|
+
Methods: `grab(rgb=False, copy=False, fresh=True)`, `latest()`, `shot(width=…, resample=…)`,
|
|
136
|
+
`shot_png()`, `shot_jpeg()`, `stats()`, `geometry()`, `screen_name`, `bench(frames)`,
|
|
137
|
+
`restart()`, `close()` (also a context manager). Module helpers: `list_screens()`,
|
|
138
|
+
`to_rgb()`, `resize()`, `png_bytes()`, `jpeg_bytes()`, `find_binary()`,
|
|
139
|
+
`install_desktop_file()`.
|
|
140
|
+
|
|
141
|
+
Notes:
|
|
142
|
+
|
|
143
|
+
* `grab()` returns a **read-only view** of the shared ring, overwritten after `slots`
|
|
144
|
+
further frames — use `copy=True` (or `.copy()`) to keep a frame, or `shot()`/`to_rgb()`.
|
|
145
|
+
* `latest()` returns the newest published frame **without** asking KWin for one.
|
|
146
|
+
* `hide_caller_windows=True` by default: KWin hides the capturing process' own windows,
|
|
147
|
+
so your overlay/terminal does not end up in the shot (`--no-hide-caller` to disable).
|
|
148
|
+
* Each `Capture` gets its own ring in `$XDG_RUNTIME_DIR`, so several can run at once
|
|
149
|
+
(e.g. one per monitor). The helper also dies with its client (`PR_SET_PDEATHSIG`).
|
|
150
|
+
|
|
151
|
+
## Performance notes (2560×1440@165 Hz, Plasma 6.6, i9-13900K)
|
|
152
|
+
|
|
153
|
+
* ~11 ms is a fixed cost inside KWin (a 320×180 grab still takes 11 ms); 1440p adds ~7 ms
|
|
154
|
+
of grab and ~5 ms to move 14 MB across the pipe.
|
|
155
|
+
* **~47 fps is the hard ceiling** — KWin serialises screenshot jobs. `depth=2` reaches it;
|
|
156
|
+
`depth=3/4` only add latency (37/59/81 ms per frame).
|
|
157
|
+
* Post-processing: OpenCV `INTER_AREA` 2560→1280 ≈ 0.1 ms, `cvtColor` ≈3 ms, JPEG
|
|
158
|
+
1280 ≈3.5 ms / 2560 ≈4.5 ms, PNG ≈23 ms (prefer JPEG), Pillow resize ≈17 ms.
|
|
159
|
+
* Without OpenCV *or* Pillow, downscaling falls back to numpy (box-average on integer
|
|
160
|
+
factors, nearest otherwise) and image encoding raises a hint to install an extra.
|
|
161
|
+
* The ring file looks like 226 MB but is sparse: 15 MB resident at 1440p.
|
|
162
|
+
|
|
163
|
+
## Repo layout
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
kwcapture/
|
|
167
|
+
__init__.py Python API (Capture, grab/shot, shm ctypes mirror)
|
|
168
|
+
__main__.py CLI: doctor / setup / install-desktop / screens / grab / demo / bench
|
|
169
|
+
_native.py find or compile the helper ($KWCAPTURE_BIN, wheel, cache, source)
|
|
170
|
+
_desktop.py the KWin authorisation desktop entry
|
|
171
|
+
native/kwcapture.c the capture daemon (sd-bus + shm ring + wl_output listing)
|
|
172
|
+
native/include/kwcapture_shm.h shared-memory protocol (asserted on both sides)
|
|
173
|
+
tests/test_kwcapture.py 27 functional tests (also `pytest tests/`)
|
|
174
|
+
bench.py comparison against mss and PIL.ImageGrab
|
|
175
|
+
probe/ the reverse-engineering experiments (Wayland global dumper, etc.)
|
|
176
|
+
AGENTS.md investigation log — how the KWin API and its auth really work
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Development:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python3 -m venv .venv && .venv/bin/pip install numpy pillow opencv-python-headless
|
|
183
|
+
make setup # build the helper into kwcapture/bin + authorise
|
|
184
|
+
make test bench # tests, then the comparison table
|
|
185
|
+
make wheel # dist/*.whl
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Troubleshooting
|
|
189
|
+
|
|
190
|
+
| symptom | cause / fix |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `The process is not authorized to take a screenshot` | `kwcapture install-desktop`, then retry (KDE's service cache notices the new file asynchronously — `Capture` already retries for a few seconds) |
|
|
193
|
+
| `no frame within 2.0s` | helper died: `Capture.restart()`, or `Capture(daemon_stderr=sys.stderr)` to see its log; `kwcapture doctor` |
|
|
194
|
+
| `failed to compile the kwcapture helper` | install `build-essential libsystemd-dev libwayland-dev`, or build it yourself and set `KWCAPTURE_BIN` |
|
|
195
|
+
| `frame needs N bytes, slot has M` | resolution went above ~5K: restart the helper |
|
|
196
|
+
| works in a terminal but not from cron/SSH | you need `WAYLAND_DISPLAY` **and** `DBUS_SESSION_BUS_ADDRESS` of the graphical session |
|
|
197
|
+
| colours wrong somewhere | `grab()` is BGRA; `shot()`/`to_rgb()` are RGB |
|
|
198
|
+
|
|
199
|
+
Non-KDE compositors need `ext-image-copy-capture-v1` / `wlr-screencopy-unstable-v1`
|
|
200
|
+
instead (kwcapture does not implement those; `probe/globals.c` shows how to check what a
|
|
201
|
+
compositor advertises).
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT — see `LICENSE`. `AGENTS.md` documents the reverse engineering behind it.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# kwcapture — fast screen capture on Wayland (KDE Plasma)
|
|
2
|
+
|
|
3
|
+
**`PIL.ImageGrab` runs at ~2 fps on Wayland** (it shells out to `spectacle`) and **`mss`
|
|
4
|
+
captures XWayland**, which is black for native Wayland windows. `kwcapture` talks to the
|
|
5
|
+
KWin compositor directly: **~40 fps at 2560×1440**, real pixels, ~26 ms from "give me a
|
|
6
|
+
frame" to a JPEG ready for a vision model.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
method fps median latency
|
|
10
|
+
mss (XWayland) 1113.8 0.9 ms <-- BLACK, useless on Wayland
|
|
11
|
+
PIL.ImageGrab (spectacle) 2.7 357.1 ms
|
|
12
|
+
kwcapture raw BGRA view 38.4 25.8 ms real pixels, zero copy
|
|
13
|
+
kwcapture RGB full res 37.9 26.0 ms
|
|
14
|
+
kwcapture JPEG 1280 wide 35.6 28.0 ms includes downscale + encode
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import kwcapture as K
|
|
19
|
+
|
|
20
|
+
cap = K.Capture() # starts the helper, first frame in ~50 ms
|
|
21
|
+
arr = cap.grab() # (H, W, 4) BGRA view into shared memory
|
|
22
|
+
rgb = cap.shot(width=1280) # (720, 1280, 3) uint8, contiguous
|
|
23
|
+
blob = cap.shot_jpeg(1280, quality=85) # bytes for a vision API
|
|
24
|
+
cap.stats() # {'grab_ms': 20.1, 'total_ms': 27.8, ...}
|
|
25
|
+
cap.close()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install kwcapture # PyPI
|
|
32
|
+
pip install "kwcapture[fast]" # + OpenCV: ~8x faster resize/encode
|
|
33
|
+
|
|
34
|
+
# straight from GitHub (builds the helper for your machine):
|
|
35
|
+
pip install "kwcapture @ git+https://github.com/tjandrasg/kwcapture.git"
|
|
36
|
+
|
|
37
|
+
# or the prebuilt linux x86-64 wheel from the release page (no compiler needed):
|
|
38
|
+
pip install https://github.com/tjandrasg/kwcapture/releases/download/v0.1.0/kwcapture-0.1.0-py3-none-linux_x86_64.whl
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Needs **KDE Plasma with KWin on Wayland** and a C compiler plus `libsystemd`/`wayland-client`
|
|
42
|
+
headers (the wheel builds a small native helper; on Debian/Ubuntu:
|
|
43
|
+
`sudo apt install build-essential libsystemd-dev libwayland-dev`). Only `numpy` is a
|
|
44
|
+
runtime dependency.
|
|
45
|
+
|
|
46
|
+
Then, in the graphical session:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
kwcapture doctor # checks session, helper, KWin authorisation, capture
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The first `Capture()` writes the one file KWin needs to authorise us
|
|
53
|
+
(`~/.local/share/applications/io.github.kwcapture-<hash>.desktop`) — no manual setup step.
|
|
54
|
+
Extras: `pip install "kwcapture[fast]"` (OpenCV: ~8x faster resize/encode) or
|
|
55
|
+
`"kwcapture[pil]"` (Pillow).
|
|
56
|
+
|
|
57
|
+
## Why a native helper (the interesting bit)
|
|
58
|
+
|
|
59
|
+
KWin exports `org.kde.KWin.ScreenShot2` over D-Bus: you hand it a file descriptor and it
|
|
60
|
+
writes raw ARGB32-premultiplied (BGRA in memory) pixels into it. But it **authorises
|
|
61
|
+
callers by `/proc/<pid>/exe`**: that binary must match the `Exec=` of a `.desktop` file
|
|
62
|
+
declaring `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2` — the trick
|
|
63
|
+
Spectacle uses. A Python interpreter can never satisfy that, so:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
kwcapture.py ──poke──▶ helper (serve) ──D-Bus──▶ kwin_wayland
|
|
67
|
+
◀──mmap ring──── ◀──pipe(fd)────────
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The helper keeps one D-Bus connection open and publishes frames into a shared-memory ring,
|
|
71
|
+
so a grab is a byte to a FIFO, a spin on a sequence counter, and a zero-copy numpy view:
|
|
72
|
+
no process per frame, no serialisation, no PNG round trip. KWin sends the reply *before*
|
|
73
|
+
the pixels land, so EOF on the pipe — not the reply — is the frame boundary.
|
|
74
|
+
|
|
75
|
+
## CLI
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
kwcapture doctor [--fix] [--build] # is everything OK? (--fix also authorises)
|
|
79
|
+
kwcapture setup # build the helper + authorise it
|
|
80
|
+
kwcapture install-desktop [--uninstall]# manage the KWin authorisation file
|
|
81
|
+
kwcapture screens # DP-1 2560x1440 @164.69Hz pos 0,0 scale 1
|
|
82
|
+
kwcapture grab -o shot.png --width 1280
|
|
83
|
+
kwcapture grab -o frame.bgra --raw # pure BGRA bytes
|
|
84
|
+
kwcapture demo --frames 60 --save f.png
|
|
85
|
+
kwcapture bench --frames 200
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Python API
|
|
89
|
+
|
|
90
|
+
`Capture(...)` arguments:
|
|
91
|
+
|
|
92
|
+
| argument | meaning |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `screen="DP-1"` | which output (default: active screen) |
|
|
95
|
+
| `area=(x, y, w, h)` | capture a region instead of a whole output |
|
|
96
|
+
| `workspace=True` | capture the entire virtual desktop |
|
|
97
|
+
| `cursor=True` | include the hardware cursor |
|
|
98
|
+
| `decoration=True` | include window decorations and shadows |
|
|
99
|
+
| `slots=4` | ring depth — how many frames until a returned view is overwritten |
|
|
100
|
+
| `depth=2` | requests in flight to KWin (2 is optimal; more adds latency, not fps) |
|
|
101
|
+
| `fps=30` | continuous capture mode; read with `latest()` for zero-latency access |
|
|
102
|
+
| `idle_exit=300` | helper quits when untouched for this many seconds |
|
|
103
|
+
| `binary=…`, `allow_build=…` | override / disable locating-or-building the helper |
|
|
104
|
+
| `install_desktop=False` | don't touch `~/.local/share/applications` |
|
|
105
|
+
| `verbose=True` | report build/authorisation steps |
|
|
106
|
+
|
|
107
|
+
Methods: `grab(rgb=False, copy=False, fresh=True)`, `latest()`, `shot(width=…, resample=…)`,
|
|
108
|
+
`shot_png()`, `shot_jpeg()`, `stats()`, `geometry()`, `screen_name`, `bench(frames)`,
|
|
109
|
+
`restart()`, `close()` (also a context manager). Module helpers: `list_screens()`,
|
|
110
|
+
`to_rgb()`, `resize()`, `png_bytes()`, `jpeg_bytes()`, `find_binary()`,
|
|
111
|
+
`install_desktop_file()`.
|
|
112
|
+
|
|
113
|
+
Notes:
|
|
114
|
+
|
|
115
|
+
* `grab()` returns a **read-only view** of the shared ring, overwritten after `slots`
|
|
116
|
+
further frames — use `copy=True` (or `.copy()`) to keep a frame, or `shot()`/`to_rgb()`.
|
|
117
|
+
* `latest()` returns the newest published frame **without** asking KWin for one.
|
|
118
|
+
* `hide_caller_windows=True` by default: KWin hides the capturing process' own windows,
|
|
119
|
+
so your overlay/terminal does not end up in the shot (`--no-hide-caller` to disable).
|
|
120
|
+
* Each `Capture` gets its own ring in `$XDG_RUNTIME_DIR`, so several can run at once
|
|
121
|
+
(e.g. one per monitor). The helper also dies with its client (`PR_SET_PDEATHSIG`).
|
|
122
|
+
|
|
123
|
+
## Performance notes (2560×1440@165 Hz, Plasma 6.6, i9-13900K)
|
|
124
|
+
|
|
125
|
+
* ~11 ms is a fixed cost inside KWin (a 320×180 grab still takes 11 ms); 1440p adds ~7 ms
|
|
126
|
+
of grab and ~5 ms to move 14 MB across the pipe.
|
|
127
|
+
* **~47 fps is the hard ceiling** — KWin serialises screenshot jobs. `depth=2` reaches it;
|
|
128
|
+
`depth=3/4` only add latency (37/59/81 ms per frame).
|
|
129
|
+
* Post-processing: OpenCV `INTER_AREA` 2560→1280 ≈ 0.1 ms, `cvtColor` ≈3 ms, JPEG
|
|
130
|
+
1280 ≈3.5 ms / 2560 ≈4.5 ms, PNG ≈23 ms (prefer JPEG), Pillow resize ≈17 ms.
|
|
131
|
+
* Without OpenCV *or* Pillow, downscaling falls back to numpy (box-average on integer
|
|
132
|
+
factors, nearest otherwise) and image encoding raises a hint to install an extra.
|
|
133
|
+
* The ring file looks like 226 MB but is sparse: 15 MB resident at 1440p.
|
|
134
|
+
|
|
135
|
+
## Repo layout
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
kwcapture/
|
|
139
|
+
__init__.py Python API (Capture, grab/shot, shm ctypes mirror)
|
|
140
|
+
__main__.py CLI: doctor / setup / install-desktop / screens / grab / demo / bench
|
|
141
|
+
_native.py find or compile the helper ($KWCAPTURE_BIN, wheel, cache, source)
|
|
142
|
+
_desktop.py the KWin authorisation desktop entry
|
|
143
|
+
native/kwcapture.c the capture daemon (sd-bus + shm ring + wl_output listing)
|
|
144
|
+
native/include/kwcapture_shm.h shared-memory protocol (asserted on both sides)
|
|
145
|
+
tests/test_kwcapture.py 27 functional tests (also `pytest tests/`)
|
|
146
|
+
bench.py comparison against mss and PIL.ImageGrab
|
|
147
|
+
probe/ the reverse-engineering experiments (Wayland global dumper, etc.)
|
|
148
|
+
AGENTS.md investigation log — how the KWin API and its auth really work
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Development:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
python3 -m venv .venv && .venv/bin/pip install numpy pillow opencv-python-headless
|
|
155
|
+
make setup # build the helper into kwcapture/bin + authorise
|
|
156
|
+
make test bench # tests, then the comparison table
|
|
157
|
+
make wheel # dist/*.whl
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Troubleshooting
|
|
161
|
+
|
|
162
|
+
| symptom | cause / fix |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `The process is not authorized to take a screenshot` | `kwcapture install-desktop`, then retry (KDE's service cache notices the new file asynchronously — `Capture` already retries for a few seconds) |
|
|
165
|
+
| `no frame within 2.0s` | helper died: `Capture.restart()`, or `Capture(daemon_stderr=sys.stderr)` to see its log; `kwcapture doctor` |
|
|
166
|
+
| `failed to compile the kwcapture helper` | install `build-essential libsystemd-dev libwayland-dev`, or build it yourself and set `KWCAPTURE_BIN` |
|
|
167
|
+
| `frame needs N bytes, slot has M` | resolution went above ~5K: restart the helper |
|
|
168
|
+
| works in a terminal but not from cron/SSH | you need `WAYLAND_DISPLAY` **and** `DBUS_SESSION_BUS_ADDRESS` of the graphical session |
|
|
169
|
+
| colours wrong somewhere | `grab()` is BGRA; `shot()`/`to_rgb()` are RGB |
|
|
170
|
+
|
|
171
|
+
Non-KDE compositors need `ext-image-copy-capture-v1` / `wlr-screencopy-unstable-v1`
|
|
172
|
+
instead (kwcapture does not implement those; `probe/globals.c` shows how to check what a
|
|
173
|
+
compositor advertises).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT — see `LICENSE`. `AGENTS.md` documents the reverse engineering behind it.
|