dxcam 0.4.0.dev1__tar.gz → 0.4.0.dev2__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 (53) hide show
  1. {dxcam-0.4.0.dev1/dxcam.egg-info → dxcam-0.4.0.dev2}/PKG-INFO +30 -13
  2. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/README.md +29 -12
  3. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/__init__.py +55 -74
  4. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/device.py +31 -2
  5. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_duplicator.py +2 -2
  6. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/stagesurf.py +18 -10
  7. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/winrt_duplicator.py +11 -35
  8. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/dxcam.py +81 -82
  9. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.c +753 -193
  10. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/base.py +2 -2
  11. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/backend.py +2 -2
  12. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/capture_worker.py +24 -29
  13. dxcam-0.4.0.dev2/dxcam/runtime/frame_buffer.py +167 -0
  14. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2/dxcam.egg-info}/PKG-INFO +30 -13
  15. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/SOURCES.txt +3 -0
  16. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/pyproject.toml +12 -5
  17. dxcam-0.4.0.dev2/tests/test_capture_lifecycle.py +665 -0
  18. dxcam-0.4.0.dev2/tests/test_factory.py +254 -0
  19. dxcam-0.4.0.dev2/tests/test_frame_buffer.py +265 -0
  20. dxcam-0.4.0.dev1/dxcam/runtime/frame_buffer.py +0 -159
  21. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/LICENSE +0 -0
  22. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/MANIFEST.in +0 -0
  23. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/__init__.py +0 -0
  24. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/d3d11.py +0 -0
  25. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/dxgi.py +0 -0
  26. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/user32.py +0 -0
  27. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/__init__.py +0 -0
  28. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/com_ptr.py +0 -0
  29. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/duplicator_protocol.py +0 -0
  30. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_errors.py +0 -0
  31. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/output.py +0 -0
  32. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/__init__.py +0 -0
  33. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_cython_kernels.c +0 -0
  34. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_cython_kernels.pyx +0 -0
  35. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.pyx +0 -0
  36. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/cv2_processor.py +0 -0
  37. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/cython_processor.py +0 -0
  38. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/numpy_processor.py +0 -0
  39. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/__init__.py +0 -0
  40. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/display_recovery.py +0 -0
  41. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/output_recovery.py +0 -0
  42. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/types.py +0 -0
  43. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/__init__.py +0 -0
  44. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/frame.py +0 -0
  45. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/io.py +0 -0
  46. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/timer.py +0 -0
  47. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/dependency_links.txt +0 -0
  48. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/requires.txt +0 -0
  49. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/top_level.txt +0 -0
  50. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/setup.cfg +0 -0
  51. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/setup.py +0 -0
  52. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/tests/test_dxgi_transient_policy.py +0 -0
  53. {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/tests/test_processor_parity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dxcam
3
- Version: 0.4.0.dev1
3
+ Version: 0.4.0.dev2
4
4
  Summary: A Python high-performance screenshot library for Windows using Desktop Duplication API
5
5
  Author: ra1nty
6
6
  License-Expression: MIT
@@ -105,13 +105,15 @@ camera = dxcam.create(
105
105
  )
106
106
  ```
107
107
  Note:
108
- - From 0.4.0 the `dxcam.create(max_buffer_len=...)` is deprecated, screen capture now always uses a tripple buffer.
108
+ - Version 0.4 uses a fixed three-slot frame buffer; `max_buffer_len` has been removed.
109
+ - Device discovery happens on the first `create()`, `device_info()`, or `output_info()` call.
110
+ - Upgrading from 0.3? See the [0.4 migration guide](https://github.com/ra1nty/DXcam/blob/dev/docs/migration-0.4.md).
109
111
 
110
112
  ### Screenshot
111
113
  ```python
112
114
  frame = camera.grab()
113
115
  ```
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.
116
+ `grab()` returns a `numpy.ndarray`. In one-shot mode it returns `None` if no new frame is available; `camera.grab(new_frame_only=False)` can reuse the last cached frame. During threaded capture, it reads the latest published frame and ignores `new_frame_only`.
115
117
 
116
118
  Use `camera.grab_into(dst)` to reuse caller-managed memory.
117
119
 
@@ -134,9 +136,9 @@ camera.is_capturing # False
134
136
  #### Consume the Screen Capture Data
135
137
  ```python
136
138
  for _ in range(1000):
137
- frame = camera.get_latest_frame() # blocks until a frame is available
139
+ frame = camera.get_latest_frame() # waits for the first available frame
138
140
  ```
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.
141
+ The capture thread publishes into a latest-only frame buffer. Once a frame is available, reads return immediately and can return the same timestamp repeatedly. Consumers control their own pacing; compare timestamps when you need only fresh frames. `target_fps` controls the producer, not the frequency of consumer reads.
140
142
 
141
143
  Useful variants:
142
144
  - `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
@@ -147,6 +149,7 @@ Useful variants:
147
149
  ### Safely Releasing Resources
148
150
  `release()` stops capture, frees buffers, and releases capture resources.
149
151
  After `release()`, the same instance cannot be reused.
152
+ In-flight readers retain their staging surfaces until readout completes, including across stop or output recovery.
150
153
 
151
154
  ```python
152
155
  camera = dxcam.create(output_idx=0, output_color="BGR")
@@ -197,10 +200,10 @@ Notes:
197
200
  - `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
198
201
 
199
202
  ### Frame Buffer
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.
203
+ DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers consume the newest published frame. A surface being read is never overwritten; if no safe write slot is available, capture skips that cycle. Older surfaces are released only after their readers finish.
201
204
 
202
205
  ```python
203
- camera = dxcam.create() # max_buffer_len is accepted but ignored
206
+ camera = dxcam.create()
204
207
  ```
205
208
 
206
209
  ### Target FPS
@@ -230,21 +233,32 @@ With `video_mode=True`, DXcam continues publishing at target FPS, reusing the pr
230
233
  ```python
231
234
  import cv2
232
235
  import dxcam
236
+ import time
233
237
 
234
238
  target_fps = 30
235
239
  camera = dxcam.create(output_color="BGR")
236
240
  camera.start(target_fps=target_fps, video_mode=True)
237
241
 
238
242
  writer = cv2.VideoWriter(
239
- "video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps, (1920, 1080)
243
+ "video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
244
+ (camera.width, camera.height),
240
245
  )
241
- for _ in range(600):
242
- writer.write(camera.get_latest_frame())
243
-
244
- camera.stop()
245
- writer.release()
246
+ try:
247
+ next_frame = time.perf_counter()
248
+ for _ in range(600):
249
+ time.sleep(max(0, next_frame - time.perf_counter()))
250
+ next_frame = time.perf_counter() + 1 / target_fps
251
+ frame = camera.get_latest_frame()
252
+ if frame is not None:
253
+ writer.write(frame)
254
+ finally:
255
+ camera.release()
256
+ writer.release()
246
257
  ```
247
258
 
259
+ Latest-frame reads return immediately once a frame exists. Pace the consumer as
260
+ above to avoid filling the video with repeated reads as fast as Python can run.
261
+
248
262
  ### Capture Backend
249
263
  DXcam supports two capture backends:
250
264
  - `dxgi` (default): Desktop Duplication API path with broad compatibility.
@@ -294,6 +308,9 @@ DXcam raises a runtime error because that backend is explicitly the direct
294
308
  Cython path.
295
309
 
296
310
  ## Benchmarks
311
+ See the [0.4 development comparison against PyPI 0.3.0](https://github.com/ra1nty/DXcam/blob/dev/benchmarks/capture_comparison.md)
312
+ for measured fresh-frame throughput, frame age, CPU use, and reproducible steps.
313
+
297
314
  When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
298
315
 
299
316
  | | DXcam | python-mss | D3DShot |
@@ -60,13 +60,15 @@ camera = dxcam.create(
60
60
  )
61
61
  ```
62
62
  Note:
63
- - From 0.4.0 the `dxcam.create(max_buffer_len=...)` is deprecated, screen capture now always uses a tripple buffer.
63
+ - Version 0.4 uses a fixed three-slot frame buffer; `max_buffer_len` has been removed.
64
+ - Device discovery happens on the first `create()`, `device_info()`, or `output_info()` call.
65
+ - Upgrading from 0.3? See the [0.4 migration guide](https://github.com/ra1nty/DXcam/blob/dev/docs/migration-0.4.md).
64
66
 
65
67
  ### Screenshot
66
68
  ```python
67
69
  frame = camera.grab()
68
70
  ```
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.
71
+ `grab()` returns a `numpy.ndarray`. In one-shot mode it returns `None` if no new frame is available; `camera.grab(new_frame_only=False)` can reuse the last cached frame. During threaded capture, it reads the latest published frame and ignores `new_frame_only`.
70
72
 
71
73
  Use `camera.grab_into(dst)` to reuse caller-managed memory.
72
74
 
@@ -89,9 +91,9 @@ camera.is_capturing # False
89
91
  #### Consume the Screen Capture Data
90
92
  ```python
91
93
  for _ in range(1000):
92
- frame = camera.get_latest_frame() # blocks until a frame is available
94
+ frame = camera.get_latest_frame() # waits for the first available frame
93
95
  ```
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.
96
+ The capture thread publishes into a latest-only frame buffer. Once a frame is available, reads return immediately and can return the same timestamp repeatedly. Consumers control their own pacing; compare timestamps when you need only fresh frames. `target_fps` controls the producer, not the frequency of consumer reads.
95
97
 
96
98
  Useful variants:
97
99
  - `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
@@ -102,6 +104,7 @@ Useful variants:
102
104
  ### Safely Releasing Resources
103
105
  `release()` stops capture, frees buffers, and releases capture resources.
104
106
  After `release()`, the same instance cannot be reused.
107
+ In-flight readers retain their staging surfaces until readout completes, including across stop or output recovery.
105
108
 
106
109
  ```python
107
110
  camera = dxcam.create(output_idx=0, output_color="BGR")
@@ -152,10 +155,10 @@ Notes:
152
155
  - `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
153
156
 
154
157
  ### Frame Buffer
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.
158
+ DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers consume the newest published frame. A surface being read is never overwritten; if no safe write slot is available, capture skips that cycle. Older surfaces are released only after their readers finish.
156
159
 
157
160
  ```python
158
- camera = dxcam.create() # max_buffer_len is accepted but ignored
161
+ camera = dxcam.create()
159
162
  ```
160
163
 
161
164
  ### Target FPS
@@ -185,21 +188,32 @@ With `video_mode=True`, DXcam continues publishing at target FPS, reusing the pr
185
188
  ```python
186
189
  import cv2
187
190
  import dxcam
191
+ import time
188
192
 
189
193
  target_fps = 30
190
194
  camera = dxcam.create(output_color="BGR")
191
195
  camera.start(target_fps=target_fps, video_mode=True)
192
196
 
193
197
  writer = cv2.VideoWriter(
194
- "video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps, (1920, 1080)
198
+ "video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
199
+ (camera.width, camera.height),
195
200
  )
196
- for _ in range(600):
197
- writer.write(camera.get_latest_frame())
198
-
199
- camera.stop()
200
- writer.release()
201
+ try:
202
+ next_frame = time.perf_counter()
203
+ for _ in range(600):
204
+ time.sleep(max(0, next_frame - time.perf_counter()))
205
+ next_frame = time.perf_counter() + 1 / target_fps
206
+ frame = camera.get_latest_frame()
207
+ if frame is not None:
208
+ writer.write(frame)
209
+ finally:
210
+ camera.release()
211
+ writer.release()
201
212
  ```
202
213
 
214
+ Latest-frame reads return immediately once a frame exists. Pace the consumer as
215
+ above to avoid filling the video with repeated reads as fast as Python can run.
216
+
203
217
  ### Capture Backend
204
218
  DXcam supports two capture backends:
205
219
  - `dxgi` (default): Desktop Duplication API path with broad compatibility.
@@ -249,6 +263,9 @@ DXcam raises a runtime error because that backend is explicitly the direct
249
263
  Cython path.
250
264
 
251
265
  ## Benchmarks
266
+ See the [0.4 development comparison against PyPI 0.3.0](https://github.com/ra1nty/DXcam/blob/dev/benchmarks/capture_comparison.md)
267
+ for measured fresh-frame throughput, frame age, CPU use, and reproducible steps.
268
+
252
269
  When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
253
270
 
254
271
  | | DXcam | python-mss | D3DShot |
@@ -22,6 +22,7 @@ import signal
22
22
  import time
23
23
  import weakref
24
24
  from types import FrameType
25
+ from threading import Lock, RLock
25
26
  from typing import Any, Callable, cast
26
27
 
27
28
  from dxcam.runtime.backend import normalize_backend_name
@@ -50,43 +51,22 @@ __all__ = [
50
51
 
51
52
  # Hide internal factory/signal plumbing from pdoc output.
52
53
  __pdoc__: dict[str, bool] = {
53
- "Singleton": False,
54
54
  "DXFactory": False,
55
- "_configure_comtypes_logging": False,
55
+ "_get_factory": False,
56
56
  "_handle_sigterm": False,
57
57
  "_install_sigterm_handler": False,
58
58
  "__factory": False,
59
59
  }
60
60
 
61
61
 
62
- def _configure_comtypes_logging() -> None:
63
- # Suppress noisy per-frame COM Release debug logs from comtypes internals.
64
- logging.getLogger("comtypes").setLevel(logging.INFO)
65
- logging.getLogger("comtypes._post_coinit.unknwn").setLevel(logging.INFO)
66
-
67
-
68
- class Singleton(type):
69
- """Metaclass that allows exactly one instance per class."""
70
-
71
- _instances = {}
72
-
73
- def __call__(cls, *args: Any, **kwargs: Any) -> Any:
74
- if cls not in cls._instances:
75
- cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
76
- else:
77
- logger.warning("Only 1 instance of %s is allowed.", cls.__name__)
78
-
79
- return cls._instances[cls]
80
-
81
-
82
- class DXFactory(metaclass=Singleton):
62
+ class DXFactory:
83
63
  """Factory that owns device/output discovery and camera singletons."""
84
64
 
85
- _camera_instances: weakref.WeakValueDictionary[
86
- tuple[int, int, CaptureBackend], DXCamera
87
- ] = weakref.WeakValueDictionary()
88
-
89
65
  def __init__(self) -> None:
66
+ self._camera_instances: weakref.WeakValueDictionary[
67
+ tuple[int, int, CaptureBackend], DXCamera
68
+ ] = weakref.WeakValueDictionary()
69
+ self._camera_lock = RLock()
90
70
  p_adapters = enum_dxgi_adapters()
91
71
  self.devices: list[Device] = []
92
72
  self.outputs: list[list[Output]] = []
@@ -104,7 +84,7 @@ class DXFactory(metaclass=Singleton):
104
84
  output_idx: int | None = None,
105
85
  region: Region | None = None,
106
86
  output_color: ColorMode = "RGB",
107
- max_buffer_len: int = 8,
87
+ *,
108
88
  backend: CaptureBackend = "dxgi",
109
89
  processor_backend: ProcessorBackend = "cv2",
110
90
  ) -> DXCamera:
@@ -127,41 +107,32 @@ class DXFactory(metaclass=Singleton):
127
107
  )
128
108
  output_idx = primary_output_indices[0]
129
109
  instance_key = (device_idx, output_idx, backend)
130
- existing_camera = self._camera_instances.get(instance_key)
131
- if existing_camera is not None and existing_camera.is_released:
132
- logger.info(
133
- "Dropping released DXCamera instance for device=%s output=%s backend=%s.",
134
- device_idx,
135
- output_idx,
136
- backend,
137
- )
138
- del self._camera_instances[instance_key]
139
- existing_camera = None
140
- if existing_camera is not None:
141
- logger.warning(
142
- "DXCamera instance already exists for device=%s output=%s backend=%s; "
143
- "returning the existing instance. Call release() before "
144
- "recreating it with new parameters.",
145
- device_idx,
146
- output_idx,
147
- backend,
110
+ with self._camera_lock:
111
+ existing_camera = self._camera_instances.get(instance_key)
112
+ if existing_camera is not None and not existing_camera.is_released:
113
+ logger.warning(
114
+ "DXCamera instance already exists for device=%s output=%s "
115
+ "backend=%s; returning the existing instance. Call release() "
116
+ "before recreating it with new parameters.",
117
+ device_idx,
118
+ output_idx,
119
+ backend,
120
+ )
121
+ return existing_camera
122
+
123
+ output = self.outputs[device_idx][output_idx]
124
+ output.update_desc()
125
+ camera = DXCamera(
126
+ output=output,
127
+ device=device,
128
+ region=region,
129
+ output_color=output_color,
130
+ backend=backend,
131
+ processor_backend=processor_backend,
148
132
  )
149
- return existing_camera
150
-
151
- output = self.outputs[device_idx][output_idx]
152
- output.update_desc()
153
- camera = DXCamera(
154
- output=output,
155
- device=device,
156
- region=region,
157
- output_color=output_color,
158
- max_buffer_len=max_buffer_len,
159
- backend=backend,
160
- processor_backend=processor_backend,
161
- )
162
- self._camera_instances[instance_key] = camera
163
- time.sleep(0.1) # Fix for https://github.com/ra1nty/DXcam/issues/31
164
- return camera
133
+ self._camera_instances[instance_key] = camera
134
+ time.sleep(0.1) # Fix for https://github.com/ra1nty/DXcam/issues/31
135
+ return camera
165
136
 
166
137
  def device_info(self) -> str:
167
138
  ret = ""
@@ -181,18 +152,30 @@ class DXFactory(metaclass=Singleton):
181
152
  return ret
182
153
 
183
154
  def clean_up(self) -> None:
184
- for _, camera in list(self._camera_instances.items()):
155
+ with self._camera_lock:
156
+ cameras = list(self._camera_instances.values())
157
+ for camera in cameras:
185
158
  camera.release()
186
159
 
187
160
 
188
- __factory = DXFactory()
189
- _configure_comtypes_logging()
161
+ __factory: DXFactory | None = None
162
+ _factory_lock = Lock()
163
+
164
+
165
+ def _get_factory() -> DXFactory:
166
+ global __factory
167
+
168
+ with _factory_lock:
169
+ if __factory is None:
170
+ __factory = DXFactory()
171
+ return __factory
190
172
 
191
173
 
192
174
  def _handle_sigterm(signum: int, frame: FrameType | None) -> None:
193
175
  logger.info("Received SIGTERM; releasing active DXCamera instances.")
194
176
  try:
195
- __factory.clean_up()
177
+ if __factory is not None:
178
+ __factory.clean_up()
196
179
  except Exception:
197
180
  logger.exception("Failed during DXCamera SIGTERM cleanup.")
198
181
 
@@ -213,8 +196,9 @@ def _install_sigterm_handler() -> None:
213
196
  if _sigterm_handler_installed:
214
197
  return
215
198
  try:
216
- _previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
199
+ previous = signal.getsignal(signal.SIGTERM)
217
200
  signal.signal(signal.SIGTERM, _handle_sigterm)
201
+ _previous_sigterm_handler = previous
218
202
  _sigterm_handler_installed = True
219
203
  except (ValueError, AttributeError):
220
204
  # ValueError: called outside the main thread.
@@ -227,7 +211,7 @@ def create(
227
211
  output_idx: int | None = None,
228
212
  region: Region | None = None,
229
213
  output_color: ColorMode = "RGB",
230
- max_buffer_len: int = 8,
214
+ *,
231
215
  backend: CaptureBackend = "dxgi",
232
216
  processor_backend: ProcessorBackend = "cv2",
233
217
  ) -> DXCamera:
@@ -239,8 +223,6 @@ def create(
239
223
  the primary output.
240
224
  region: Optional capture region as ``(left, top, right, bottom)``.
241
225
  output_color: Output pixel format.
242
- max_buffer_len: Kept for API compatibility. Threaded capture uses a
243
- fixed three-slot latest-only frame buffer.
244
226
  backend: Capture backend, ``"dxgi"`` or ``"winrt"``.
245
227
  processor_backend: Post-processing backend, ``"cv2"`` (default),
246
228
  ``"cython"``, or ``"numpy"``. The ``"cython"`` backend uses the
@@ -262,12 +244,11 @@ def create(
262
244
  >>> cam.release()
263
245
  """
264
246
  _install_sigterm_handler()
265
- return __factory.create(
247
+ return _get_factory().create(
266
248
  device_idx=device_idx,
267
249
  output_idx=output_idx,
268
250
  region=region,
269
251
  output_color=output_color,
270
- max_buffer_len=max_buffer_len,
271
252
  backend=backend,
272
253
  processor_backend=processor_backend,
273
254
  )
@@ -280,7 +261,7 @@ def device_info() -> str:
280
261
  >>> import dxcam
281
262
  >>> print(dxcam.device_info())
282
263
  """
283
- return __factory.device_info()
264
+ return _get_factory().device_info()
284
265
 
285
266
 
286
267
  def output_info() -> str:
@@ -290,4 +271,4 @@ def output_info() -> str:
290
271
  >>> import dxcam
291
272
  >>> print(dxcam.output_info())
292
273
  """
293
- return __factory.output_info()
274
+ return _get_factory().output_info()
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ctypes
4
- from dataclasses import dataclass
5
- from typing import Any, cast
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Iterator, cast
6
7
 
7
8
  import comtypes
8
9
  from dxcam._libs.d3d11 import (
@@ -11,6 +12,7 @@ from dxcam._libs.d3d11 import (
11
12
  D3D_FEATURE_LEVEL_11_0,
12
13
  ID3D11Device,
13
14
  ID3D11DeviceContext,
15
+ ID3D11Multithread,
14
16
  )
15
17
  from dxcam._libs.dxgi import (
16
18
  DXGI_ADAPTER_DESC1,
@@ -32,6 +34,7 @@ class Device:
32
34
  context: Any = None
33
35
  im_context: Any = None
34
36
  desc: DXGI_ADAPTER_DESC1 | None = None
37
+ _multithread: Any | None = field(default=None, init=False, repr=False)
35
38
 
36
39
  def __post_init__(self) -> None:
37
40
  self.desc = DXGI_ADAPTER_DESC1()
@@ -63,6 +66,32 @@ class Device:
63
66
  )
64
67
  device = cast(Any, self.device)
65
68
  device.GetImmediateContext(ctypes.byref(self.im_context))
69
+ try:
70
+ context = cast(Any, self.im_context)
71
+ multithread = context.QueryInterface(ID3D11Multithread)
72
+ multithread.SetMultithreadProtected(True)
73
+ if not multithread.GetMultithreadProtected():
74
+ raise RuntimeError("The device left multithread protection disabled.")
75
+ except Exception as exc:
76
+ raise RuntimeError(
77
+ "DXcam requires native Direct3D multithread protection "
78
+ "(ID3D11Multithread), but it could not be enabled."
79
+ ) from exc
80
+ self._multithread = multithread
81
+
82
+ @contextmanager
83
+ def context_guard(self) -> Iterator[None]:
84
+ """Serialize context/DXGI calls across every camera using this device."""
85
+ multithread = self._multithread
86
+ if multithread is None:
87
+ raise RuntimeError(
88
+ "Native Direct3D multithread protection is not initialized."
89
+ )
90
+ multithread.Enter()
91
+ try:
92
+ yield
93
+ finally:
94
+ multithread.Leave()
66
95
 
67
96
  def enum_outputs(self) -> list[Any]:
68
97
  i = 0
@@ -221,7 +221,7 @@ class DXGIDuplicator:
221
221
  So DXCam default to early release.
222
222
 
223
223
  Returns:
224
- bool: Sucessfully
224
+ bool: Successfully
225
225
  """
226
226
  if self.duplicator is None or not self._frame_held:
227
227
  return True
@@ -263,7 +263,7 @@ class DXGIDuplicator:
263
263
  self.duplicator = None
264
264
 
265
265
  def __repr__(self) -> str:
266
- return "<{} Initalized:{}>".format(
266
+ return "<{} Initialized:{}>".format(
267
267
  self.__class__.__name__,
268
268
  self.duplicator is not None,
269
269
  )
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ctypes
4
+ from _thread import LockType
4
5
  from contextlib import contextmanager
5
6
  from dataclasses import InitVar, dataclass, field
7
+ from threading import Lock
6
8
  from typing import Any, Iterator, cast
7
9
 
8
10
  from dxcam._libs.d3d11 import (
@@ -30,6 +32,7 @@ class StageSurface:
30
32
  texture: Any = None
31
33
  interface: Any = None
32
34
  _copy_region_box: D3D11_BOX = field(default_factory=D3D11_BOX, repr=False)
35
+ _map_lock: LockType = field(default_factory=Lock, init=False, repr=False)
33
36
  output: InitVar[Output | None] = None
34
37
  device: InitVar[Device | None] = None
35
38
  dim: InitVar[tuple[int, int] | None] = None
@@ -106,25 +109,30 @@ class StageSurface:
106
109
  self.rebuild(dim=dim)
107
110
 
108
111
  def map(self) -> DXGI_MAPPED_RECT:
109
- if self.interface is None:
112
+ if self.interface is None or self._device is None:
110
113
  raise RuntimeError("StageSurface interface is not initialized.")
111
114
  rect: DXGI_MAPPED_RECT = DXGI_MAPPED_RECT()
112
- self.interface.Map(ctypes.byref(rect), 1)
115
+ with self._device.context_guard():
116
+ self.interface.Map(ctypes.byref(rect), 1)
113
117
  return rect
114
118
 
115
119
  def unmap(self) -> None:
116
- if self.interface is None:
120
+ if self.interface is None or self._device is None:
117
121
  raise RuntimeError("StageSurface interface is not initialized.")
118
- self.interface.Unmap()
122
+ with self._device.context_guard():
123
+ self.interface.Unmap()
119
124
 
120
125
  @contextmanager
121
126
  def mapped(self) -> Iterator[DXGI_MAPPED_RECT]:
122
- """Context-manager wrapper around map/unmap."""
123
- rect = self.map()
124
- try:
125
- yield rect
126
- finally:
127
- self.unmap()
127
+ """Map/unmap under the device guard, without guarding CPU work."""
128
+ # Several readers can lease the latest frame, but DXGI permits only one
129
+ # active mapping of a surface. Leases keep it alive while readers queue.
130
+ with self._map_lock:
131
+ rect = self.map()
132
+ try:
133
+ yield rect
134
+ finally:
135
+ self.unmap()
128
136
 
129
137
  def copy_region_from(
130
138
  self,
@@ -12,7 +12,7 @@ from typing import Any, Callable, Iterator, Literal, cast
12
12
 
13
13
  import comtypes
14
14
 
15
- from dxcam._libs.d3d11 import ID3D11Multithread, ID3D11Texture2D
15
+ from dxcam._libs.d3d11 import ID3D11Texture2D
16
16
  from dxcam._libs.dxgi import IDXGIDevice, IDXGISurface
17
17
  from dxcam.core.com_ptr import clear_com_pointer
18
18
  from dxcam.core.device import Device
@@ -102,7 +102,6 @@ class WinRTDuplicator:
102
102
  _session: Any | None = field(default=None, init=False, repr=False)
103
103
  _capture_item: Any | None = field(default=None, init=False, repr=False)
104
104
  _winrt_device: Any | None = field(default=None, init=False, repr=False)
105
- _multithread: Any | None = field(default=None, init=False, repr=False)
106
105
  _dxgi_surface: Any = field(
107
106
  default_factory=lambda: ctypes.POINTER(IDXGISurface)(), init=False, repr=False
108
107
  )
@@ -138,7 +137,6 @@ class WinRTDuplicator:
138
137
  )
139
138
  self._border_required = self._resolve_bool_env("DXCAM_WINRT_BORDER_REQUIRED")
140
139
  self._configure_qpc_frequency()
141
- self._configure_multithread_protection(device=device)
142
140
  self._create_capture_session(output=output, device=device)
143
141
 
144
142
  def _resolve_frame_wait_seconds(self) -> float:
@@ -246,7 +244,9 @@ class WinRTDuplicator:
246
244
  mode = self._dirty_region_mode_enum.REPORT_AND_RENDER
247
245
  self._session.dirty_region_mode = mode
248
246
  except Exception:
249
- logger.warning("Failed to set session dirty_region_mode.", exc_info=True)
247
+ logger.warning(
248
+ "Failed to set session dirty_region_mode.", exc_info=True
249
+ )
250
250
  if self._cursor_capture_enabled is not None:
251
251
  try:
252
252
  self._session.is_cursor_capture_enabled = self._cursor_capture_enabled
@@ -259,7 +259,9 @@ class WinRTDuplicator:
259
259
  try:
260
260
  self._session.is_border_required = self._border_required
261
261
  except Exception:
262
- logger.warning("Failed to set session is_border_required.", exc_info=True)
262
+ logger.warning(
263
+ "Failed to set session is_border_required.", exc_info=True
264
+ )
263
265
 
264
266
  def _configure_qpc_frequency(self) -> None:
265
267
  freq = ctypes.c_longlong()
@@ -329,34 +331,6 @@ class WinRTDuplicator:
329
331
  if self._frame_arrived_event is not None:
330
332
  self._frame_arrived_event.set()
331
333
 
332
- def _configure_multithread_protection(self, device: Device) -> None:
333
- try:
334
- self._multithread = device.im_context.QueryInterface(ID3D11Multithread)
335
- except comtypes.COMError:
336
- logger.debug("ID3D11Multithread not available for WinRT backend.")
337
- self._multithread = None
338
- return
339
- try:
340
- self._multithread.SetMultithreadProtected(True)
341
- except Exception:
342
- logger.debug("Failed to enable multithread protection.", exc_info=True)
343
-
344
- def enter_multithread(self) -> None:
345
- if self._multithread is None:
346
- return
347
- try:
348
- self._multithread.Enter()
349
- except Exception:
350
- logger.debug("Failed to enter ID3D11Multithread lock.", exc_info=True)
351
-
352
- def leave_multithread(self) -> None:
353
- if self._multithread is None:
354
- return
355
- try:
356
- self._multithread.Leave()
357
- except Exception:
358
- logger.debug("Failed to leave ID3D11Multithread lock.", exc_info=True)
359
-
360
334
  def _monitor_handle_to_int(self, hmonitor: Any) -> int:
361
335
  value = getattr(hmonitor, "value", hmonitor)
362
336
  monitor = int(value or 0)
@@ -372,7 +346,9 @@ class WinRTDuplicator:
372
346
  try:
373
347
  close_fn()
374
348
  except Exception:
375
- logger.debug("Ignoring exception while closing %s.", name, exc_info=True)
349
+ logger.debug(
350
+ "Ignoring exception while closing %s.", name, exc_info=True
351
+ )
376
352
 
377
353
  def _release_dxgi_surface(self) -> None:
378
354
  # The pointer returned by winrt interop behaves like a borrowed pointer in
@@ -567,7 +543,7 @@ class WinRTDuplicator:
567
543
  self._winrt_device = None
568
544
 
569
545
  def __repr__(self) -> str:
570
- return "<{} Initalized:{}>".format(
546
+ return "<{} Initialized:{}>".format(
571
547
  self.__class__.__name__,
572
548
  self._session is not None,
573
549
  )