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