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