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
File without changes
@@ -0,0 +1,350 @@
1
+ """Minimal COM support on top of ctypes.
2
+
3
+ * :class:`ComPtr` calls interface methods by vtable index (indices are listed
4
+ in ``.claude/skills/windows-capture-backend/reference/vtables.md``).
5
+ * :class:`ComObject` implements COM interfaces in Python (event delegates,
6
+ completion handlers).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import collections
12
+ import ctypes
13
+ import logging
14
+ import sys
15
+ import threading
16
+ import uuid
17
+ from collections.abc import Callable, Sequence
18
+ from ctypes import POINTER, Structure, byref, c_long, c_ubyte, c_ulong, c_ushort, c_void_p
19
+ from typing import Any, ClassVar
20
+
21
+ from ...errors import NativeError
22
+
23
+ log = logging.getLogger("capturekit.windows")
24
+
25
+ if sys.platform != "win32": # pragma: no cover - guarded by _platform
26
+ raise ImportError("capturekit._backends.windows requires Windows")
27
+
28
+ WINFUNCTYPE = ctypes.WINFUNCTYPE
29
+
30
+ S_OK = 0
31
+ S_FALSE = 1
32
+ E_NOINTERFACE = -2147467262 # 0x80004002
33
+ E_POINTER = -2147467261 # 0x80004003
34
+ E_FAIL = -2147467259 # 0x80004005
35
+ RPC_E_CHANGED_MODE = -2147417850 # 0x80010106
36
+
37
+
38
+ class GUID(Structure):
39
+ _fields_ = [
40
+ ("Data1", c_ulong),
41
+ ("Data2", c_ushort),
42
+ ("Data3", c_ushort),
43
+ ("Data4", c_ubyte * 8),
44
+ ]
45
+
46
+ @classmethod
47
+ def from_str(cls, text: str) -> GUID:
48
+ return cls.from_buffer_copy(uuid.UUID(text).bytes_le)
49
+
50
+ def to_uuid(self) -> uuid.UUID:
51
+ return uuid.UUID(bytes_le=bytes(self))
52
+
53
+ def __eq__(self, other: object) -> bool:
54
+ return isinstance(other, GUID) and bytes(self) == bytes(other)
55
+
56
+ def __hash__(self) -> int:
57
+ return hash(bytes(self))
58
+
59
+ def __repr__(self) -> str:
60
+ return f"GUID('{self.to_uuid()}')"
61
+
62
+
63
+ IID_IUnknown = GUID.from_str("00000000-0000-0000-C000-000000000046")
64
+ IID_IAgileObject = GUID.from_str("94ea2b94-e9cc-49e0-c0ff-ee64ca8f5b90")
65
+ IID_IInspectable = GUID.from_str("AF86E2E0-B12D-4c6a-9C5A-D7AA65101E90")
66
+
67
+
68
+ def hr_u32(hr: int) -> int:
69
+ return hr & 0xFFFFFFFF
70
+
71
+
72
+ def check_hr(hr: int, what: str) -> None:
73
+ if hr < 0:
74
+ raise NativeError(what, hr)
75
+
76
+
77
+ _proto_cache: dict[tuple[Any, tuple[Any, ...]], Any] = {}
78
+
79
+
80
+ def _prototype(restype: Any, argtypes: tuple[Any, ...]) -> Any:
81
+ key = (restype, argtypes)
82
+ proto = _proto_cache.get(key)
83
+ if proto is None:
84
+ proto = WINFUNCTYPE(restype, c_void_p, *argtypes)
85
+ _proto_cache[key] = proto
86
+ return proto
87
+
88
+
89
+ class ComPtr:
90
+ """Owning reference to a COM interface pointer.
91
+
92
+ Release is explicit (``release()`` / context manager). ``__del__`` is a
93
+ safety net only; correct code releases deterministically on the owning
94
+ thread.
95
+ """
96
+
97
+ __slots__ = ("_ptr", "name")
98
+
99
+ def __init__(self, ptr: int | None, name: str = "") -> None:
100
+ if not ptr:
101
+ raise NativeError(f"null {name or 'COM'} pointer")
102
+ self._ptr: int | None = int(ptr)
103
+ self.name = name
104
+
105
+ @property
106
+ def ptr(self) -> int:
107
+ if self._ptr is None:
108
+ raise NativeError(f"use of released {self.name or 'COM'} pointer")
109
+ return self._ptr
110
+
111
+ @property
112
+ def as_param(self) -> c_void_p:
113
+ return c_void_p(self.ptr)
114
+
115
+ def method(self, index: int, restype: Any, argtypes: Sequence[Any]) -> Any:
116
+ ptr = self.ptr
117
+ vtbl = c_void_p.from_address(ptr).value
118
+ assert vtbl is not None
119
+ fn_addr = c_void_p.from_address(vtbl + index * ctypes.sizeof(c_void_p)).value
120
+ return _prototype(restype, tuple(argtypes))(fn_addr)
121
+
122
+ def call(self, index: int, restype: Any, argtypes: Sequence[Any], *args: Any) -> Any:
123
+ return self.method(index, restype, argtypes)(self.ptr, *args)
124
+
125
+ def hcall(self, index: int, argtypes: Sequence[Any], *args: Any, what: str = "") -> int:
126
+ """Call a method returning HRESULT; raise on failure, return the HRESULT."""
127
+ hr: int = self.method(index, c_long, argtypes)(self.ptr, *args)
128
+ if hr < 0:
129
+ raise NativeError(what or f"{self.name}[{index}]", hr)
130
+ return hr
131
+
132
+ def out_ptr(
133
+ self, index: int, argtypes: Sequence[Any], *args: Any, what: str = "", name: str = ""
134
+ ) -> ComPtr:
135
+ """Call a method whose last parameter is ``void**`` and wrap the result."""
136
+ out = c_void_p()
137
+ self.hcall(index, (*argtypes, c_void_p), *args, byref(out), what=what)
138
+ return ComPtr(out.value, name or what)
139
+
140
+ def query(self, iid: GUID, name: str = "") -> ComPtr:
141
+ out = c_void_p()
142
+ hr = self.call(0, c_long, (c_void_p, c_void_p), byref(iid), byref(out))
143
+ check_hr(hr, f"QueryInterface({name or iid!r})")
144
+ return ComPtr(out.value, name)
145
+
146
+ def try_query(self, iid: GUID, name: str = "") -> ComPtr | None:
147
+ out = c_void_p()
148
+ hr = self.call(0, c_long, (c_void_p, c_void_p), byref(iid), byref(out))
149
+ if hr < 0 or not out.value:
150
+ return None
151
+ return ComPtr(out.value, name)
152
+
153
+ def add_ref(self) -> ComPtr:
154
+ self.call(1, c_ulong, ())
155
+ return ComPtr(self.ptr, self.name)
156
+
157
+ def release(self) -> None:
158
+ if self._ptr is not None:
159
+ ptr, self._ptr = self._ptr, None
160
+ vtbl = c_void_p.from_address(ptr).value
161
+ assert vtbl is not None
162
+ fn = c_void_p.from_address(vtbl + 2 * ctypes.sizeof(c_void_p)).value
163
+ _prototype(c_ulong, ())(fn)(ptr)
164
+
165
+ def __enter__(self) -> ComPtr:
166
+ return self
167
+
168
+ def __exit__(self, *exc: object) -> None:
169
+ self.release()
170
+
171
+ def __del__(self) -> None:
172
+ if self._ptr is not None and not sys.is_finalizing():
173
+ try:
174
+ self.release()
175
+ except Exception: # pragma: no cover - last-resort cleanup
176
+ pass
177
+
178
+ def __repr__(self) -> str:
179
+ return f"<ComPtr {self.name} {self._ptr and hex(self._ptr)}>"
180
+
181
+
182
+ def release_all(*ptrs: ComPtr | None) -> None:
183
+ for p in ptrs:
184
+ if p is not None:
185
+ p.release()
186
+
187
+
188
+ class _ComObjectStruct(Structure):
189
+ _fields_ = [("lpVtbl", c_void_p)]
190
+
191
+
192
+ class ComObject:
193
+ """Base class for COM objects implemented in Python.
194
+
195
+ Subclasses set ``_iids_`` (interfaces they implement besides IUnknown /
196
+ IAgileObject) and ``_methods_``: a list of ``(name, restype, argtypes)``
197
+ for the vtable slots after IUnknown, each implemented as a Python method
198
+ taking the raw arguments (without ``this``).
199
+
200
+ The object keeps itself alive while its COM refcount is > 0, so native
201
+ code can never call into freed memory. Exceptions raised by methods are
202
+ logged and converted to ``E_FAIL``.
203
+ """
204
+
205
+ _iids_: ClassVar[Sequence[GUID]] = ()
206
+ _methods_: ClassVar[Sequence[tuple[str, Any, Sequence[Any]]]] = ()
207
+ _alive: ClassVar[set[ComObject]] = set()
208
+ _alive_lock: ClassVar[threading.Lock] = threading.Lock()
209
+ _graveyard: ClassVar[collections.deque[ComObject]] = collections.deque(maxlen=256)
210
+
211
+ def __init__(self, extra_iids: Sequence[GUID] = ()) -> None:
212
+ self._refs = 1
213
+ self._ref_lock = threading.Lock()
214
+ qi = WINFUNCTYPE(c_long, c_void_p, POINTER(GUID), POINTER(c_void_p))(self._qi)
215
+ addref = WINFUNCTYPE(c_ulong, c_void_p)(self._addref)
216
+ release = WINFUNCTYPE(c_ulong, c_void_p)(self._release)
217
+ funcs: list[Any] = [qi, addref, release]
218
+ for name, restype, argtypes in self._methods_:
219
+ impl = getattr(self, name)
220
+ funcs.append(WINFUNCTYPE(restype, c_void_p, *argtypes)(self._guard(impl, restype)))
221
+ self._funcs = funcs # keep callbacks alive
222
+ self._vtbl = (c_void_p * len(funcs))(*[ctypes.cast(f, c_void_p) for f in funcs])
223
+ self._struct = _ComObjectStruct(ctypes.cast(self._vtbl, c_void_p))
224
+ self._supported = {bytes(IID_IUnknown), bytes(IID_IAgileObject)} | {
225
+ bytes(i) for i in (*self._iids_, *extra_iids)
226
+ }
227
+ with ComObject._alive_lock:
228
+ ComObject._alive.add(self)
229
+
230
+ @property
231
+ def ptr(self) -> int:
232
+ return ctypes.addressof(self._struct)
233
+
234
+ def _guard(self, fn: Callable[..., Any], restype: Any) -> Callable[..., Any]:
235
+ def wrapper(_this: int, *args: Any) -> Any:
236
+ try:
237
+ res = fn(*args)
238
+ return S_OK if res is None and restype is c_long else res
239
+ except Exception:
240
+ log.exception("exception in COM callback %s", fn.__name__)
241
+ return E_FAIL if restype is c_long else 0
242
+
243
+ return wrapper
244
+
245
+ def _qi(self, _this: int, riid: Any, ppv: Any) -> int:
246
+ if not ppv:
247
+ return E_POINTER
248
+ if bytes(riid.contents) in self._supported:
249
+ ppv[0] = self.ptr
250
+ self._addref(_this)
251
+ return S_OK
252
+ ppv[0] = None
253
+ return E_NOINTERFACE
254
+
255
+ def _addref(self, _this: int) -> int:
256
+ with self._ref_lock:
257
+ self._refs += 1
258
+ return self._refs
259
+
260
+ def _release(self, _this: int) -> int:
261
+ with self._ref_lock:
262
+ self._refs -= 1
263
+ refs = self._refs
264
+ if refs == 0:
265
+ with ComObject._alive_lock:
266
+ ComObject._alive.discard(self)
267
+ # Do not free now: this method may be running inside our own
268
+ # ctypes thunk (native Release call); freeing the thunk before
269
+ # it returns crashes the process. Keep dead objects in a
270
+ # bounded graveyard so they die long after their last call.
271
+ ComObject._graveyard.append(self)
272
+ return refs
273
+
274
+ def release(self) -> None:
275
+ """Drop the Python-side initial reference."""
276
+ self._release(0)
277
+
278
+
279
+ # --- COM apartment ---------------------------------------------------------
280
+
281
+ _ole32 = ctypes.WinDLL("ole32", use_last_error=True)
282
+ _ole32.CoInitializeEx.argtypes = (c_void_p, ctypes.c_uint)
283
+ _ole32.CoInitializeEx.restype = c_long
284
+ _ole32.CoUninitialize.argtypes = ()
285
+ _ole32.CoUninitialize.restype = None
286
+ _ole32.CoCreateInstance.argtypes = (c_void_p, c_void_p, ctypes.c_uint, c_void_p, c_void_p)
287
+ _ole32.CoCreateInstance.restype = c_long
288
+ _ole32.CoTaskMemFree.argtypes = (c_void_p,)
289
+ _ole32.CoTaskMemFree.restype = None
290
+ _ole32.PropVariantClear.argtypes = (c_void_p,)
291
+ _ole32.PropVariantClear.restype = c_long
292
+
293
+ _ole32.CoIncrementMTAUsage.argtypes = (c_void_p,)
294
+ _ole32.CoIncrementMTAUsage.restype = c_long
295
+
296
+ COINIT_MULTITHREADED = 0x0
297
+ CLSCTX_ALL = 0x17
298
+
299
+ _mta_cookie = c_void_p()
300
+ _mta_lock = threading.Lock()
301
+
302
+
303
+ def ensure_mta_usage() -> None:
304
+ """Keep the process MTA alive for the rest of the process lifetime.
305
+
306
+ Without this, the last MTA thread's CoUninitialize/RoUninitialize tears
307
+ down the MTA and may unload WinRT capture DLLs while thread-pool
308
+ callbacks are still pending (reproduced: access violation shortly after
309
+ closing a WGC session whose window had been destroyed). The cookie is
310
+ intentionally never released.
311
+ """
312
+ with _mta_lock:
313
+ if not _mta_cookie.value:
314
+ check_hr(_ole32.CoIncrementMTAUsage(byref(_mta_cookie)), "CoIncrementMTAUsage")
315
+
316
+
317
+ class ComApartment:
318
+ """Context manager: join the MTA on this thread for the duration."""
319
+
320
+ def __enter__(self) -> ComApartment:
321
+ ensure_mta_usage()
322
+ hr = _ole32.CoInitializeEx(None, COINIT_MULTITHREADED)
323
+ # S_FALSE = already initialised (balanced by CoUninitialize too).
324
+ # RPC_E_CHANGED_MODE = caller's thread is STA: usable, but don't uninit.
325
+ self._uninit = hr >= 0
326
+ if hr < 0 and hr != RPC_E_CHANGED_MODE:
327
+ check_hr(hr, "CoInitializeEx")
328
+ return self
329
+
330
+ def __exit__(self, *exc: object) -> None:
331
+ if self._uninit:
332
+ _ole32.CoUninitialize()
333
+
334
+
335
+ def co_create_instance(clsid: GUID, iid: GUID, name: str = "") -> ComPtr:
336
+ out = c_void_p()
337
+ check_hr(
338
+ _ole32.CoCreateInstance(byref(clsid), None, CLSCTX_ALL, byref(iid), byref(out)),
339
+ f"CoCreateInstance({name})",
340
+ )
341
+ return ComPtr(out.value, name)
342
+
343
+
344
+ def co_task_mem_free(ptr: int | None) -> None:
345
+ if ptr:
346
+ _ole32.CoTaskMemFree(ptr)
347
+
348
+
349
+ def prop_variant_clear(pv: Any) -> None:
350
+ _ole32.PropVariantClear(byref(pv))
@@ -0,0 +1,307 @@
1
+ """Direct3D 11 / DXGI bindings used by the capture backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from ctypes import POINTER, Structure, byref, c_int, c_long, c_uint, c_void_p
7
+ from ctypes import wintypes as wt
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+
12
+ from ...errors import NativeError
13
+ from ._com import GUID, ComPtr, check_hr
14
+
15
+ _d3d11 = ctypes.WinDLL("d3d11")
16
+ _dxgi = ctypes.WinDLL("dxgi")
17
+
18
+ _d3d11.D3D11CreateDevice.argtypes = (
19
+ c_void_p, c_int, c_void_p, c_uint, c_void_p, c_uint, c_uint,
20
+ POINTER(c_void_p), POINTER(c_int), POINTER(c_void_p),
21
+ ) # fmt: skip
22
+ _d3d11.D3D11CreateDevice.restype = c_long
23
+ _d3d11.CreateDirect3D11DeviceFromDXGIDevice.argtypes = (c_void_p, POINTER(c_void_p))
24
+ _d3d11.CreateDirect3D11DeviceFromDXGIDevice.restype = c_long
25
+ _dxgi.CreateDXGIFactory1.argtypes = (c_void_p, POINTER(c_void_p))
26
+ _dxgi.CreateDXGIFactory1.restype = c_long
27
+
28
+ IID_IDXGIFactory1 = GUID.from_str("770aae78-f26f-4dba-a829-253c83d1b387")
29
+ IID_IDXGIDevice = GUID.from_str("54ec77fa-1377-44e6-8c32-88fd5f44c84c")
30
+ IID_IDXGIOutput1 = GUID.from_str("00cddea8-939b-4b83-a340-a685226666cc")
31
+ IID_ID3D11Texture2D = GUID.from_str("6f15aaf2-d208-4e89-9ab4-489535d34f9c")
32
+ IID_ID3D10Multithread = GUID.from_str("9B7E4E00-342C-4106-A19F-4F2704F689F0")
33
+
34
+ D3D_DRIVER_TYPE_UNKNOWN = 0
35
+ D3D_DRIVER_TYPE_HARDWARE = 1
36
+ D3D11_CREATE_DEVICE_BGRA_SUPPORT = 0x20
37
+ D3D11_SDK_VERSION = 7
38
+ DXGI_FORMAT_B8G8R8A8_UNORM = 87
39
+ D3D11_USAGE_STAGING = 3
40
+ D3D11_CPU_ACCESS_READ = 0x20000
41
+ D3D11_MAP_READ = 1
42
+
43
+ # ID3D11Device
44
+ _DEV_CreateTexture2D = 5
45
+ _DEV_GetImmediateContext = 40
46
+ # ID3D11DeviceContext
47
+ _CTX_Map = 14
48
+ _CTX_Unmap = 15
49
+ _CTX_CopySubresourceRegion = 46
50
+ _CTX_Flush = 111
51
+ # ID3D11Texture2D
52
+ _TEX_GetDesc = 10
53
+ # ID3D10Multithread
54
+ _MT_SetMultithreadProtected = 5
55
+
56
+
57
+ class D3D11_TEXTURE2D_DESC(Structure):
58
+ _fields_ = [
59
+ ("Width", c_uint),
60
+ ("Height", c_uint),
61
+ ("MipLevels", c_uint),
62
+ ("ArraySize", c_uint),
63
+ ("Format", c_uint),
64
+ ("SampleCount", c_uint),
65
+ ("SampleQuality", c_uint),
66
+ ("Usage", c_uint),
67
+ ("BindFlags", c_uint),
68
+ ("CPUAccessFlags", c_uint),
69
+ ("MiscFlags", c_uint),
70
+ ]
71
+
72
+
73
+ class D3D11_MAPPED_SUBRESOURCE(Structure):
74
+ _fields_ = [("pData", c_void_p), ("RowPitch", c_uint), ("DepthPitch", c_uint)]
75
+
76
+
77
+ class D3D11_BOX(Structure):
78
+ _fields_ = [
79
+ ("left", c_uint),
80
+ ("top", c_uint),
81
+ ("front", c_uint),
82
+ ("right", c_uint),
83
+ ("bottom", c_uint),
84
+ ("back", c_uint),
85
+ ]
86
+
87
+
88
+ class LUID(Structure):
89
+ _fields_ = [("LowPart", wt.DWORD), ("HighPart", wt.LONG)]
90
+
91
+
92
+ class DXGI_ADAPTER_DESC1(Structure):
93
+ _fields_ = [
94
+ ("Description", wt.WCHAR * 128),
95
+ ("VendorId", c_uint),
96
+ ("DeviceId", c_uint),
97
+ ("SubSysId", c_uint),
98
+ ("Revision", c_uint),
99
+ ("DedicatedVideoMemory", ctypes.c_size_t),
100
+ ("DedicatedSystemMemory", ctypes.c_size_t),
101
+ ("SharedSystemMemory", ctypes.c_size_t),
102
+ ("AdapterLuid", LUID),
103
+ ("Flags", c_uint),
104
+ ]
105
+
106
+
107
+ class DXGI_OUTPUT_DESC(Structure):
108
+ _fields_ = [
109
+ ("DeviceName", wt.WCHAR * 32),
110
+ ("DesktopCoordinates", wt.RECT),
111
+ ("AttachedToDesktop", wt.BOOL),
112
+ ("Rotation", c_uint),
113
+ ("Monitor", wt.HMONITOR),
114
+ ]
115
+
116
+
117
+ def create_device(adapter: ComPtr | None = None) -> tuple[ComPtr, ComPtr]:
118
+ """Create a BGRA-capable D3D11 device and its immediate context.
119
+
120
+ With ``adapter`` the device is created on that adapter (required for DXGI
121
+ duplication of outputs owned by it).
122
+ """
123
+ dev, ctx = c_void_p(), c_void_p()
124
+ level = c_int()
125
+ hr = _d3d11.D3D11CreateDevice(
126
+ adapter.as_param if adapter else None,
127
+ D3D_DRIVER_TYPE_UNKNOWN if adapter else D3D_DRIVER_TYPE_HARDWARE,
128
+ None,
129
+ D3D11_CREATE_DEVICE_BGRA_SUPPORT,
130
+ None,
131
+ 0,
132
+ D3D11_SDK_VERSION,
133
+ byref(dev),
134
+ byref(level),
135
+ byref(ctx),
136
+ )
137
+ check_hr(hr, "D3D11CreateDevice")
138
+ device = ComPtr(dev.value, "ID3D11Device")
139
+ context = ComPtr(ctx.value, "ID3D11DeviceContext")
140
+ mt = device.try_query(IID_ID3D10Multithread, "ID3D10Multithread")
141
+ if mt is not None:
142
+ # WGC's free-threaded frame pool uses the device from its own thread.
143
+ mt.call(_MT_SetMultithreadProtected, c_int, (c_int,), 1)
144
+ mt.release()
145
+ return device, context
146
+
147
+
148
+ def create_winrt_device(device: ComPtr) -> ComPtr:
149
+ """Wrap a D3D11 device as WinRT ``IDirect3DDevice`` (IInspectable)."""
150
+ with device.query(IID_IDXGIDevice, "IDXGIDevice") as dxgi_dev:
151
+ out = c_void_p()
152
+ check_hr(
153
+ _d3d11.CreateDirect3D11DeviceFromDXGIDevice(dxgi_dev.as_param, byref(out)),
154
+ "CreateDirect3D11DeviceFromDXGIDevice",
155
+ )
156
+ return ComPtr(out.value, "IDirect3DDevice")
157
+
158
+
159
+ def create_dxgi_factory() -> ComPtr:
160
+ out = c_void_p()
161
+ check_hr(_dxgi.CreateDXGIFactory1(byref(IID_IDXGIFactory1), byref(out)), "CreateDXGIFactory1")
162
+ return ComPtr(out.value, "IDXGIFactory1")
163
+
164
+
165
+ DXGI_ERROR_NOT_FOUND = -2005270526 # 0x887A0002
166
+
167
+
168
+ def enum_outputs() -> list[tuple[ComPtr, ComPtr, DXGI_OUTPUT_DESC, DXGI_ADAPTER_DESC1]]:
169
+ """All (adapter, output, output_desc, adapter_desc) attached to the desktop.
170
+
171
+ Caller owns (and must release) the returned ComPtrs.
172
+ """
173
+ result = []
174
+ with create_dxgi_factory() as factory:
175
+ a = 0
176
+ while True:
177
+ out = c_void_p()
178
+ hr = factory.call(12, c_long, (c_uint, c_void_p), a, byref(out)) # EnumAdapters1
179
+ if hr == DXGI_ERROR_NOT_FOUND:
180
+ break
181
+ check_hr(hr, "EnumAdapters1")
182
+ adapter = ComPtr(out.value, "IDXGIAdapter1")
183
+ adesc = DXGI_ADAPTER_DESC1()
184
+ adapter.hcall(10, (c_void_p,), byref(adesc), what="GetDesc1")
185
+ o = 0
186
+ while True:
187
+ oout = c_void_p()
188
+ hr = adapter.call(7, c_long, (c_uint, c_void_p), o, byref(oout)) # EnumOutputs
189
+ if hr == DXGI_ERROR_NOT_FOUND:
190
+ break
191
+ check_hr(hr, "EnumOutputs")
192
+ output = ComPtr(oout.value, "IDXGIOutput")
193
+ odesc = DXGI_OUTPUT_DESC()
194
+ output.hcall(7, (c_void_p,), byref(odesc), what="IDXGIOutput::GetDesc")
195
+ if odesc.AttachedToDesktop:
196
+ result.append((adapter.add_ref(), output, odesc, adesc))
197
+ else:
198
+ output.release()
199
+ o += 1
200
+ adapter.release()
201
+ a += 1
202
+ return result
203
+
204
+
205
+ def texture_desc(texture: ComPtr) -> D3D11_TEXTURE2D_DESC:
206
+ desc = D3D11_TEXTURE2D_DESC()
207
+ texture.call(_TEX_GetDesc, None, (c_void_p,), byref(desc))
208
+ return desc
209
+
210
+
211
+ class StagingReader:
212
+ """GPU→CPU readback of a sub-rectangle through a CPU-readable texture.
213
+
214
+ Only the requested box is copied on the GPU; the CPU side performs exactly
215
+ one row-pitch-aware copy into the destination numpy array.
216
+ """
217
+
218
+ def __init__(self, device: ComPtr, context: ComPtr) -> None:
219
+ self._device = device
220
+ self._context = context
221
+ self._staging: ComPtr | None = None
222
+ self._size = (0, 0)
223
+ self._format = DXGI_FORMAT_B8G8R8A8_UNORM
224
+
225
+ def _ensure(self, width: int, height: int, fmt: int) -> ComPtr:
226
+ if self._staging is not None and self._size == (width, height) and self._format == fmt:
227
+ return self._staging
228
+ if self._staging is not None:
229
+ self._staging.release()
230
+ self._staging = None
231
+ desc = D3D11_TEXTURE2D_DESC(
232
+ Width=width,
233
+ Height=height,
234
+ MipLevels=1,
235
+ ArraySize=1,
236
+ Format=fmt,
237
+ SampleCount=1,
238
+ SampleQuality=0,
239
+ Usage=D3D11_USAGE_STAGING,
240
+ BindFlags=0,
241
+ CPUAccessFlags=D3D11_CPU_ACCESS_READ,
242
+ MiscFlags=0,
243
+ )
244
+ self._staging = self._device.out_ptr(
245
+ _DEV_CreateTexture2D,
246
+ (c_void_p, c_void_p),
247
+ byref(desc),
248
+ None,
249
+ what="CreateTexture2D(staging)",
250
+ name="staging",
251
+ )
252
+ self._size = (width, height)
253
+ self._format = fmt
254
+ return self._staging
255
+
256
+ def copy_box(
257
+ self,
258
+ src_texture: ComPtr,
259
+ x: int,
260
+ y: int,
261
+ width: int,
262
+ height: int,
263
+ fmt: int = DXGI_FORMAT_B8G8R8A8_UNORM,
264
+ ) -> None:
265
+ """Queue a GPU copy of ``src[x:x+w, y:y+h]`` into the staging texture."""
266
+ staging = self._ensure(width, height, fmt)
267
+ box = D3D11_BOX(x, y, 0, x + width, y + height, 1)
268
+ self._context.call(
269
+ _CTX_CopySubresourceRegion,
270
+ None,
271
+ (c_void_p, c_uint, c_uint, c_uint, c_uint, c_void_p, c_uint, c_void_p),
272
+ staging.as_param, 0, 0, 0, 0, src_texture.as_param, 0, byref(box),
273
+ ) # fmt: skip
274
+
275
+ def read_into(self, out: npt.NDArray[np.uint8], dst_x: int = 0, dst_y: int = 0) -> None:
276
+ """Map the staging texture and copy it into ``out[dst_y:, dst_x:]``.
277
+
278
+ The copied area is clipped to ``out``; Map blocks until the GPU copy
279
+ has completed.
280
+ """
281
+ if self._staging is None:
282
+ raise NativeError("read_into before copy_box")
283
+ w, h = self._size
284
+ mapped = D3D11_MAPPED_SUBRESOURCE()
285
+ self._context.hcall(
286
+ _CTX_Map, (c_void_p, c_uint, c_uint, c_uint, c_void_p),
287
+ self._staging.as_param, 0, D3D11_MAP_READ, 0, byref(mapped), what="Map(staging)",
288
+ ) # fmt: skip
289
+ try:
290
+ pitch = int(mapped.RowPitch)
291
+ if not mapped.pData or pitch < w * 4:
292
+ raise NativeError(f"invalid mapped staging texture (pitch={pitch}, w={w})")
293
+ cw = max(0, min(w, out.shape[1] - dst_x))
294
+ ch = max(0, min(h, out.shape[0] - dst_y))
295
+ if cw and ch:
296
+ raw = (ctypes.c_ubyte * (pitch * (ch - 1) + cw * 4)).from_address(mapped.pData)
297
+ src = np.lib.stride_tricks.as_strided(
298
+ np.frombuffer(raw, dtype=np.uint8), shape=(ch, cw, 4), strides=(pitch, 4, 1)
299
+ )
300
+ out[dst_y : dst_y + ch, dst_x : dst_x + cw] = src
301
+ finally:
302
+ self._context.call(_CTX_Unmap, None, (c_void_p, c_uint), self._staging.as_param, 0)
303
+
304
+ def release(self) -> None:
305
+ if self._staging is not None:
306
+ self._staging.release()
307
+ self._staging = None