dxcam 0.2.0.dev1__tar.gz → 0.3.0.dev1__tar.gz

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 (43) hide show
  1. {dxcam-0.2.0.dev1/dxcam.egg-info → dxcam-0.3.0.dev1}/PKG-INFO +38 -60
  2. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/README.md +37 -59
  3. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/__init__.py +8 -6
  4. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/_libs/dxgi.py +2 -0
  5. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/backend.py +24 -0
  6. dxcam-0.3.0.dev1/dxcam/core/capture_loop.py +85 -0
  7. dxcam-0.3.0.dev1/dxcam/core/capture_runtime.py +123 -0
  8. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/device.py +10 -2
  9. dxcam-0.3.0.dev1/dxcam/core/display_recovery.py +143 -0
  10. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/dxgi_duplicator.py +60 -42
  11. dxcam-0.3.0.dev1/dxcam/core/dxgi_errors.py +154 -0
  12. dxcam-0.3.0.dev1/dxcam/core/output_recovery.py +143 -0
  13. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/dxcam.py +186 -201
  14. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/processor/__init__.py +2 -0
  15. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/processor/_numpy_kernels.c +152 -152
  16. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/processor/base.py +36 -1
  17. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/util/io.py +15 -3
  18. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1/dxcam.egg-info}/PKG-INFO +38 -60
  19. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam.egg-info/SOURCES.txt +7 -1
  20. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/pyproject.toml +2 -1
  21. dxcam-0.3.0.dev1/tests/test_dxgi_transient_policy.py +60 -0
  22. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/LICENSE +0 -0
  23. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/MANIFEST.in +0 -0
  24. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/_libs/__init__.py +0 -0
  25. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/_libs/d3d11.py +0 -0
  26. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/_libs/user32.py +0 -0
  27. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/__init__.py +0 -0
  28. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/duplicator.py +0 -0
  29. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/output.py +0 -0
  30. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/stagesurf.py +0 -0
  31. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/core/winrt_duplicator.py +0 -0
  32. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/processor/_numpy_kernels.pyx +0 -0
  33. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/processor/cv2_processor.py +0 -0
  34. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/processor/numpy_processor.py +0 -0
  35. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/types.py +0 -0
  36. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/util/__init__.py +0 -0
  37. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam/util/timer.py +0 -0
  38. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam.egg-info/dependency_links.txt +0 -0
  39. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam.egg-info/requires.txt +0 -0
  40. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/dxcam.egg-info/top_level.txt +0 -0
  41. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/setup.cfg +0 -0
  42. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/setup.py +0 -0
  43. {dxcam-0.2.0.dev1 → dxcam-0.3.0.dev1}/tests/test_bgra_conversion_parity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dxcam
3
- Version: 0.2.0.dev1
3
+ Version: 0.3.0.dev1
4
4
  Summary: A Python high-performance screenshot library for Windows using Desktop Duplication API
5
5
  Author: ra1nty
6
6
  License-Expression: MIT
@@ -49,19 +49,22 @@ Dynamic: license-file
49
49
  ```python
50
50
  import dxcam
51
51
 
52
- camera = dxcam.create()
53
- frame = camera.grab()
52
+ with dxcam.create() as camera:
53
+ frame = camera.grab()
54
54
  ```
55
55
 
56
+ > **Live API Docs:** [https://ra1nty.github.io/DXcam/](https://ra1nty.github.io/DXcam/)
57
+
56
58
  ## Introduction
57
59
  DXcam is a high-performance python screenshot and capture library for Windows based on the Desktop Duplication API.
58
60
  It is designed for low-latency, high-FPS capture pipelines (including full-screen Direct3D applications).
59
61
 
60
62
  Compared with common Python alternatives, DXcam focuses on:
61
- - Higher capture throughput
63
+ - Higher capture throughput (240+fps on 1080p)
62
64
  - Stable capture for full-screen exclusive Direct3D apps
63
- - Correct handling of scaled/stretched outputs
64
- - Better FPS pacing for continuous/video capture
65
+ - Better FPS pacing for continuous video capture
66
+ - Support DXGI / Windows Graphics Capture dual backend
67
+ - Seamless integration for AI Agent / Computer Vision use cases.
65
68
 
66
69
  ## Installation
67
70
  ### From PyPI (pip)
@@ -79,39 +82,12 @@ Notes:
79
82
  - Official Windows wheels are built for CPython `3.10` to `3.14`.
80
83
  - Binary wheels include the Cython kernels used by processor backends.
81
84
 
82
- ### From source (uv)
83
- ```bash
84
- uv sync
85
- # include OpenCV conversion backend
86
- uv sync --extra cv2
87
- # include optional Cython tooling
88
- uv sync --extra cython
89
- # include WinRT backend
90
- uv sync --extra winrt
91
- ```
92
-
93
- Build local Cython kernels from source:
94
- ```bash
95
- set DXCAM_BUILD_CYTHON=1
96
- uv pip install -e .[cython] --no-build-isolation
97
- ```
98
-
99
- ### Dev environment (uv + ruff + ty)
100
- ```bash
101
- uv venv --python 3.11 .venv
102
- uv sync --dev
103
- uv run ruff check dxcam
104
- uv run ty check dxcam
105
- ```
106
-
107
- ### API docs (pdoc)
108
- Generate autodocs for the public API surface:
109
- ```bash
110
- uv run pdoc -d google -o site dxcam dxcam.dxcam dxcam.types
111
- ```
85
+ ### From source
86
+ Please refer to [CONTRIBUTING](CONTRIBUTING.md).
112
87
 
113
- Preview locally by opening `site/index.html`.
114
- CI builds docs on pull requests, and docs are deployed from `main` via GitHub Pages.
88
+ ### Contributing / Dev
89
+ Contributions are welcome!
90
+ Development setup and contributor workflow are documented in [CONTRIBUTING.md](CONTRIBUTING.md).
115
91
 
116
92
  ## Usage
117
93
  Each output (monitor) is associated with one `DXCamera` instance.
@@ -121,7 +97,7 @@ import dxcam
121
97
  camera = dxcam.create() # primary output on device 0
122
98
  ```
123
99
 
124
- To specify backend:
100
+ To specify backends:
125
101
  ```python
126
102
  camera = dxcam.create(
127
103
  backend="dxgi", # default Desktop Duplication backend
@@ -133,7 +109,7 @@ camera = dxcam.create(
133
109
  ```python
134
110
  frame = camera.grab()
135
111
  ```
136
- `grab()` returns a `numpy.ndarray`. `None` if no new frame is available since the last capture (for backward compatibility); use `new_frame_only=False` to reuse the latest cached one-shot frame.
112
+ `grab()` returns a `numpy.ndarray`. `None` if no new frame is available since the last capture (for backward compatibility); use `camera.grab(new_frame_only=False)` to make dxcam always return the latest frame.
137
113
 
138
114
  Use `copy=False` (or `camera.grab_view()`) for a zero-copy view. This is faster, but the returned buffer can be overwritten by later captures.
139
115
 
@@ -167,6 +143,24 @@ Useful variants:
167
143
 
168
144
  > When `start()` capture is running, calling `grab()` reads from the in-memory ring buffer instead of directly polling DXGI.
169
145
 
146
+ ### Safely Releasing Resources
147
+ `release()` stops capture, frees buffers, and releases capture resources.
148
+ After `release()`, the same instance cannot be reused.
149
+
150
+ ```python
151
+ camera = dxcam.create(output_idx=0, output_color="BGR")
152
+ camera.release()
153
+ # camera.start() # raises RuntimeError
154
+ ```
155
+ Equivalently you can use context manager:
156
+ ```python
157
+ with dxcam.create() as camera:
158
+ frame = camera.grab()
159
+ # resource released automatically
160
+ ```
161
+
162
+ **Full API Docs:** [https://ra1nty.github.io/DXcam/](https://ra1nty.github.io/DXcam/)
163
+
170
164
  ## Advanced Usage and Remarks
171
165
  ### Multiple monitors / GPUs
172
166
  ```python
@@ -257,12 +251,13 @@ DXcam supports two capture backends:
257
251
 
258
252
  Use it like this:
259
253
  ```python
260
- camera = dxcam.create(backend="dxgi") # default
254
+ camera = dxcam.create(backend="dxgi")
261
255
  camera = dxcam.create(backend="winrt")
262
256
  ```
263
257
 
264
258
  Guideline:
265
- - Start with `dxgi` for most workloads.
259
+ - If you need cursor rendering, use `winrt`.
260
+ - Start with `dxgi` for most workloads, especially one-shot grab.
266
261
  - Try `winrt` if it performs better on your machine or fits your app constraints.
267
262
 
268
263
  ### Processor Backend
@@ -293,16 +288,6 @@ If `processor_backend="numpy"` is selected but compiled kernels are unavailable,
293
288
  DXcam logs a warning and falls back to `cv2` behavior. In that fallback path,
294
289
  install OpenCV for non-`BGRA` output modes.
295
290
 
296
- ### Safely Releasing Resources
297
- `release()` stops capture, frees buffers, and releases capture resources.
298
- After `release()`, the same instance cannot be reused.
299
-
300
- ```python
301
- camera = dxcam.create(output_idx=0, output_color="BGR")
302
- camera.release()
303
- # camera.start() # raises RuntimeError
304
- ```
305
-
306
291
  ## Benchmarks
307
292
  When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
308
293
 
@@ -319,16 +304,9 @@ The benchmark is across 5 runs, with a light-moderate usage on my PC (5900X + 30
319
304
  | 60fps | 61.71, 0.26 :checkered_flag: | N/A | 47.11, 1.33 |
320
305
  | 30fps | 30.08, 0.02 :checkered_flag: | N/A | 21.24, 0.17 |
321
306
 
322
- Processor backend comparison helper:
323
- ```bash
324
- python benchmarks/dxcam_processor_compare.py --backend dxgi --target-fps 120 --target-frames 1000
325
- python benchmarks/dxcam_capture.py --backend dxgi --processor-backend numpy
326
- python benchmarks/numpy_processor_micro.py --width 3840 --height 2160 --modes RGB --variants process into --processor-backends cv2 numpy
327
- ```
328
-
329
307
 
330
308
  ## Work Referenced
331
309
 
332
310
  [OBS Studio](https://github.com/obsproject/obs-studio) - implementation ideas and references.
333
311
 
334
- [D3DShot](https://github.com/SerpentAI/D3DShot/) : DXcam borrows the ctypes header directly from the no-longer maintained D3DShot.
312
+ [D3DShot](https://github.com/SerpentAI/D3DShot/) : DXcam borrowed some ctypes header from the no-longer maintained D3DShot.
@@ -4,19 +4,22 @@
4
4
  ```python
5
5
  import dxcam
6
6
 
7
- camera = dxcam.create()
8
- frame = camera.grab()
7
+ with dxcam.create() as camera:
8
+ frame = camera.grab()
9
9
  ```
10
10
 
11
+ > **Live API Docs:** [https://ra1nty.github.io/DXcam/](https://ra1nty.github.io/DXcam/)
12
+
11
13
  ## Introduction
12
14
  DXcam is a high-performance python screenshot and capture library for Windows based on the Desktop Duplication API.
13
15
  It is designed for low-latency, high-FPS capture pipelines (including full-screen Direct3D applications).
14
16
 
15
17
  Compared with common Python alternatives, DXcam focuses on:
16
- - Higher capture throughput
18
+ - Higher capture throughput (240+fps on 1080p)
17
19
  - Stable capture for full-screen exclusive Direct3D apps
18
- - Correct handling of scaled/stretched outputs
19
- - Better FPS pacing for continuous/video capture
20
+ - Better FPS pacing for continuous video capture
21
+ - Support DXGI / Windows Graphics Capture dual backend
22
+ - Seamless integration for AI Agent / Computer Vision use cases.
20
23
 
21
24
  ## Installation
22
25
  ### From PyPI (pip)
@@ -34,39 +37,12 @@ Notes:
34
37
  - Official Windows wheels are built for CPython `3.10` to `3.14`.
35
38
  - Binary wheels include the Cython kernels used by processor backends.
36
39
 
37
- ### From source (uv)
38
- ```bash
39
- uv sync
40
- # include OpenCV conversion backend
41
- uv sync --extra cv2
42
- # include optional Cython tooling
43
- uv sync --extra cython
44
- # include WinRT backend
45
- uv sync --extra winrt
46
- ```
47
-
48
- Build local Cython kernels from source:
49
- ```bash
50
- set DXCAM_BUILD_CYTHON=1
51
- uv pip install -e .[cython] --no-build-isolation
52
- ```
53
-
54
- ### Dev environment (uv + ruff + ty)
55
- ```bash
56
- uv venv --python 3.11 .venv
57
- uv sync --dev
58
- uv run ruff check dxcam
59
- uv run ty check dxcam
60
- ```
61
-
62
- ### API docs (pdoc)
63
- Generate autodocs for the public API surface:
64
- ```bash
65
- uv run pdoc -d google -o site dxcam dxcam.dxcam dxcam.types
66
- ```
40
+ ### From source
41
+ Please refer to [CONTRIBUTING](CONTRIBUTING.md).
67
42
 
68
- Preview locally by opening `site/index.html`.
69
- CI builds docs on pull requests, and docs are deployed from `main` via GitHub Pages.
43
+ ### Contributing / Dev
44
+ Contributions are welcome!
45
+ Development setup and contributor workflow are documented in [CONTRIBUTING.md](CONTRIBUTING.md).
70
46
 
71
47
  ## Usage
72
48
  Each output (monitor) is associated with one `DXCamera` instance.
@@ -76,7 +52,7 @@ import dxcam
76
52
  camera = dxcam.create() # primary output on device 0
77
53
  ```
78
54
 
79
- To specify backend:
55
+ To specify backends:
80
56
  ```python
81
57
  camera = dxcam.create(
82
58
  backend="dxgi", # default Desktop Duplication backend
@@ -88,7 +64,7 @@ camera = dxcam.create(
88
64
  ```python
89
65
  frame = camera.grab()
90
66
  ```
91
- `grab()` returns a `numpy.ndarray`. `None` if no new frame is available since the last capture (for backward compatibility); use `new_frame_only=False` to reuse the latest cached one-shot frame.
67
+ `grab()` returns a `numpy.ndarray`. `None` if no new frame is available since the last capture (for backward compatibility); use `camera.grab(new_frame_only=False)` to make dxcam always return the latest frame.
92
68
 
93
69
  Use `copy=False` (or `camera.grab_view()`) for a zero-copy view. This is faster, but the returned buffer can be overwritten by later captures.
94
70
 
@@ -122,6 +98,24 @@ Useful variants:
122
98
 
123
99
  > When `start()` capture is running, calling `grab()` reads from the in-memory ring buffer instead of directly polling DXGI.
124
100
 
101
+ ### Safely Releasing Resources
102
+ `release()` stops capture, frees buffers, and releases capture resources.
103
+ After `release()`, the same instance cannot be reused.
104
+
105
+ ```python
106
+ camera = dxcam.create(output_idx=0, output_color="BGR")
107
+ camera.release()
108
+ # camera.start() # raises RuntimeError
109
+ ```
110
+ Equivalently you can use context manager:
111
+ ```python
112
+ with dxcam.create() as camera:
113
+ frame = camera.grab()
114
+ # resource released automatically
115
+ ```
116
+
117
+ **Full API Docs:** [https://ra1nty.github.io/DXcam/](https://ra1nty.github.io/DXcam/)
118
+
125
119
  ## Advanced Usage and Remarks
126
120
  ### Multiple monitors / GPUs
127
121
  ```python
@@ -212,12 +206,13 @@ DXcam supports two capture backends:
212
206
 
213
207
  Use it like this:
214
208
  ```python
215
- camera = dxcam.create(backend="dxgi") # default
209
+ camera = dxcam.create(backend="dxgi")
216
210
  camera = dxcam.create(backend="winrt")
217
211
  ```
218
212
 
219
213
  Guideline:
220
- - Start with `dxgi` for most workloads.
214
+ - If you need cursor rendering, use `winrt`.
215
+ - Start with `dxgi` for most workloads, especially one-shot grab.
221
216
  - Try `winrt` if it performs better on your machine or fits your app constraints.
222
217
 
223
218
  ### Processor Backend
@@ -248,16 +243,6 @@ If `processor_backend="numpy"` is selected but compiled kernels are unavailable,
248
243
  DXcam logs a warning and falls back to `cv2` behavior. In that fallback path,
249
244
  install OpenCV for non-`BGRA` output modes.
250
245
 
251
- ### Safely Releasing Resources
252
- `release()` stops capture, frees buffers, and releases capture resources.
253
- After `release()`, the same instance cannot be reused.
254
-
255
- ```python
256
- camera = dxcam.create(output_idx=0, output_color="BGR")
257
- camera.release()
258
- # camera.start() # raises RuntimeError
259
- ```
260
-
261
246
  ## Benchmarks
262
247
  When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
263
248
 
@@ -274,16 +259,9 @@ The benchmark is across 5 runs, with a light-moderate usage on my PC (5900X + 30
274
259
  | 60fps | 61.71, 0.26 :checkered_flag: | N/A | 47.11, 1.33 |
275
260
  | 30fps | 30.08, 0.02 :checkered_flag: | N/A | 21.24, 0.17 |
276
261
 
277
- Processor backend comparison helper:
278
- ```bash
279
- python benchmarks/dxcam_processor_compare.py --backend dxgi --target-fps 120 --target-frames 1000
280
- python benchmarks/dxcam_capture.py --backend dxgi --processor-backend numpy
281
- python benchmarks/numpy_processor_micro.py --width 3840 --height 2160 --modes RGB --variants process into --processor-backends cv2 numpy
282
- ```
283
-
284
262
 
285
263
  ## Work Referenced
286
264
 
287
265
  [OBS Studio](https://github.com/obsproject/obs-studio) - implementation ideas and references.
288
266
 
289
- [D3DShot](https://github.com/SerpentAI/D3DShot/) : DXcam borrows the ctypes header directly from the no-longer maintained D3DShot.
267
+ [D3DShot](https://github.com/SerpentAI/D3DShot/) : DXcam borrowed some ctypes header from the no-longer maintained D3DShot.
@@ -82,9 +82,9 @@ class Singleton(type):
82
82
  class DXFactory(metaclass=Singleton):
83
83
  """Factory that owns device/output discovery and camera singletons."""
84
84
 
85
- _camera_instances: weakref.WeakValueDictionary[tuple[int, int, CaptureBackend], DXCamera] = (
86
- weakref.WeakValueDictionary()
87
- )
85
+ _camera_instances: weakref.WeakValueDictionary[
86
+ tuple[int, int, CaptureBackend], DXCamera
87
+ ] = weakref.WeakValueDictionary()
88
88
 
89
89
  def __init__(self) -> None:
90
90
  p_adapters = enum_dxgi_adapters()
@@ -104,7 +104,7 @@ class DXFactory(metaclass=Singleton):
104
104
  output_idx: int | None = None,
105
105
  region: Region | None = None,
106
106
  output_color: ColorMode = "RGB",
107
- max_buffer_len: int = 64,
107
+ max_buffer_len: int = 8,
108
108
  backend: CaptureBackend = "dxgi",
109
109
  processor_backend: ProcessorBackend = "cv2",
110
110
  ) -> DXCamera:
@@ -122,7 +122,9 @@ class DXFactory(metaclass=Singleton):
122
122
  if metadata and metadata[1]
123
123
  ]
124
124
  if not primary_output_indices:
125
- raise RuntimeError(f"No primary output found for device index {device_idx}")
125
+ raise RuntimeError(
126
+ f"No primary output found for device index {device_idx}"
127
+ )
126
128
  output_idx = primary_output_indices[0]
127
129
  instance_key = (device_idx, output_idx, backend)
128
130
  existing_camera = self._camera_instances.get(instance_key)
@@ -225,7 +227,7 @@ def create(
225
227
  output_idx: int | None = None,
226
228
  region: Region | None = None,
227
229
  output_color: ColorMode = "RGB",
228
- max_buffer_len: int = 64,
230
+ max_buffer_len: int = 8,
229
231
  backend: CaptureBackend = "dxgi",
230
232
  processor_backend: ProcessorBackend = "cv2",
231
233
  ) -> DXCamera:
@@ -5,8 +5,10 @@ from .d3d11 import ID3D11Device
5
5
 
6
6
 
7
7
  DXGI_ERROR_ACCESS_LOST = 0x887A0026
8
+ DXGI_ERROR_DEVICE_REMOVED = 0x887A0005
8
9
  DXGI_ERROR_NOT_FOUND = 0x887A0002
9
10
  DXGI_ERROR_SESSION_DISCONNECTED = 0x887A0028
11
+ DXGI_ERROR_UNSUPPORTED = 0x887A0004
10
12
  DXGI_ERROR_WAIT_TIMEOUT = 0x887A0027
11
13
 
12
14
  DXGI_OUTDUPL_FLAG_NONE = 0x0
@@ -29,6 +29,17 @@ _BACKEND_CREATORS: dict[
29
29
 
30
30
 
31
31
  def normalize_backend_name(backend: str) -> CaptureBackend:
32
+ """Normalize and validate a capture backend name.
33
+
34
+ Args:
35
+ backend: Backend name provided by user input.
36
+
37
+ Returns:
38
+ Lower-cased validated backend literal (``"dxgi"`` or ``"winrt"``).
39
+
40
+ Raises:
41
+ ValueError: If ``backend`` is not a supported capture backend.
42
+ """
32
43
  normalized = backend.lower()
33
44
  if normalized not in _SUPPORTED_BACKENDS:
34
45
  supported = ", ".join(_SUPPORTED_BACKENDS)
@@ -42,6 +53,19 @@ def create_backend_duplicator(
42
53
  output: Output,
43
54
  device: Device,
44
55
  ) -> Any:
56
+ """Create a backend-specific duplicator instance.
57
+
58
+ Args:
59
+ backend: Selected capture backend.
60
+ output: Output descriptor to capture from.
61
+ device: Device descriptor associated with ``output``.
62
+
63
+ Returns:
64
+ A duplicator instance that implements the capture backend contract.
65
+
66
+ Raises:
67
+ ValueError: If ``backend`` has no registered factory.
68
+ """
45
69
  creator = _BACKEND_CREATORS.get(backend)
46
70
  if creator is None:
47
71
  # Defensive fallback in case literals are expanded without wiring.
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from _thread import LockType
4
+ from threading import Event
5
+ from typing import Callable
6
+
7
+ import numpy as np
8
+
9
+ from dxcam.core.capture_runtime import CaptureRuntime
10
+ from dxcam.types import Frame, Region
11
+
12
+ GrabIntoFn = Callable[[Region, Frame], tuple[bool, int, int, int]]
13
+ ProcessStagingFrameFn = Callable[[int, int], Frame]
14
+ HandleFrameSizeChangeFn = Callable[[int, int], None]
15
+
16
+
17
+ class CaptureLoopRunner:
18
+ """Runs one capture-loop iteration and updates the ring buffer."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ lock: LockType,
24
+ frame_available_event: Event,
25
+ runtime: CaptureRuntime,
26
+ grab_into: GrabIntoFn,
27
+ process_staging_frame: ProcessStagingFrameFn,
28
+ handle_frame_size_change: HandleFrameSizeChangeFn,
29
+ ) -> None:
30
+ self._lock = lock
31
+ self._frame_available_event = frame_available_event
32
+ self._runtime = runtime
33
+ self._grab_into = grab_into
34
+ self._process_staging_frame = process_staging_frame
35
+ self._handle_frame_size_change = handle_frame_size_change
36
+
37
+ def run_once(self, *, region: Region, video_mode: bool) -> None:
38
+ with self._lock:
39
+ write_slot = self._runtime.reserve_write_slot()
40
+ if write_slot is None:
41
+ return
42
+ write_idx, write_dst = write_slot
43
+
44
+ captured, frame_ticks, frame_width, frame_height = self._grab_into(
45
+ region,
46
+ write_dst,
47
+ )
48
+ if captured:
49
+ with self._lock:
50
+ if self._runtime.commit_write(write_idx, frame_ticks):
51
+ self._frame_available_event.set()
52
+ return
53
+
54
+ if frame_width > 0 and frame_height > 0:
55
+ frame = self._process_staging_frame(frame_width, frame_height)
56
+ with self._lock:
57
+ current_shape = self._runtime.current_frame_shape()
58
+ if current_shape is None:
59
+ return
60
+ current_height, current_width = current_shape
61
+ if frame.shape[0] != current_height or frame.shape[1] != current_width:
62
+ self._handle_frame_size_change(frame.shape[0], frame.shape[1])
63
+
64
+ write_slot = self._runtime.reserve_write_slot()
65
+ if write_slot is None:
66
+ return
67
+ write_idx, write_dst = write_slot
68
+
69
+ np.copyto(write_dst, frame)
70
+ with self._lock:
71
+ if self._runtime.commit_write(write_idx, frame_ticks):
72
+ self._frame_available_event.set()
73
+ return
74
+
75
+ if video_mode:
76
+ with self._lock:
77
+ duplicate_copy = self._runtime.reserve_duplicate_copy()
78
+ if duplicate_copy is None:
79
+ return
80
+ write_idx, write_dst, previous_dst, frame_ticks = duplicate_copy
81
+ np.copyto(write_dst, previous_dst)
82
+ with self._lock:
83
+ if self._runtime.commit_write(write_idx, frame_ticks):
84
+ self._frame_available_event.set()
85
+ return
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+ from dxcam.types import Frame
9
+
10
+
11
+ @dataclass
12
+ class CaptureRuntime:
13
+ """Ring-buffer runtime state for threaded capture.
14
+
15
+ In threaded mode, producer flow is:
16
+ 1) reserve slot under lock
17
+ 2) copy frame data outside lock
18
+ 3) commit metadata under lock
19
+
20
+ Callers must ensure frame-buffer clear/realloc never runs concurrently from
21
+ a non-producer thread while producer is active.
22
+ """
23
+
24
+ max_buffer_len: int
25
+ channel_size: int
26
+ frame_buffer: Frame | None = None
27
+ frame_time_ticks: NDArray[np.int64] | None = None
28
+ head: int = 0
29
+ tail: int = 0
30
+ full: bool = False
31
+ has_frame: bool = False
32
+ frame_count: int = 0
33
+ latest_frame_ticks: int | None = None
34
+
35
+ def allocate_for_shape(self, frame_height: int, frame_width: int) -> None:
36
+ frame_shape = (frame_height, frame_width, self.channel_size)
37
+ self.frame_buffer = np.empty(
38
+ (self.max_buffer_len, *frame_shape),
39
+ dtype=np.uint8,
40
+ )
41
+ self.frame_time_ticks = np.zeros(self.max_buffer_len, dtype=np.int64)
42
+ self.head = 0
43
+ self.tail = 0
44
+ self.full = False
45
+ self.has_frame = False
46
+ self.frame_count = 0
47
+ self.latest_frame_ticks = None
48
+
49
+ def clear(self) -> None:
50
+ self.frame_buffer = None
51
+ self.frame_time_ticks = None
52
+ self.head = 0
53
+ self.tail = 0
54
+ self.full = False
55
+ self.has_frame = False
56
+ self.frame_count = 0
57
+ self.latest_frame_ticks = None
58
+
59
+ def current_frame_shape(self) -> tuple[int, int] | None:
60
+ if self.frame_buffer is None:
61
+ return None
62
+ return self.frame_buffer.shape[1], self.frame_buffer.shape[2]
63
+
64
+ def reserve_write_slot(self) -> tuple[int, Frame] | None:
65
+ if self.frame_buffer is None:
66
+ return None
67
+ write_idx = self.head
68
+ return write_idx, self.frame_buffer[write_idx]
69
+
70
+ def reserve_duplicate_copy(
71
+ self,
72
+ ) -> tuple[int, Frame, Frame, int] | None:
73
+ if (
74
+ self.frame_buffer is None
75
+ or self.frame_time_ticks is None
76
+ or not self.has_frame
77
+ ):
78
+ return None
79
+ write_idx = self.head
80
+ previous_idx = (self.head - 1) % self.max_buffer_len
81
+ dst = self.frame_buffer[write_idx]
82
+ src = self.frame_buffer[previous_idx]
83
+ frame_ticks = int(self.frame_time_ticks[previous_idx])
84
+ return write_idx, dst, src, frame_ticks
85
+
86
+ def commit_write(self, write_idx: int, frame_ticks: int) -> bool:
87
+ if self.frame_buffer is None or self.frame_time_ticks is None:
88
+ return False
89
+ if write_idx != self.head:
90
+ return False
91
+ if self.full:
92
+ self.tail = (self.tail + 1) % self.max_buffer_len
93
+ self.frame_time_ticks[write_idx] = frame_ticks
94
+ self.head = (write_idx + 1) % self.max_buffer_len
95
+ self.latest_frame_ticks = frame_ticks
96
+ self.frame_count += 1
97
+ self.full = self.head == self.tail
98
+ self.has_frame = True
99
+ return True
100
+
101
+ def peek_latest(self, copy: bool = True) -> Frame | None:
102
+ if self.frame_buffer is None or not self.has_frame:
103
+ return None
104
+ latest_idx = (self.head - 1) % self.max_buffer_len
105
+ frame = self.frame_buffer[latest_idx]
106
+ return np.array(frame, copy=True) if copy else frame
107
+
108
+ def peek_latest_with_ticks(
109
+ self,
110
+ copy: bool = True,
111
+ ) -> tuple[Frame, int] | None:
112
+ if (
113
+ self.frame_buffer is None
114
+ or self.frame_time_ticks is None
115
+ or not self.has_frame
116
+ ):
117
+ return None
118
+ latest_idx = (self.head - 1) % self.max_buffer_len
119
+ frame = self.frame_buffer[latest_idx]
120
+ frame_ticks = int(self.frame_time_ticks[latest_idx])
121
+ if copy:
122
+ return np.array(frame, copy=True), frame_ticks
123
+ return frame, frame_ticks
@@ -14,9 +14,13 @@ from dxcam._libs.d3d11 import (
14
14
  )
15
15
  from dxcam._libs.dxgi import (
16
16
  DXGI_ADAPTER_DESC1,
17
- DXGI_ERROR_NOT_FOUND,
18
17
  IDXGIOutput1,
19
18
  )
19
+ from dxcam.core.dxgi_errors import (
20
+ DXGITransientContext,
21
+ com_error_hresult_u32,
22
+ is_transient_hresult,
23
+ )
20
24
 
21
25
 
22
26
  @dataclass
@@ -70,7 +74,11 @@ class Device:
70
74
  p_outputs.append(p_output)
71
75
  i += 1
72
76
  except comtypes.COMError as ce:
73
- if ctypes.c_int32(DXGI_ERROR_NOT_FOUND).value == ce.args[0]:
77
+ hresult_u32 = com_error_hresult_u32(ce)
78
+ if is_transient_hresult(
79
+ hresult_u32,
80
+ DXGITransientContext.ENUM_OUTPUTS,
81
+ ):
74
82
  break
75
83
  raise
76
84
  return p_outputs