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.
Files changed (41) hide show
  1. capturekit/__init__.py +164 -0
  2. capturekit/_backends/__init__.py +0 -0
  3. capturekit/_backends/base.py +89 -0
  4. capturekit/_backends/linux/__init__.py +1 -0
  5. capturekit/_backends/linux/_xlib.py +655 -0
  6. capturekit/_backends/linux/api.py +160 -0
  7. capturekit/_backends/linux/pulse.py +464 -0
  8. capturekit/_backends/linux/x11.py +720 -0
  9. capturekit/_backends/windows/__init__.py +0 -0
  10. capturekit/_backends/windows/_com.py +350 -0
  11. capturekit/_backends/windows/_d3d11.py +307 -0
  12. capturekit/_backends/windows/_win32.py +420 -0
  13. capturekit/_backends/windows/_winrt.py +162 -0
  14. capturekit/_backends/windows/api.py +214 -0
  15. capturekit/_backends/windows/cursor.py +66 -0
  16. capturekit/_backends/windows/dxgi_duplication.py +310 -0
  17. capturekit/_backends/windows/wasapi.py +570 -0
  18. capturekit/_backends/windows/wgc.py +398 -0
  19. capturekit/_pipeline/__init__.py +1 -0
  20. capturekit/_pipeline/aligner.py +177 -0
  21. capturekit/_pipeline/clock.py +47 -0
  22. capturekit/_pipeline/frame_queue.py +120 -0
  23. capturekit/_pipeline/mixer.py +80 -0
  24. capturekit/_pipeline/timeline.py +133 -0
  25. capturekit/_platform.py +24 -0
  26. capturekit/_version.py +1 -0
  27. capturekit/_worker.py +100 -0
  28. capturekit/capabilities.py +79 -0
  29. capturekit/capture.py +141 -0
  30. capturekit/config.py +85 -0
  31. capturekit/encoding/__init__.py +5 -0
  32. capturekit/encoding/ffmpeg.py +356 -0
  33. capturekit/encoding/pipes.py +212 -0
  34. capturekit/errors.py +109 -0
  35. capturekit/py.typed +0 -0
  36. capturekit/recording.py +940 -0
  37. capturekit/sources.py +124 -0
  38. capturekit/types.py +211 -0
  39. capturekit-0.1.0.dist-info/METADATA +112 -0
  40. capturekit-0.1.0.dist-info/RECORD +41 -0
  41. 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