rapidshot 2.2.0__tar.gz → 2.3.0__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 (61) hide show
  1. {rapidshot-2.2.0/rapidshot.egg-info → rapidshot-2.3.0}/PKG-INFO +121 -11
  2. {rapidshot-2.2.0 → rapidshot-2.3.0}/README.md +120 -10
  3. {rapidshot-2.2.0 → rapidshot-2.3.0}/pyproject.toml +1 -1
  4. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/__init__.py +1 -1
  5. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/capture.py +1 -0
  6. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/duplicator.py +53 -0
  7. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/frame.py +14 -1
  8. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/native.py +44 -3
  9. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/preprocess.py +37 -3
  10. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/cupy_processor.py +108 -110
  11. rapidshot-2.3.0/rapidshot/util/desktop.py +147 -0
  12. {rapidshot-2.2.0 → rapidshot-2.3.0/rapidshot.egg-info}/PKG-INFO +121 -11
  13. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/SOURCES.txt +8 -0
  14. {rapidshot-2.2.0 → rapidshot-2.3.0}/setup.py +1 -1
  15. rapidshot-2.3.0/tests/test_cuda_interop.py +220 -0
  16. rapidshot-2.3.0/tests/test_cupy_processor.py +175 -0
  17. rapidshot-2.3.0/tests/test_desktop_refusal.py +174 -0
  18. rapidshot-2.3.0/tests/test_exclusive_fullscreen.py +263 -0
  19. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_gpu_preprocess.py +7 -4
  20. rapidshot-2.3.0/tests/test_output_change_recovery.py +125 -0
  21. rapidshot-2.3.0/tests/test_preprocess_cache.py +146 -0
  22. rapidshot-2.3.0/tests/test_protected_content.py +174 -0
  23. {rapidshot-2.2.0 → rapidshot-2.3.0}/LICENSE +0 -0
  24. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/__init__.py +0 -0
  25. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/d3d11.py +0 -0
  26. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/dxgi.py +0 -0
  27. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/user32.py +0 -0
  28. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/__init__.py +0 -0
  29. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/device.py +0 -0
  30. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/output.py +0 -0
  31. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/stagesurf.py +0 -0
  32. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/memory_pool.py +0 -0
  33. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/__init__.py +0 -0
  34. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/base.py +0 -0
  35. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/numpy_processor.py +0 -0
  36. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/pillow_processor.py +0 -0
  37. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/py.typed +0 -0
  38. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/__init__.py +0 -0
  39. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/ctypes_helpers.py +0 -0
  40. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/errors.py +0 -0
  41. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/io.py +0 -0
  42. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/logging.py +0 -0
  43. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/timer.py +0 -0
  44. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/topology.py +0 -0
  45. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/dependency_links.txt +0 -0
  46. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/requires.txt +0 -0
  47. {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/top_level.txt +0 -0
  48. {rapidshot-2.2.0 → rapidshot-2.3.0}/setup.cfg +0 -0
  49. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_color_modes.py +0 -0
  50. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_cross_adapter.py +0 -0
  51. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_dirty_accumulator.py +0 -0
  52. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_dirty_rects.py +0 -0
  53. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_failure_paths.py +0 -0
  54. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_frame.py +0 -0
  55. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_memory_pool.py +0 -0
  56. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_native.py +0 -0
  57. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_output_pooling.py +0 -0
  58. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_perf_harness.py +0 -0
  59. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_preprocess.py +0 -0
  60. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_region_mapping.py +0 -0
  61. {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_topology.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapidshot
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A high-performance screencapture library for Windows using Desktop Duplication API
5
5
  Home-page: https://github.com/Zaatra/Rapidshot
6
6
  Author: Rapidshot Contributors
@@ -149,8 +149,9 @@ built around have no column here because the other libraries have no equivalent:
149
149
 
150
150
  - **`grab_frame()`** hands back a GPU-resident frame in 0.17–0.21 ms that never
151
151
  crosses to system memory.
152
- - **The GPU tensor path** turns a frame into NCHW float32 in one dispatch, 2 µs of
153
- calling-thread time.
152
+ - **The GPU tensor path** turns a frame into model-ready NCHW float32 in one
153
+ dispatch — 0.075 ms for a 2560×1600 frame, against 3.1 ms to do the same work
154
+ on the CPU — and the result stays in VRAM, where CUDA or DirectML can read it.
154
155
  - **Cross-adapter transfer** moves a frame to the discrete GPU on hybrid laptops,
155
156
  which Desktop Duplication cannot capture from at all.
156
157
  - **Dirty-rect metadata**, so a consumer can skip regions that did not change.
@@ -196,6 +197,10 @@ rewritten.
196
197
  the memory system can move, and byte-identical to the pure-Python path
197
198
  - **Only process what changed**: frames carry the compositor's dirty-rect
198
199
  metadata, typically under 1% of the screen on a normal desktop
200
+ - **Feed CUDA directly**: the GPU tensor exposes a shared NT handle, so CuPy or
201
+ PyTorch can read it in place via `cudaImportExternalMemory` — capture to
202
+ `cupy.ndarray` with no CPU round-trip. One dispatch costs **0.075 ms** at
203
+ 2560×1600 → 640×640, against 3.1 ms for the same work on the CPU
199
204
  - **Hybrid GPU laptops**: move a frame to the discrete GPU that Desktop
200
205
  Duplication cannot capture from
201
206
  - **Multi-backend support**: NumPy, PIL, and CUDA/CuPy backends
@@ -206,8 +211,9 @@ rewritten.
206
211
  - **Flexible output formats**: RGB, RGBA, BGR, BGRA, and grayscale support
207
212
  - **Region-based capture**: Efficient capture of specific screen regions
208
213
  - **Rotation handling**: Automatic handling of rotated displays
209
- - **Actionable diagnostics**: headless machines and hybrid GPU setups are
210
- detected and explained rather than failing opaquely
214
+ - **Actionable diagnostics**: headless machines, hybrid GPU setups, locked
215
+ workstations, UAC prompts and Session 0 services are detected and named
216
+ rather than failing opaquely
211
217
 
212
218
  ## Installation
213
219
 
@@ -319,14 +325,24 @@ writer.release()
319
325
  ### NVIDIA GPU Acceleration
320
326
 
321
327
  ```python
322
- # Create a ScreenCapture instance with NVIDIA GPU acceleration
323
- screencapture = rapidshot.create(nvidia_gpu=True)
328
+ # Frames come back as cupy.ndarray, converted on the device
329
+ screencapture = rapidshot.create(output_color="RGB", nvidia_gpu=True)
324
330
 
325
- # Screenshots will be processed on the GPU for improved performance
326
- frame = screencapture.grab()
327
- frame.release()
331
+ frame = screencapture.grab() # cupy.ndarray, stays in VRAM
328
332
  ```
329
333
 
334
+ > **Note:** unlike the CPU path, `nvidia_gpu=True` returns a bare
335
+ > `cupy.ndarray` rather than a pooled buffer, so there is no `.release()` to
336
+ > call — CuPy's allocator owns it. `pool_size_frames` does not apply.
337
+
338
+ Colour conversion runs in CuPy on the GPU and is **byte-identical** to the CPU
339
+ path, so turning `nvidia_gpu` on changes no pixel. OpenCV is not required.
340
+
341
+ > **Fixed in 2.3.0:** before this release, any `output_color` other than
342
+ > `"BGRA"` silently returned an unconverted 4-channel BGRA array — wrong shape
343
+ > and wrong channel order, reported as success. Conversion went through OpenCV,
344
+ > which is not a dependency, and the failure was swallowed.
345
+
330
346
  ### Cursor Capture
331
347
 
332
348
  RapidShot provides comprehensive cursor capture capabilities, allowing you to track cursor position, visibility, and shape in your screen captures.
@@ -682,6 +698,7 @@ with screencapture.grab_frame() as frame:
682
698
  print(pre.shape) # (1, 3, 640, 640)
683
699
  resource = pre.output_resource_address # ID3D12Resource*
684
700
  gpu_va = pre.output_gpu_address # GPU virtual address
701
+ handle = pre.shared_output_handle # shared NT handle, for CUDA etc.
685
702
  ```
686
703
 
687
704
  `process()` resizes, normalises, converts BGRA→RGB and transposes to NCHW in a
@@ -690,6 +707,49 @@ frame**; here it is one dispatch and the result stays in VRAM. Optional
690
707
  arguments cover the usual normalisation ranges (`scale=2.0, bias=-1.0` for
691
708
  −1..1) and channel order (`bgr=True`).
692
709
 
710
+ #### Reading the tensor from CuPy / CUDA
711
+
712
+ `shared_output_handle` is a shared NT handle for the tensor, which CUDA imports
713
+ with `cudaImportExternalMemory` — so the frame reaches a CUDA consumer without
714
+ ever touching the CPU. **`examples/gpu_tensor_to_cupy.py` is a complete, working
715
+ version** of the ~60 lines of `ctypes` this takes; it verifies the resulting
716
+ `cupy.ndarray` is byte-identical to a readback of the same dispatch.
717
+
718
+ `CudaTensor` is defined in `examples/gpu_tensor_to_cupy.py`, not exported by
719
+ the package — copy it into your project rather than importing it:
720
+
721
+ ```python
722
+ with CudaTensor(pre, (1, 3, 640, 640)) as view:
723
+ tensor = view.array # a cupy.ndarray in VRAM
724
+ while capturing:
725
+ with screencapture.grab_frame() as frame: # a *new* frame each pass
726
+ if frame is None:
727
+ continue # nothing changed on screen
728
+ view.sync() # queued CUDA work must finish
729
+ pre.process(frame) # overwrites tensor's buffer
730
+ model(tensor)
731
+ ```
732
+
733
+ Note the fresh `grab_frame()` inside the loop. The preprocessor and the CUDA
734
+ import are built once and reused; the *frame* is not — a released frame raises
735
+ `FrameReleasedError`, and reusing a live one just re-processes the same image.
736
+ Releasing it before `model()` also matters: DXGI cannot acquire the next frame
737
+ while a reference to the previous surface is outstanding.
738
+
739
+ `sync()` is not optional. `process()` waits on the D3D12 fence but knows
740
+ nothing about CUDA work you have queued against the same memory, so a kernel
741
+ still reading the tensor when the next dispatch lands sees a half-overwritten
742
+ frame — with no error, just wrong numbers.
743
+
744
+ Keep the `CudaTensor` for as long as you use the array: it owns the CUDA
745
+ import and the preprocessor that owns the VRAM. On a machine with more than
746
+ one CUDA device, pass `device=N` — the tensor can only be imported by the
747
+ device that owns the adapter which captured the frame.
748
+
749
+ The import is paid once. After that `pre.process(frame)` overwrites the same
750
+ buffer the CuPy array points at, so a capture loop pays nothing per frame to
751
+ keep the view.
752
+
693
753
  **Where Rapidshot stops.** The output is an `ID3D12Resource` on the DirectML
694
754
  device — exactly what ONNX Runtime's DirectML provider consumes. Rapidshot
695
755
  deliberately does **not** bind it to a session: that would couple this library
@@ -826,6 +886,25 @@ What is measured, reproducibly, is the per-frame cost of RapidShot's own paths
826
886
  The first three are synthetic and deterministic: they move when the library
827
887
  changes and not otherwise, which is why they are the ones on the badges above.
828
888
 
889
+ **Second machine, NVIDIA RTX 4060 Laptop** (`benchmarks/baseline-rtx4060.json`,
890
+ P-core-pinned, 1920×1080 synthetic source, minimums):
891
+
892
+ | Path | Per frame |
893
+ | --- | --- |
894
+ | Colour conversion, BGRA→RGB | 0.198 ms |
895
+ | Colour conversion, BGRA→GRAY | 0.252 ms |
896
+ | CPU resize + normalise + NCHW | 3.09 ms |
897
+ | **GPU dispatch → NCHW tensor, D3D12** | **0.070 ms** (2560×1600 source) |
898
+ | GPU dispatch + forced readback | 2.20 ms |
899
+ | `grab_frame()` — texture stays on the GPU | 0.099–0.156 ms |
900
+
901
+ Cross-machine comparison is not valid — different CPU, GPU, panel and, on two
902
+ rows, different code. Both recordings are kept so each answers for its own
903
+ machine.
904
+
905
+ Sustained-load check: 211,726 frames of `grab_frame()` → GPU tensor in 12
906
+ minutes, zero errors, VRAM flat, no throughput decay.
907
+
829
908
  **The last two are not stable measurements and should not be quoted as single
830
909
  numbers.** `grab()` converts only the parts of the frame that changed, so its
831
910
  cost tracks what is happening on screen; across seven recordings on unchanged
@@ -851,11 +930,42 @@ breakdown and how each figure is measured.
851
930
  only for the optional CuPy acceleration.
852
931
  - **RAM:** 8 GB+ (depending on the resolution and number of screencapture instances used)
853
932
 
933
+ ### Tested configurations
934
+
935
+ Nothing in RapidShot branches on GPU vendor. This is what has actually been
936
+ run, which is not the same claim:
937
+
938
+ | Configuration | State |
939
+ | --- | --- |
940
+ | Intel iGPU, single adapter | Verified |
941
+ | NVIDIA dGPU, single adapter, native extension built | Verified |
942
+ | NVIDIA dGPU, single adapter, no extension | Verified |
943
+ | Any AMD GPU | **Not tested** |
944
+ | Hybrid / switchable graphics (Optimus, AMD) | **Not tested** |
945
+ | Headless with a virtual display | **Not tested** |
946
+
947
+ CuPy is the only NVIDIA-bound feature, because CUDA is; `nvidia_gpu=True` falls
948
+ back to the CPU processor when CuPy is unavailable. AMD and Intel consumers
949
+ reach the same GPU tensor through DirectML via `output_resource_address`.
950
+
854
951
  ### Troubleshooting
855
952
 
856
953
  - **ImportError with CuPy:** Ensure you have compatible CUDA drivers installed.
857
- - **Black screens when capturing:** Verify the application isn't running in exclusive fullscreen mode.
954
+ `pip install cupy-cuda13x[ctk]` installs no headers against `cuda-toolkit`
955
+ 13.3.x and CuPy then fails at its first JIT; use
956
+ `pip install "cuda-toolkit[cudart,nvrtc]==13.2.*"` instead.
957
+ - **"Desktop duplication was denied":** the message names the cause. A
958
+ non-input desktop, a locked workstation, an open UAC prompt and a Session 0
959
+ service cannot capture the user's screen; protected (HDCP/DRM) content is a
960
+ separate case and is reported as such.
961
+ - **Black screens when capturing:** protected content is blanked by the OS —
962
+ check `frame.protected_content`. Exclusive fullscreen is handled: capture
963
+ detects the transition, rebuilds, and continues.
858
964
  - **Low performance:** Experiment with different backends (NUMPY vs. CUPY) to optimize performance.
965
+ - **Unstable benchmark numbers:** on a hybrid P-core/E-core CPU, pin the
966
+ process to the performance cores. `benchmarks/perf_suite.py` does this
967
+ itself; unpinned it reported false regressions up to 2.57× against
968
+ unchanged code.
859
969
 
860
970
  ## Contributing
861
971
 
@@ -83,8 +83,9 @@ built around have no column here because the other libraries have no equivalent:
83
83
 
84
84
  - **`grab_frame()`** hands back a GPU-resident frame in 0.17–0.21 ms that never
85
85
  crosses to system memory.
86
- - **The GPU tensor path** turns a frame into NCHW float32 in one dispatch, 2 µs of
87
- calling-thread time.
86
+ - **The GPU tensor path** turns a frame into model-ready NCHW float32 in one
87
+ dispatch — 0.075 ms for a 2560×1600 frame, against 3.1 ms to do the same work
88
+ on the CPU — and the result stays in VRAM, where CUDA or DirectML can read it.
88
89
  - **Cross-adapter transfer** moves a frame to the discrete GPU on hybrid laptops,
89
90
  which Desktop Duplication cannot capture from at all.
90
91
  - **Dirty-rect metadata**, so a consumer can skip regions that did not change.
@@ -130,6 +131,10 @@ rewritten.
130
131
  the memory system can move, and byte-identical to the pure-Python path
131
132
  - **Only process what changed**: frames carry the compositor's dirty-rect
132
133
  metadata, typically under 1% of the screen on a normal desktop
134
+ - **Feed CUDA directly**: the GPU tensor exposes a shared NT handle, so CuPy or
135
+ PyTorch can read it in place via `cudaImportExternalMemory` — capture to
136
+ `cupy.ndarray` with no CPU round-trip. One dispatch costs **0.075 ms** at
137
+ 2560×1600 → 640×640, against 3.1 ms for the same work on the CPU
133
138
  - **Hybrid GPU laptops**: move a frame to the discrete GPU that Desktop
134
139
  Duplication cannot capture from
135
140
  - **Multi-backend support**: NumPy, PIL, and CUDA/CuPy backends
@@ -140,8 +145,9 @@ rewritten.
140
145
  - **Flexible output formats**: RGB, RGBA, BGR, BGRA, and grayscale support
141
146
  - **Region-based capture**: Efficient capture of specific screen regions
142
147
  - **Rotation handling**: Automatic handling of rotated displays
143
- - **Actionable diagnostics**: headless machines and hybrid GPU setups are
144
- detected and explained rather than failing opaquely
148
+ - **Actionable diagnostics**: headless machines, hybrid GPU setups, locked
149
+ workstations, UAC prompts and Session 0 services are detected and named
150
+ rather than failing opaquely
145
151
 
146
152
  ## Installation
147
153
 
@@ -253,14 +259,24 @@ writer.release()
253
259
  ### NVIDIA GPU Acceleration
254
260
 
255
261
  ```python
256
- # Create a ScreenCapture instance with NVIDIA GPU acceleration
257
- screencapture = rapidshot.create(nvidia_gpu=True)
262
+ # Frames come back as cupy.ndarray, converted on the device
263
+ screencapture = rapidshot.create(output_color="RGB", nvidia_gpu=True)
258
264
 
259
- # Screenshots will be processed on the GPU for improved performance
260
- frame = screencapture.grab()
261
- frame.release()
265
+ frame = screencapture.grab() # cupy.ndarray, stays in VRAM
262
266
  ```
263
267
 
268
+ > **Note:** unlike the CPU path, `nvidia_gpu=True` returns a bare
269
+ > `cupy.ndarray` rather than a pooled buffer, so there is no `.release()` to
270
+ > call — CuPy's allocator owns it. `pool_size_frames` does not apply.
271
+
272
+ Colour conversion runs in CuPy on the GPU and is **byte-identical** to the CPU
273
+ path, so turning `nvidia_gpu` on changes no pixel. OpenCV is not required.
274
+
275
+ > **Fixed in 2.3.0:** before this release, any `output_color` other than
276
+ > `"BGRA"` silently returned an unconverted 4-channel BGRA array — wrong shape
277
+ > and wrong channel order, reported as success. Conversion went through OpenCV,
278
+ > which is not a dependency, and the failure was swallowed.
279
+
264
280
  ### Cursor Capture
265
281
 
266
282
  RapidShot provides comprehensive cursor capture capabilities, allowing you to track cursor position, visibility, and shape in your screen captures.
@@ -616,6 +632,7 @@ with screencapture.grab_frame() as frame:
616
632
  print(pre.shape) # (1, 3, 640, 640)
617
633
  resource = pre.output_resource_address # ID3D12Resource*
618
634
  gpu_va = pre.output_gpu_address # GPU virtual address
635
+ handle = pre.shared_output_handle # shared NT handle, for CUDA etc.
619
636
  ```
620
637
 
621
638
  `process()` resizes, normalises, converts BGRA→RGB and transposes to NCHW in a
@@ -624,6 +641,49 @@ frame**; here it is one dispatch and the result stays in VRAM. Optional
624
641
  arguments cover the usual normalisation ranges (`scale=2.0, bias=-1.0` for
625
642
  −1..1) and channel order (`bgr=True`).
626
643
 
644
+ #### Reading the tensor from CuPy / CUDA
645
+
646
+ `shared_output_handle` is a shared NT handle for the tensor, which CUDA imports
647
+ with `cudaImportExternalMemory` — so the frame reaches a CUDA consumer without
648
+ ever touching the CPU. **`examples/gpu_tensor_to_cupy.py` is a complete, working
649
+ version** of the ~60 lines of `ctypes` this takes; it verifies the resulting
650
+ `cupy.ndarray` is byte-identical to a readback of the same dispatch.
651
+
652
+ `CudaTensor` is defined in `examples/gpu_tensor_to_cupy.py`, not exported by
653
+ the package — copy it into your project rather than importing it:
654
+
655
+ ```python
656
+ with CudaTensor(pre, (1, 3, 640, 640)) as view:
657
+ tensor = view.array # a cupy.ndarray in VRAM
658
+ while capturing:
659
+ with screencapture.grab_frame() as frame: # a *new* frame each pass
660
+ if frame is None:
661
+ continue # nothing changed on screen
662
+ view.sync() # queued CUDA work must finish
663
+ pre.process(frame) # overwrites tensor's buffer
664
+ model(tensor)
665
+ ```
666
+
667
+ Note the fresh `grab_frame()` inside the loop. The preprocessor and the CUDA
668
+ import are built once and reused; the *frame* is not — a released frame raises
669
+ `FrameReleasedError`, and reusing a live one just re-processes the same image.
670
+ Releasing it before `model()` also matters: DXGI cannot acquire the next frame
671
+ while a reference to the previous surface is outstanding.
672
+
673
+ `sync()` is not optional. `process()` waits on the D3D12 fence but knows
674
+ nothing about CUDA work you have queued against the same memory, so a kernel
675
+ still reading the tensor when the next dispatch lands sees a half-overwritten
676
+ frame — with no error, just wrong numbers.
677
+
678
+ Keep the `CudaTensor` for as long as you use the array: it owns the CUDA
679
+ import and the preprocessor that owns the VRAM. On a machine with more than
680
+ one CUDA device, pass `device=N` — the tensor can only be imported by the
681
+ device that owns the adapter which captured the frame.
682
+
683
+ The import is paid once. After that `pre.process(frame)` overwrites the same
684
+ buffer the CuPy array points at, so a capture loop pays nothing per frame to
685
+ keep the view.
686
+
627
687
  **Where Rapidshot stops.** The output is an `ID3D12Resource` on the DirectML
628
688
  device — exactly what ONNX Runtime's DirectML provider consumes. Rapidshot
629
689
  deliberately does **not** bind it to a session: that would couple this library
@@ -760,6 +820,25 @@ What is measured, reproducibly, is the per-frame cost of RapidShot's own paths
760
820
  The first three are synthetic and deterministic: they move when the library
761
821
  changes and not otherwise, which is why they are the ones on the badges above.
762
822
 
823
+ **Second machine, NVIDIA RTX 4060 Laptop** (`benchmarks/baseline-rtx4060.json`,
824
+ P-core-pinned, 1920×1080 synthetic source, minimums):
825
+
826
+ | Path | Per frame |
827
+ | --- | --- |
828
+ | Colour conversion, BGRA→RGB | 0.198 ms |
829
+ | Colour conversion, BGRA→GRAY | 0.252 ms |
830
+ | CPU resize + normalise + NCHW | 3.09 ms |
831
+ | **GPU dispatch → NCHW tensor, D3D12** | **0.070 ms** (2560×1600 source) |
832
+ | GPU dispatch + forced readback | 2.20 ms |
833
+ | `grab_frame()` — texture stays on the GPU | 0.099–0.156 ms |
834
+
835
+ Cross-machine comparison is not valid — different CPU, GPU, panel and, on two
836
+ rows, different code. Both recordings are kept so each answers for its own
837
+ machine.
838
+
839
+ Sustained-load check: 211,726 frames of `grab_frame()` → GPU tensor in 12
840
+ minutes, zero errors, VRAM flat, no throughput decay.
841
+
763
842
  **The last two are not stable measurements and should not be quoted as single
764
843
  numbers.** `grab()` converts only the parts of the frame that changed, so its
765
844
  cost tracks what is happening on screen; across seven recordings on unchanged
@@ -785,11 +864,42 @@ breakdown and how each figure is measured.
785
864
  only for the optional CuPy acceleration.
786
865
  - **RAM:** 8 GB+ (depending on the resolution and number of screencapture instances used)
787
866
 
867
+ ### Tested configurations
868
+
869
+ Nothing in RapidShot branches on GPU vendor. This is what has actually been
870
+ run, which is not the same claim:
871
+
872
+ | Configuration | State |
873
+ | --- | --- |
874
+ | Intel iGPU, single adapter | Verified |
875
+ | NVIDIA dGPU, single adapter, native extension built | Verified |
876
+ | NVIDIA dGPU, single adapter, no extension | Verified |
877
+ | Any AMD GPU | **Not tested** |
878
+ | Hybrid / switchable graphics (Optimus, AMD) | **Not tested** |
879
+ | Headless with a virtual display | **Not tested** |
880
+
881
+ CuPy is the only NVIDIA-bound feature, because CUDA is; `nvidia_gpu=True` falls
882
+ back to the CPU processor when CuPy is unavailable. AMD and Intel consumers
883
+ reach the same GPU tensor through DirectML via `output_resource_address`.
884
+
788
885
  ### Troubleshooting
789
886
 
790
887
  - **ImportError with CuPy:** Ensure you have compatible CUDA drivers installed.
791
- - **Black screens when capturing:** Verify the application isn't running in exclusive fullscreen mode.
888
+ `pip install cupy-cuda13x[ctk]` installs no headers against `cuda-toolkit`
889
+ 13.3.x and CuPy then fails at its first JIT; use
890
+ `pip install "cuda-toolkit[cudart,nvrtc]==13.2.*"` instead.
891
+ - **"Desktop duplication was denied":** the message names the cause. A
892
+ non-input desktop, a locked workstation, an open UAC prompt and a Session 0
893
+ service cannot capture the user's screen; protected (HDCP/DRM) content is a
894
+ separate case and is reported as such.
895
+ - **Black screens when capturing:** protected content is blanked by the OS —
896
+ check `frame.protected_content`. Exclusive fullscreen is handled: capture
897
+ detects the transition, rebuilds, and continues.
792
898
  - **Low performance:** Experiment with different backends (NUMPY vs. CUPY) to optimize performance.
899
+ - **Unstable benchmark numbers:** on a hybrid P-core/E-core CPU, pin the
900
+ process to the performance cores. `benchmarks/perf_suite.py` does this
901
+ itself; unpinned it reported false regressions up to 2.57× against
902
+ unchanged code.
793
903
 
794
904
  ## Contributing
795
905
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rapidshot"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "A high-performance screencapture library for Windows using Desktop Duplication API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -539,7 +539,7 @@ def get_version_info() -> Dict[str, Any]:
539
539
  return info
540
540
 
541
541
  # Version information
542
- __version__ = "2.2.0"
542
+ __version__ = "2.3.0"
543
543
  __author__ = "Rapidshot Contributors"
544
544
  __description__ = "High-performance screencapture library for Windows using Desktop Duplication API"
545
545
 
@@ -610,6 +610,7 @@ class ScreenCapture:
610
610
  rotation_angle=self.rotation_angle,
611
611
  present_time_qpc=duplicator.last_present_time,
612
612
  accumulated_frames=duplicator.accumulated_frames,
613
+ source_id=duplicator.instance_id,
613
614
  protected_content=duplicator.protected_content_detected,
614
615
  cursor_visible=duplicator.cursor_visible,
615
616
  dirty_rects=duplicator.dirty_rects,
@@ -1,4 +1,5 @@
1
1
  import ctypes
2
+ import itertools
2
3
  import logging
3
4
  import os
4
5
  import comtypes # type: ignore[import-untyped]
@@ -33,8 +34,10 @@ from rapidshot._libs.dxgi import (
33
34
  IDXGIResource,
34
35
  RECT,
35
36
  )
37
+ from rapidshot._libs.dxgi import E_ACCESSDENIED
36
38
  from rapidshot.core.device import Device
37
39
  from rapidshot.core.output import Output
40
+ from rapidshot.util.desktop import describe_desktop_access
38
41
  from rapidshot.util.errors import (
39
42
  RapidShotError,
40
43
  RapidShotDXGIError,
@@ -71,6 +74,13 @@ DUPLICATE_OUTPUT1_FORMATS = (
71
74
  # pre-1.5 DuplicateOutput path if a driver misbehaves on DuplicateOutput1.
72
75
  _DUPLICATE_OUTPUT_ENV = "RAPIDSHOT_DUPLICATE_OUTPUT"
73
76
 
77
+ # Process-unique id per Duplicator. Consumers that cache anything keyed on a
78
+ # captured texture's address need this: COM addresses are recycled, so a
79
+ # released surface and a later unrelated one can share a pointer. Pairing the
80
+ # pointer with the duplicator that produced it makes the identity sound,
81
+ # because a recycled address necessarily belongs to a different duplicator.
82
+ _duplicator_ids = itertools.count(1)
83
+
74
84
 
75
85
  def _format_hresult(hresult) -> str:
76
86
  """Render an HRESULT as 0x-prefixed hex, tolerating non-integer values."""
@@ -79,6 +89,37 @@ def _format_hresult(hresult) -> str:
79
89
  return str(hresult)
80
90
 
81
91
 
92
+ def _desktop_refusal(hresult, detail: str = "") -> Optional[RapidShotError]:
93
+ """Return a desktop-specific error if that is what a refusal really means.
94
+
95
+ ``DuplicateOutput`` reports both "protected content is on screen" and "you
96
+ are not on the input desktop" as a bare ``E_ACCESSDENIED``. Reporting the
97
+ second as the first is actively misleading: a locked workstation, an open
98
+ UAC prompt, and a Session 0 service all told the user to close a protected
99
+ player window that does not exist. Measured 2026-08-06 by moving a thread
100
+ to a fresh desktop with ``CreateDesktop``, which reproduces the HRESULT
101
+ exactly.
102
+
103
+ Only plain ``E_ACCESSDENIED`` is ambiguous. ``DXGI_ERROR_ACCESS_DENIED``
104
+ and ``DXGI_ERROR_CANNOT_PROTECT_CONTENT`` are specific to protected
105
+ content, so they are left alone.
106
+
107
+ Returns:
108
+ A ``RapidShotConfigError`` naming the real cause, or None to let the
109
+ caller report protected content.
110
+ """
111
+ if hresult != E_ACCESSDENIED:
112
+ return None
113
+ state = describe_desktop_access()
114
+ if state.blocked_reason is None:
115
+ return None
116
+ prefix = f"{detail} — " if detail else ""
117
+ return RapidShotConfigError(
118
+ f"{prefix}Desktop duplication was denied: {state.blocked_reason}",
119
+ hresult=hresult,
120
+ )
121
+
122
+
82
123
  def _legacy_duplication_forced() -> bool:
83
124
  """True if the env var asks for the pre-DuplicateOutput1 code path."""
84
125
  return os.environ.get(_DUPLICATE_OUTPUT_ENV, "").strip().lower() in (
@@ -152,6 +193,8 @@ class Duplicator:
152
193
  # True when the driver merged rects rather than reporting them individually,
153
194
  # so the regions are an over-estimate of what actually changed.
154
195
  rects_coalesced: bool = False
196
+ # See _duplicator_ids: identifies which duplicator a texture came from.
197
+ instance_id: int = field(default_factory=lambda: next(_duplicator_ids))
155
198
  _frame_acquired: bool = False
156
199
 
157
200
  def __post_init__(self, output: Output, device: Device) -> None:
@@ -232,6 +275,13 @@ class Duplicator:
232
275
  except comtypes.COMError as ce:
233
276
  hresult = ce.args[0] if ce.args else None
234
277
  if hresult in DXGI_PROTECTED_CONTENT_ERRORS:
278
+ # A bare E_ACCESSDENIED means one of two unrelated
279
+ # things, and blaming protected content for both sends
280
+ # people hunting for a player window that is not there.
281
+ # Ask the window station before deciding.
282
+ desktop_error = _desktop_refusal(hresult)
283
+ if desktop_error is not None:
284
+ raise desktop_error from ce
235
285
  # Protected surfaces are a permanent refusal for this
236
286
  # output, not something the legacy path can work around.
237
287
  self.protected_content_detected = True
@@ -263,6 +313,9 @@ class Duplicator:
263
313
  detail = f"{context}: {ce}"
264
314
 
265
315
  if hresult in DXGI_PROTECTED_CONTENT_ERRORS:
316
+ desktop_error = _desktop_refusal(hresult, detail)
317
+ if desktop_error is not None:
318
+ return desktop_error
266
319
  self.protected_content_detected = True
267
320
  return RapidShotProtectedContentError(
268
321
  f"{detail} (protected/HDCP content is blocking duplication)",
@@ -78,7 +78,7 @@ class Frame:
78
78
  "_texture", "_on_release", "_released", "_region", "_rotation_angle",
79
79
  "_present_time_qpc", "_accumulated_frames", "_protected_content",
80
80
  "_cursor_visible", "_width", "_height", "_dirty_rects",
81
- "_rects_coalesced",
81
+ "_rects_coalesced", "_source_id",
82
82
  )
83
83
 
84
84
  def __init__(
@@ -93,6 +93,7 @@ class Frame:
93
93
  cursor_visible: bool = False,
94
94
  dirty_rects: Optional[List[Tuple[int, int, int, int]]] = None,
95
95
  rects_coalesced: bool = False,
96
+ source_id: int = 0,
96
97
  ) -> None:
97
98
  self._texture = texture
98
99
  self._on_release = on_release
@@ -107,6 +108,7 @@ class Frame:
107
108
  self._height = region[3] - region[1]
108
109
  self._dirty_rects = self._clip_to_region(dirty_rects)
109
110
  self._rects_coalesced = rects_coalesced
111
+ self._source_id = source_id
110
112
 
111
113
  def _clip_to_region(self, rects):
112
114
  """Translate desktop-coordinate rects into this frame's coordinates.
@@ -205,6 +207,17 @@ class Frame:
205
207
  """
206
208
  return self._accumulated_frames
207
209
 
210
+ @property
211
+ def source_id(self) -> int:
212
+ """Which duplicator produced this frame's texture.
213
+
214
+ Consumers caching anything keyed on the texture address need this too:
215
+ COM addresses are recycled, so a released surface and a later unrelated
216
+ one can share a pointer. The pair is a sound identity; the pointer
217
+ alone is not.
218
+ """
219
+ return self._source_id
220
+
208
221
  @property
209
222
  def protected_content(self) -> bool:
210
223
  """True if HDCP/DRM content was blanked out of this frame by the OS."""
@@ -254,7 +254,10 @@ class GpuPreprocessor:
254
254
  """
255
255
  import numpy as np
256
256
 
257
- flat = np.asarray(self._impl.read_back(), dtype=np.float32)
257
+ # The extension hands back raw bytes, not a list of floats: a list cost
258
+ # one Python object per element and dominated the readback benchmark it
259
+ # was meant to price. `frombuffer` is a reinterpret, not a conversion.
260
+ flat = np.frombuffer(self._impl.read_back(), dtype=np.float32)
258
261
  return flat.reshape(1, 3, self.out_height, self.out_width)
259
262
 
260
263
  @property
@@ -319,19 +322,57 @@ class GpuPreprocessor12:
319
322
  def process(self, frame, scale: float = 1.0, bias: float = 0.0,
320
323
  bgr: bool = False) -> None:
321
324
  """Convert one frame. The result stays on the DirectML device."""
322
- self._impl.process(_texture_address(frame), scale, bias, bgr)
325
+ # The texture address alone is not an identity -- COM addresses get
326
+ # recycled -- so the frame's source_id goes with it. See Frame.source_id.
327
+ self._impl.process(_texture_address(frame), scale, bias, bgr,
328
+ source_id=getattr(frame, "source_id", 0))
323
329
 
324
330
  def read_back(self):
325
331
  """Copy the tensor to the CPU as (1, 3, H, W). Verification only."""
326
332
  import numpy as np
327
333
 
328
- flat = np.asarray(self._impl.read_back(), dtype=np.float32)
334
+ # Raw bytes rather than a list of floats — see the note on the D3D11
335
+ # preprocessor's read_back.
336
+ flat = np.frombuffer(self._impl.read_back(), dtype=np.float32)
329
337
  return flat.reshape(1, 3, self.out_height, self.out_width)
330
338
 
331
339
  @property
332
340
  def shape(self):
333
341
  return tuple(self._impl.shape)
334
342
 
343
+ @property
344
+ def shared_output_handle(self) -> int:
345
+ """
346
+ Shared NT handle for the tensor, for a consumer on another device or API.
347
+
348
+ This is what CUDA's ``cudaImportExternalMemory`` takes with
349
+ ``cudaExternalMemoryHandleTypeD3D12Resource``. Pair it with
350
+ :attr:`output_byte_size`, which is the size such an importer must map.
351
+
352
+ **Borrowed, not owned.** It is closed when this preprocessor is dropped;
353
+ do not close it yourself, and do not use it after that point.
354
+ """
355
+ return int(self._impl.shared_output_handle)
356
+
357
+ @property
358
+ def output_byte_size(self) -> int:
359
+ """Size of the tensor in bytes — what an importer must map."""
360
+ return int(self._impl.output_byte_size)
361
+
362
+ @property
363
+ def adapter_luid(self) -> bytes:
364
+ """
365
+ LUID of the adapter holding the tensor, as 8 little-endian bytes.
366
+
367
+ The tensor is not cross-adapter, so only a consumer on *this* adapter
368
+ can import it. CUDA exposes the same identity via ``cuDeviceGetLuid``,
369
+ which is how a caller picks the right device — and how it finds out
370
+ there isn't one. That is the ordinary hybrid-laptop case: capture runs
371
+ on the iGPU, CUDA reports exactly one device, and it is the wrong one,
372
+ so counting devices cannot detect the mismatch.
373
+ """
374
+ return bytes(self._impl.adapter_luid)
375
+
335
376
  @property
336
377
  def output_resource_address(self) -> int:
337
378
  """