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,214 @@
1
+ """Windows backend facade used by the public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import platform
7
+ from collections.abc import Iterator
8
+
9
+ from ...capabilities import (
10
+ Capabilities,
11
+ Capability,
12
+ experimental,
13
+ supported,
14
+ unavailable,
15
+ unsupported,
16
+ )
17
+ from ...errors import (
18
+ CapabilityUnavailableError,
19
+ TargetNotFoundError,
20
+ UnsupportedDisplayServerError,
21
+ )
22
+ from ...sources import (
23
+ AudioSourceSpec,
24
+ MicrophoneSource,
25
+ MonitorBackend,
26
+ MonitorSource,
27
+ ProcessAudioSource,
28
+ SystemAudioSource,
29
+ WindowSource,
30
+ audio_source_label,
31
+ )
32
+ from ...types import AudioDevice, AudioSession, Monitor, Window
33
+ from ..base import AudioBackend, VideoBackend
34
+ from . import _win32, wasapi
35
+ from ._winrt import RoApartment
36
+
37
+ PLATFORM = "windows"
38
+ WGC_WINDOW_MIN_BUILD = 18362 # CreateForWindow interop (1903)
39
+ WGC_BORDER_MIN_BUILD = 20348
40
+
41
+
42
+ def _no_display(display: str | None) -> None:
43
+ if display is not None:
44
+ raise UnsupportedDisplayServerError("display strings are only meaningful on Linux/X11")
45
+
46
+
47
+ @contextlib.contextmanager
48
+ def thread_context() -> Iterator[None]:
49
+ """Per-thread runtime initialisation for backend worker threads."""
50
+ with RoApartment():
51
+ yield
52
+
53
+
54
+ def list_monitors(display: str | None = None) -> list[Monitor]:
55
+ _no_display(display)
56
+ return _win32.list_monitors()
57
+
58
+
59
+ def list_windows(display: str | None = None, include_untitled: bool = False) -> list[Window]:
60
+ _no_display(display)
61
+ return _win32.list_windows(include_untitled)
62
+
63
+
64
+ def get_window(handle: int, display: str | None = None) -> Window:
65
+ _no_display(display)
66
+ if not _win32.is_window(handle):
67
+ raise TargetNotFoundError(f"window {handle:#x} does not exist")
68
+ return _win32.describe_window(handle)
69
+
70
+
71
+ def resolve_monitor(spec: Monitor | int | None, display: str | None = None) -> Monitor:
72
+ monitors = list_monitors(display)
73
+ if not monitors:
74
+ raise TargetNotFoundError("no monitors found")
75
+ if spec is None:
76
+ return next((m for m in monitors if m.is_primary), monitors[0])
77
+ if isinstance(spec, int):
78
+ if not 0 <= spec < len(monitors):
79
+ raise TargetNotFoundError(f"monitor index {spec} out of range (0..{len(monitors) - 1})")
80
+ return monitors[spec]
81
+ for m in monitors:
82
+ if m.id == spec.id or m.native_handle == spec.native_handle:
83
+ return m
84
+ raise TargetNotFoundError(f"monitor {spec.id} is no longer connected")
85
+
86
+
87
+ def open_video(source: MonitorSource | WindowSource) -> VideoBackend:
88
+ """Create (not start) a video backend. Must be started on a thread inside
89
+ :func:`thread_context`."""
90
+ from .dxgi_duplication import DxgiDuplicationBackend
91
+ from .wgc import WgcBackend
92
+
93
+ if isinstance(source, WindowSource):
94
+ _no_display(source.display)
95
+ if _build() < WGC_WINDOW_MIN_BUILD:
96
+ raise CapabilityUnavailableError("window capture needs Windows 10 1903 or newer")
97
+ return WgcBackend(
98
+ hwnd=source.handle,
99
+ region=source.region,
100
+ client_area_only=source.client_area_only,
101
+ include_cursor=source.include_cursor,
102
+ border=source.border,
103
+ )
104
+ _no_display(source.display)
105
+ monitor = resolve_monitor(source.monitor)
106
+ if source.backend is MonitorBackend.WGC:
107
+ return WgcBackend(
108
+ monitor=monitor, region=source.region, include_cursor=source.include_cursor
109
+ )
110
+ if source.backend not in (MonitorBackend.AUTO, MonitorBackend.DXGI):
111
+ raise CapabilityUnavailableError(
112
+ f"monitor backend {source.backend.value} is not available on Windows"
113
+ )
114
+ return DxgiDuplicationBackend(monitor, source.region, source.include_cursor)
115
+
116
+
117
+ def list_audio_devices() -> list[AudioDevice]:
118
+ return wasapi.list_devices()
119
+
120
+
121
+ def list_audio_sessions() -> list[AudioSession]:
122
+ return wasapi.list_sessions()
123
+
124
+
125
+ def _device_id(dev: AudioDevice | str | None) -> str | None:
126
+ if isinstance(dev, AudioDevice):
127
+ return dev.id
128
+ return dev
129
+
130
+
131
+ def open_audio(spec: AudioSourceSpec, sample_rate: int, channels: int = 2) -> AudioBackend:
132
+ name = audio_source_label(spec)
133
+ if isinstance(spec, MicrophoneSource):
134
+ return wasapi.WasapiCapture(
135
+ "microphone",
136
+ device_id=_device_id(spec.device),
137
+ sample_rate=sample_rate,
138
+ channels=channels,
139
+ name=name,
140
+ )
141
+ if isinstance(spec, SystemAudioSource):
142
+ return wasapi.WasapiCapture(
143
+ "system",
144
+ device_id=_device_id(spec.device),
145
+ sample_rate=sample_rate,
146
+ channels=channels,
147
+ name=name,
148
+ )
149
+ if isinstance(spec, ProcessAudioSource):
150
+ if spec.stream_ids:
151
+ raise CapabilityUnavailableError(
152
+ "stream_ids selection is Linux-only; on Windows select the process by PID"
153
+ )
154
+ return wasapi.WasapiCapture(
155
+ "process",
156
+ pid=spec.pid,
157
+ include_tree=spec.include_children,
158
+ sample_rate=sample_rate,
159
+ channels=channels,
160
+ name=name,
161
+ )
162
+ raise TypeError(f"unknown audio source {spec!r}")
163
+
164
+
165
+ def _build() -> int:
166
+ return wasapi.windows_build()
167
+
168
+
169
+ def capabilities(display: str | None = None) -> Capabilities:
170
+ _no_display(display)
171
+ build = _build()
172
+ from .wgc import is_supported
173
+
174
+ with RoApartment():
175
+ wgc_ok = is_supported()
176
+ if not wgc_ok:
177
+ window: Capability = unavailable("Windows Graphics Capture not supported on this system")
178
+ elif build < WGC_WINDOW_MIN_BUILD:
179
+ window = unavailable("window capture requires Windows 10 1903 (build 18362)+")
180
+ else:
181
+ window = supported("Windows Graphics Capture (CreateForWindow)")
182
+ border = (
183
+ experimental("IsBorderRequired=false accepted on this build; OS may still enforce it")
184
+ if build >= WGC_BORDER_MIN_BUILD and wgc_ok
185
+ else unavailable(f"requires build {WGC_BORDER_MIN_BUILD}+")
186
+ )
187
+ proc = (
188
+ supported("WASAPI process loopback (includes child processes)")
189
+ if wasapi.process_loopback_supported()
190
+ else unavailable(f"requires build {wasapi.PROCESS_LOOPBACK_MIN_BUILD}+ (this: {build})")
191
+ )
192
+ return Capabilities(
193
+ platform=PLATFORM,
194
+ os_version=f"{platform.release()} build {build}",
195
+ display_server="dwm",
196
+ monitor_capture=supported("DXGI Desktop Duplication (WGC monitor capture optional)"),
197
+ window_capture=window,
198
+ window_capture_borderless=border,
199
+ window_capture_minimized=unsupported(
200
+ "minimized windows are not rendered by DWM; WGC delivers no new frames "
201
+ "(the recording repeats the last frame)"
202
+ ),
203
+ cursor_capture=supported("DXGI: software-composited; WGC: IsCursorCaptureEnabled"),
204
+ microphone_audio=supported("WASAPI capture endpoint"),
205
+ system_audio=supported("WASAPI endpoint loopback"),
206
+ process_audio=proc,
207
+ audio_session_enumeration=supported("IAudioSessionManager2"),
208
+ recording=supported("FFmpeg subprocess (requires an FFmpeg executable)"),
209
+ )
210
+
211
+
212
+ __all__ = [
213
+ "audio_source_label",
214
+ ]
@@ -0,0 +1,66 @@
1
+ """Software cursor compositing for DXGI duplication (which excludes the cursor)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+
8
+ POINTER_MONOCHROME = 1
9
+ POINTER_COLOR = 2
10
+ POINTER_MASKED_COLOR = 4
11
+
12
+ Buffer = npt.NDArray[np.uint8]
13
+
14
+
15
+ class CursorShape:
16
+ def __init__(self, kind: int, width: int, height: int, pitch: int, data: bytes) -> None:
17
+ self.kind = kind
18
+ self.width = width
19
+ self.height = height
20
+ self.pitch = pitch
21
+ self.data = data
22
+ row_bytes = (width + 7) // 8 if kind == POINTER_MONOCHROME else width * 4
23
+ if width <= 0 or height <= 0 or pitch < row_bytes or pitch * height > len(data):
24
+ raise ValueError(f"malformed pointer shape {kind} {width}x{height} pitch {pitch}")
25
+ raw = np.frombuffer(data, dtype=np.uint8)
26
+ if kind == POINTER_MONOCHROME:
27
+ # height covers AND mask followed by XOR mask, 1 bpp
28
+ h = height // 2
29
+ bits = np.unpackbits(raw[: pitch * height].reshape(height, pitch), axis=1)[:, :width]
30
+ self.and_mask = bits[:h].astype(bool)
31
+ self.xor_mask = bits[h:].astype(bool)
32
+ self.height = h
33
+ self.bgra = None
34
+ else:
35
+ self.bgra = (
36
+ raw[: pitch * height]
37
+ .reshape(height, pitch)[:, : width * 4]
38
+ .reshape(height, width, 4)
39
+ )
40
+
41
+
42
+ def composite(out: Buffer, shape: CursorShape, x: int, y: int) -> None:
43
+ """Draw ``shape`` with its top-left at ``(x, y)`` in ``out`` (clipped)."""
44
+ h, w = shape.height, shape.width
45
+ x0, y0 = max(0, x), max(0, y)
46
+ x1, y1 = min(out.shape[1], x + w), min(out.shape[0], y + h)
47
+ if x0 >= x1 or y0 >= y1:
48
+ return
49
+ dst = out[y0:y1, x0:x1, :3]
50
+ sy, sx = slice(y0 - y, y1 - y), slice(x0 - x, x1 - x)
51
+ if shape.kind == POINTER_MONOCHROME:
52
+ and_m = shape.and_mask[sy, sx]
53
+ xor_m = shape.xor_mask[sy, sx]
54
+ res = np.where(and_m[..., None], dst, 0).astype(np.uint8)
55
+ res = np.where(xor_m[..., None], res ^ 0xFF, res).astype(np.uint8)
56
+ dst[...] = res
57
+ return
58
+ assert shape.bgra is not None
59
+ src = shape.bgra[sy, sx]
60
+ if shape.kind == POINTER_COLOR:
61
+ a = src[..., 3:4].astype(np.uint16)
62
+ blended = (src[..., :3].astype(np.uint16) * a + dst.astype(np.uint16) * (255 - a)) // 255
63
+ dst[...] = blended.astype(np.uint8)
64
+ else: # masked colour: alpha 0 → replace, 0xFF → XOR
65
+ mask = src[..., 3:4] == 0xFF
66
+ dst[...] = np.where(mask, dst ^ src[..., :3], src[..., :3])
@@ -0,0 +1,310 @@
1
+ """Monitor capture with DXGI Desktop Duplication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import logging
7
+ import time
8
+ from ctypes import Structure, byref, c_int64, c_long, c_uint, c_void_p
9
+ from ctypes import wintypes as wt
10
+
11
+ import numpy as np
12
+
13
+ from ..._pipeline.clock import now_ns
14
+ from ...errors import AccessDeniedError, CapabilityUnavailableError, NativeError
15
+ from ...types import Monitor, Rect, validate_region
16
+ from ..base import AcquiredFrame, Buffer, VideoBackendInfo, blit_clear, crop_box
17
+ from . import _d3d11
18
+ from ._com import ComPtr, check_hr, hr_u32
19
+ from .cursor import CursorShape, composite
20
+
21
+ log = logging.getLogger("capturekit.windows.dxgi")
22
+
23
+ DXGI_ERROR_ACCESS_LOST = -2005270490 # 0x887A0026
24
+ DXGI_ERROR_WAIT_TIMEOUT = -2005270489 # 0x887A0027
25
+ DXGI_ERROR_UNSUPPORTED = -2005270524 # 0x887A0004
26
+ DXGI_ERROR_NOT_CURRENTLY_AVAILABLE = -2005270494 # 0x887A0022
27
+ DXGI_ERROR_SESSION_DISCONNECTED = -2005270492 # 0x887A0024
28
+ E_ACCESSDENIED = -2147024891 # 0x80070005
29
+ DXGI_MODE_ROTATION_IDENTITY = 1
30
+ DXGI_MODE_ROTATION_UNSPECIFIED = 0
31
+
32
+ # IDXGIOutputDuplication
33
+ _DUP_GetDesc = 7
34
+ _DUP_AcquireNextFrame = 8
35
+ _DUP_GetFramePointerShape = 11
36
+ _DUP_ReleaseFrame = 14
37
+ # IDXGIOutput1
38
+ _OUT1_DuplicateOutput = 22
39
+
40
+
41
+ class _TransientUnavailableError(CapabilityUnavailableError):
42
+ """Duplication temporarily unavailable (exclusive full-screen app, too many
43
+ duplications); recovery retries. Other CapabilityUnavailableErrors (e.g. a
44
+ monitor rotated mid-capture) are permanent and propagate."""
45
+
46
+
47
+ class DXGI_OUTDUPL_POINTER_POSITION(Structure):
48
+ _fields_ = [("Position", wt.POINT), ("Visible", wt.BOOL)]
49
+
50
+
51
+ class DXGI_OUTDUPL_FRAME_INFO(Structure):
52
+ _fields_ = [
53
+ ("LastPresentTime", c_int64),
54
+ ("LastMouseUpdateTime", c_int64),
55
+ ("AccumulatedFrames", c_uint),
56
+ ("RectsCoalesced", wt.BOOL),
57
+ ("ProtectedContentMaskedOut", wt.BOOL),
58
+ ("PointerPosition", DXGI_OUTDUPL_POINTER_POSITION),
59
+ ("TotalMetadataBufferSize", c_uint),
60
+ ("PointerShapeBufferSize", c_uint),
61
+ ]
62
+
63
+
64
+ class DXGI_OUTDUPL_POINTER_SHAPE_INFO(Structure):
65
+ _fields_ = [
66
+ ("Type", c_uint),
67
+ ("Width", c_uint),
68
+ ("Height", c_uint),
69
+ ("Pitch", c_uint),
70
+ ("HotSpot", wt.POINT),
71
+ ]
72
+
73
+
74
+ class DXGI_MODE_DESC(Structure):
75
+ _fields_ = [
76
+ ("Width", c_uint),
77
+ ("Height", c_uint),
78
+ ("RefreshNumerator", c_uint),
79
+ ("RefreshDenominator", c_uint),
80
+ ("Format", c_uint),
81
+ ("ScanlineOrdering", c_uint),
82
+ ("Scaling", c_uint),
83
+ ]
84
+
85
+
86
+ class DXGI_OUTDUPL_DESC(Structure):
87
+ _fields_ = [
88
+ ("ModeDesc", DXGI_MODE_DESC),
89
+ ("Rotation", c_uint),
90
+ ("DesktopImageInSystemMemory", wt.BOOL),
91
+ ]
92
+
93
+
94
+ _kernel32 = ctypes.WinDLL("kernel32")
95
+ _qpc_freq = c_int64()
96
+ _kernel32.QueryPerformanceFrequency(byref(_qpc_freq))
97
+
98
+
99
+ class DxgiDuplicationBackend:
100
+ """Captures one monitor. Rotated outputs are rejected (use the WGC backend)."""
101
+
102
+ def __init__(self, monitor: Monitor, region: Rect | None, include_cursor: bool) -> None:
103
+ self.monitor = monitor
104
+ self.region = region
105
+ self.include_cursor = include_cursor
106
+ self._adapter: ComPtr | None = None
107
+ self._output1: ComPtr | None = None
108
+ self._device: ComPtr | None = None
109
+ self._context: ComPtr | None = None
110
+ self._dup: ComPtr | None = None
111
+ self._reader: _d3d11.StagingReader | None = None
112
+ self._size = (monitor.width, monitor.height)
113
+ self._crop = crop_box(Rect(0, 0, *self._size), region)
114
+ self._have_image = False
115
+ self._cursor_shape: CursorShape | None = None
116
+ self._cursor_pos = (0, 0)
117
+ self._cursor_visible = False
118
+ self.protected_frames = 0
119
+ self.access_lost_count = 0
120
+ self.info = VideoBackendInfo("dxgi-duplication", self._crop.size)
121
+
122
+ # -- lifecycle ---------------------------------------------------------
123
+ def start(self) -> None:
124
+ outputs = _d3d11.enum_outputs()
125
+ try:
126
+ match = None
127
+ for adapter, output, odesc, adesc in outputs:
128
+ if int(odesc.Monitor or 0) == self.monitor.native_handle and match is None:
129
+ match = (adapter, output, adesc)
130
+ else:
131
+ adapter.release()
132
+ output.release()
133
+ if match is None:
134
+ raise CapabilityUnavailableError(
135
+ f"no DXGI output for monitor {self.monitor.id}; the display may be driven "
136
+ "by an indirect/virtual driver — try MonitorBackend.WGC"
137
+ )
138
+ adapter, output, adesc = match
139
+ self._adapter = adapter
140
+ self.info.details["adapter"] = adesc.Description
141
+ self._output1 = output.query(_d3d11.IID_IDXGIOutput1, "IDXGIOutput1")
142
+ output.release()
143
+ self._device, self._context = _d3d11.create_device(self._adapter)
144
+ self._reader = _d3d11.StagingReader(self._device, self._context)
145
+ self._duplicate()
146
+ except BaseException:
147
+ self.close()
148
+ raise
149
+
150
+ def _duplicate(self) -> None:
151
+ assert self._output1 is not None and self._device is not None
152
+ out = c_void_p()
153
+ hr = self._output1.call(
154
+ _OUT1_DuplicateOutput, c_long, (c_void_p, c_void_p), self._device.as_param, byref(out)
155
+ )
156
+ if hr == DXGI_ERROR_UNSUPPORTED:
157
+ raise CapabilityUnavailableError(
158
+ "DXGI Desktop Duplication is not supported for this output/adapter "
159
+ "(hybrid-GPU or remote session) — try MonitorBackend.WGC"
160
+ )
161
+ if hr == DXGI_ERROR_NOT_CURRENTLY_AVAILABLE: # transient: retried by _recover
162
+ raise _TransientUnavailableError(
163
+ "DXGI Desktop Duplication is currently unavailable (too many duplication "
164
+ "sessions or a full-screen exclusive application)"
165
+ )
166
+ if hr == E_ACCESSDENIED:
167
+ raise AccessDeniedError("desktop duplication denied (secure desktop / lock screen?)")
168
+ check_hr(hr, "IDXGIOutput1::DuplicateOutput")
169
+ dup = ComPtr(out.value, "IDXGIOutputDuplication")
170
+ try:
171
+ self._validate_duplication(dup)
172
+ except BaseException:
173
+ dup.release()
174
+ raise
175
+ self._dup = dup
176
+
177
+ def _validate_duplication(self, dup: ComPtr) -> None:
178
+ desc = DXGI_OUTDUPL_DESC()
179
+ dup.call(_DUP_GetDesc, None, (c_void_p,), byref(desc))
180
+ if desc.Rotation not in (DXGI_MODE_ROTATION_IDENTITY, DXGI_MODE_ROTATION_UNSPECIFIED):
181
+ raise CapabilityUnavailableError(
182
+ "rotated monitors are not supported by the DXGI backend; use MonitorBackend.WGC"
183
+ )
184
+ size = (int(desc.ModeDesc.Width), int(desc.ModeDesc.Height))
185
+ if size != self._size:
186
+ log.info("duplication size %s differs from monitor rect %s", size, self._size)
187
+ self._size = size
188
+ if self.region is not None:
189
+ validate_region(self.region, self._size)
190
+ self._crop = crop_box(Rect(0, 0, *self._size), self.region)
191
+ self.info.initial_size = self._crop.size
192
+ self.info.details["refresh"] = (
193
+ desc.ModeDesc.RefreshNumerator / desc.ModeDesc.RefreshDenominator
194
+ if desc.ModeDesc.RefreshDenominator else None
195
+ ) # fmt: skip
196
+
197
+ def close(self) -> None:
198
+ if self._reader is not None:
199
+ self._reader.release()
200
+ self._reader = None
201
+ for name in ("_dup", "_output1", "_context", "_device", "_adapter"):
202
+ p = getattr(self, name)
203
+ if p is not None:
204
+ p.release()
205
+ setattr(self, name, None)
206
+
207
+ # -- capture -------------------------------------------------------------
208
+ def _recover(self) -> None:
209
+ self.access_lost_count += 1
210
+ if self._dup is not None:
211
+ self._dup.release()
212
+ self._dup = None
213
+ try:
214
+ self._duplicate()
215
+ except (AccessDeniedError, _TransientUnavailableError, NativeError) as exc:
216
+ # Secure desktop / mode switch in progress: retry on next acquire.
217
+ log.debug("duplication not yet available: %s", exc)
218
+
219
+ def acquire(self, out: Buffer | None, timeout_s: float) -> AcquiredFrame | None:
220
+ # AcquireNextFrame may return pointer-only updates; keep waiting for an
221
+ # image until the timeout expires.
222
+ deadline = now_ns() + int(timeout_s * 1e9)
223
+ while True:
224
+ remaining = max(0.0, (deadline - now_ns()) / 1e9)
225
+ frame = self._acquire_once(out, remaining)
226
+ if frame is not None or remaining <= 0:
227
+ return frame
228
+
229
+ def _acquire_once(self, out: Buffer | None, timeout_s: float) -> AcquiredFrame | None:
230
+ if self._dup is None:
231
+ self._recover()
232
+ if self._dup is None:
233
+ if timeout_s > 0:
234
+ time.sleep(min(timeout_s, 0.05))
235
+ return None
236
+ dup = self._dup
237
+ info = DXGI_OUTDUPL_FRAME_INFO()
238
+ res = c_void_p()
239
+ hr = dup.call(
240
+ _DUP_AcquireNextFrame, c_long, (c_uint, c_void_p, c_void_p),
241
+ max(0, int(timeout_s * 1000)), byref(info), byref(res),
242
+ ) # fmt: skip
243
+ if hr == DXGI_ERROR_WAIT_TIMEOUT:
244
+ return None
245
+ if hr in (DXGI_ERROR_ACCESS_LOST, E_ACCESSDENIED, DXGI_ERROR_SESSION_DISCONNECTED):
246
+ log.info("duplication lost (0x%08X); recreating", hr_u32(hr))
247
+ self._recover()
248
+ return None
249
+ check_hr(hr, "AcquireNextFrame")
250
+ resource = ComPtr(res.value, "IDXGIResource")
251
+ try:
252
+ assert self._reader is not None
253
+ # Frames with LastPresentTime == 0 carry only pointer/metadata updates;
254
+ # their texture is not a valid desktop image (verified: all-zero).
255
+ new_image = info.LastPresentTime != 0
256
+ cursor_changed = False
257
+ if self.include_cursor and info.LastMouseUpdateTime != 0:
258
+ pos = (info.PointerPosition.Position.x, info.PointerPosition.Position.y)
259
+ vis = bool(info.PointerPosition.Visible)
260
+ cursor_changed = pos != self._cursor_pos or vis != self._cursor_visible
261
+ self._cursor_pos, self._cursor_visible = pos, vis
262
+ if info.PointerShapeBufferSize:
263
+ self._read_pointer_shape(info.PointerShapeBufferSize)
264
+ cursor_changed = True
265
+ if info.ProtectedContentMaskedOut:
266
+ self.protected_frames += 1
267
+ if new_image:
268
+ with resource.query(_d3d11.IID_ID3D11Texture2D, "ID3D11Texture2D") as tex:
269
+ c = self._crop
270
+ self._reader.copy_box(tex, c.x, c.y, c.width, c.height)
271
+ self._have_image = True
272
+ if not (new_image or (cursor_changed and self._have_image)):
273
+ return None
274
+ w, h = self._crop.size
275
+ dest = out if out is not None else np.empty((h, w, 4), dtype=np.uint8)
276
+ # Map waits for the GPU copy; done before ReleaseFrame so DWM cannot
277
+ # overwrite the desktop surface while it is being copied.
278
+ self._reader.read_into(dest)
279
+ blit_clear(dest, min(w, dest.shape[1]), min(h, dest.shape[0]))
280
+ if self.include_cursor and self._cursor_visible and self._cursor_shape is not None:
281
+ composite(
282
+ dest,
283
+ self._cursor_shape,
284
+ self._cursor_pos[0] - self._crop.x,
285
+ self._cursor_pos[1] - self._crop.y,
286
+ )
287
+ ts = (info.LastPresentTime * 1_000_000_000 // _qpc_freq.value) if new_image else 0
288
+ return AcquiredFrame(dest, ts or now_ns(), self._size)
289
+ finally:
290
+ resource.release()
291
+ dup.call(_DUP_ReleaseFrame, c_long, ())
292
+
293
+ def _read_pointer_shape(self, size: int) -> None:
294
+ assert self._dup is not None
295
+ buf = (ctypes.c_ubyte * size)()
296
+ required = c_uint()
297
+ sinfo = DXGI_OUTDUPL_POINTER_SHAPE_INFO()
298
+ hr = self._dup.call(
299
+ _DUP_GetFramePointerShape, c_long, (c_uint, c_void_p, c_void_p, c_void_p),
300
+ size, buf, byref(required), byref(sinfo),
301
+ ) # fmt: skip
302
+ if hr < 0:
303
+ log.debug("GetFramePointerShape failed 0x%08X", hr_u32(hr))
304
+ return
305
+ try:
306
+ self._cursor_shape = CursorShape(
307
+ int(sinfo.Type), int(sinfo.Width), int(sinfo.Height), int(sinfo.Pitch), bytes(buf)
308
+ )
309
+ except ValueError as exc:
310
+ log.debug("ignoring cursor shape: %s", exc)