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.
Files changed (64) hide show
  1. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/PKG-INFO +3 -3
  2. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/README.md +2 -2
  3. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/__init__.py +1 -1
  4. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/record.py +40 -3
  5. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/PKG-INFO +3 -3
  6. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/pyproject.toml +1 -1
  7. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_record.py +55 -6
  8. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/LICENSE +0 -0
  9. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/__main__.py +0 -0
  10. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_bundle.py +0 -0
  11. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_compat.py +0 -0
  12. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_desktop_runtime.py +0 -0
  13. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_devices.py +0 -0
  14. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_download.py +0 -0
  15. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_enumerate.py +0 -0
  16. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_helper.py +0 -0
  17. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_log.py +0 -0
  18. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_process.py +0 -0
  19. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_sigcheck.ps1 +0 -0
  20. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_sigcheck.py +0 -0
  21. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/_winapi.py +0 -0
  22. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/agent.py +0 -0
  23. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/capture.py +0 -0
  24. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/cli.py +0 -0
  25. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/config.py +0 -0
  26. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/desktop.py +0 -0
  27. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/desktop_capture.py +0 -0
  28. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/desktop_session.py +0 -0
  29. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/display_setup.py +0 -0
  30. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/driver.py +0 -0
  31. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/driver_manifest.json +0 -0
  32. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/errors.py +0 -0
  33. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/manifest.py +0 -0
  34. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/session.py +0 -0
  35. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/window.py +0 -0
  36. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop/workspace.py +0 -0
  37. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/SOURCES.txt +0 -0
  38. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
  39. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/entry_points.txt +0 -0
  40. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/requires.txt +0 -0
  41. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/VirtualDesktop.egg-info/top_level.txt +0 -0
  42. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/setup.cfg +0 -0
  43. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_accelerators.py +0 -0
  44. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_agent.py +0 -0
  45. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_artifact_regression.py +0 -0
  46. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_capture.py +0 -0
  47. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_cli.py +0 -0
  48. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_compat.py +0 -0
  49. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_config_and_winapi.py +0 -0
  50. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_desktop_isolation.py +0 -0
  51. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_destructive_lifecycle.py +0 -0
  52. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_display_setup.py +0 -0
  53. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_download.py +0 -0
  54. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_driver.py +0 -0
  55. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_hide_modes.py +0 -0
  56. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_integration_windows.py +0 -0
  57. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_manifest.py +0 -0
  58. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_no_dialogs.py +0 -0
  59. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_packaging.py +0 -0
  60. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_process.py +0 -0
  61. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_virtual_desktop.py +0 -0
  62. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_workspace.py +0 -0
  63. {virtualdesktop-1.6.2 → virtualdesktop-1.6.4}/tests/test_workspace_instances.py +0 -0
  64. {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.2
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` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
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
- | 桌面截图/录制报 `desktop-capture-failed`,且**物理屏和虚拟屏都失败** | 桌面设备上下文的**像素读取能力**整体失效(`BitBlt` 与 `GetPixel` 都返回 error 6 / 0xFFFFFFFF) | 这不是本库的问题也不是驱动残留:**注销重登或重启系统**即可恢复。窗口截图(`PrintWindow`)不受影响,可继续用 |
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` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
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
- | 桌面截图/录制报 `desktop-capture-failed`,且**物理屏和虚拟屏都失败** | 桌面设备上下文的**像素读取能力**整体失效(`BitBlt` 与 `GetPixel` 都返回 error 6 / 0xFFFFFFFF) | 这不是本库的问题也不是驱动残留:**注销重登或重启系统**即可恢复。窗口截图(`PrintWindow`)不受影响,可继续用 |
623
+ | 桌面截图/录制**所有**显示器都报 `desktop-capture-failed`,`BitBlt` 与 `GetPixel` 一起失效 | 桌面设备上下文的像素读取能力整体失效(显示栈状态异常,不是本库也不是驱动残留) | **注销重登或重启系统**即可恢复;窗口截图(`PrintWindow`)不受影响,可继续用 |
624
624
  | 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
625
625
 
626
626
  **离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
@@ -72,7 +72,7 @@ from .record import (
72
72
  supported_formats,
73
73
  )
74
74
 
75
- __version__ = "1.6.2"
75
+ __version__ = "1.6.4"
76
76
 
77
77
  __all__ = [
78
78
  "__version__",
@@ -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:.1f} fps, {self.bytes_written / 1024:.0f} KB)"
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
- self.fps = float(fps)
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._frames / elapsed if elapsed > 0 else 0.0,
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.2
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` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
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
- | 桌面截图/录制报 `desktop-capture-failed`,且**物理屏和虚拟屏都失败** | 桌面设备上下文的**像素读取能力**整体失效(`BitBlt` 与 `GetPixel` 都返回 error 6 / 0xFFFFFFFF) | 这不是本库的问题也不是驱动残留:**注销重登或重启系统**即可恢复。窗口截图(`PrintWindow`)不受影响,可继续用 |
680
+ | 桌面截图/录制**所有**显示器都报 `desktop-capture-failed`,`BitBlt` 与 `GetPixel` 一起失效 | 桌面设备上下文的像素读取能力整体失效(显示栈状态异常,不是本库也不是驱动残留) | **注销重登或重启系统**即可恢复;窗口截图(`PrintWindow`)不受影响,可继续用 |
681
681
  | 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
682
682
 
683
683
  **离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "VirtualDesktop"
7
- version = "1.6.2"
7
+ version = "1.6.4"
8
8
  description = "win10-win11 虚拟桌面"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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 >= 5, report.to_dict()
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 == [monitor.width, monitor.height]
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 == [monitor.width, monitor.height]
576
- assert report.size != [whole[2] - whole[0], whole[3] - whole[1]] or len(
577
- [m for m in __import__("VirtualDesktop").enumerate_monitors()]
578
- ) == 1
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