dxcam 0.3.0.dev1__tar.gz → 0.4.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 (56) hide show
  1. {dxcam-0.3.0.dev1/dxcam.egg-info → dxcam-0.4.0.dev1}/PKG-INFO +23 -18
  2. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/README.md +22 -17
  3. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/__init__.py +10 -7
  4. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/_libs/d3d11.py +11 -0
  5. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/_libs/dxgi.py +1 -0
  6. dxcam-0.4.0.dev1/dxcam/core/__init__.py +6 -0
  7. dxcam-0.4.0.dev1/dxcam/core/com_ptr.py +26 -0
  8. dxcam-0.4.0.dev1/dxcam/core/duplicator_protocol.py +18 -0
  9. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/core/dxgi_duplicator.py +76 -48
  10. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/core/stagesurf.py +75 -9
  11. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/core/winrt_duplicator.py +34 -10
  12. dxcam-0.4.0.dev1/dxcam/dxcam.py +820 -0
  13. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/processor/__init__.py +1 -2
  14. dxcam-0.4.0.dev1/dxcam/processor/_cython_kernels.c +34023 -0
  15. dxcam-0.4.0.dev1/dxcam/processor/_cython_kernels.pyx +900 -0
  16. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/processor/_numpy_kernels.c +152 -152
  17. dxcam-0.4.0.dev1/dxcam/processor/base.py +90 -0
  18. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/processor/cv2_processor.py +1 -2
  19. dxcam-0.4.0.dev1/dxcam/processor/cython_processor.py +158 -0
  20. dxcam-0.4.0.dev1/dxcam/runtime/__init__.py +18 -0
  21. {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev1/dxcam/runtime}/backend.py +6 -5
  22. dxcam-0.4.0.dev1/dxcam/runtime/capture_worker.py +138 -0
  23. {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev1/dxcam/runtime}/display_recovery.py +5 -4
  24. dxcam-0.4.0.dev1/dxcam/runtime/frame_buffer.py +159 -0
  25. {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev1/dxcam/runtime}/output_recovery.py +33 -22
  26. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/types.py +1 -1
  27. dxcam-0.4.0.dev1/dxcam/util/frame.py +76 -0
  28. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/util/io.py +18 -14
  29. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1/dxcam.egg-info}/PKG-INFO +23 -18
  30. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam.egg-info/SOURCES.txt +14 -8
  31. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/pyproject.toml +3 -2
  32. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/setup.py +6 -0
  33. dxcam-0.4.0.dev1/tests/test_processor_parity.py +187 -0
  34. dxcam-0.3.0.dev1/dxcam/core/__init__.py +0 -10
  35. dxcam-0.3.0.dev1/dxcam/core/capture_loop.py +0 -85
  36. dxcam-0.3.0.dev1/dxcam/core/capture_runtime.py +0 -123
  37. dxcam-0.3.0.dev1/dxcam/core/duplicator.py +0 -22
  38. dxcam-0.3.0.dev1/dxcam/dxcam.py +0 -839
  39. dxcam-0.3.0.dev1/dxcam/processor/base.py +0 -125
  40. dxcam-0.3.0.dev1/tests/test_bgra_conversion_parity.py +0 -88
  41. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/LICENSE +0 -0
  42. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/MANIFEST.in +0 -0
  43. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/_libs/__init__.py +0 -0
  44. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/_libs/user32.py +0 -0
  45. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/core/device.py +0 -0
  46. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/core/dxgi_errors.py +0 -0
  47. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/core/output.py +0 -0
  48. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/processor/_numpy_kernels.pyx +0 -0
  49. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/processor/numpy_processor.py +0 -0
  50. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/util/__init__.py +0 -0
  51. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam/util/timer.py +0 -0
  52. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam.egg-info/dependency_links.txt +0 -0
  53. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam.egg-info/requires.txt +0 -0
  54. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/dxcam.egg-info/top_level.txt +0 -0
  55. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/setup.cfg +0 -0
  56. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev1}/tests/test_dxgi_transient_policy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dxcam
3
- Version: 0.3.0.dev1
3
+ Version: 0.4.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
@@ -104,14 +104,16 @@ camera = dxcam.create(
104
104
  processor_backend="cv2" # default OpenCV processor
105
105
  )
106
106
  ```
107
+ Note:
108
+ - From 0.4.0 the `dxcam.create(max_buffer_len=...)` is deprecated, screen capture now always uses a tripple buffer.
107
109
 
108
110
  ### Screenshot
109
111
  ```python
110
112
  frame = camera.grab()
111
113
  ```
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.
114
+ `grab()` returns a `numpy.ndarray`. `None` if no new frame is available since the last capture; use `camera.grab(new_frame_only=False)` to make dxcam always return the latest frame.
113
115
 
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.
116
+ Use `camera.grab_into(dst)` to reuse caller-managed memory.
115
117
 
116
118
  To capture a region:
117
119
  ```python
@@ -134,14 +136,13 @@ camera.is_capturing # False
134
136
  for _ in range(1000):
135
137
  frame = camera.get_latest_frame() # blocks until a frame is available
136
138
  ```
137
- >The screen capture mode spins up a thread that polls newly rendered frames and stores them in an in-memory ring buffer. The blocking and `video_mode` behavior is designed for downstream video recording and machine learning workloads.
139
+ >The screen capture mode spins up a thread that polls newly rendered frames and publishes them into a latest-only in-memory frame buffer. The blocking and `video_mode` behavior is designed for downstream video recording and machine learning workloads.
138
140
 
139
141
  Useful variants:
140
142
  - `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
141
- - `camera.get_latest_frame_view()` -> zero-copy view into the frame buffer
142
- - `camera.grab(copy=False)` / `camera.grab_view()` -> zero-copy latest-frame snapshot
143
+ - `camera.get_latest_frame_into(dst)` -> write latest frame into caller-provided array
143
144
 
144
- > When `start()` capture is running, calling `grab()` reads from the in-memory ring buffer instead of directly polling DXGI.
145
+ > When `start()` capture is running, calling `grab()` reads from the in-memory frame buffer instead of directly polling the capture backend.
145
146
 
146
147
  ### Safely Releasing Resources
147
148
  `release()` stops capture, frees buffers, and releases capture resources.
@@ -193,13 +194,13 @@ Supported modes: `"RGB"`, `"RGBA"`, `"BGR"`, `"BGRA"`, `"GRAY"`.
193
194
  Notes:
194
195
  - Data is returned as `numpy.ndarray`.
195
196
  - `BGRA` does not require OpenCV and is the leanest dependency path.
196
- - `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2` or compiled `numpy` backend).
197
+ - `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
197
198
 
198
199
  ### Frame Buffer
199
- DXcam uses a fixed-size ring buffer in-memory. New frames overwrite old frames when full.
200
+ DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers always consume the newest published frame, and older staged frames may be overwritten once they are no longer leased.
200
201
 
201
202
  ```python
202
- camera = dxcam.create(max_buffer_len=120) # default is 8
203
+ camera = dxcam.create() # max_buffer_len is accepted but ignored
203
204
  ```
204
205
 
205
206
  ### Target FPS
@@ -224,7 +225,7 @@ For `backend="dxgi"`, this value comes from `DXGI_OUTDUPL_FRAME_INFO.LastPresent
224
225
  For `backend="winrt"`, this value is derived from WinRT `SystemRelativeTime`.
225
226
 
226
227
  ### Video Mode
227
- With `video_mode=True`, DXcam fills the buffer at target FPS, reusing the previous frame if needed, even if no new frame is rendered.
228
+ With `video_mode=True`, DXcam continues publishing at target FPS, reusing the previous frame when no new frame is rendered.
228
229
 
229
230
  ```python
230
231
  import cv2
@@ -261,22 +262,22 @@ Guideline:
261
262
  - Try `winrt` if it performs better on your machine or fits your app constraints.
262
263
 
263
264
  ### Processor Backend
264
- DXcam capture backends (`dxgi`/`winrt`) first acquire a BGRA frame.
265
- The processor backend then handles post-processing:
265
+ DXcam capture backends (`dxgi`/`winrt`) acquire raw BGRA frame. The processor backend then handles post-processing:
266
266
  - optional rotation/cropping preparation
267
267
  - color conversion to your `output_color`
268
268
 
269
269
  Recommended backend choice:
270
270
  - OpenCV installed: use `cv2` (default)
271
- - No OpenCV installed: use `numpy` (Cython kernels)
271
+ - No OpenCV installed: use `numpy`/`cython`
272
272
 
273
273
  Use it like this:
274
274
  ```python
275
275
  camera = dxcam.create(processor_backend="cv2")
276
+ camera = dxcam.create(processor_backend="cython")
276
277
  camera = dxcam.create(processor_backend="numpy")
277
278
  ```
278
279
 
279
- Official Windows wheels already include the compiled NumPy kernels.
280
+ Official Windows wheels already include the compiled Cython processor kernels.
280
281
 
281
282
  Only for source installs:
282
283
  ```bash
@@ -288,6 +289,10 @@ If `processor_backend="numpy"` is selected but compiled kernels are unavailable,
288
289
  DXcam logs a warning and falls back to `cv2` behavior. In that fallback path,
289
290
  install OpenCV for non-`BGRA` output modes.
290
291
 
292
+ If `processor_backend="cython"` is selected but compiled kernels are unavailable,
293
+ DXcam raises a runtime error because that backend is explicitly the direct
294
+ Cython path.
295
+
291
296
  ## Benchmarks
292
297
  When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
293
298
 
@@ -299,10 +304,10 @@ When using a similar logic (only capture newly rendered frames) running on a 240
299
304
  The benchmark is across 5 runs, with a light-moderate usage on my PC (5900X + 3090; Chrome ~30tabs, VS Code opened, etc.), I used the [Blur Buster UFO test](https://www.testufo.com/framerates#count=5&background=stars&pps=960) to constantly render 240 fps on my monitor. DXcam captured almost every frame rendered. You will see some benchmarks online claiming 1000+fps capture while most of them is busy-spinning a for loop on a staled frame (no new frame rendered on screen in test scenario).
300
305
 
301
306
  ### For Targeting FPS:
302
- | (Target)\\(mean,std) | DXcam | python-mss | D3DShot |
307
+ | (Target)\\(mean,std) | DXcam (4k) | python-mss | D3DShot (1080p) |
303
308
  |------------- |-------- |------------|---------|
304
- | 60fps | 61.71, 0.26 :checkered_flag: | N/A | 47.11, 1.33 |
305
- | 30fps | 30.08, 0.02 :checkered_flag: | N/A | 21.24, 0.17 |
309
+ | 60fps | 59.99, 0.04 :checkered_flag: | N/A | 47.11, 1.33 |
310
+ | 30fps | 30.00, 0.00 :checkered_flag: | N/A | 21.24, 0.17 |
306
311
 
307
312
 
308
313
  ## Work Referenced
@@ -59,14 +59,16 @@ camera = dxcam.create(
59
59
  processor_backend="cv2" # default OpenCV processor
60
60
  )
61
61
  ```
62
+ Note:
63
+ - From 0.4.0 the `dxcam.create(max_buffer_len=...)` is deprecated, screen capture now always uses a tripple buffer.
62
64
 
63
65
  ### Screenshot
64
66
  ```python
65
67
  frame = camera.grab()
66
68
  ```
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.
69
+ `grab()` returns a `numpy.ndarray`. `None` if no new frame is available since the last capture; use `camera.grab(new_frame_only=False)` to make dxcam always return the latest frame.
68
70
 
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.
71
+ Use `camera.grab_into(dst)` to reuse caller-managed memory.
70
72
 
71
73
  To capture a region:
72
74
  ```python
@@ -89,14 +91,13 @@ camera.is_capturing # False
89
91
  for _ in range(1000):
90
92
  frame = camera.get_latest_frame() # blocks until a frame is available
91
93
  ```
92
- >The screen capture mode spins up a thread that polls newly rendered frames and stores them in an in-memory ring buffer. The blocking and `video_mode` behavior is designed for downstream video recording and machine learning workloads.
94
+ >The screen capture mode spins up a thread that polls newly rendered frames and publishes them into a latest-only in-memory frame buffer. The blocking and `video_mode` behavior is designed for downstream video recording and machine learning workloads.
93
95
 
94
96
  Useful variants:
95
97
  - `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
96
- - `camera.get_latest_frame_view()` -> zero-copy view into the frame buffer
97
- - `camera.grab(copy=False)` / `camera.grab_view()` -> zero-copy latest-frame snapshot
98
+ - `camera.get_latest_frame_into(dst)` -> write latest frame into caller-provided array
98
99
 
99
- > When `start()` capture is running, calling `grab()` reads from the in-memory ring buffer instead of directly polling DXGI.
100
+ > When `start()` capture is running, calling `grab()` reads from the in-memory frame buffer instead of directly polling the capture backend.
100
101
 
101
102
  ### Safely Releasing Resources
102
103
  `release()` stops capture, frees buffers, and releases capture resources.
@@ -148,13 +149,13 @@ Supported modes: `"RGB"`, `"RGBA"`, `"BGR"`, `"BGRA"`, `"GRAY"`.
148
149
  Notes:
149
150
  - Data is returned as `numpy.ndarray`.
150
151
  - `BGRA` does not require OpenCV and is the leanest dependency path.
151
- - `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2` or compiled `numpy` backend).
152
+ - `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
152
153
 
153
154
  ### Frame Buffer
154
- DXcam uses a fixed-size ring buffer in-memory. New frames overwrite old frames when full.
155
+ DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers always consume the newest published frame, and older staged frames may be overwritten once they are no longer leased.
155
156
 
156
157
  ```python
157
- camera = dxcam.create(max_buffer_len=120) # default is 8
158
+ camera = dxcam.create() # max_buffer_len is accepted but ignored
158
159
  ```
159
160
 
160
161
  ### Target FPS
@@ -179,7 +180,7 @@ For `backend="dxgi"`, this value comes from `DXGI_OUTDUPL_FRAME_INFO.LastPresent
179
180
  For `backend="winrt"`, this value is derived from WinRT `SystemRelativeTime`.
180
181
 
181
182
  ### Video Mode
182
- With `video_mode=True`, DXcam fills the buffer at target FPS, reusing the previous frame if needed, even if no new frame is rendered.
183
+ With `video_mode=True`, DXcam continues publishing at target FPS, reusing the previous frame when no new frame is rendered.
183
184
 
184
185
  ```python
185
186
  import cv2
@@ -216,22 +217,22 @@ Guideline:
216
217
  - Try `winrt` if it performs better on your machine or fits your app constraints.
217
218
 
218
219
  ### Processor Backend
219
- DXcam capture backends (`dxgi`/`winrt`) first acquire a BGRA frame.
220
- The processor backend then handles post-processing:
220
+ DXcam capture backends (`dxgi`/`winrt`) acquire raw BGRA frame. The processor backend then handles post-processing:
221
221
  - optional rotation/cropping preparation
222
222
  - color conversion to your `output_color`
223
223
 
224
224
  Recommended backend choice:
225
225
  - OpenCV installed: use `cv2` (default)
226
- - No OpenCV installed: use `numpy` (Cython kernels)
226
+ - No OpenCV installed: use `numpy`/`cython`
227
227
 
228
228
  Use it like this:
229
229
  ```python
230
230
  camera = dxcam.create(processor_backend="cv2")
231
+ camera = dxcam.create(processor_backend="cython")
231
232
  camera = dxcam.create(processor_backend="numpy")
232
233
  ```
233
234
 
234
- Official Windows wheels already include the compiled NumPy kernels.
235
+ Official Windows wheels already include the compiled Cython processor kernels.
235
236
 
236
237
  Only for source installs:
237
238
  ```bash
@@ -243,6 +244,10 @@ If `processor_backend="numpy"` is selected but compiled kernels are unavailable,
243
244
  DXcam logs a warning and falls back to `cv2` behavior. In that fallback path,
244
245
  install OpenCV for non-`BGRA` output modes.
245
246
 
247
+ If `processor_backend="cython"` is selected but compiled kernels are unavailable,
248
+ DXcam raises a runtime error because that backend is explicitly the direct
249
+ Cython path.
250
+
246
251
  ## Benchmarks
247
252
  When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
248
253
 
@@ -254,10 +259,10 @@ When using a similar logic (only capture newly rendered frames) running on a 240
254
259
  The benchmark is across 5 runs, with a light-moderate usage on my PC (5900X + 3090; Chrome ~30tabs, VS Code opened, etc.), I used the [Blur Buster UFO test](https://www.testufo.com/framerates#count=5&background=stars&pps=960) to constantly render 240 fps on my monitor. DXcam captured almost every frame rendered. You will see some benchmarks online claiming 1000+fps capture while most of them is busy-spinning a for loop on a staled frame (no new frame rendered on screen in test scenario).
255
260
 
256
261
  ### For Targeting FPS:
257
- | (Target)\\(mean,std) | DXcam | python-mss | D3DShot |
262
+ | (Target)\\(mean,std) | DXcam (4k) | python-mss | D3DShot (1080p) |
258
263
  |------------- |-------- |------------|---------|
259
- | 60fps | 61.71, 0.26 :checkered_flag: | N/A | 47.11, 1.33 |
260
- | 30fps | 30.08, 0.02 :checkered_flag: | N/A | 21.24, 0.17 |
264
+ | 60fps | 59.99, 0.04 :checkered_flag: | N/A | 47.11, 1.33 |
265
+ | 30fps | 30.00, 0.00 :checkered_flag: | N/A | 21.24, 0.17 |
261
266
 
262
267
 
263
268
  ## Work Referenced
@@ -24,7 +24,7 @@ import weakref
24
24
  from types import FrameType
25
25
  from typing import Any, Callable, cast
26
26
 
27
- from dxcam.core.backend import normalize_backend_name
27
+ from dxcam.runtime.backend import normalize_backend_name
28
28
  from dxcam.dxcam import DXCamera, Output, Device
29
29
  from dxcam.processor import normalize_processor_backend_name
30
30
  from dxcam.types import CaptureBackend, ColorMode, ProcessorBackend, Region
@@ -140,8 +140,8 @@ class DXFactory(metaclass=Singleton):
140
140
  if existing_camera is not None:
141
141
  logger.warning(
142
142
  "DXCamera instance already exists for device=%s output=%s backend=%s; "
143
- "returning existing instance. Delete the old object with `del obj` "
144
- "to recreate it with new parameters.",
143
+ "returning the existing instance. Call release() before "
144
+ "recreating it with new parameters.",
145
145
  device_idx,
146
146
  output_idx,
147
147
  backend,
@@ -239,11 +239,14 @@ def create(
239
239
  the primary output.
240
240
  region: Optional capture region as ``(left, top, right, bottom)``.
241
241
  output_color: Output pixel format.
242
- max_buffer_len: Ring-buffer size used in threaded capture mode.
242
+ max_buffer_len: Kept for API compatibility. Threaded capture uses a
243
+ fixed three-slot latest-only frame buffer.
243
244
  backend: Capture backend, ``"dxgi"`` or ``"winrt"``.
244
- processor_backend: Post-processing backend, ``"cv2"`` (default)
245
- or ``"numpy"``. The ``"numpy"`` backend uses compiled Cython
246
- kernels when available and falls back to cv2 behavior otherwise.
245
+ processor_backend: Post-processing backend, ``"cv2"`` (default),
246
+ ``"cython"``, or ``"numpy"``. The ``"cython"`` backend uses the
247
+ direct compiled Cython processor, while ``"numpy"`` uses compiled
248
+ conversion kernels when available and falls back to cv2 behavior
249
+ otherwise.
247
250
 
248
251
  Returns:
249
252
  A :class:`dxcam.dxcam.DXCamera` instance.
@@ -19,6 +19,9 @@ D3D11_USAGE_IMMUTABLE = 1
19
19
  D3D11_USAGE_DYNAMIC = 2
20
20
  D3D11_USAGE_STAGING = 3
21
21
 
22
+ D3D11_MAP_READ = 1
23
+ D3D11_MAP_FLAG_DO_NOT_WAIT = 0x100000
24
+
22
25
  DXGI_FORMAT_B8G8R8A8_UNORM = 87
23
26
 
24
27
 
@@ -55,6 +58,14 @@ class D3D11_TEXTURE2D_DESC(ctypes.Structure):
55
58
  ]
56
59
 
57
60
 
61
+ class D3D11_MAPPED_SUBRESOURCE(ctypes.Structure):
62
+ _fields_ = [
63
+ ("pData", ctypes.c_void_p),
64
+ ("RowPitch", wintypes.UINT),
65
+ ("DepthPitch", wintypes.UINT),
66
+ ]
67
+
68
+
58
69
  class ID3D11DeviceChild(comtypes.IUnknown):
59
70
  _iid_ = comtypes.GUID("{1841e5c8-16b0-489b-bcc8-44cfb0d5deae}")
60
71
  _methods_ = [
@@ -10,6 +10,7 @@ DXGI_ERROR_NOT_FOUND = 0x887A0002
10
10
  DXGI_ERROR_SESSION_DISCONNECTED = 0x887A0028
11
11
  DXGI_ERROR_UNSUPPORTED = 0x887A0004
12
12
  DXGI_ERROR_WAIT_TIMEOUT = 0x887A0027
13
+ DXGI_ERROR_WAS_STILL_DRAWING = 0x887A000A
13
14
 
14
15
  DXGI_OUTDUPL_FLAG_NONE = 0x0
15
16
  DXGI_OUTDUPL_FLAG_COMPOSITED_UI_CAPTURE_ONLY = 0x1
@@ -0,0 +1,6 @@
1
+ __all__ = ["Device", "Output", "StageSurface"]
2
+
3
+
4
+ from dxcam.core.device import Device
5
+ from dxcam.core.output import Output
6
+ from dxcam.core.stagesurf import StageSurface
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ from typing import Any
5
+
6
+
7
+ def clear_com_pointer(pointer: Any) -> None:
8
+ """Set a COM pointer value to NULL in-place without calling Release()."""
9
+ if pointer is None:
10
+ return
11
+ try:
12
+ raw = ctypes.cast(ctypes.byref(pointer), ctypes.POINTER(ctypes.c_void_p))
13
+ raw[0] = None
14
+ except Exception:
15
+ # Best-effort cleanup helper; callers may still overwrite the attribute.
16
+ return
17
+
18
+
19
+ def release_com_pointer(pointer: Any) -> None:
20
+ """Release a COM pointer once, then null it to avoid late double-Release."""
21
+ if pointer is None or not pointer:
22
+ return
23
+ try:
24
+ pointer.Release()
25
+ finally:
26
+ clear_com_pointer(pointer)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ContextManager, Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class FrameDuplicator(Protocol):
8
+ """Minimal capture backend contract consumed by DXCamera/runtime."""
9
+
10
+ texture: Any
11
+
12
+ def acquire_frame(
13
+ self, wait_for_frame: bool = False
14
+ ) -> ContextManager[tuple[bool, bool, int]]: ...
15
+
16
+ def ticks_to_seconds(self, ticks: int) -> float: ...
17
+
18
+ def release(self) -> None: ...
@@ -3,8 +3,9 @@ from __future__ import annotations
3
3
  import ctypes
4
4
  import logging
5
5
  import os
6
+ from contextlib import contextmanager
6
7
  from dataclasses import InitVar, dataclass, field
7
- from typing import Any, cast
8
+ from typing import Any, Iterator, cast
8
9
 
9
10
  import comtypes
10
11
 
@@ -17,6 +18,7 @@ from dxcam._libs.dxgi import (
17
18
  IDXGIOutput5,
18
19
  IDXGIResource,
19
20
  )
21
+ from dxcam.core.com_ptr import release_com_pointer
20
22
  from dxcam.core.device import Device
21
23
  from dxcam.core.dxgi_errors import (
22
24
  DXGITransientContext,
@@ -40,7 +42,6 @@ class DXGIDuplicator:
40
42
  """Desktop Duplication API wrapper for acquiring frame textures."""
41
43
 
42
44
  texture: Any = field(default_factory=lambda: ctypes.POINTER(ID3D11Texture2D)())
43
- early_release_frame: bool = True
44
45
  duplicator: Any = None
45
46
  updated: bool = False
46
47
  output: InitVar[Output | None] = None
@@ -51,6 +52,10 @@ class DXGIDuplicator:
51
52
  performance_frequency: int = 0
52
53
  _frame_held: bool = False
53
54
 
55
+ def _drop_texture_reference(self) -> None:
56
+ release_com_pointer(self.texture)
57
+ self.texture = ctypes.POINTER(ID3D11Texture2D)()
58
+
54
59
  def __post_init__(self, output: Output | None, device: Device | None) -> None:
55
60
  if output is None or device is None:
56
61
  raise ValueError(
@@ -126,60 +131,77 @@ class DXGIDuplicator:
126
131
  self.duplicator = ctypes.POINTER(IDXGIOutputDuplication)()
127
132
  return False
128
133
 
129
- def update_frame(self, wait_for_frame: bool = False) -> bool:
134
+ def _update_frame(self, wait_for_frame: bool = False) -> bool:
130
135
  del wait_for_frame # DXGI path does not use extra wait logic.
131
136
  if self._frame_held:
132
- if not self.release_frame():
137
+ if not self._release_frame():
133
138
  self.updated = False
134
139
  return False
135
140
 
136
141
  info = DXGI_OUTDUPL_FRAME_INFO()
137
142
  res = ctypes.POINTER(IDXGIResource)()
138
143
  try:
139
- self.duplicator.AcquireNextFrame(
140
- 0,
141
- ctypes.byref(info),
142
- ctypes.byref(res),
143
- )
144
- except comtypes.COMError as ce:
145
- hresult = com_error_hresult_u32(ce)
146
- if hresult == DXGI_ERROR_WAIT_TIMEOUT_U32:
147
- self.updated = False
148
- self.accumulated_frames = 0
149
- return True
150
- if is_transient_hresult(
151
- hresult,
152
- DXGITransientContext.FRAME_INFO,
153
- DXGITransientContext.SYSTEM_TRANSITION,
154
- ):
155
- logger.warning(
156
- "Desktop duplication access loss/system transition detected "
157
- "(HRESULT=0x%08X). Triggering output-change recovery.",
158
- hresult,
144
+ try:
145
+ self.duplicator.AcquireNextFrame(
146
+ 0,
147
+ ctypes.byref(info),
148
+ ctypes.byref(res),
159
149
  )
150
+ except comtypes.COMError as ce:
151
+ hresult = com_error_hresult_u32(ce)
152
+ if hresult == DXGI_ERROR_WAIT_TIMEOUT_U32:
153
+ self.updated = False
154
+ self.accumulated_frames = 0
155
+ return True
156
+ if is_transient_hresult(
157
+ hresult,
158
+ DXGITransientContext.FRAME_INFO,
159
+ DXGITransientContext.SYSTEM_TRANSITION,
160
+ ):
161
+ logger.warning(
162
+ "Desktop duplication access loss/system transition detected "
163
+ "(HRESULT=0x%08X). Triggering output-change recovery.",
164
+ hresult,
165
+ )
166
+ self.updated = False
167
+ self._frame_held = False
168
+ return False
169
+ raise
170
+ self._frame_held = True
171
+ try:
172
+ resource = cast(Any, res)
173
+ self._drop_texture_reference()
174
+ self.texture = resource.QueryInterface(ID3D11Texture2D)
175
+ except comtypes.COMError:
176
+ self._release_frame()
177
+ self.texture = ctypes.POINTER(ID3D11Texture2D)()
160
178
  self.updated = False
161
- self._frame_held = False
162
- return False
163
- raise
164
- self._frame_held = True
165
- try:
166
- resource = cast(Any, res)
167
- self.texture = resource.QueryInterface(ID3D11Texture2D)
168
- except comtypes.COMError:
169
- self.release_frame()
170
- self.texture = ctypes.POINTER(ID3D11Texture2D)()
171
- self.updated = False
179
+ return True
180
+ present_ticks = int(info.LastPresentTime)
181
+ self.accumulated_frames = int(info.AccumulatedFrames)
182
+ if present_ticks > 0:
183
+ self.latest_frame_ticks = present_ticks
184
+ else:
185
+ mouse_ticks = int(info.LastMouseUpdateTime)
186
+ if mouse_ticks > 0:
187
+ self.latest_frame_ticks = mouse_ticks
188
+ self.updated = True
172
189
  return True
173
- present_ticks = int(info.LastPresentTime)
174
- self.accumulated_frames = int(info.AccumulatedFrames)
175
- if present_ticks > 0:
176
- self.latest_frame_ticks = present_ticks
177
- else:
178
- mouse_ticks = int(info.LastMouseUpdateTime)
179
- if mouse_ticks > 0:
180
- self.latest_frame_ticks = mouse_ticks
181
- self.updated = True
182
- return True
190
+ finally:
191
+ release_com_pointer(res)
192
+
193
+ @contextmanager
194
+ def acquire_frame(
195
+ self, wait_for_frame: bool = False
196
+ ) -> Iterator[tuple[bool, bool, int]]:
197
+ ok = self._update_frame(wait_for_frame=wait_for_frame)
198
+ updated = ok and self.updated
199
+ frame_ticks = self.latest_frame_ticks
200
+ try:
201
+ yield ok, updated, frame_ticks
202
+ finally:
203
+ if ok and updated:
204
+ self._finish_frame()
183
205
 
184
206
  @property
185
207
  def latest_frame_time(self) -> float:
@@ -190,7 +212,7 @@ class DXGIDuplicator:
190
212
  return 0.0
191
213
  return ticks / self.performance_frequency
192
214
 
193
- def release_frame(self) -> bool:
215
+ def _release_frame(self) -> bool:
194
216
  """Per Microsoft Doc
195
217
  https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_2/nf-dxgi1_2-idxgioutputduplication-releaseframe#remarks
196
218
  This should be called just before AquireNextFrame, but we found audio artifacts
@@ -203,6 +225,7 @@ class DXGIDuplicator:
203
225
  """
204
226
  if self.duplicator is None or not self._frame_held:
205
227
  return True
228
+ self._drop_texture_reference()
206
229
  try:
207
230
  self.duplicator.ReleaseFrame()
208
231
  except comtypes.COMError as ce:
@@ -223,11 +246,16 @@ class DXGIDuplicator:
223
246
  self._frame_held = False
224
247
  return True
225
248
 
249
+ def _finish_frame(self) -> bool:
250
+ # DXGI path releases immediately after staging copy for better pacing.
251
+ return self._release_frame()
252
+
226
253
  def release(self) -> None:
254
+ self._drop_texture_reference()
227
255
  if self.duplicator is not None:
228
- self.release_frame()
256
+ self._release_frame()
229
257
  try:
230
- self.duplicator.Release()
258
+ release_com_pointer(self.duplicator)
231
259
  except comtypes.COMError:
232
260
  logger.debug(
233
261
  "Ignoring COMError while releasing duplicator.", exc_info=True