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,398 @@
|
|
|
1
|
+
"""Windows Graphics Capture (WGC) through the raw WinRT ABI.
|
|
2
|
+
|
|
3
|
+
Window capture targets the HWND itself via
|
|
4
|
+
``IGraphicsCaptureItemInterop::CreateForWindow``: frames contain the
|
|
5
|
+
window's own composition surface, so overlapping windows never appear.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from ctypes import Structure, byref, c_int, c_int32, c_int64, c_long, c_ubyte, c_void_p
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from ..._pipeline.clock import hns_to_ns, now_ns
|
|
17
|
+
from ...errors import (
|
|
18
|
+
AccessDeniedError,
|
|
19
|
+
CapabilityUnavailableError,
|
|
20
|
+
NativeError,
|
|
21
|
+
TargetNotFoundError,
|
|
22
|
+
WindowClosedError,
|
|
23
|
+
)
|
|
24
|
+
from ...types import Monitor, Rect, validate_region
|
|
25
|
+
from ..base import AcquiredFrame, Buffer, VideoBackendInfo, blit_clear, crop_box
|
|
26
|
+
from . import _d3d11, _win32
|
|
27
|
+
from ._com import GUID, ComPtr, check_hr
|
|
28
|
+
from ._winrt import (
|
|
29
|
+
EventDelegate,
|
|
30
|
+
EventRegistration,
|
|
31
|
+
activation_factory,
|
|
32
|
+
close_winrt,
|
|
33
|
+
typed_event_handler_iid,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("capturekit.windows.wgc")
|
|
37
|
+
|
|
38
|
+
IID_IGraphicsCaptureItemInterop = GUID.from_str("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")
|
|
39
|
+
IID_IGraphicsCaptureItem = GUID.from_str("79C3F95B-31F7-4EC2-A464-632EF5D30760")
|
|
40
|
+
IID_IDirect3D11CaptureFramePoolStatics2 = GUID.from_str("589B103F-6BBC-5DF5-A991-02E28B3B66D5")
|
|
41
|
+
IID_IGraphicsCaptureSession2 = GUID.from_str("2C39AE40-7D2E-5044-804E-8B6799D4CF9E")
|
|
42
|
+
IID_IGraphicsCaptureSession3 = GUID.from_str("F2CDD966-22AE-5EA1-9596-3A289344C3BE")
|
|
43
|
+
IID_IGraphicsCaptureSessionStatics = GUID.from_str("2224A540-5974-49AA-B232-0882536F4CB5")
|
|
44
|
+
IID_IDirect3DDxgiInterfaceAccess = GUID.from_str("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1")
|
|
45
|
+
|
|
46
|
+
FRAME_ARRIVED_IID = typed_event_handler_iid(
|
|
47
|
+
"Windows.Graphics.Capture.Direct3D11CaptureFramePool", "24eb6d22-1975-422e-82e7-780dbd8ddf24"
|
|
48
|
+
)
|
|
49
|
+
ITEM_CLOSED_IID = typed_event_handler_iid(
|
|
50
|
+
"Windows.Graphics.Capture.GraphicsCaptureItem", "79c3f95b-31f7-4ec2-a464-632ef5d30760"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# vtable slots (IInspectable occupies 0..5)
|
|
54
|
+
_INTEROP_CreateForWindow = 3
|
|
55
|
+
_INTEROP_CreateForMonitor = 4
|
|
56
|
+
_ITEM_get_Size = 7
|
|
57
|
+
_ITEM_add_Closed = 8
|
|
58
|
+
_ITEM_remove_Closed = 9
|
|
59
|
+
_POOLSTATICS2_CreateFreeThreaded = 6
|
|
60
|
+
_POOL_Recreate = 6
|
|
61
|
+
_POOL_TryGetNextFrame = 7
|
|
62
|
+
_POOL_add_FrameArrived = 8
|
|
63
|
+
_POOL_remove_FrameArrived = 9
|
|
64
|
+
_POOL_CreateCaptureSession = 10
|
|
65
|
+
_SESSION_StartCapture = 6
|
|
66
|
+
_SESSION2_put_IsCursorCaptureEnabled = 7
|
|
67
|
+
_SESSION3_put_IsBorderRequired = 7
|
|
68
|
+
_FRAME_get_Surface = 6
|
|
69
|
+
_FRAME_get_SystemRelativeTime = 7
|
|
70
|
+
_FRAME_get_ContentSize = 8
|
|
71
|
+
_SESSIONSTATICS_IsSupported = 6
|
|
72
|
+
_DXGIACCESS_GetInterface = 3
|
|
73
|
+
|
|
74
|
+
E_ACCESSDENIED = -2147024891
|
|
75
|
+
E_INVALIDARG = -2147024809
|
|
76
|
+
|
|
77
|
+
PIXEL_FORMAT_B8G8R8A8 = _d3d11.DXGI_FORMAT_B8G8R8A8_UNORM
|
|
78
|
+
POOL_BUFFERS = 2
|
|
79
|
+
REFRESH_AFTER_RESIZE_NS = 150_000_000
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SizeInt32(Structure):
|
|
83
|
+
_fields_ = [("Width", c_int32), ("Height", c_int32)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_supported() -> bool:
|
|
87
|
+
"""``GraphicsCaptureSession.IsSupported()`` (False if WinRT type missing)."""
|
|
88
|
+
try:
|
|
89
|
+
statics = activation_factory(
|
|
90
|
+
"Windows.Graphics.Capture.GraphicsCaptureSession", IID_IGraphicsCaptureSessionStatics
|
|
91
|
+
)
|
|
92
|
+
except NativeError:
|
|
93
|
+
return False
|
|
94
|
+
with statics:
|
|
95
|
+
val = c_ubyte()
|
|
96
|
+
statics.hcall(_SESSIONSTATICS_IsSupported, (c_void_p,), byref(val), what="IsSupported")
|
|
97
|
+
return bool(val.value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class WgcBackend:
|
|
101
|
+
"""WGC capture of a window (``hwnd``) or a monitor (``monitor``)."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
hwnd: int | None = None,
|
|
107
|
+
monitor: Monitor | None = None,
|
|
108
|
+
region: Rect | None = None,
|
|
109
|
+
client_area_only: bool = False,
|
|
110
|
+
include_cursor: bool = False,
|
|
111
|
+
border: bool | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
if (hwnd is None) == (monitor is None):
|
|
114
|
+
raise ValueError("exactly one of hwnd or monitor is required")
|
|
115
|
+
self.hwnd = hwnd
|
|
116
|
+
self.monitor = monitor
|
|
117
|
+
self.region = region
|
|
118
|
+
self.client_area_only = client_area_only
|
|
119
|
+
self.include_cursor = include_cursor
|
|
120
|
+
self.border = border
|
|
121
|
+
self._device: ComPtr | None = None
|
|
122
|
+
self._context: ComPtr | None = None
|
|
123
|
+
self._rt_device: ComPtr | None = None
|
|
124
|
+
self._item: ComPtr | None = None
|
|
125
|
+
self._pool: ComPtr | None = None
|
|
126
|
+
self._session: ComPtr | None = None
|
|
127
|
+
self._reader: _d3d11.StagingReader | None = None
|
|
128
|
+
self._frame_reg: EventRegistration | None = None
|
|
129
|
+
self._closed_reg: EventRegistration | None = None
|
|
130
|
+
self._frame_event = threading.Event()
|
|
131
|
+
self._closed = threading.Event()
|
|
132
|
+
self._pool_size = (0, 0)
|
|
133
|
+
self._out_size = (0, 0)
|
|
134
|
+
self.border_applied: bool | None = None
|
|
135
|
+
self.cursor_applied: bool | None = None
|
|
136
|
+
self.resize_count = 0
|
|
137
|
+
self.session_restarts = 0
|
|
138
|
+
self._refresh_deadline = 0
|
|
139
|
+
self.info = VideoBackendInfo("wgc-window" if hwnd is not None else "wgc-monitor", (0, 0))
|
|
140
|
+
|
|
141
|
+
# -- lifecycle ---------------------------------------------------------
|
|
142
|
+
def start(self) -> None:
|
|
143
|
+
try:
|
|
144
|
+
self._start()
|
|
145
|
+
except BaseException:
|
|
146
|
+
self.close()
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _start(self) -> None:
|
|
150
|
+
if self.hwnd is not None:
|
|
151
|
+
if not _win32.is_window(self.hwnd):
|
|
152
|
+
raise TargetNotFoundError(f"window {self.hwnd:#x} does not exist")
|
|
153
|
+
if _win32.display_affinity(self.hwnd) != 0:
|
|
154
|
+
log.warning(
|
|
155
|
+
"window %#x excludes itself from capture (display affinity); "
|
|
156
|
+
"frames will be blank — capturekit does not bypass this",
|
|
157
|
+
self.hwnd,
|
|
158
|
+
)
|
|
159
|
+
if not is_supported():
|
|
160
|
+
raise CapabilityUnavailableError("Windows Graphics Capture is not supported here")
|
|
161
|
+
self._device, self._context = _d3d11.create_device()
|
|
162
|
+
self._rt_device = _d3d11.create_winrt_device(self._device)
|
|
163
|
+
self._reader = _d3d11.StagingReader(self._device, self._context)
|
|
164
|
+
self._item = self._create_item()
|
|
165
|
+
|
|
166
|
+
size = SizeInt32()
|
|
167
|
+
self._item.hcall(_ITEM_get_Size, (c_void_p,), byref(size), what="GraphicsCaptureItem.Size")
|
|
168
|
+
if size.Width <= 0 or size.Height <= 0:
|
|
169
|
+
raise CapabilityUnavailableError(
|
|
170
|
+
"capture item has zero size (window minimized at start or not renderable)"
|
|
171
|
+
)
|
|
172
|
+
self._open_session((size.Width, size.Height))
|
|
173
|
+
closed_delegate = EventDelegate(ITEM_CLOSED_IID, lambda s, a: self._on_closed())
|
|
174
|
+
self._closed_reg = EventRegistration.add(
|
|
175
|
+
self._item, _ITEM_add_Closed, _ITEM_remove_Closed, closed_delegate, "Closed"
|
|
176
|
+
)
|
|
177
|
+
base = self._base_rect(self._pool_size)
|
|
178
|
+
if self.region is not None:
|
|
179
|
+
validate_region(self.region, base.size)
|
|
180
|
+
self._out_size = crop_box(base, self.region).size
|
|
181
|
+
self.info.initial_size = self._out_size
|
|
182
|
+
self.info.details.update(border=self.border_applied, cursor=self.cursor_applied)
|
|
183
|
+
|
|
184
|
+
def _open_session(self, size: tuple[int, int]) -> None:
|
|
185
|
+
"""Create frame pool + capture session for ``size`` and start capturing."""
|
|
186
|
+
assert self._rt_device is not None and self._item is not None
|
|
187
|
+
self._pool_size = size
|
|
188
|
+
with activation_factory(
|
|
189
|
+
"Windows.Graphics.Capture.Direct3D11CaptureFramePool",
|
|
190
|
+
IID_IDirect3D11CaptureFramePoolStatics2,
|
|
191
|
+
) as statics:
|
|
192
|
+
self._pool = statics.out_ptr(
|
|
193
|
+
_POOLSTATICS2_CreateFreeThreaded, (c_void_p, c_int, c_int32, SizeInt32),
|
|
194
|
+
self._rt_device.as_param, PIXEL_FORMAT_B8G8R8A8, POOL_BUFFERS, SizeInt32(*size),
|
|
195
|
+
what="Direct3D11CaptureFramePool.CreateFreeThreaded", name="FramePool",
|
|
196
|
+
) # fmt: skip
|
|
197
|
+
self._session = self._pool.out_ptr(
|
|
198
|
+
_POOL_CreateCaptureSession, (c_void_p,), self._item.as_param,
|
|
199
|
+
what="CreateCaptureSession", name="GraphicsCaptureSession",
|
|
200
|
+
) # fmt: skip
|
|
201
|
+
self._configure_session()
|
|
202
|
+
frame_delegate = EventDelegate(FRAME_ARRIVED_IID, lambda s, a: self._frame_event.set())
|
|
203
|
+
self._frame_reg = EventRegistration.add(
|
|
204
|
+
self._pool, _POOL_add_FrameArrived, _POOL_remove_FrameArrived, frame_delegate,
|
|
205
|
+
"FrameArrived",
|
|
206
|
+
) # fmt: skip
|
|
207
|
+
self._session.hcall(_SESSION_StartCapture, (), what="StartCapture")
|
|
208
|
+
|
|
209
|
+
def _close_session(self) -> None:
|
|
210
|
+
if self._frame_reg is not None:
|
|
211
|
+
try:
|
|
212
|
+
self._frame_reg.remove()
|
|
213
|
+
except NativeError:
|
|
214
|
+
pass
|
|
215
|
+
self._frame_reg = None
|
|
216
|
+
close_winrt(self._session)
|
|
217
|
+
self._session = None
|
|
218
|
+
close_winrt(self._pool)
|
|
219
|
+
self._pool = None
|
|
220
|
+
|
|
221
|
+
def _create_item(self) -> ComPtr:
|
|
222
|
+
with activation_factory(
|
|
223
|
+
"Windows.Graphics.Capture.GraphicsCaptureItem", IID_IGraphicsCaptureItemInterop
|
|
224
|
+
) as interop:
|
|
225
|
+
out = c_void_p()
|
|
226
|
+
if self.hwnd is not None:
|
|
227
|
+
hr = interop.call(
|
|
228
|
+
_INTEROP_CreateForWindow, c_long, (c_void_p, c_void_p, c_void_p),
|
|
229
|
+
c_void_p(self.hwnd), byref(IID_IGraphicsCaptureItem), byref(out),
|
|
230
|
+
) # fmt: skip
|
|
231
|
+
what = f"CreateForWindow({self.hwnd:#x})"
|
|
232
|
+
else:
|
|
233
|
+
assert self.monitor is not None
|
|
234
|
+
hr = interop.call(
|
|
235
|
+
_INTEROP_CreateForMonitor, c_long, (c_void_p, c_void_p, c_void_p),
|
|
236
|
+
c_void_p(self.monitor.native_handle), byref(IID_IGraphicsCaptureItem),
|
|
237
|
+
byref(out),
|
|
238
|
+
) # fmt: skip
|
|
239
|
+
what = f"CreateForMonitor({self.monitor.id})"
|
|
240
|
+
if hr == E_ACCESSDENIED:
|
|
241
|
+
raise AccessDeniedError(f"{what}: access denied")
|
|
242
|
+
if hr == E_INVALIDARG:
|
|
243
|
+
raise CapabilityUnavailableError(
|
|
244
|
+
f"{what}: target cannot be captured (invalid, non-top-level or system window)"
|
|
245
|
+
)
|
|
246
|
+
check_hr(hr, what)
|
|
247
|
+
return ComPtr(out.value, "GraphicsCaptureItem")
|
|
248
|
+
|
|
249
|
+
def _configure_session(self) -> None:
|
|
250
|
+
assert self._session is not None
|
|
251
|
+
s2 = self._session.try_query(IID_IGraphicsCaptureSession2, "IGraphicsCaptureSession2")
|
|
252
|
+
if s2 is not None:
|
|
253
|
+
with s2:
|
|
254
|
+
hr = s2.call(
|
|
255
|
+
_SESSION2_put_IsCursorCaptureEnabled,
|
|
256
|
+
c_long,
|
|
257
|
+
(c_ubyte,),
|
|
258
|
+
1 if self.include_cursor else 0,
|
|
259
|
+
)
|
|
260
|
+
self.cursor_applied = hr >= 0 and self.include_cursor
|
|
261
|
+
if self.border is not None:
|
|
262
|
+
s3 = self._session.try_query(IID_IGraphicsCaptureSession3, "IGraphicsCaptureSession3")
|
|
263
|
+
if s3 is None:
|
|
264
|
+
log.info("IsBorderRequired not available on this Windows build")
|
|
265
|
+
self.border_applied = None
|
|
266
|
+
else:
|
|
267
|
+
with s3:
|
|
268
|
+
hr = s3.call(
|
|
269
|
+
_SESSION3_put_IsBorderRequired, c_long, (c_ubyte,), 1 if self.border else 0
|
|
270
|
+
)
|
|
271
|
+
self.border_applied = self.border if hr >= 0 else None
|
|
272
|
+
if hr < 0:
|
|
273
|
+
log.info(
|
|
274
|
+
"IsBorderRequired=%s rejected (0x%08X)", self.border, hr & 0xFFFFFFFF
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def _on_closed(self) -> None:
|
|
278
|
+
self._closed.set()
|
|
279
|
+
self._frame_event.set()
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
self._close_session()
|
|
283
|
+
if self._closed_reg is not None:
|
|
284
|
+
try:
|
|
285
|
+
self._closed_reg.remove()
|
|
286
|
+
except NativeError:
|
|
287
|
+
pass
|
|
288
|
+
self._closed_reg = None
|
|
289
|
+
if self._reader is not None:
|
|
290
|
+
self._reader.release()
|
|
291
|
+
self._reader = None
|
|
292
|
+
for name in ("_item", "_rt_device", "_context", "_device"):
|
|
293
|
+
p = getattr(self, name)
|
|
294
|
+
if p is not None:
|
|
295
|
+
p.release()
|
|
296
|
+
setattr(self, name, None)
|
|
297
|
+
|
|
298
|
+
# -- capture -----------------------------------------------------------
|
|
299
|
+
@property
|
|
300
|
+
def target_closed(self) -> bool:
|
|
301
|
+
return self._closed.is_set() or (self.hwnd is not None and not _win32.is_window(self.hwnd))
|
|
302
|
+
|
|
303
|
+
def _base_rect(self, content: tuple[int, int]) -> Rect:
|
|
304
|
+
full = Rect(0, 0, *content)
|
|
305
|
+
if self.hwnd is not None and self.client_area_only:
|
|
306
|
+
return _win32.client_rect_in_window(self.hwnd).intersect(full)
|
|
307
|
+
return full
|
|
308
|
+
|
|
309
|
+
def _next_frame(self) -> ComPtr | None:
|
|
310
|
+
"""Drain the pool, keeping only the newest frame."""
|
|
311
|
+
assert self._pool is not None
|
|
312
|
+
latest: ComPtr | None = None
|
|
313
|
+
while True:
|
|
314
|
+
out = c_void_p()
|
|
315
|
+
try:
|
|
316
|
+
self._pool.hcall(
|
|
317
|
+
_POOL_TryGetNextFrame, (c_void_p,), byref(out), what="TryGetNextFrame"
|
|
318
|
+
)
|
|
319
|
+
except BaseException:
|
|
320
|
+
close_winrt(latest) # do not starve the 2-buffer pool
|
|
321
|
+
raise
|
|
322
|
+
if not out.value:
|
|
323
|
+
return latest
|
|
324
|
+
if latest is not None:
|
|
325
|
+
close_winrt(latest)
|
|
326
|
+
latest = ComPtr(out.value, "Direct3D11CaptureFrame")
|
|
327
|
+
|
|
328
|
+
def acquire(self, out: Buffer | None, timeout_s: float) -> AcquiredFrame | None:
|
|
329
|
+
if self.target_closed:
|
|
330
|
+
raise WindowClosedError(f"capture target {self.hwnd or self.monitor} was closed")
|
|
331
|
+
self._frame_event.clear()
|
|
332
|
+
frame = self._next_frame()
|
|
333
|
+
if frame is None and timeout_s > 0:
|
|
334
|
+
self._frame_event.wait(timeout_s)
|
|
335
|
+
if self.target_closed:
|
|
336
|
+
raise WindowClosedError(f"capture target {self.hwnd or self.monitor} was closed")
|
|
337
|
+
frame = self._next_frame()
|
|
338
|
+
if frame is None:
|
|
339
|
+
if self._refresh_deadline and now_ns() > self._refresh_deadline:
|
|
340
|
+
# Resized window has not presented a full-size frame (static
|
|
341
|
+
# content): a new session always delivers an initial frame.
|
|
342
|
+
self._refresh_deadline = 0
|
|
343
|
+
self.session_restarts += 1
|
|
344
|
+
self._close_session()
|
|
345
|
+
self._open_session(self._pool_size)
|
|
346
|
+
return None
|
|
347
|
+
try:
|
|
348
|
+
return self._process(frame, out)
|
|
349
|
+
finally:
|
|
350
|
+
close_winrt(frame)
|
|
351
|
+
|
|
352
|
+
def _process(self, frame: ComPtr, out: Buffer | None) -> AcquiredFrame:
|
|
353
|
+
assert self._reader is not None and self._pool is not None
|
|
354
|
+
ts = c_int64()
|
|
355
|
+
frame.hcall(
|
|
356
|
+
_FRAME_get_SystemRelativeTime, (c_void_p,), byref(ts), what="SystemRelativeTime"
|
|
357
|
+
)
|
|
358
|
+
cs = SizeInt32()
|
|
359
|
+
frame.hcall(_FRAME_get_ContentSize, (c_void_p,), byref(cs), what="ContentSize")
|
|
360
|
+
content = (max(0, cs.Width), max(0, cs.Height))
|
|
361
|
+
|
|
362
|
+
surface = frame.out_ptr(_FRAME_get_Surface, (), what="Frame.Surface", name="Surface")
|
|
363
|
+
with surface, surface.query(IID_IDirect3DDxgiInterfaceAccess, "DxgiAccess") as access:
|
|
364
|
+
tex = access.out_ptr(
|
|
365
|
+
_DXGIACCESS_GetInterface, (c_void_p,), byref(_d3d11.IID_ID3D11Texture2D),
|
|
366
|
+
what="GetInterface(ID3D11Texture2D)", name="ID3D11Texture2D",
|
|
367
|
+
) # fmt: skip
|
|
368
|
+
with tex:
|
|
369
|
+
desc = _d3d11.texture_desc(tex)
|
|
370
|
+
visible = (min(content[0], desc.Width), min(content[1], desc.Height))
|
|
371
|
+
box = crop_box(self._base_rect(visible), self.region)
|
|
372
|
+
if out is None:
|
|
373
|
+
w, h = box.size if not box.is_empty() else self._out_size
|
|
374
|
+
dest = np.empty((max(1, h), max(1, w), 4), dtype=np.uint8)
|
|
375
|
+
else:
|
|
376
|
+
dest = out
|
|
377
|
+
if box.is_empty():
|
|
378
|
+
dest[...] = 0
|
|
379
|
+
else:
|
|
380
|
+
self._reader.copy_box(tex, box.x, box.y, box.width, box.height)
|
|
381
|
+
self._reader.read_into(dest)
|
|
382
|
+
blit_clear(dest, min(box.width, dest.shape[1]), min(box.height, dest.shape[0]))
|
|
383
|
+
|
|
384
|
+
if content != self._pool_size and content[0] > 0 and content[1] > 0:
|
|
385
|
+
# Window resized: new frames need a pool matching the new size.
|
|
386
|
+
assert self._rt_device is not None
|
|
387
|
+
self._pool.hcall(
|
|
388
|
+
_POOL_Recreate, (c_void_p, c_int, c_int32, SizeInt32),
|
|
389
|
+
self._rt_device.as_param, PIXEL_FORMAT_B8G8R8A8, POOL_BUFFERS,
|
|
390
|
+
SizeInt32(*content), what="FramePool.Recreate",
|
|
391
|
+
) # fmt: skip
|
|
392
|
+
self._pool_size = content
|
|
393
|
+
self.resize_count += 1
|
|
394
|
+
self._refresh_deadline = now_ns() + REFRESH_AFTER_RESIZE_NS
|
|
395
|
+
elif (desc.Width, desc.Height) == content:
|
|
396
|
+
self._refresh_deadline = 0
|
|
397
|
+
stamp = hns_to_ns(ts.value) if ts.value > 0 else now_ns()
|
|
398
|
+
return AcquiredFrame(dest, stamp, content)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Platform-independent timing, synchronisation and buffering primitives."""
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Audio alignment to the media timeline: gap filling, overlap trimming and
|
|
2
|
+
gentle drift correction. See the av-sync skill for the model.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
|
|
12
|
+
from .clock import NS_PER_S
|
|
13
|
+
from .timeline import Timeline
|
|
14
|
+
|
|
15
|
+
Samples = npt.NDArray[np.float32]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class AlignerStats:
|
|
20
|
+
packets: int = 0
|
|
21
|
+
packets_dropped_paused: int = 0
|
|
22
|
+
samples_in: int = 0
|
|
23
|
+
samples_out: int = 0
|
|
24
|
+
silence_inserted: int = 0
|
|
25
|
+
samples_trimmed: int = 0
|
|
26
|
+
drift_corrected: int = 0 # net samples added (+) / removed (-) by resampling
|
|
27
|
+
discontinuities: int = 0
|
|
28
|
+
last_error_samples: float = 0.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AudioAligner:
|
|
32
|
+
"""Converts timestamped packets into a gapless sample stream.
|
|
33
|
+
|
|
34
|
+
``push`` / ``heartbeat`` / ``finish`` return the blocks to emit, in order.
|
|
35
|
+
One aligner per source; not thread-safe (owned by the source's thread).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
timeline: Timeline,
|
|
41
|
+
sample_rate: int = 48_000,
|
|
42
|
+
channels: int = 2,
|
|
43
|
+
*,
|
|
44
|
+
gap_ms: float = 20.0,
|
|
45
|
+
overlap_ms: float = 20.0,
|
|
46
|
+
deadband_ms: float = 2.0,
|
|
47
|
+
max_ratio: float = 0.002,
|
|
48
|
+
smoothing: float = 0.05,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.timeline = timeline
|
|
51
|
+
self.rate = sample_rate
|
|
52
|
+
self.channels = channels
|
|
53
|
+
self.written = 0
|
|
54
|
+
self._gap = int(sample_rate * gap_ms / 1000)
|
|
55
|
+
self._overlap = int(sample_rate * overlap_ms / 1000)
|
|
56
|
+
self._deadband = sample_rate * deadband_ms / 1000
|
|
57
|
+
self._max_ratio = max_ratio
|
|
58
|
+
self._alpha = smoothing
|
|
59
|
+
self._err = 0.0
|
|
60
|
+
self._have_err = False
|
|
61
|
+
self._synced = False
|
|
62
|
+
self._generation = 0
|
|
63
|
+
self.stats = AlignerStats()
|
|
64
|
+
|
|
65
|
+
def _pos(self, media_ns: int) -> int:
|
|
66
|
+
return media_ns * self.rate // NS_PER_S
|
|
67
|
+
|
|
68
|
+
def _silence(self, n: int) -> Samples:
|
|
69
|
+
self.stats.silence_inserted += n
|
|
70
|
+
return np.zeros((n, self.channels), dtype=np.float32)
|
|
71
|
+
|
|
72
|
+
def push(self, data: Samples, timestamp_ns: int, discontinuity: bool = False) -> list[Samples]:
|
|
73
|
+
st = self.stats
|
|
74
|
+
st.packets += 1
|
|
75
|
+
n = int(data.shape[0])
|
|
76
|
+
st.samples_in += n
|
|
77
|
+
if discontinuity:
|
|
78
|
+
st.discontinuities += 1
|
|
79
|
+
media = self.timeline.media_ns(timestamp_ns)
|
|
80
|
+
if media is None or n == 0:
|
|
81
|
+
st.packets_dropped_paused += 1
|
|
82
|
+
return []
|
|
83
|
+
gen = self.timeline.generation
|
|
84
|
+
if gen != self._generation:
|
|
85
|
+
# New segment (start or resume): re-anchor exactly instead of
|
|
86
|
+
# treating the pause boundary as clock drift.
|
|
87
|
+
self._generation = gen
|
|
88
|
+
self._synced = False
|
|
89
|
+
self._have_err = False
|
|
90
|
+
expected = self._pos(media)
|
|
91
|
+
err = expected - self.written
|
|
92
|
+
out: list[Samples] = []
|
|
93
|
+
if not self._synced:
|
|
94
|
+
# First packet: align exactly (any offset is start-up latency, not drift).
|
|
95
|
+
self._synced = True
|
|
96
|
+
if err > 0:
|
|
97
|
+
out.append(self._silence(err))
|
|
98
|
+
self.written += err
|
|
99
|
+
err = 0
|
|
100
|
+
elif err < 0:
|
|
101
|
+
trim = -err
|
|
102
|
+
st.samples_trimmed += min(trim, n)
|
|
103
|
+
if trim >= n:
|
|
104
|
+
self._synced = False
|
|
105
|
+
return out
|
|
106
|
+
data = data[trim:]
|
|
107
|
+
n -= trim
|
|
108
|
+
err = 0
|
|
109
|
+
if err > self._gap:
|
|
110
|
+
out.append(self._silence(err))
|
|
111
|
+
self.written += err
|
|
112
|
+
self._have_err = False
|
|
113
|
+
elif -err > self._overlap:
|
|
114
|
+
trim = -err
|
|
115
|
+
st.samples_trimmed += min(trim, n)
|
|
116
|
+
if trim >= n:
|
|
117
|
+
return out
|
|
118
|
+
data = data[trim:]
|
|
119
|
+
n -= trim
|
|
120
|
+
self._have_err = False
|
|
121
|
+
else:
|
|
122
|
+
# Small error: smooth it (timestamps jitter ~1 ms) and nudge the
|
|
123
|
+
# stream length by resampling at most max_ratio per packet.
|
|
124
|
+
if not self._have_err:
|
|
125
|
+
self._err = float(err)
|
|
126
|
+
self._have_err = True
|
|
127
|
+
else:
|
|
128
|
+
self._err += self._alpha * (err - self._err)
|
|
129
|
+
st.last_error_samples = self._err
|
|
130
|
+
if abs(self._err) > self._deadband:
|
|
131
|
+
limit = max(1, int(n * self._max_ratio))
|
|
132
|
+
corr = int(max(-limit, min(limit, round(self._err * 0.1))))
|
|
133
|
+
if corr:
|
|
134
|
+
data = _resample(data, n + corr)
|
|
135
|
+
st.drift_corrected += corr
|
|
136
|
+
self._err -= corr
|
|
137
|
+
n += corr
|
|
138
|
+
out.append(np.ascontiguousarray(data, dtype=np.float32))
|
|
139
|
+
self.written += n
|
|
140
|
+
st.samples_out = self.written
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def heartbeat(self, wall_now_ns: int, margin_ns: int = 60_000_000) -> list[Samples]:
|
|
144
|
+
"""Fill silence up to ``now - margin`` when no packets arrive
|
|
145
|
+
(endpoint loopback produces no packets while nothing plays)."""
|
|
146
|
+
if not self.timeline.started:
|
|
147
|
+
return []
|
|
148
|
+
target = self._pos(max(0, self.timeline.media_now(wall_now_ns) - margin_ns))
|
|
149
|
+
missing = target - self.written
|
|
150
|
+
if missing <= 0:
|
|
151
|
+
return []
|
|
152
|
+
self.written += missing
|
|
153
|
+
self.stats.samples_out = self.written
|
|
154
|
+
self._have_err = False
|
|
155
|
+
return [self._silence(missing)]
|
|
156
|
+
|
|
157
|
+
def finish(self, media_end_ns: int) -> list[Samples]:
|
|
158
|
+
"""Pad with silence so the stream ends exactly at ``media_end_ns``."""
|
|
159
|
+
target = self._pos(media_end_ns)
|
|
160
|
+
missing = target - self.written
|
|
161
|
+
if missing <= 0:
|
|
162
|
+
return []
|
|
163
|
+
self.written += missing
|
|
164
|
+
self.stats.samples_out = self.written
|
|
165
|
+
return [self._silence(missing)]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resample(data: Samples, m: int) -> Samples:
|
|
169
|
+
n = data.shape[0]
|
|
170
|
+
if n < 2 or m < 1:
|
|
171
|
+
return data
|
|
172
|
+
x_new = np.linspace(0.0, n - 1, m)
|
|
173
|
+
x_old = np.arange(n, dtype=np.float64)
|
|
174
|
+
out = np.empty((m, data.shape[1]), dtype=np.float32)
|
|
175
|
+
for ch in range(data.shape[1]):
|
|
176
|
+
out[:, ch] = np.interp(x_new, x_old, data[:, ch])
|
|
177
|
+
return out
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The single capturekit clock.
|
|
2
|
+
|
|
3
|
+
Windows: ``time.perf_counter_ns`` is QueryPerformanceCounter scaled to ns with
|
|
4
|
+
no offset (verified, see native-api-research skill), so QPC-based timestamps
|
|
5
|
+
from WGC, DXGI and WASAPI convert without an offset.
|
|
6
|
+
Linux: CLOCK_MONOTONIC, which PulseAudio/PipeWire and X server-side
|
|
7
|
+
timestamps can be related to.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
now_ns = time.perf_counter_ns
|
|
18
|
+
else:
|
|
19
|
+
now_ns = time.monotonic_ns
|
|
20
|
+
|
|
21
|
+
NS_PER_S = 1_000_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hns_to_ns(hundred_ns: int) -> int:
|
|
25
|
+
"""Convert 100-ns QPC units (WASAPI u64QPCPosition, WinRT TimeSpan)."""
|
|
26
|
+
return hundred_ns * 100
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def qpc_ticks_to_ns(ticks: int, frequency: int) -> int:
|
|
30
|
+
return ticks * NS_PER_S // frequency
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sleep_until(deadline_ns: int, stop: threading.Event | None = None) -> bool:
|
|
34
|
+
"""Sleep until the absolute ``deadline_ns``.
|
|
35
|
+
|
|
36
|
+
Returns ``False`` if ``stop`` was set while waiting. Wakes at most every
|
|
37
|
+
50 ms to observe ``stop``. On Python >= 3.11 Windows, ``time.sleep`` uses a
|
|
38
|
+
high-resolution waitable timer, so the residual error is typically below
|
|
39
|
+
1 ms; callers measure and report actual jitter instead of assuming it.
|
|
40
|
+
"""
|
|
41
|
+
while True:
|
|
42
|
+
if stop is not None and stop.is_set():
|
|
43
|
+
return False
|
|
44
|
+
remaining = deadline_ns - now_ns()
|
|
45
|
+
if remaining <= 0:
|
|
46
|
+
return True
|
|
47
|
+
time.sleep(min(remaining, 50_000_000) / NS_PER_S)
|