VirtualDesktop 1.4.1__tar.gz → 1.6.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 (64) hide show
  1. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/PKG-INFO +144 -29
  2. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/README.md +142 -27
  3. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/__init__.py +18 -1
  4. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_desktop_runtime.py +79 -0
  5. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_winapi.py +25 -0
  6. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/agent.py +109 -17
  7. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/capture.py +6 -1
  8. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/desktop_session.py +9 -2
  9. virtualdesktop-1.6.0/VirtualDesktop/record.py +605 -0
  10. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/window.py +1240 -889
  11. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/workspace.py +307 -33
  12. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop.egg-info/PKG-INFO +144 -29
  13. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop.egg-info/SOURCES.txt +2 -0
  14. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/pyproject.toml +2 -2
  15. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_agent.py +28 -5
  16. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_no_dialogs.py +28 -2
  17. virtualdesktop-1.6.0/tests/test_record.py +386 -0
  18. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_workspace_instances.py +12 -6
  19. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/LICENSE +0 -0
  20. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/__main__.py +0 -0
  21. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_bundle.py +0 -0
  22. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_compat.py +0 -0
  23. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_devices.py +0 -0
  24. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_download.py +0 -0
  25. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_enumerate.py +0 -0
  26. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_helper.py +0 -0
  27. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_log.py +0 -0
  28. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_process.py +0 -0
  29. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_sigcheck.ps1 +0 -0
  30. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/_sigcheck.py +0 -0
  31. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/cli.py +0 -0
  32. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/config.py +0 -0
  33. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/desktop.py +0 -0
  34. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/desktop_capture.py +0 -0
  35. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/display_setup.py +0 -0
  36. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/driver.py +0 -0
  37. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/driver_manifest.json +0 -0
  38. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/errors.py +0 -0
  39. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/manifest.py +0 -0
  40. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop/session.py +0 -0
  41. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
  42. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop.egg-info/entry_points.txt +0 -0
  43. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop.egg-info/requires.txt +0 -0
  44. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/VirtualDesktop.egg-info/top_level.txt +0 -0
  45. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/setup.cfg +0 -0
  46. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_accelerators.py +0 -0
  47. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_artifact_regression.py +0 -0
  48. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_capture.py +0 -0
  49. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_cli.py +0 -0
  50. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_compat.py +0 -0
  51. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_config_and_winapi.py +0 -0
  52. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_desktop_isolation.py +0 -0
  53. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_destructive_lifecycle.py +0 -0
  54. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_display_setup.py +0 -0
  55. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_download.py +0 -0
  56. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_driver.py +0 -0
  57. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_hide_modes.py +0 -0
  58. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_integration_windows.py +0 -0
  59. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_manifest.py +0 -0
  60. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_packaging.py +0 -0
  61. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_process.py +0 -0
  62. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_virtual_desktop.py +0 -0
  63. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_workspace.py +0 -0
  64. {virtualdesktop-1.4.1 → virtualdesktop-1.6.0}/tests/test_workspace_move.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: VirtualDesktop
3
- Version: 1.4.1
4
- Summary: Multi-window virtual-display workspaces on Windows: one pip install bootstraps the virtual display driver, hosts programs on a virtual or private desktop, keeps them off the physical screens, and hands out real window handles for automation and PrintWindow capture.
3
+ Version: 1.6.0
4
+ Summary: win10-win11 虚拟桌面
5
5
  Author: VirtualDesktop contributors
6
6
  License: MIT License
7
7
 
@@ -57,7 +57,9 @@ Dynamic: license-file
57
57
 
58
58
  # VirtualDesktop
59
59
 
60
- 在 Windows 上把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
60
+ win10-win11 虚拟桌面
61
+
62
+ 把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
61
63
  退出时自动还原。
62
64
 
63
65
  ```python
@@ -89,40 +91,46 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
89
91
  | 底层 | 内核**对象**(`CreateDesktopW`),独立窗口命名空间 + DACL | 显示**设备**(IddCx 驱动),系统多出一块 `\\.\DISPLAYn` |
90
92
  | 需要权限 | **零**:不需要驱动、不弹 UAC | 装一次驱动,弹**一次 UAC** |
91
93
  | 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
92
- | 程序窗口截图 | **不能** | **能** |
93
- | 桌面截图 | **不能** | **能** |
94
+ | **程序窗口截图** | **能**(窗口需已显示) | **能** |
95
+ | **桌面截图** | **不能**(没有屏幕 DC) | **能** |
94
96
  | 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
95
97
  | 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
96
98
  | 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
97
99
  | 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
98
100
  | 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
99
- | 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
101
+ | 推荐场景 | 多实例并行、强隔离、不需要桌面截图 | UI 自动化、截图 OCR、无人值守抓界面 |
100
102
 
101
103
  ### 隔离强度分级
102
104
 
103
- 两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
105
+ 两套方案的隔离**不是"强/弱"两档,而是几个层面各不相同**,按你需要防住什么来选:
104
106
 
105
107
  | 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
106
108
  |---|---|---|---|
107
109
  | 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
108
110
  | 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
109
- | 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
111
+ | 窗口**像素**(能否截图) | **不隔离** | 不隔离 | `PrintWindow` 让**应用自己绘制**,不需要屏幕,所以私有桌面同样能截 |
110
112
  | 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
111
113
  | 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
112
114
 
113
- > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
114
- > 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
115
- > 目标进程的权限,只是把它挪到了另一个窗口命名空间。
115
+ > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**和**真实输入**,
116
+ > 挡不住"已知句柄后投递消息",也挡不住窗口自己绘制出来的像素。所以不要把私有桌面当作
117
+ > 进程级安全沙箱使用——它没有降低目标进程的权限,只是把它挪到了另一个窗口命名空间。
118
+ >
119
+ > 反过来,**"私有桌面截不了图"是个流传很广的误解**。真正的限制只有两条:
120
+ > ①**桌面截图**(拍整块屏幕)不行,因为那块桌面没有被合成到任何输出上;
121
+ > ②被 `SW_HIDE` 隐藏的窗口拍出来是纯色块,因为它压根不绘制。窗口只要**显示着**
122
+ > (本库默认如此),私有桌面上的窗口截图与共享模式**逐字节一致**。
116
123
 
117
124
  ### 推荐怎么选
118
125
 
119
126
  ```
120
- 要截图(窗口截图或桌面截图) → 虚拟显示器
127
+ 要桌面截图(拍整块屏幕/虚拟屏) → 虚拟显示器
121
128
  要用真实鼠标 / 滚轮 / 键盘,或 pyautogui → 虚拟显示器
122
129
  要用 pywinauto / UIA 从外部直接驱动窗口 → 虚拟显示器
123
130
  只处理一个窗口、要截图,越省事越好 → OffscreenSession(虚拟显示器侧)
131
+ 要窗口截图、但连驱动都不想装 → 私有桌面
124
132
  ─────────────────────────────────────────────────────────────
125
- 要多个实例互不干扰、且不需要截图 → 私有桌面
133
+ 要多个实例互不干扰 → 私有桌面
126
134
  不能弹 UAC、不能装驱动 → 私有桌面
127
135
  要"退出后什么都不留"(进程随桌面一起结束) → 私有桌面
128
136
  需要窗口完全不出现在任何可见位置 → 私有桌面
@@ -131,7 +139,7 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
131
139
  两条常用组合:
132
140
 
133
141
  - **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
134
- - **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`
142
+ - **想并行跑很多互不干扰的实例,同时要窗口截图** → 每个实例一个 `VirtualWorkspace("名字")`
135
143
 
136
144
  ---
137
145
 
@@ -218,17 +226,27 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
218
226
  `exec` 是逃生舱:里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`,
219
227
  也能用你自己装的任何库。代理本身只依赖 ctypes 与 comtypes。
220
228
 
221
- ### 私有桌面不能截图
229
+ ### 私有桌面的截图能力
222
230
 
223
- 两种截图都不行,且原因不同:
231
+ **程序窗口能截,桌面不能截**,原因是两条不同的路:
224
232
 
225
233
  | 类型 | 结果 | 原因 |
226
234
  |---|---|---|
227
- | 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成,没有像素可读 |
228
- | 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
235
+ | 程序窗口截图 | **能** | `PrintWindow` 让**窗口自己绘制**到调用方给的 DC,不需要屏幕,也不需要合成 |
236
+ | 桌面截图 | **不能** | 拍整块屏幕需要该桌面的屏幕 DC,拿不到(`BitBlt` 返回拒绝访问) |
237
+
238
+ ```python
239
+ with VirtualWorkspace("lab") as space, space.agent() as agent:
240
+ window = agent.launch(["charmap.exe"])
241
+ frame = agent.screenshot(window, r"C:\绝对路径\window.png") # 路径必须绝对
242
+ print(frame.method, frame.size, frame.analysis.blank)
243
+ ```
229
244
 
230
- `agent.screenshot()` 会**明确报错**(`code="capture-needs-shared-workspace"`)而不是产出空白图。
231
- 要读状态用 `agent.info()` / `agent.text()`;要像素请改用虚拟显示器。
245
+ 路径必须**绝对**:截图由桌面内的进程执行,它的工作目录不是调用者的。省略 `path` 则只返回
246
+ 内存中的帧、不落盘。
247
+
248
+ > 窗口必须是**显示状态**。被 `SW_HIDE` 隐藏的窗口会返回纯色块(`code="capture-blank-window"`),
249
+ > 因为它压根不绘制——本库启动程序时会由放置流程负责在目标位置恢复显示。
232
250
 
233
251
  ## 能力边界
234
252
 
@@ -241,15 +259,15 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
241
259
  | 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
242
260
  | 点击控件(`BM_CLICK` 等窗口消息) | **能** | 消息直接投递给窗口,不需要指针 |
243
261
  | 送入字符(`WM_CHAR` 消息) | **能** | 同上 |
244
- | **程序窗口截图** | **不能** | 桌面从不被系统合成,窗口没有像素 |
262
+ | **程序窗口截图** | **能** | `PrintWindow` 让窗口自己绘制,不需要屏幕或合成 |
245
263
  | **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
246
264
  | 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面,没有指针 |
247
265
  | 滚轮(真实滚轮事件) | **不能** | 同"移动真实鼠标" |
248
266
  | 发送真实键盘输入(`SendInput`) | **不能** | 键盘只发给活动桌面 |
249
267
  | `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |
250
268
 
251
- 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
252
- 真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
269
+ 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用;程序窗口截图可以,桌面截图不行**。
270
+ 需要桌面截图或真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
253
271
 
254
272
  ## 支持的能力与调用方式
255
273
 
@@ -263,7 +281,9 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
263
281
  | 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
264
282
  | 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
265
283
  | 在桌面内跑任意 Python | **支持** | `agent.exec("...")`(可用 comtypes、pywinauto) |
266
- | 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因;改用虚拟显示器 |
284
+ | 程序窗口截图 | **支持** | `agent.screenshot(window, "绝对路径")` |
285
+ | **窗口录制** | **支持** | `space.record_window(window, "out.mp4", fps=10)`(走代理,上限约 20 fps) |
286
+ | 桌面截图 / 桌面录制 | **不支持** | 拿不到该桌面的屏幕 DC;需要拍屏幕请用虚拟显示器 |
267
287
  | 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`;只能在目标桌面重新 `launch` |
268
288
 
269
289
  `agent.uia_tree()` 在**桌面内**遍历 UIA,这是私有桌面唯一可用的取树方式:
@@ -479,6 +499,91 @@ configure_virtual_display(size="1920x1080", dpi="125%") # 传 None 表示跟
479
499
  print(current_primary_size()) # (1920, 1080)
480
500
  ```
481
501
 
502
+ ## 录制
503
+
504
+ 录制**只作用于本实例自己的桌面或窗口**,从不拍主屏、也不会去拍"所有屏幕"——需要那种行为的
505
+ 请直接用系统截图工具。
506
+
507
+ 两种录制目标,用同一个 `Recorder`:
508
+
509
+ ```python
510
+ from VirtualDesktop import VirtualWorkspace
511
+
512
+ with VirtualWorkspace(VirtualWorkspace.SHARED) as space: # hide_mode 默认 virtual_screen
513
+ space.launch(["charmap.exe"])
514
+
515
+ # ① 录整个虚拟桌面(工作区自己的那块虚拟屏)
516
+ with space.record_desktop("desktop.mp4", fps=15, duration=10) as rec:
517
+ ... # 这期间发生的一切都被录下
518
+ print(rec.report().describe())
519
+
520
+ # ② 只录指定窗口(任何 hide_mode 都可以,包括私有桌面)
521
+ window = space.windows()[0]
522
+ with space.record_window(window, "window.gif", fps=10, duration=5) as rec2:
523
+ ...
524
+ ```
525
+
526
+ 窗口录制还有**私有桌面**版本(走代理进程,帧率上限约 20 fps):
527
+
528
+ ```python
529
+ with VirtualWorkspace("lab") as space, space.agent() as agent:
530
+ window = agent.launch(["charmap.exe"])
531
+ with space.record_window(window, r"C:\绝对路径\window.mp4", fps=10, duration=5):
532
+ ...
533
+ ```
534
+
535
+ ### 输出格式
536
+
537
+ 扩展名决定格式。**视频需要 ffmpeg 在 PATH 上**;其余只依赖 Pillow,没装 ffmpeg 也能用:
538
+
539
+ | 扩展名 | 写入方 | 说明 |
540
+ |---|---|---|
541
+ | `.mp4` `.mkv` `.mov` `.avi` `.webm` | ffmpeg | H.264 + `yuv420p`;`quality` 映射到 CRF(0–51,默认 23) |
542
+ | `.gif` | Pillow | 动画 GIF;`quality` 映射到调色板颜色数(8–256) |
543
+ | `.png` `.jpg` `.webp` … | Pillow | 写成**目录下的编号帧**,另附 `frames.json` |
544
+
545
+ 查当前环境支持情况:
546
+
547
+ ```python
548
+ from VirtualDesktop import supported_formats
549
+ print(supported_formats()) # {'.mp4': 'ffmpeg', '.gif': 'pillow', ...}
550
+ ```
551
+
552
+ > 内容完全相同的连续帧会被 GIF 优化合并成一个条目(延迟累加),所以 GIF 的**帧数可能少于
553
+ > 录制帧数**,但总时长不变。`report().frames` 报的是实际**采集**帧数。
554
+
555
+ ### 流式输出
556
+
557
+ 需要边录边处理(送编码器、推到 socket、实时 OCR)时用 `record_stream()`:文件照常写,
558
+ 同时逐帧交给你:
559
+
560
+ ```python
561
+ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
562
+ for frame in stream:
563
+ ... # frame.image 是刚采集到的 PIL 图像
564
+ ```
565
+
566
+ 它是个**有界**队列(默认最多缓存 240 帧):消费端跟不上时丢最旧的帧,不会无限吃内存。
567
+ 录制到时限后迭代会**自动结束**,不会挂起。
568
+
569
+ ### 参数
570
+
571
+ `record_desktop()` / `record_window()` 都接受:
572
+
573
+ | 参数 | 含义 |
574
+ |---|---|
575
+ | `fps` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
576
+ | `duration` | 秒;到点自动停止,`stopped_by="duration"` |
577
+ | `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
578
+ | `quality` | 见上方格式表;不同后端含义不同 |
579
+ | `format` | 覆盖扩展名推断 |
580
+ | `on_frame` | `(frame, index)` 回调,每帧一次;回调抛异常不会中断录制 |
581
+ | `ffmpeg` | 指定 ffmpeg 可执行文件路径 |
582
+
583
+ `report()` 返回 `RecordingReport`,含 `frames` / `fps` / `duration` / `size` / `bytes`
584
+ / `dropped` / `stopped_by` / `error`。**在 `with` 块内调用也是安全的**——它会先收尾再统计,
585
+ 所以报的大小是成品文件的大小。
586
+
482
587
  ## 能力边界
483
588
 
484
589
  | 想做的事 | 虚拟显示器 | offscreen |
@@ -489,6 +594,8 @@ print(current_primary_size()) # (1920, 1080)
489
594
  | 送入字符(窗口消息) | 能 | 能 |
490
595
  | **程序窗口截图** | **能** | **能** |
491
596
  | **桌面截图** | **能**(拍到窗口) | **不能**(窗口在所有显示器之外) |
597
+ | **窗口录制** | **能** | **能** |
598
+ | **桌面录制** | **能**(录工作区自己的虚拟屏) | **不能**(没有属于自己的屏幕区域) |
492
599
  | 外部 pywinauto / UIA 直接连上 | 能 | 能 |
493
600
  | 移动真实鼠标来点击 | 能 | **不能**(窗口在屏幕之外) |
494
601
  | 滚轮 | 能 | **不能** |
@@ -509,6 +616,8 @@ print(current_primary_size()) # (1920, 1080)
509
616
  | 完整 UIA 树 | **支持** | 外部 UIA,或 `--ui-tree DIR` |
510
617
  | 程序窗口截图 | **支持** | `ws.capture(hwnd, save_to=...)` / `CaptureEngine.capture(hwnd)` |
511
618
  | 桌面截图 | **支持**(`virtual_screen`) | `ws.capture_desktop()` / `capture_monitor()` / `capture_all_screens()` |
619
+ | **窗口录制** | **支持** | `ws.record_window(hwnd, "out.mp4", fps=15, duration=10)` |
620
+ | **桌面录制** | **支持**(`virtual_screen`) | `ws.record_desktop("out.mp4", fps=15)` |
512
621
  | 真实鼠标 / 滚轮 / 键盘 | **支持**(`virtual_screen`) | pywinauto `click_input()` / `type_keys()`,或 pyautogui |
513
622
  | 在显示器之间移动窗口 | **支持** | `move_to("\\\\.\\DISPLAY2")` / `move_all_to(...)` / `move_to_primary()` |
514
623
  | 退出时还原窗口 | **支持** | `with` 退出自动还原;异常退出同样还原 |
@@ -556,7 +665,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
556
665
  | `DriverNotFoundError` | 未装虚拟显示器且不允许安装 | 去掉限制,或先跑 `VirtualDesktop install-driver` |
557
666
  | `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
558
667
  | `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
559
- | `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
668
+ | `capture-blank-window` | 窗口被 `SW_HIDE` 隐藏,不绘制,只能拍到纯色块 | 让它处于显示状态;本库启动程序时会自动在目标位置恢复显示 |
560
669
  | `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
561
670
  | 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
562
671
  | 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
@@ -564,7 +673,9 @@ VIRTUALDESKTOP_LOG_FILE=<path>
564
673
  | 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 `bitblt` 试试,或确认窗口有内容 |
565
674
  | 桌面截图少了右下角 | 进程不感知 DPI,矩形按逻辑坐标算 | 先调 `CaptureEngine().ensure_dpi_aware()` |
566
675
  | 桌面截图拍不到工作区窗口 | 用了 `offscreen`,窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
567
- | `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`,或用 `virtual_screen` |
676
+ | `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
677
+ | 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
678
+ | 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
568
679
  | 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
569
680
 
570
681
  **离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
@@ -573,9 +684,13 @@ VIRTUALDESKTOP_LOG_FILE=<path>
573
684
 
574
685
  ## 已知限制
575
686
 
576
- - **私有桌面不能截图**:程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读;
577
- 而该桌面的屏幕 DC 也拿不到(`BitBlt` 返回拒绝访问)。用 `agent.info()` / `agent.text()` 读状态,
578
- 需要像素请改用虚拟显示器。
687
+ - **私有桌面不能做桌面截图**:拍整块屏幕不行,因为该桌面没有被合成到任何输出上,屏幕 DC 也
688
+ 拿不到。**但程序窗口截图可以**——`PrintWindow` 让窗口自己绘制到调用方给的 DC,不需要屏幕,
689
+ 所以私有桌面上的窗口截图与共享模式逐字节一致。
690
+ - **窗口必须是显示状态才可截图**:被 `SW_HIDE` 隐藏的窗口会返回纯色块(它压根不绘制)。
691
+ 本库启动程序时按此处理,由放置流程负责在正确位置恢复显示。
692
+ - **共享模式下程序启动瞬间可能极短暂可见**:程序自己显示窗口,库随即隐藏并移走它。这个间隙在
693
+ 私有桌面上不存在(窗口创建在另一块桌面上,从不经过你的屏幕);需要完全杜绝请用私有桌面。
579
694
  - **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**:那里没有活动指针和键盘焦点;消息驱动可用。
580
695
  - **窗口无法在私有桌面之间搬运**:Windows 不允许跨桌面移动窗口。
581
696
  - 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
@@ -1,6 +1,8 @@
1
1
  # VirtualDesktop
2
2
 
3
- 在 Windows 上把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
3
+ win10-win11 虚拟桌面
4
+
5
+ 把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
4
6
  退出时自动还原。
5
7
 
6
8
  ```python
@@ -32,40 +34,46 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
32
34
  | 底层 | 内核**对象**(`CreateDesktopW`),独立窗口命名空间 + DACL | 显示**设备**(IddCx 驱动),系统多出一块 `\\.\DISPLAYn` |
33
35
  | 需要权限 | **零**:不需要驱动、不弹 UAC | 装一次驱动,弹**一次 UAC** |
34
36
  | 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
35
- | 程序窗口截图 | **不能** | **能** |
36
- | 桌面截图 | **不能** | **能** |
37
+ | **程序窗口截图** | **能**(窗口需已显示) | **能** |
38
+ | **桌面截图** | **不能**(没有屏幕 DC) | **能** |
37
39
  | 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
38
40
  | 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
39
41
  | 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
40
42
  | 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
41
43
  | 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
42
- | 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
44
+ | 推荐场景 | 多实例并行、强隔离、不需要桌面截图 | UI 自动化、截图 OCR、无人值守抓界面 |
43
45
 
44
46
  ### 隔离强度分级
45
47
 
46
- 两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
48
+ 两套方案的隔离**不是"强/弱"两档,而是几个层面各不相同**,按你需要防住什么来选:
47
49
 
48
50
  | 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
49
51
  |---|---|---|---|
50
52
  | 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
51
53
  | 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
52
- | 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
54
+ | 窗口**像素**(能否截图) | **不隔离** | 不隔离 | `PrintWindow` 让**应用自己绘制**,不需要屏幕,所以私有桌面同样能截 |
53
55
  | 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
54
56
  | 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
55
57
 
56
- > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
57
- > 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
58
- > 目标进程的权限,只是把它挪到了另一个窗口命名空间。
58
+ > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**和**真实输入**,
59
+ > 挡不住"已知句柄后投递消息",也挡不住窗口自己绘制出来的像素。所以不要把私有桌面当作
60
+ > 进程级安全沙箱使用——它没有降低目标进程的权限,只是把它挪到了另一个窗口命名空间。
61
+ >
62
+ > 反过来,**"私有桌面截不了图"是个流传很广的误解**。真正的限制只有两条:
63
+ > ①**桌面截图**(拍整块屏幕)不行,因为那块桌面没有被合成到任何输出上;
64
+ > ②被 `SW_HIDE` 隐藏的窗口拍出来是纯色块,因为它压根不绘制。窗口只要**显示着**
65
+ > (本库默认如此),私有桌面上的窗口截图与共享模式**逐字节一致**。
59
66
 
60
67
  ### 推荐怎么选
61
68
 
62
69
  ```
63
- 要截图(窗口截图或桌面截图) → 虚拟显示器
70
+ 要桌面截图(拍整块屏幕/虚拟屏) → 虚拟显示器
64
71
  要用真实鼠标 / 滚轮 / 键盘,或 pyautogui → 虚拟显示器
65
72
  要用 pywinauto / UIA 从外部直接驱动窗口 → 虚拟显示器
66
73
  只处理一个窗口、要截图,越省事越好 → OffscreenSession(虚拟显示器侧)
74
+ 要窗口截图、但连驱动都不想装 → 私有桌面
67
75
  ─────────────────────────────────────────────────────────────
68
- 要多个实例互不干扰、且不需要截图 → 私有桌面
76
+ 要多个实例互不干扰 → 私有桌面
69
77
  不能弹 UAC、不能装驱动 → 私有桌面
70
78
  要"退出后什么都不留"(进程随桌面一起结束) → 私有桌面
71
79
  需要窗口完全不出现在任何可见位置 → 私有桌面
@@ -74,7 +82,7 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
74
82
  两条常用组合:
75
83
 
76
84
  - **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
77
- - **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`
85
+ - **想并行跑很多互不干扰的实例,同时要窗口截图** → 每个实例一个 `VirtualWorkspace("名字")`
78
86
 
79
87
  ---
80
88
 
@@ -161,17 +169,27 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
161
169
  `exec` 是逃生舱:里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`,
162
170
  也能用你自己装的任何库。代理本身只依赖 ctypes 与 comtypes。
163
171
 
164
- ### 私有桌面不能截图
172
+ ### 私有桌面的截图能力
165
173
 
166
- 两种截图都不行,且原因不同:
174
+ **程序窗口能截,桌面不能截**,原因是两条不同的路:
167
175
 
168
176
  | 类型 | 结果 | 原因 |
169
177
  |---|---|---|
170
- | 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成,没有像素可读 |
171
- | 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
178
+ | 程序窗口截图 | **能** | `PrintWindow` 让**窗口自己绘制**到调用方给的 DC,不需要屏幕,也不需要合成 |
179
+ | 桌面截图 | **不能** | 拍整块屏幕需要该桌面的屏幕 DC,拿不到(`BitBlt` 返回拒绝访问) |
180
+
181
+ ```python
182
+ with VirtualWorkspace("lab") as space, space.agent() as agent:
183
+ window = agent.launch(["charmap.exe"])
184
+ frame = agent.screenshot(window, r"C:\绝对路径\window.png") # 路径必须绝对
185
+ print(frame.method, frame.size, frame.analysis.blank)
186
+ ```
172
187
 
173
- `agent.screenshot()` 会**明确报错**(`code="capture-needs-shared-workspace"`)而不是产出空白图。
174
- 要读状态用 `agent.info()` / `agent.text()`;要像素请改用虚拟显示器。
188
+ 路径必须**绝对**:截图由桌面内的进程执行,它的工作目录不是调用者的。省略 `path` 则只返回
189
+ 内存中的帧、不落盘。
190
+
191
+ > 窗口必须是**显示状态**。被 `SW_HIDE` 隐藏的窗口会返回纯色块(`code="capture-blank-window"`),
192
+ > 因为它压根不绘制——本库启动程序时会由放置流程负责在目标位置恢复显示。
175
193
 
176
194
  ## 能力边界
177
195
 
@@ -184,15 +202,15 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
184
202
  | 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
185
203
  | 点击控件(`BM_CLICK` 等窗口消息) | **能** | 消息直接投递给窗口,不需要指针 |
186
204
  | 送入字符(`WM_CHAR` 消息) | **能** | 同上 |
187
- | **程序窗口截图** | **不能** | 桌面从不被系统合成,窗口没有像素 |
205
+ | **程序窗口截图** | **能** | `PrintWindow` 让窗口自己绘制,不需要屏幕或合成 |
188
206
  | **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
189
207
  | 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面,没有指针 |
190
208
  | 滚轮(真实滚轮事件) | **不能** | 同"移动真实鼠标" |
191
209
  | 发送真实键盘输入(`SendInput`) | **不能** | 键盘只发给活动桌面 |
192
210
  | `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |
193
211
 
194
- 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
195
- 真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
212
+ 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用;程序窗口截图可以,桌面截图不行**。
213
+ 需要桌面截图或真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
196
214
 
197
215
  ## 支持的能力与调用方式
198
216
 
@@ -206,7 +224,9 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
206
224
  | 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
207
225
  | 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
208
226
  | 在桌面内跑任意 Python | **支持** | `agent.exec("...")`(可用 comtypes、pywinauto) |
209
- | 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因;改用虚拟显示器 |
227
+ | 程序窗口截图 | **支持** | `agent.screenshot(window, "绝对路径")` |
228
+ | **窗口录制** | **支持** | `space.record_window(window, "out.mp4", fps=10)`(走代理,上限约 20 fps) |
229
+ | 桌面截图 / 桌面录制 | **不支持** | 拿不到该桌面的屏幕 DC;需要拍屏幕请用虚拟显示器 |
210
230
  | 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`;只能在目标桌面重新 `launch` |
211
231
 
212
232
  `agent.uia_tree()` 在**桌面内**遍历 UIA,这是私有桌面唯一可用的取树方式:
@@ -422,6 +442,91 @@ configure_virtual_display(size="1920x1080", dpi="125%") # 传 None 表示跟
422
442
  print(current_primary_size()) # (1920, 1080)
423
443
  ```
424
444
 
445
+ ## 录制
446
+
447
+ 录制**只作用于本实例自己的桌面或窗口**,从不拍主屏、也不会去拍"所有屏幕"——需要那种行为的
448
+ 请直接用系统截图工具。
449
+
450
+ 两种录制目标,用同一个 `Recorder`:
451
+
452
+ ```python
453
+ from VirtualDesktop import VirtualWorkspace
454
+
455
+ with VirtualWorkspace(VirtualWorkspace.SHARED) as space: # hide_mode 默认 virtual_screen
456
+ space.launch(["charmap.exe"])
457
+
458
+ # ① 录整个虚拟桌面(工作区自己的那块虚拟屏)
459
+ with space.record_desktop("desktop.mp4", fps=15, duration=10) as rec:
460
+ ... # 这期间发生的一切都被录下
461
+ print(rec.report().describe())
462
+
463
+ # ② 只录指定窗口(任何 hide_mode 都可以,包括私有桌面)
464
+ window = space.windows()[0]
465
+ with space.record_window(window, "window.gif", fps=10, duration=5) as rec2:
466
+ ...
467
+ ```
468
+
469
+ 窗口录制还有**私有桌面**版本(走代理进程,帧率上限约 20 fps):
470
+
471
+ ```python
472
+ with VirtualWorkspace("lab") as space, space.agent() as agent:
473
+ window = agent.launch(["charmap.exe"])
474
+ with space.record_window(window, r"C:\绝对路径\window.mp4", fps=10, duration=5):
475
+ ...
476
+ ```
477
+
478
+ ### 输出格式
479
+
480
+ 扩展名决定格式。**视频需要 ffmpeg 在 PATH 上**;其余只依赖 Pillow,没装 ffmpeg 也能用:
481
+
482
+ | 扩展名 | 写入方 | 说明 |
483
+ |---|---|---|
484
+ | `.mp4` `.mkv` `.mov` `.avi` `.webm` | ffmpeg | H.264 + `yuv420p`;`quality` 映射到 CRF(0–51,默认 23) |
485
+ | `.gif` | Pillow | 动画 GIF;`quality` 映射到调色板颜色数(8–256) |
486
+ | `.png` `.jpg` `.webp` … | Pillow | 写成**目录下的编号帧**,另附 `frames.json` |
487
+
488
+ 查当前环境支持情况:
489
+
490
+ ```python
491
+ from VirtualDesktop import supported_formats
492
+ print(supported_formats()) # {'.mp4': 'ffmpeg', '.gif': 'pillow', ...}
493
+ ```
494
+
495
+ > 内容完全相同的连续帧会被 GIF 优化合并成一个条目(延迟累加),所以 GIF 的**帧数可能少于
496
+ > 录制帧数**,但总时长不变。`report().frames` 报的是实际**采集**帧数。
497
+
498
+ ### 流式输出
499
+
500
+ 需要边录边处理(送编码器、推到 socket、实时 OCR)时用 `record_stream()`:文件照常写,
501
+ 同时逐帧交给你:
502
+
503
+ ```python
504
+ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
505
+ for frame in stream:
506
+ ... # frame.image 是刚采集到的 PIL 图像
507
+ ```
508
+
509
+ 它是个**有界**队列(默认最多缓存 240 帧):消费端跟不上时丢最旧的帧,不会无限吃内存。
510
+ 录制到时限后迭代会**自动结束**,不会挂起。
511
+
512
+ ### 参数
513
+
514
+ `record_desktop()` / `record_window()` 都接受:
515
+
516
+ | 参数 | 含义 |
517
+ |---|---|
518
+ | `fps` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
519
+ | `duration` | 秒;到点自动停止,`stopped_by="duration"` |
520
+ | `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
521
+ | `quality` | 见上方格式表;不同后端含义不同 |
522
+ | `format` | 覆盖扩展名推断 |
523
+ | `on_frame` | `(frame, index)` 回调,每帧一次;回调抛异常不会中断录制 |
524
+ | `ffmpeg` | 指定 ffmpeg 可执行文件路径 |
525
+
526
+ `report()` 返回 `RecordingReport`,含 `frames` / `fps` / `duration` / `size` / `bytes`
527
+ / `dropped` / `stopped_by` / `error`。**在 `with` 块内调用也是安全的**——它会先收尾再统计,
528
+ 所以报的大小是成品文件的大小。
529
+
425
530
  ## 能力边界
426
531
 
427
532
  | 想做的事 | 虚拟显示器 | offscreen |
@@ -432,6 +537,8 @@ print(current_primary_size()) # (1920, 1080)
432
537
  | 送入字符(窗口消息) | 能 | 能 |
433
538
  | **程序窗口截图** | **能** | **能** |
434
539
  | **桌面截图** | **能**(拍到窗口) | **不能**(窗口在所有显示器之外) |
540
+ | **窗口录制** | **能** | **能** |
541
+ | **桌面录制** | **能**(录工作区自己的虚拟屏) | **不能**(没有属于自己的屏幕区域) |
435
542
  | 外部 pywinauto / UIA 直接连上 | 能 | 能 |
436
543
  | 移动真实鼠标来点击 | 能 | **不能**(窗口在屏幕之外) |
437
544
  | 滚轮 | 能 | **不能** |
@@ -452,6 +559,8 @@ print(current_primary_size()) # (1920, 1080)
452
559
  | 完整 UIA 树 | **支持** | 外部 UIA,或 `--ui-tree DIR` |
453
560
  | 程序窗口截图 | **支持** | `ws.capture(hwnd, save_to=...)` / `CaptureEngine.capture(hwnd)` |
454
561
  | 桌面截图 | **支持**(`virtual_screen`) | `ws.capture_desktop()` / `capture_monitor()` / `capture_all_screens()` |
562
+ | **窗口录制** | **支持** | `ws.record_window(hwnd, "out.mp4", fps=15, duration=10)` |
563
+ | **桌面录制** | **支持**(`virtual_screen`) | `ws.record_desktop("out.mp4", fps=15)` |
455
564
  | 真实鼠标 / 滚轮 / 键盘 | **支持**(`virtual_screen`) | pywinauto `click_input()` / `type_keys()`,或 pyautogui |
456
565
  | 在显示器之间移动窗口 | **支持** | `move_to("\\\\.\\DISPLAY2")` / `move_all_to(...)` / `move_to_primary()` |
457
566
  | 退出时还原窗口 | **支持** | `with` 退出自动还原;异常退出同样还原 |
@@ -499,7 +608,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
499
608
  | `DriverNotFoundError` | 未装虚拟显示器且不允许安装 | 去掉限制,或先跑 `VirtualDesktop install-driver` |
500
609
  | `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
501
610
  | `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
502
- | `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
611
+ | `capture-blank-window` | 窗口被 `SW_HIDE` 隐藏,不绘制,只能拍到纯色块 | 让它处于显示状态;本库启动程序时会自动在目标位置恢复显示 |
503
612
  | `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
504
613
  | 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
505
614
  | 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
@@ -507,7 +616,9 @@ VIRTUALDESKTOP_LOG_FILE=<path>
507
616
  | 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 `bitblt` 试试,或确认窗口有内容 |
508
617
  | 桌面截图少了右下角 | 进程不感知 DPI,矩形按逻辑坐标算 | 先调 `CaptureEngine().ensure_dpi_aware()` |
509
618
  | 桌面截图拍不到工作区窗口 | 用了 `offscreen`,窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
510
- | `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`,或用 `virtual_screen` |
619
+ | `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
620
+ | 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
621
+ | 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
511
622
  | 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
512
623
 
513
624
  **离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
@@ -516,9 +627,13 @@ VIRTUALDESKTOP_LOG_FILE=<path>
516
627
 
517
628
  ## 已知限制
518
629
 
519
- - **私有桌面不能截图**:程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读;
520
- 而该桌面的屏幕 DC 也拿不到(`BitBlt` 返回拒绝访问)。用 `agent.info()` / `agent.text()` 读状态,
521
- 需要像素请改用虚拟显示器。
630
+ - **私有桌面不能做桌面截图**:拍整块屏幕不行,因为该桌面没有被合成到任何输出上,屏幕 DC 也
631
+ 拿不到。**但程序窗口截图可以**——`PrintWindow` 让窗口自己绘制到调用方给的 DC,不需要屏幕,
632
+ 所以私有桌面上的窗口截图与共享模式逐字节一致。
633
+ - **窗口必须是显示状态才可截图**:被 `SW_HIDE` 隐藏的窗口会返回纯色块(它压根不绘制)。
634
+ 本库启动程序时按此处理,由放置流程负责在正确位置恢复显示。
635
+ - **共享模式下程序启动瞬间可能极短暂可见**:程序自己显示窗口,库随即隐藏并移走它。这个间隙在
636
+ 私有桌面上不存在(窗口创建在另一块桌面上,从不经过你的屏幕);需要完全杜绝请用私有桌面。
522
637
  - **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**:那里没有活动指针和键盘焦点;消息驱动可用。
523
638
  - **窗口无法在私有桌面之间搬运**:Windows 不允许跨桌面移动窗口。
524
639
  - 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
@@ -62,8 +62,17 @@ from .window import WindowMounter, WindowRecord, find_window, window_diagnostics
62
62
  from .workspace import SHARED, VirtualWorkspace, WorkspaceWindow
63
63
  from .agent import AgentProcess, AgentSession
64
64
  from .display_setup import configure_virtual_display, current_primary_dpi, current_primary_size
65
+ from .record import (
66
+ GIF_FORMATS,
67
+ IMAGE_FORMATS,
68
+ VIDEO_FORMATS,
69
+ Recorder,
70
+ RecordingReport,
71
+ record_stream,
72
+ supported_formats,
73
+ )
65
74
 
66
- __version__ = "1.4.1"
75
+ __version__ = "1.6.0"
67
76
 
68
77
  __all__ = [
69
78
  "__version__",
@@ -106,6 +115,14 @@ __all__ = [
106
115
  "Frame",
107
116
  "analyze_image",
108
117
  "DEFAULT_METHODS",
118
+ # recording
119
+ "Recorder",
120
+ "RecordingReport",
121
+ "record_stream",
122
+ "supported_formats",
123
+ "VIDEO_FORMATS",
124
+ "GIF_FORMATS",
125
+ "IMAGE_FORMATS",
109
126
  # configuration
110
127
  "DriverConfig",
111
128
  "SessionConfig",