capturekit 0.1.0__py3-none-any.whl
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.
- capturekit/__init__.py +164 -0
- capturekit/_backends/__init__.py +0 -0
- capturekit/_backends/base.py +89 -0
- capturekit/_backends/linux/__init__.py +1 -0
- capturekit/_backends/linux/_xlib.py +655 -0
- capturekit/_backends/linux/api.py +160 -0
- capturekit/_backends/linux/pulse.py +464 -0
- capturekit/_backends/linux/x11.py +720 -0
- capturekit/_backends/windows/__init__.py +0 -0
- capturekit/_backends/windows/_com.py +350 -0
- capturekit/_backends/windows/_d3d11.py +307 -0
- capturekit/_backends/windows/_win32.py +420 -0
- capturekit/_backends/windows/_winrt.py +162 -0
- capturekit/_backends/windows/api.py +214 -0
- capturekit/_backends/windows/cursor.py +66 -0
- capturekit/_backends/windows/dxgi_duplication.py +310 -0
- capturekit/_backends/windows/wasapi.py +570 -0
- capturekit/_backends/windows/wgc.py +398 -0
- capturekit/_pipeline/__init__.py +1 -0
- capturekit/_pipeline/aligner.py +177 -0
- capturekit/_pipeline/clock.py +47 -0
- capturekit/_pipeline/frame_queue.py +120 -0
- capturekit/_pipeline/mixer.py +80 -0
- capturekit/_pipeline/timeline.py +133 -0
- capturekit/_platform.py +24 -0
- capturekit/_version.py +1 -0
- capturekit/_worker.py +100 -0
- capturekit/capabilities.py +79 -0
- capturekit/capture.py +141 -0
- capturekit/config.py +85 -0
- capturekit/encoding/__init__.py +5 -0
- capturekit/encoding/ffmpeg.py +356 -0
- capturekit/encoding/pipes.py +212 -0
- capturekit/errors.py +109 -0
- capturekit/py.typed +0 -0
- capturekit/recording.py +940 -0
- capturekit/sources.py +124 -0
- capturekit/types.py +211 -0
- capturekit-0.1.0.dist-info/METADATA +112 -0
- capturekit-0.1.0.dist-info/RECORD +41 -0
- capturekit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""X11 video: display resolution, monitors, windows, XShm and XComposite capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from ctypes import POINTER, byref, c_int, c_uint
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ..._pipeline.clock import now_ns
|
|
13
|
+
from ...errors import (
|
|
14
|
+
CapabilityUnavailableError,
|
|
15
|
+
NativeError,
|
|
16
|
+
TargetNotFoundError,
|
|
17
|
+
UnsupportedDisplayServerError,
|
|
18
|
+
WindowClosedError,
|
|
19
|
+
)
|
|
20
|
+
from ...types import Monitor, Rect, Window, validate_region
|
|
21
|
+
from ..base import AcquiredFrame, Buffer, VideoBackendInfo, blit_clear, crop_box
|
|
22
|
+
from . import _xlib as X
|
|
23
|
+
from ._xlib import XDisplay, XError
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("capturekit.linux.x11")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- display / session ------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_display(display: str | None) -> tuple[str, bool]:
|
|
32
|
+
"""Return ``(display_name, explicit)``.
|
|
33
|
+
|
|
34
|
+
Without an explicit display a Wayland session is rejected: ``$DISPLAY``
|
|
35
|
+
would point at XWayland, which only contains X11 clients, not the desktop.
|
|
36
|
+
"""
|
|
37
|
+
if display is not None:
|
|
38
|
+
return display, True
|
|
39
|
+
if os.environ.get("WAYLAND_DISPLAY") or os.environ.get("XDG_SESSION_TYPE") == "wayland":
|
|
40
|
+
raise UnsupportedDisplayServerError(
|
|
41
|
+
"Wayland session detected: native Wayland capture is not supported. Pass an "
|
|
42
|
+
"explicit X11 display (e.g. display=':99' for an Xvfb server)."
|
|
43
|
+
)
|
|
44
|
+
name = os.environ.get("DISPLAY")
|
|
45
|
+
if not name:
|
|
46
|
+
raise UnsupportedDisplayServerError("no X11 display: $DISPLAY is not set")
|
|
47
|
+
return name, False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def open_display(display: str | None) -> XDisplay:
|
|
51
|
+
name, explicit = resolve_display(display)
|
|
52
|
+
d = XDisplay(name)
|
|
53
|
+
if d.has_extension("XWAYLAND") and not explicit:
|
|
54
|
+
d.close()
|
|
55
|
+
raise UnsupportedDisplayServerError(
|
|
56
|
+
f"{name} is XWayland (Wayland desktop); pass an explicit X11 display to capture "
|
|
57
|
+
"X11 clients on it"
|
|
58
|
+
)
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def server_kind(d: XDisplay) -> str:
|
|
63
|
+
if d.has_extension("XWAYLAND"):
|
|
64
|
+
return "xwayland"
|
|
65
|
+
names = [m.name for m in _monitors(d)]
|
|
66
|
+
if names == ["screen"] and _wm_window(d) is None:
|
|
67
|
+
return "xvfb" # heuristic: vfb's single RandR output is named "screen"
|
|
68
|
+
return "x11"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- monitors -------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _atom_name(d: XDisplay, atom: int) -> str:
|
|
75
|
+
p = X.x11.XGetAtomName(d.ptr, atom)
|
|
76
|
+
if not p:
|
|
77
|
+
return f"atom-{atom}"
|
|
78
|
+
try:
|
|
79
|
+
return ctypes.string_at(p).decode("utf-8", "replace")
|
|
80
|
+
finally:
|
|
81
|
+
X.x11.XFree(p)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _monitors(d: XDisplay) -> list[Monitor]:
|
|
85
|
+
found: list[tuple[str, Rect, bool]] = []
|
|
86
|
+
if X.xrandr is not None and d.has_extension("RANDR"):
|
|
87
|
+
major, minor = c_int(), c_int()
|
|
88
|
+
X.xrandr.XRRQueryVersion(d.ptr, byref(major), byref(minor))
|
|
89
|
+
if (major.value, minor.value) >= (1, 5):
|
|
90
|
+
n = c_int()
|
|
91
|
+
mons = d.checked(
|
|
92
|
+
"XRRGetMonitors", X.xrandr.XRRGetMonitors, d.ptr, d.root, True, byref(n)
|
|
93
|
+
)
|
|
94
|
+
if mons:
|
|
95
|
+
try:
|
|
96
|
+
for i in range(n.value):
|
|
97
|
+
m = mons[i]
|
|
98
|
+
found.append(
|
|
99
|
+
(
|
|
100
|
+
_atom_name(d, m.name),
|
|
101
|
+
Rect(m.x, m.y, m.width, m.height),
|
|
102
|
+
bool(m.primary),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
finally:
|
|
106
|
+
X.xrandr.XRRFreeMonitors(mons)
|
|
107
|
+
if not found and (major.value, minor.value) >= (1, 2):
|
|
108
|
+
res = X.xrandr.XRRGetScreenResourcesCurrent(d.ptr, d.root)
|
|
109
|
+
if res:
|
|
110
|
+
try:
|
|
111
|
+
primary_out = X.xrandr.XRRGetOutputPrimary(d.ptr, d.root)
|
|
112
|
+
r = res.contents
|
|
113
|
+
for i in range(r.ncrtc):
|
|
114
|
+
ci = X.xrandr.XRRGetCrtcInfo(d.ptr, res, r.crtcs[i])
|
|
115
|
+
if not ci:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
c = ci.contents
|
|
119
|
+
if c.mode and c.width > 0 and c.height > 0:
|
|
120
|
+
outs = [c.outputs[k] for k in range(c.noutput)]
|
|
121
|
+
found.append(
|
|
122
|
+
(
|
|
123
|
+
f"crtc-{i}",
|
|
124
|
+
Rect(c.x, c.y, c.width, c.height),
|
|
125
|
+
primary_out in outs,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
finally:
|
|
129
|
+
X.xrandr.XRRFreeCrtcInfo(ci)
|
|
130
|
+
finally:
|
|
131
|
+
X.xrandr.XRRFreeScreenResources(res)
|
|
132
|
+
if (
|
|
133
|
+
not found
|
|
134
|
+
and X.xinerama is not None
|
|
135
|
+
and d.has_extension("XINERAMA")
|
|
136
|
+
and X.xinerama.XineramaIsActive(d.ptr)
|
|
137
|
+
):
|
|
138
|
+
n = c_int()
|
|
139
|
+
scr = X.xinerama.XineramaQueryScreens(d.ptr, byref(n))
|
|
140
|
+
if scr:
|
|
141
|
+
try:
|
|
142
|
+
for i in range(n.value):
|
|
143
|
+
s = scr[i]
|
|
144
|
+
found.append(
|
|
145
|
+
(
|
|
146
|
+
f"xinerama-{s.screen_number}",
|
|
147
|
+
Rect(s.x_org, s.y_org, s.width, s.height),
|
|
148
|
+
i == 0,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
finally:
|
|
152
|
+
X.x11.XFree(scr)
|
|
153
|
+
if not found:
|
|
154
|
+
w = X.x11.XDisplayWidth(d.ptr, d.screen)
|
|
155
|
+
h = X.x11.XDisplayHeight(d.ptr, d.screen)
|
|
156
|
+
found.append(("root", Rect(0, 0, w, h), True))
|
|
157
|
+
if not any(p for _, _, p in found):
|
|
158
|
+
found[0] = (found[0][0], found[0][1], True)
|
|
159
|
+
found.sort(key=lambda t: (not t[2], t[1].x, t[1].y))
|
|
160
|
+
return [
|
|
161
|
+
Monitor(id=name, index=i, name=name, rect=rect, is_primary=primary, display=d.name)
|
|
162
|
+
for i, (name, rect, primary) in enumerate(found)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_monitors(display: str | None) -> list[Monitor]:
|
|
167
|
+
with open_display(display) as d:
|
|
168
|
+
return _monitors(d)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- windows --------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _wm_window(d: XDisplay) -> int | None:
|
|
175
|
+
vals = d.get_longs(d.root, "_NET_SUPPORTING_WM_CHECK", X.XA_WINDOW)
|
|
176
|
+
if not vals:
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
child = d.get_longs(vals[0], "_NET_SUPPORTING_WM_CHECK", X.XA_WINDOW)
|
|
180
|
+
except XError:
|
|
181
|
+
return None
|
|
182
|
+
return vals[0] if child and child[0] == vals[0] else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _attributes(d: XDisplay, w: int) -> X.XWindowAttributes:
|
|
186
|
+
attrs = X.XWindowAttributes()
|
|
187
|
+
d.checked("XGetWindowAttributes", X.x11.XGetWindowAttributes, d.ptr, w, byref(attrs))
|
|
188
|
+
return attrs
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _query_tree(d: XDisplay, w: int) -> tuple[int, list[int]]:
|
|
192
|
+
root, parent = X.Window(), X.Window()
|
|
193
|
+
children = POINTER(X.Window)()
|
|
194
|
+
n = c_uint()
|
|
195
|
+
d.checked(
|
|
196
|
+
"XQueryTree",
|
|
197
|
+
X.x11.XQueryTree,
|
|
198
|
+
d.ptr,
|
|
199
|
+
w,
|
|
200
|
+
byref(root),
|
|
201
|
+
byref(parent),
|
|
202
|
+
byref(children),
|
|
203
|
+
byref(n),
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
return int(parent.value), [int(children[i]) for i in range(n.value)]
|
|
207
|
+
finally:
|
|
208
|
+
if children:
|
|
209
|
+
X.x11.XFree(children)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _toplevel_frame(d: XDisplay, w: int) -> int:
|
|
213
|
+
"""The ancestor of ``w`` directly below the root (the WM frame, if any)."""
|
|
214
|
+
cur = w
|
|
215
|
+
for _ in range(64):
|
|
216
|
+
parent, _ = _query_tree(d, cur)
|
|
217
|
+
if parent in (0, d.root):
|
|
218
|
+
return cur
|
|
219
|
+
cur = parent
|
|
220
|
+
return cur
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _client_candidates(d: XDisplay) -> list[int]:
|
|
224
|
+
if _wm_window(d) is not None:
|
|
225
|
+
stacking = d.get_longs(d.root, "_NET_CLIENT_LIST_STACKING", X.XA_WINDOW)
|
|
226
|
+
if stacking:
|
|
227
|
+
return stacking
|
|
228
|
+
return d.get_longs(d.root, "_NET_CLIENT_LIST", X.XA_WINDOW)
|
|
229
|
+
# No EWMH window manager (bare Xvfb): mapped top-level InputOutput windows.
|
|
230
|
+
_, children = _query_tree(d, d.root)
|
|
231
|
+
out = []
|
|
232
|
+
for w in children:
|
|
233
|
+
try:
|
|
234
|
+
a = _attributes(d, w)
|
|
235
|
+
except XError:
|
|
236
|
+
continue
|
|
237
|
+
if a.class_ == X.InputOutput and not a.override_redirect and a.width > 1 and a.height > 1:
|
|
238
|
+
out.append(w)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _window_pid(d: XDisplay, w: int) -> int | None:
|
|
243
|
+
if X.xres is not None and d.has_extension("X-Resource"):
|
|
244
|
+
spec = X.XResClientIdSpec(w, X.XRES_CLIENT_ID_PID_MASK)
|
|
245
|
+
num = ctypes.c_long()
|
|
246
|
+
ids = POINTER(X.XResClientIdValue)()
|
|
247
|
+
try:
|
|
248
|
+
d.checked(
|
|
249
|
+
"XResQueryClientIds",
|
|
250
|
+
X.xres.XResQueryClientIds,
|
|
251
|
+
d.ptr,
|
|
252
|
+
1,
|
|
253
|
+
byref(spec),
|
|
254
|
+
byref(num),
|
|
255
|
+
byref(ids),
|
|
256
|
+
)
|
|
257
|
+
except XError:
|
|
258
|
+
num.value = 0
|
|
259
|
+
if ids:
|
|
260
|
+
try:
|
|
261
|
+
for i in range(num.value):
|
|
262
|
+
pid = X.xres.XResGetClientPid(byref(ids[i]))
|
|
263
|
+
if pid > 0:
|
|
264
|
+
return int(pid)
|
|
265
|
+
finally:
|
|
266
|
+
X.xres.XResClientIdsDestroy(num, ids)
|
|
267
|
+
vals = d.get_longs(w, "_NET_WM_PID", X.XA_CARDINAL)
|
|
268
|
+
return vals[0] if vals else None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _process_name(pid: int | None) -> str | None:
|
|
272
|
+
if not pid:
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
with open(f"/proc/{pid}/comm", encoding="utf-8") as f:
|
|
276
|
+
return f.read().strip() or None
|
|
277
|
+
except OSError:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def describe_window(d: XDisplay, w: int) -> Window:
|
|
282
|
+
try:
|
|
283
|
+
a = _attributes(d, w)
|
|
284
|
+
title = d.get_string(w, "_NET_WM_NAME") or d.get_string(w, "WM_NAME") or ""
|
|
285
|
+
cls = d.get_property(w, "WM_CLASS")
|
|
286
|
+
class_name = None
|
|
287
|
+
if cls is not None:
|
|
288
|
+
parts = cls[2].split(b"\x00")
|
|
289
|
+
class_name = (parts[1] if len(parts) > 1 and parts[1] else parts[0]).decode(
|
|
290
|
+
"utf-8", "replace"
|
|
291
|
+
) or None
|
|
292
|
+
x, y, child = c_int(), c_int(), X.Window()
|
|
293
|
+
X.x11.XTranslateCoordinates(d.ptr, w, d.root, 0, 0, byref(x), byref(y), byref(child))
|
|
294
|
+
states = d.get_longs(w, "_NET_WM_STATE")
|
|
295
|
+
hidden = d.atom("_NET_WM_STATE_HIDDEN") in states
|
|
296
|
+
pid = _window_pid(d, w)
|
|
297
|
+
except XError as exc:
|
|
298
|
+
if exc.error_code in (X.BadWindow, X.BadDrawable):
|
|
299
|
+
raise TargetNotFoundError(f"X window {w:#x} does not exist") from exc
|
|
300
|
+
raise
|
|
301
|
+
return Window(
|
|
302
|
+
handle=int(w),
|
|
303
|
+
title=title,
|
|
304
|
+
class_name=class_name,
|
|
305
|
+
pid=pid,
|
|
306
|
+
process_name=_process_name(pid),
|
|
307
|
+
rect=Rect(x.value, y.value, a.width, a.height),
|
|
308
|
+
is_visible=a.map_state == X.IsViewable,
|
|
309
|
+
is_minimized=hidden or a.map_state != X.IsViewable,
|
|
310
|
+
display=d.name,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def list_windows(display: str | None, include_untitled: bool) -> list[Window]:
|
|
315
|
+
with open_display(display) as d:
|
|
316
|
+
out = []
|
|
317
|
+
for w in _client_candidates(d):
|
|
318
|
+
try:
|
|
319
|
+
info = describe_window(d, w)
|
|
320
|
+
except TargetNotFoundError:
|
|
321
|
+
continue # vanished while enumerating
|
|
322
|
+
if not include_untitled and not info.title:
|
|
323
|
+
continue
|
|
324
|
+
out.append(info)
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_window(handle: int, display: str | None) -> Window:
|
|
329
|
+
with open_display(display) as d:
|
|
330
|
+
return describe_window(d, handle)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --- image grabbing -------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class Grabber:
|
|
337
|
+
"""Reads a rectangle of a drawable into BGRA, via MIT-SHM when possible."""
|
|
338
|
+
|
|
339
|
+
def __init__(self, d: XDisplay, visual: int, depth: int) -> None:
|
|
340
|
+
self.d = d
|
|
341
|
+
self.visual = visual
|
|
342
|
+
self.depth = depth
|
|
343
|
+
# Validate the pixel layout from the Visual: images read from pixmaps
|
|
344
|
+
# carry zero colour masks (the GetImage reply has no visual for a
|
|
345
|
+
# pixmap), so the returned XImage cannot be used for this check.
|
|
346
|
+
vis = X.Visual.from_address(visual)
|
|
347
|
+
masks = (int(vis.red_mask), int(vis.green_mask), int(vis.blue_mask))
|
|
348
|
+
if depth not in (24, 32) or masks != (0xFF0000, 0xFF00, 0xFF):
|
|
349
|
+
raise CapabilityUnavailableError(
|
|
350
|
+
f"unsupported X visual: depth {depth}, masks "
|
|
351
|
+
f"{masks[0]:#x}/{masks[1]:#x}/{masks[2]:#x} (only 24/32-bit BGRX is supported)"
|
|
352
|
+
)
|
|
353
|
+
self.use_shm = X.xext is not None and d.has_extension("MIT-SHM")
|
|
354
|
+
self._img: ctypes._Pointer[X.XImage] | None = None
|
|
355
|
+
self._seg = X.XShmSegmentInfo()
|
|
356
|
+
self._size = (0, 0)
|
|
357
|
+
self.shm_failed_reason = ""
|
|
358
|
+
|
|
359
|
+
def _create_shm(self, w: int, h: int) -> bool:
|
|
360
|
+
assert X.xext is not None
|
|
361
|
+
self._free_shm()
|
|
362
|
+
img = X.xext.XShmCreateImage(
|
|
363
|
+
self.d.ptr, self.visual, self.depth, X.ZPixmap, None, byref(self._seg), w, h
|
|
364
|
+
)
|
|
365
|
+
if not img:
|
|
366
|
+
return False
|
|
367
|
+
size = img.contents.bytes_per_line * h
|
|
368
|
+
shmid = X.libc.shmget(X.IPC_PRIVATE, size, X.IPC_CREAT | 0o600)
|
|
369
|
+
if shmid < 0:
|
|
370
|
+
X.x11.XDestroyImage(img)
|
|
371
|
+
self.shm_failed_reason = f"shmget failed (errno {ctypes.get_errno()})"
|
|
372
|
+
return False
|
|
373
|
+
addr = X.libc.shmat(shmid, None, 0)
|
|
374
|
+
if addr in (None, ctypes.c_void_p(-1).value):
|
|
375
|
+
X.libc.shmctl(shmid, X.IPC_RMID, None)
|
|
376
|
+
X.x11.XDestroyImage(img)
|
|
377
|
+
self.shm_failed_reason = "shmat failed"
|
|
378
|
+
return False
|
|
379
|
+
self._seg.shmid = shmid
|
|
380
|
+
self._seg.shmaddr = addr
|
|
381
|
+
self._seg.readOnly = False
|
|
382
|
+
img.contents.data = addr
|
|
383
|
+
try:
|
|
384
|
+
self.d.checked("XShmAttach", X.xext.XShmAttach, self.d.ptr, byref(self._seg))
|
|
385
|
+
except XError as exc:
|
|
386
|
+
# Remote display or different IPC namespace: use XGetImage.
|
|
387
|
+
self.shm_failed_reason = str(exc)
|
|
388
|
+
X.libc.shmdt(addr)
|
|
389
|
+
X.libc.shmctl(shmid, X.IPC_RMID, None)
|
|
390
|
+
img.contents.data = None
|
|
391
|
+
X.x11.XDestroyImage(img)
|
|
392
|
+
self._seg = X.XShmSegmentInfo()
|
|
393
|
+
return False
|
|
394
|
+
# Mark for removal now: the segment lives until the last detach, so a
|
|
395
|
+
# crash cannot leak it.
|
|
396
|
+
X.libc.shmctl(shmid, X.IPC_RMID, None)
|
|
397
|
+
self._img = img
|
|
398
|
+
self._size = (w, h)
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
def _free_shm(self) -> None:
|
|
402
|
+
if self._img is not None:
|
|
403
|
+
assert X.xext is not None
|
|
404
|
+
if self.d.alive:
|
|
405
|
+
X.xext.XShmDetach(self.d.ptr, byref(self._seg))
|
|
406
|
+
X.x11.XSync(self.d.ptr, False)
|
|
407
|
+
X.libc.shmdt(self._seg.shmaddr)
|
|
408
|
+
self._img.contents.data = None
|
|
409
|
+
X.x11.XDestroyImage(self._img)
|
|
410
|
+
self._img = None
|
|
411
|
+
self._seg = X.XShmSegmentInfo()
|
|
412
|
+
self._size = (0, 0)
|
|
413
|
+
|
|
414
|
+
def close(self) -> None:
|
|
415
|
+
self._free_shm()
|
|
416
|
+
|
|
417
|
+
def grab(self, drawable: int, x: int, y: int, w: int, h: int, out: Buffer) -> None:
|
|
418
|
+
"""Copy ``drawable[x:x+w, y:y+h]`` into the top-left of ``out``."""
|
|
419
|
+
if self.use_shm and self._size != (w, h) and not self._create_shm(w, h):
|
|
420
|
+
log.info("MIT-SHM unavailable (%s); using XGetImage", self.shm_failed_reason)
|
|
421
|
+
self.use_shm = False
|
|
422
|
+
if self.use_shm:
|
|
423
|
+
assert X.xext is not None and self._img is not None
|
|
424
|
+
self.d.checked(
|
|
425
|
+
"XShmGetImage",
|
|
426
|
+
X.xext.XShmGetImage,
|
|
427
|
+
self.d.ptr,
|
|
428
|
+
drawable,
|
|
429
|
+
self._img,
|
|
430
|
+
x,
|
|
431
|
+
y,
|
|
432
|
+
X.AllPlanes,
|
|
433
|
+
)
|
|
434
|
+
self._copy(self._img.contents, out)
|
|
435
|
+
return
|
|
436
|
+
img = self.d.checked(
|
|
437
|
+
"XGetImage", X.x11.XGetImage, self.d.ptr, drawable, x, y, w, h, X.AllPlanes, X.ZPixmap
|
|
438
|
+
)
|
|
439
|
+
if not img:
|
|
440
|
+
raise NativeError("XGetImage returned NULL")
|
|
441
|
+
try:
|
|
442
|
+
self._copy(img.contents, out)
|
|
443
|
+
finally:
|
|
444
|
+
X.x11.XDestroyImage(img)
|
|
445
|
+
|
|
446
|
+
@staticmethod
|
|
447
|
+
def _copy(img: X.XImage, out: Buffer) -> None:
|
|
448
|
+
if img.bits_per_pixel != 32 or img.byte_order != X.LSBFirst:
|
|
449
|
+
raise CapabilityUnavailableError(
|
|
450
|
+
f"unsupported X image format: {img.bits_per_pixel} bpp, byte order "
|
|
451
|
+
f"{img.byte_order} (only 32 bpp little-endian is supported)"
|
|
452
|
+
)
|
|
453
|
+
w, h, stride = img.width, img.height, img.bytes_per_line
|
|
454
|
+
cw, ch = min(w, out.shape[1]), min(h, out.shape[0])
|
|
455
|
+
raw = (ctypes.c_ubyte * (stride * (h - 1) + w * 4)).from_address(img.data)
|
|
456
|
+
src = np.lib.stride_tricks.as_strided(
|
|
457
|
+
np.frombuffer(raw, dtype=np.uint8), shape=(h, w, 4), strides=(stride, 4, 1)
|
|
458
|
+
)
|
|
459
|
+
out[:ch, :cw] = src[:ch, :cw]
|
|
460
|
+
if img.depth != 32:
|
|
461
|
+
out[:ch, :cw, 3] = 255 # X byte of depth-24 pixels is undefined
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --- monitor backend -----------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class X11MonitorBackend:
|
|
468
|
+
"""Root-window capture of one monitor (MIT-SHM, XGetImage fallback)."""
|
|
469
|
+
|
|
470
|
+
def __init__(self, monitor: Monitor, region: Rect | None, display: str | None) -> None:
|
|
471
|
+
self.monitor = monitor
|
|
472
|
+
self.region = region
|
|
473
|
+
self.display = display if display is not None else monitor.display
|
|
474
|
+
self._d: XDisplay | None = None
|
|
475
|
+
self._grabber: Grabber | None = None
|
|
476
|
+
self._crop = crop_box(Rect(0, 0, monitor.width, monitor.height), region)
|
|
477
|
+
self.info = VideoBackendInfo("x11-shm", self._crop.size)
|
|
478
|
+
|
|
479
|
+
def start(self) -> None:
|
|
480
|
+
name, explicit = resolve_display(self.display)
|
|
481
|
+
d = open_display(name if explicit else None)
|
|
482
|
+
if d.has_extension("XWAYLAND"):
|
|
483
|
+
d.close()
|
|
484
|
+
raise UnsupportedDisplayServerError(
|
|
485
|
+
"monitor capture on XWayland would only show X11 clients, not the Wayland "
|
|
486
|
+
"desktop; not supported (window capture of X11 clients is)"
|
|
487
|
+
)
|
|
488
|
+
self._d = d
|
|
489
|
+
if self.region is not None:
|
|
490
|
+
validate_region(self.region, (self.monitor.width, self.monitor.height))
|
|
491
|
+
visual = X.x11.XDefaultVisual(d.ptr, d.screen)
|
|
492
|
+
depth = X.x11.XDefaultDepth(d.ptr, d.screen)
|
|
493
|
+
self._grabber = Grabber(d, visual, depth)
|
|
494
|
+
self.info.name = "x11-shm" if self._grabber.use_shm else "x11-getimage"
|
|
495
|
+
self.info.details["server"] = server_kind(d)
|
|
496
|
+
|
|
497
|
+
def acquire(self, out: Buffer | None, timeout_s: float) -> AcquiredFrame | None:
|
|
498
|
+
assert self._d is not None and self._grabber is not None
|
|
499
|
+
c = self._crop.translate(self.monitor.rect.x, self.monitor.rect.y)
|
|
500
|
+
dest = out if out is not None else np.empty((c.height, c.width, 4), np.uint8)
|
|
501
|
+
self._grabber.grab(self._d.root, c.x, c.y, c.width, c.height, dest)
|
|
502
|
+
blit_clear(dest, min(c.width, dest.shape[1]), min(c.height, dest.shape[0]))
|
|
503
|
+
if not self._grabber.use_shm:
|
|
504
|
+
self.info.name = "x11-getimage"
|
|
505
|
+
# Root capture has no change notification: every poll is a new frame.
|
|
506
|
+
return AcquiredFrame(dest, now_ns(), (self.monitor.width, self.monitor.height))
|
|
507
|
+
|
|
508
|
+
def close(self) -> None:
|
|
509
|
+
if self._grabber is not None:
|
|
510
|
+
self._grabber.close()
|
|
511
|
+
self._grabber = None
|
|
512
|
+
if self._d is not None:
|
|
513
|
+
self._d.close()
|
|
514
|
+
self._d = None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# --- window backend (XComposite) -----------------------------------------------------
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class CompositeWindowBackend:
|
|
521
|
+
"""Exclusive window capture: the window is redirected off-screen with
|
|
522
|
+
XComposite (Automatic mode, shareable with compositing managers) and
|
|
523
|
+
read from its own pixmap, so overlapping windows never appear."""
|
|
524
|
+
|
|
525
|
+
def __init__(
|
|
526
|
+
self, window: int, region: Rect | None, client_area_only: bool, display: str | None
|
|
527
|
+
) -> None:
|
|
528
|
+
self.window = window
|
|
529
|
+
self.region = region
|
|
530
|
+
self.client_area_only = client_area_only
|
|
531
|
+
self.display = display
|
|
532
|
+
self._d: XDisplay | None = None
|
|
533
|
+
self._target = window
|
|
534
|
+
self._pixmap = 0
|
|
535
|
+
self._damage = 0
|
|
536
|
+
self._damage_event = -1
|
|
537
|
+
self._dirty = True
|
|
538
|
+
self._viewable = False
|
|
539
|
+
self._closed = False
|
|
540
|
+
self._size = (0, 0)
|
|
541
|
+
self._grabber: Grabber | None = None
|
|
542
|
+
self._redirected = False
|
|
543
|
+
self.info = VideoBackendInfo("x11-composite", (0, 0))
|
|
544
|
+
|
|
545
|
+
def start(self) -> None:
|
|
546
|
+
if self.display is None:
|
|
547
|
+
raise CapabilityUnavailableError("X11 window capture needs the window's display")
|
|
548
|
+
d = open_display(self.display)
|
|
549
|
+
self._d = d
|
|
550
|
+
try:
|
|
551
|
+
if X.xcomposite is None or not d.has_extension("Composite"):
|
|
552
|
+
raise CapabilityUnavailableError(
|
|
553
|
+
f"the X server {d.name} lacks the Composite extension; exclusive window "
|
|
554
|
+
"capture is unavailable (capturekit does not fall back to screen cropping)"
|
|
555
|
+
)
|
|
556
|
+
major, minor = c_int(0), c_int(4)
|
|
557
|
+
X.xcomposite.XCompositeQueryVersion(d.ptr, byref(major), byref(minor))
|
|
558
|
+
if (major.value, minor.value) < (0, 2):
|
|
559
|
+
raise CapabilityUnavailableError("Composite >= 0.2 required")
|
|
560
|
+
try:
|
|
561
|
+
self._target = (
|
|
562
|
+
self.window if self.client_area_only else _toplevel_frame(d, self.window)
|
|
563
|
+
)
|
|
564
|
+
attrs = _attributes(d, self._target)
|
|
565
|
+
except XError as exc:
|
|
566
|
+
raise TargetNotFoundError(f"X window {self.window:#x} does not exist") from exc
|
|
567
|
+
d.checked(
|
|
568
|
+
"XSelectInput", X.x11.XSelectInput, d.ptr, self._target, X.StructureNotifyMask
|
|
569
|
+
)
|
|
570
|
+
d.checked(
|
|
571
|
+
"XCompositeRedirectWindow",
|
|
572
|
+
X.xcomposite.XCompositeRedirectWindow,
|
|
573
|
+
d.ptr,
|
|
574
|
+
self._target,
|
|
575
|
+
X.CompositeRedirectAutomatic,
|
|
576
|
+
)
|
|
577
|
+
self._redirected = True
|
|
578
|
+
if X.xdamage is not None and d.has_extension("DAMAGE"):
|
|
579
|
+
self._damage = int(
|
|
580
|
+
d.checked(
|
|
581
|
+
"XDamageCreate",
|
|
582
|
+
X.xdamage.XDamageCreate,
|
|
583
|
+
d.ptr,
|
|
584
|
+
self._target,
|
|
585
|
+
X.XDamageReportNonEmpty,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
base = d.extension_event_base("DAMAGE")
|
|
589
|
+
self._damage_event = base if base is not None else -1
|
|
590
|
+
self._grabber = Grabber(d, attrs.visual, attrs.depth)
|
|
591
|
+
self._size = (attrs.width, attrs.height)
|
|
592
|
+
self._viewable = attrs.map_state == X.IsViewable
|
|
593
|
+
if self._viewable:
|
|
594
|
+
self._name_pixmap()
|
|
595
|
+
base_rect = Rect(0, 0, *self._size)
|
|
596
|
+
if self.region is not None:
|
|
597
|
+
validate_region(self.region, base_rect.size)
|
|
598
|
+
self.info.initial_size = crop_box(base_rect, self.region).size
|
|
599
|
+
self.info.details.update(
|
|
600
|
+
server=server_kind(d),
|
|
601
|
+
damage=bool(self._damage),
|
|
602
|
+
shm=self._grabber.use_shm,
|
|
603
|
+
depth=attrs.depth,
|
|
604
|
+
)
|
|
605
|
+
except BaseException:
|
|
606
|
+
self.close()
|
|
607
|
+
raise
|
|
608
|
+
|
|
609
|
+
def _name_pixmap(self) -> None:
|
|
610
|
+
assert self._d is not None and X.xcomposite is not None
|
|
611
|
+
self._free_pixmap()
|
|
612
|
+
try:
|
|
613
|
+
self._pixmap = int(
|
|
614
|
+
self._d.checked(
|
|
615
|
+
"XCompositeNameWindowPixmap",
|
|
616
|
+
X.xcomposite.XCompositeNameWindowPixmap,
|
|
617
|
+
self._d.ptr,
|
|
618
|
+
self._target,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
except XError as exc:
|
|
622
|
+
if exc.error_code == X.BadMatch: # not viewable (unmapped / iconified)
|
|
623
|
+
self._viewable = False
|
|
624
|
+
return
|
|
625
|
+
if exc.error_code in (X.BadWindow, X.BadDrawable):
|
|
626
|
+
self._closed = True
|
|
627
|
+
return
|
|
628
|
+
raise
|
|
629
|
+
self._dirty = True
|
|
630
|
+
|
|
631
|
+
def _free_pixmap(self) -> None:
|
|
632
|
+
if self._pixmap and self._d is not None and self._d.alive:
|
|
633
|
+
X.x11.XFreePixmap(self._d.ptr, self._pixmap)
|
|
634
|
+
self._pixmap = 0
|
|
635
|
+
|
|
636
|
+
def _drain_events(self) -> None:
|
|
637
|
+
d = self._d
|
|
638
|
+
assert d is not None
|
|
639
|
+
ev = X.XEvent()
|
|
640
|
+
while X.x11.XPending(d.ptr) > 0:
|
|
641
|
+
X.x11.XNextEvent(d.ptr, byref(ev))
|
|
642
|
+
etype = ctypes.cast(byref(ev), POINTER(c_int))[0]
|
|
643
|
+
if etype == X.ConfigureNotify:
|
|
644
|
+
ce = ctypes.cast(byref(ev), POINTER(X.XConfigureEvent))[0]
|
|
645
|
+
if ce.window == self._target and (ce.width, ce.height) != self._size:
|
|
646
|
+
self._size = (ce.width, ce.height)
|
|
647
|
+
if self._viewable:
|
|
648
|
+
self._name_pixmap() # storage is reallocated on resize
|
|
649
|
+
elif etype in (X.UnmapNotify, X.MapNotify, X.DestroyNotify):
|
|
650
|
+
se = ctypes.cast(byref(ev), POINTER(X.XStructureEvent))[0]
|
|
651
|
+
if se.window != self._target:
|
|
652
|
+
continue
|
|
653
|
+
if etype == X.DestroyNotify:
|
|
654
|
+
self._closed = True
|
|
655
|
+
elif etype == X.UnmapNotify:
|
|
656
|
+
self._viewable = False
|
|
657
|
+
self._free_pixmap()
|
|
658
|
+
else:
|
|
659
|
+
self._viewable = True
|
|
660
|
+
self._name_pixmap()
|
|
661
|
+
elif etype == self._damage_event:
|
|
662
|
+
self._dirty = True
|
|
663
|
+
d.ensure_alive()
|
|
664
|
+
|
|
665
|
+
def acquire(self, out: Buffer | None, timeout_s: float) -> AcquiredFrame | None:
|
|
666
|
+
d = self._d
|
|
667
|
+
assert d is not None and self._grabber is not None
|
|
668
|
+
self._drain_events()
|
|
669
|
+
if self._closed:
|
|
670
|
+
raise WindowClosedError(f"X window {self.window:#x} was destroyed")
|
|
671
|
+
if not self._viewable or not self._pixmap:
|
|
672
|
+
return None # unmapped/minimized: no content, never substitute the screen
|
|
673
|
+
if self._damage and not self._dirty:
|
|
674
|
+
return None
|
|
675
|
+
base = Rect(0, 0, *self._size)
|
|
676
|
+
box = crop_box(base, self.region)
|
|
677
|
+
if out is None:
|
|
678
|
+
dest = np.empty((max(1, box.height), max(1, box.width), 4), np.uint8)
|
|
679
|
+
else:
|
|
680
|
+
dest = out
|
|
681
|
+
if box.is_empty():
|
|
682
|
+
dest[...] = 0
|
|
683
|
+
else:
|
|
684
|
+
if self._damage:
|
|
685
|
+
assert X.xdamage is not None
|
|
686
|
+
X.xdamage.XDamageSubtract(d.ptr, self._damage, 0, 0)
|
|
687
|
+
self._dirty = False
|
|
688
|
+
try:
|
|
689
|
+
self._grabber.grab(self._pixmap, box.x, box.y, box.width, box.height, dest)
|
|
690
|
+
except XError as exc:
|
|
691
|
+
# Window resized/unmapped between the event drain and the read.
|
|
692
|
+
log.debug("pixmap read failed (%s); renaming", exc)
|
|
693
|
+
self._drain_events()
|
|
694
|
+
if self._closed:
|
|
695
|
+
raise WindowClosedError(f"X window {self.window:#x} was destroyed") from exc
|
|
696
|
+
if self._viewable:
|
|
697
|
+
self._name_pixmap()
|
|
698
|
+
self._dirty = True
|
|
699
|
+
return None
|
|
700
|
+
blit_clear(dest, min(box.width, dest.shape[1]), min(box.height, dest.shape[0]))
|
|
701
|
+
return AcquiredFrame(dest, now_ns(), self._size)
|
|
702
|
+
|
|
703
|
+
def close(self) -> None:
|
|
704
|
+
d = self._d
|
|
705
|
+
if d is None:
|
|
706
|
+
return
|
|
707
|
+
if self._grabber is not None:
|
|
708
|
+
self._grabber.close()
|
|
709
|
+
self._grabber = None
|
|
710
|
+
if d.alive:
|
|
711
|
+
if self._damage and X.xdamage is not None:
|
|
712
|
+
X.xdamage.XDamageDestroy(d.ptr, self._damage)
|
|
713
|
+
self._free_pixmap()
|
|
714
|
+
if self._redirected and X.xcomposite is not None and not self._closed:
|
|
715
|
+
X.xcomposite.XCompositeUnredirectWindow(
|
|
716
|
+
d.ptr, self._target, X.CompositeRedirectAutomatic
|
|
717
|
+
)
|
|
718
|
+
X.x11.XSync(d.ptr, False)
|
|
719
|
+
d.close()
|
|
720
|
+
self._d = None
|