VirtualDesktop 1.6.2__tar.gz → 1.6.4__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.
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/PKG-INFO +3 -3
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/README.md +2 -2
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/__init__.py +1 -1
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/record.py +40 -3
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/PKG-INFO +3 -3
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/pyproject.toml +1 -1
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_record.py +55 -6
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/LICENSE +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/__main__.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_bundle.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_compat.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_desktop_runtime.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_devices.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_download.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_enumerate.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_helper.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_log.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_process.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_sigcheck.ps1 +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_sigcheck.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_winapi.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/agent.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/capture.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/cli.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/config.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/desktop.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/desktop_capture.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/desktop_session.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/display_setup.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/driver.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/driver_manifest.json +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/errors.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/manifest.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/session.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/window.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/workspace.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/SOURCES.txt +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/entry_points.txt +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/requires.txt +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/top_level.txt +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/setup.cfg +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_accelerators.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_agent.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_artifact_regression.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_capture.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_cli.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_compat.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_config_and_winapi.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_desktop_isolation.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_destructive_lifecycle.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_display_setup.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_download.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_driver.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_hide_modes.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_integration_windows.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_manifest.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_no_dialogs.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_packaging.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_process.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_virtual_desktop.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_workspace.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_workspace_instances.py +0 -0
- {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_workspace_move.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: VirtualDesktop
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.4
|
|
4
4
|
Summary: win10-win11 虚拟桌面
|
|
5
5
|
Author: VirtualDesktop contributors
|
|
6
6
|
License: MIT License
|
|
@@ -572,7 +572,7 @@ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
|
|
|
572
572
|
|
|
573
573
|
| 参数 | 含义 |
|
|
574
574
|
|---|---|
|
|
575
|
-
| `fps` |
|
|
575
|
+
| `fps` | 目标帧率(默认 10)。**范围 1–90**,超出会被钳制:`report().fps` 报实际使用的值,`report().fps_requested` 只在被钳制时给出原值,`report().fps_clamped` 为布尔。低于目标会被丢弃并计入 `report().dropped`;`report().measured_fps` 是达成帧率(跟不上目标时低于 `fps`) |
|
|
576
576
|
| `duration` | 秒;到点自动停止,`stopped_by="duration"` |
|
|
577
577
|
| `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
|
|
578
578
|
| — | 不传 `duration` 时有一个 **1 小时硬上限**(`MAX_DURATION`):录制线程持有 `Recorder` 引用,被丢弃且未 `stop()` 的对象无法回收,没有上限就会一直录下去(并带着 ffmpeg 子进程)。正常用法请始终用 `with` |
|
|
@@ -677,7 +677,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
677
677
|
| `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
|
|
678
678
|
| 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
|
|
679
679
|
| 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
|
|
680
|
-
|
|
|
680
|
+
| 桌面截图/录制**所有**显示器都报 `desktop-capture-failed`,`BitBlt` 与 `GetPixel` 一起失效 | 桌面设备上下文的像素读取能力整体失效(显示栈状态异常,不是本库也不是驱动残留) | **注销重登或重启系统**即可恢复;窗口截图(`PrintWindow`)不受影响,可继续用 |
|
|
681
681
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
682
682
|
|
|
683
683
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
@@ -515,7 +515,7 @@ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
|
|
|
515
515
|
|
|
516
516
|
| 参数 | 含义 |
|
|
517
517
|
|---|---|
|
|
518
|
-
| `fps` |
|
|
518
|
+
| `fps` | 目标帧率(默认 10)。**范围 1–90**,超出会被钳制:`report().fps` 报实际使用的值,`report().fps_requested` 只在被钳制时给出原值,`report().fps_clamped` 为布尔。低于目标会被丢弃并计入 `report().dropped`;`report().measured_fps` 是达成帧率(跟不上目标时低于 `fps`) |
|
|
519
519
|
| `duration` | 秒;到点自动停止,`stopped_by="duration"` |
|
|
520
520
|
| `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
|
|
521
521
|
| — | 不传 `duration` 时有一个 **1 小时硬上限**(`MAX_DURATION`):录制线程持有 `Recorder` 引用,被丢弃且未 `stop()` 的对象无法回收,没有上限就会一直录下去(并带着 ffmpeg 子进程)。正常用法请始终用 `with` |
|
|
@@ -620,7 +620,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
620
620
|
| `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
|
|
621
621
|
| 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
|
|
622
622
|
| 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
|
|
623
|
-
|
|
|
623
|
+
| 桌面截图/录制**所有**显示器都报 `desktop-capture-failed`,`BitBlt` 与 `GetPixel` 一起失效 | 桌面设备上下文的像素读取能力整体失效(显示栈状态异常,不是本库也不是驱动残留) | **注销重登或重启系统**即可恢复;窗口截图(`PrintWindow`)不受影响,可继续用 |
|
|
624
624
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
625
625
|
|
|
626
626
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
@@ -60,6 +60,8 @@ __all__ = [
|
|
|
60
60
|
"GIF_FORMATS",
|
|
61
61
|
"IMAGE_FORMATS",
|
|
62
62
|
"MAX_DURATION",
|
|
63
|
+
"MAX_FPS",
|
|
64
|
+
"MIN_FPS",
|
|
63
65
|
"Recorder",
|
|
64
66
|
"RecordingReport",
|
|
65
67
|
"VIDEO_FORMATS",
|
|
@@ -86,6 +88,12 @@ _PIPE_PIXEL_FORMAT = "rgb24"
|
|
|
86
88
|
#: explicit larger ``duration``.
|
|
87
89
|
MAX_DURATION = 3600.0
|
|
88
90
|
|
|
91
|
+
#: Frame-rate range a recording is held to. ``fps`` outside it is clamped (and reported), because
|
|
92
|
+
#: the alternative is either a refusal over a cosmetic parameter or a file whose real rate differs
|
|
93
|
+
#: from the one the caller asked for without saying so.
|
|
94
|
+
MAX_FPS = 90.0
|
|
95
|
+
MIN_FPS = 1.0
|
|
96
|
+
|
|
89
97
|
|
|
90
98
|
def supported_formats(ffmpeg: Optional[str] = None) -> Dict[str, str]:
|
|
91
99
|
"""Which extensions this machine can write, and what writes them."""
|
|
@@ -114,6 +122,7 @@ class RecordingReport:
|
|
|
114
122
|
path: str
|
|
115
123
|
format: str
|
|
116
124
|
frames: int
|
|
125
|
+
#: The frame rate actually used (``fps`` after clamping).
|
|
117
126
|
fps: float
|
|
118
127
|
duration: float
|
|
119
128
|
size: Optional[Tuple[int, int]] = None
|
|
@@ -122,11 +131,20 @@ class RecordingReport:
|
|
|
122
131
|
dropped: int = 0
|
|
123
132
|
error: str = ""
|
|
124
133
|
backend: str = ""
|
|
134
|
+
#: The ``fps`` the caller asked for, when it differed from the one actually used.
|
|
135
|
+
fps_requested: Optional[float] = None
|
|
136
|
+
#: Frames per second actually achieved, which is below ``fps`` whenever frames were dropped.
|
|
137
|
+
measured_fps: float = 0.0
|
|
125
138
|
|
|
126
139
|
@property
|
|
127
140
|
def ok(self) -> bool:
|
|
128
141
|
return not self.error and self.frames > 0
|
|
129
142
|
|
|
143
|
+
@property
|
|
144
|
+
def fps_clamped(self) -> bool:
|
|
145
|
+
"""Whether ``fps`` was adjusted into ``[MIN_FPS, MAX_FPS]``."""
|
|
146
|
+
return self.fps_requested is not None and self.fps_requested != self.fps
|
|
147
|
+
|
|
130
148
|
def to_dict(self) -> dict:
|
|
131
149
|
return {
|
|
132
150
|
"target": self.target,
|
|
@@ -135,6 +153,9 @@ class RecordingReport:
|
|
|
135
153
|
"backend": self.backend,
|
|
136
154
|
"frames": self.frames,
|
|
137
155
|
"fps": round(self.fps, 3),
|
|
156
|
+
"measured_fps": round(self.measured_fps, 3),
|
|
157
|
+
"fps_requested": self.fps_requested,
|
|
158
|
+
"fps_clamped": self.fps_clamped,
|
|
138
159
|
"duration": round(self.duration, 3),
|
|
139
160
|
"size": list(self.size) if self.size else None,
|
|
140
161
|
"bytes": self.bytes_written,
|
|
@@ -146,9 +167,11 @@ class RecordingReport:
|
|
|
146
167
|
def describe(self) -> str:
|
|
147
168
|
if self.error:
|
|
148
169
|
return f"{self.target} -> {self.path}: {self.error}"
|
|
170
|
+
clamped = f" (asked {self.fps_requested:g})" if self.fps_clamped else ""
|
|
149
171
|
return (
|
|
150
172
|
f"{self.target} -> {self.path} ({self.format}, {self.frames} frames, "
|
|
151
|
-
f"{self.duration:.1f}s, {self.fps
|
|
173
|
+
f"{self.duration:.1f}s, {self.fps:g} fps{clamped}, "
|
|
174
|
+
f"{self.bytes_written / 1024:.0f} KB)"
|
|
152
175
|
)
|
|
153
176
|
|
|
154
177
|
|
|
@@ -325,7 +348,18 @@ class Recorder:
|
|
|
325
348
|
self.path = Path(path)
|
|
326
349
|
self.source = source
|
|
327
350
|
self.target = target
|
|
328
|
-
|
|
351
|
+
# Clamp into the supported range rather than refusing or silently producing something else.
|
|
352
|
+
# Below MIN_FPS a recording is a slideshow; above MAX_FPS the capture path is the bottleneck
|
|
353
|
+
# anyway (PrintWindow measured at ~23 fps on this class of machine), so a higher number would
|
|
354
|
+
# only make the recorder drop frames and misrepresent the result.
|
|
355
|
+
self.fps_requested = float(fps)
|
|
356
|
+
self.fps = min(max(float(fps), MIN_FPS), MAX_FPS)
|
|
357
|
+
self.fps_clamped = self.fps != self.fps_requested
|
|
358
|
+
if self.fps_clamped:
|
|
359
|
+
log.info(
|
|
360
|
+
"fps %.3f is outside [%s, %s]; recording at %.3f",
|
|
361
|
+
self.fps_requested, MIN_FPS, MAX_FPS, self.fps,
|
|
362
|
+
)
|
|
329
363
|
self.duration = duration
|
|
330
364
|
self.max_frames = max_frames
|
|
331
365
|
self.format = _resolve_format(self.path) if format is None else (
|
|
@@ -405,7 +439,8 @@ class Recorder:
|
|
|
405
439
|
path=str(self.path),
|
|
406
440
|
format=self.format,
|
|
407
441
|
frames=self._frames,
|
|
408
|
-
fps=self.
|
|
442
|
+
fps=self.fps,
|
|
443
|
+
measured_fps=self._frames / elapsed if elapsed > 0 else 0.0,
|
|
409
444
|
duration=elapsed,
|
|
410
445
|
size=self._size,
|
|
411
446
|
bytes_written=bytes_written,
|
|
@@ -413,6 +448,8 @@ class Recorder:
|
|
|
413
448
|
dropped=self._dropped,
|
|
414
449
|
error=error,
|
|
415
450
|
backend=self._backend,
|
|
451
|
+
# Only reported when it differed: an absent value means "you got what you asked for".
|
|
452
|
+
fps_requested=self.fps_requested if self.fps_clamped else None,
|
|
416
453
|
)
|
|
417
454
|
|
|
418
455
|
def __enter__(self) -> "Recorder":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: VirtualDesktop
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.4
|
|
4
4
|
Summary: win10-win11 虚拟桌面
|
|
5
5
|
Author: VirtualDesktop contributors
|
|
6
6
|
License: MIT License
|
|
@@ -572,7 +572,7 @@ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
|
|
|
572
572
|
|
|
573
573
|
| 参数 | 含义 |
|
|
574
574
|
|---|---|
|
|
575
|
-
| `fps` |
|
|
575
|
+
| `fps` | 目标帧率(默认 10)。**范围 1–90**,超出会被钳制:`report().fps` 报实际使用的值,`report().fps_requested` 只在被钳制时给出原值,`report().fps_clamped` 为布尔。低于目标会被丢弃并计入 `report().dropped`;`report().measured_fps` 是达成帧率(跟不上目标时低于 `fps`) |
|
|
576
576
|
| `duration` | 秒;到点自动停止,`stopped_by="duration"` |
|
|
577
577
|
| `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
|
|
578
578
|
| — | 不传 `duration` 时有一个 **1 小时硬上限**(`MAX_DURATION`):录制线程持有 `Recorder` 引用,被丢弃且未 `stop()` 的对象无法回收,没有上限就会一直录下去(并带着 ffmpeg 子进程)。正常用法请始终用 `with` |
|
|
@@ -677,7 +677,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
677
677
|
| `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
|
|
678
678
|
| 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
|
|
679
679
|
| 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
|
|
680
|
-
|
|
|
680
|
+
| 桌面截图/录制**所有**显示器都报 `desktop-capture-failed`,`BitBlt` 与 `GetPixel` 一起失效 | 桌面设备上下文的像素读取能力整体失效(显示栈状态异常,不是本库也不是驱动残留) | **注销重登或重启系统**即可恢复;窗口截图(`PrintWindow`)不受影响,可继续用 |
|
|
681
681
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
682
682
|
|
|
683
683
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
@@ -115,6 +115,37 @@ def test_fps_must_be_positive():
|
|
|
115
115
|
assert excinfo.value.code == "bad-fps"
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def test_fps_is_clamped_into_the_supported_range():
|
|
119
|
+
"""``fps`` outside ``[MIN_FPS, MAX_FPS]`` is clamped, and the request is still reported.
|
|
120
|
+
|
|
121
|
+
Clamping rather than refusing keeps a cosmetic parameter from failing a recording, and reporting
|
|
122
|
+
``fps_requested`` keeps it from silently producing a file at a rate the caller did not ask for.
|
|
123
|
+
"""
|
|
124
|
+
from VirtualDesktop.record import MAX_FPS, MIN_FPS
|
|
125
|
+
|
|
126
|
+
assert (MIN_FPS, MAX_FPS) == (1.0, 90.0)
|
|
127
|
+
|
|
128
|
+
cases = [
|
|
129
|
+
(0.5, MIN_FPS, True),
|
|
130
|
+
(MIN_FPS, MIN_FPS, False),
|
|
131
|
+
(30.0, 30.0, False),
|
|
132
|
+
(MAX_FPS, MAX_FPS, False),
|
|
133
|
+
(MAX_FPS * 2, MAX_FPS, True),
|
|
134
|
+
(1e6, MAX_FPS, True),
|
|
135
|
+
]
|
|
136
|
+
for requested, expected, clamped in cases:
|
|
137
|
+
recorder = Recorder("unused.gif", lambda: None, fps=requested)
|
|
138
|
+
assert recorder.fps == expected, (requested, recorder.fps)
|
|
139
|
+
assert recorder.fps_clamped is clamped, (requested, recorder.fps_clamped)
|
|
140
|
+
report = recorder.report()
|
|
141
|
+
assert report.fps == expected
|
|
142
|
+
assert report.fps_clamped is clamped
|
|
143
|
+
# The original request is reported only when it was overridden: an absent value means the
|
|
144
|
+
# caller got exactly what they asked for.
|
|
145
|
+
assert report.fps_requested == (float(requested) if clamped else None)
|
|
146
|
+
assert report.to_dict()["fps_clamped"] is clamped
|
|
147
|
+
|
|
148
|
+
|
|
118
149
|
# -- window recording -----------------------------------------------------------------
|
|
119
150
|
|
|
120
151
|
|
|
@@ -142,6 +173,13 @@ def test_recording_a_window_writes_a_playable_gif(tmp_path):
|
|
|
142
173
|
|
|
143
174
|
|
|
144
175
|
def test_recording_a_window_writes_an_h264_file_when_ffmpeg_exists(tmp_path):
|
|
176
|
+
"""The file must be a real H.264 MP4, finalised before the process exits.
|
|
177
|
+
|
|
178
|
+
The frame count is deliberately *not* pinned to a fraction of ``fps``: the first capture of a
|
|
179
|
+
just-launched window can take seconds on a loaded machine, and a short recording then legitimately
|
|
180
|
+
contains a single frame. What matters here is that the encoder ran, the container is valid, and
|
|
181
|
+
the pipe was closed by the recorder rather than by the interpreter exiting.
|
|
182
|
+
"""
|
|
145
183
|
if not shutil.which("ffmpeg"):
|
|
146
184
|
pytest.skip("ffmpeg is not on PATH")
|
|
147
185
|
target = tmp_path / "win.mp4"
|
|
@@ -153,7 +191,7 @@ def test_recording_a_window_writes_an_h264_file_when_ffmpeg_exists(tmp_path):
|
|
|
153
191
|
|
|
154
192
|
assert report.ok, report.describe()
|
|
155
193
|
assert report.backend == "ffmpeg"
|
|
156
|
-
assert report.frames >=
|
|
194
|
+
assert report.frames >= 1, report.to_dict()
|
|
157
195
|
# Finalised inside the block, so the file is complete before the process exits.
|
|
158
196
|
assert report.bytes_written > 0
|
|
159
197
|
head = target.read_bytes()[:16]
|
|
@@ -550,7 +588,13 @@ def test_desktop_recording_writes_the_workspaces_own_monitor(tmp_path):
|
|
|
550
588
|
assert report.ok, report.describe()
|
|
551
589
|
assert report.target == f"desktop:{monitor.device}"
|
|
552
590
|
# The recording is the monitor's rectangle, not a full-screen sweep.
|
|
553
|
-
assert report.size ==
|
|
591
|
+
assert tuple(report.size) == (monitor.width, monitor.height)
|
|
592
|
+
# And it contains real pixels: a desktop recording of a monitor with a window on it must not be
|
|
593
|
+
# a blank frame.
|
|
594
|
+
from PIL import Image
|
|
595
|
+
|
|
596
|
+
with Image.open(target) as image:
|
|
597
|
+
assert _variance(image) > 20.0, "the recorded desktop looks blank"
|
|
554
598
|
|
|
555
599
|
|
|
556
600
|
@desktop_recording
|
|
@@ -572,7 +616,12 @@ def test_desktop_recording_is_bounded_by_the_monitor_not_the_whole_desktop(tmp_p
|
|
|
572
616
|
from VirtualDesktop._winapi import virtual_screen_rect
|
|
573
617
|
|
|
574
618
|
whole = virtual_screen_rect()
|
|
575
|
-
assert report.size ==
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
619
|
+
assert tuple(report.size) == (monitor.width, monitor.height)
|
|
620
|
+
# The virtual desktop spans every monitor, so a recording of the workspace's own monitor must be
|
|
621
|
+
# narrower than it unless there is only one monitor attached.
|
|
622
|
+
whole_size = (whole[2] - whole[0], whole[3] - whole[1])
|
|
623
|
+
monitors = __import__("VirtualDesktop").enumerate_monitors()
|
|
624
|
+
if len(monitors) > 1:
|
|
625
|
+
assert tuple(report.size) != whole_size, (
|
|
626
|
+
f"recorded {report.size} which is the whole virtual desktop, not the monitor"
|
|
627
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|