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.
@@ -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.
@@ -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
@@ -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.