window-grabber 0.1.0__py3-none-win32.whl

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 (28) hide show
  1. window_grabber-0.1.0.data/purelib/window_capture/__init__.py +54 -0
  2. window_grabber-0.1.0.data/purelib/window_capture/__main__.py +120 -0
  3. window_grabber-0.1.0.data/purelib/window_capture/_api.py +359 -0
  4. window_grabber-0.1.0.data/purelib/window_capture/_comparison.py +117 -0
  5. window_grabber-0.1.0.data/purelib/window_capture/_deadline.py +25 -0
  6. window_grabber-0.1.0.data/purelib/window_capture/_discovery.py +83 -0
  7. window_grabber-0.1.0.data/purelib/window_capture/_modes.py +67 -0
  8. window_grabber-0.1.0.data/purelib/window_capture/_pool.py +67 -0
  9. window_grabber-0.1.0.data/purelib/window_capture/_protocol.py +109 -0
  10. window_grabber-0.1.0.data/purelib/window_capture/_response.py +61 -0
  11. window_grabber-0.1.0.data/purelib/window_capture/_transport.py +286 -0
  12. window_grabber-0.1.0.data/purelib/window_capture/bin/manifest.json +21 -0
  13. window_grabber-0.1.0.data/purelib/window_capture/bin/window-capture-hook-x86.dll +0 -0
  14. window_grabber-0.1.0.data/purelib/window_capture/bin/window-capture-legacy.exe +0 -0
  15. window_grabber-0.1.0.data/purelib/window_capture/bin/window-capture-native.exe +0 -0
  16. window_grabber-0.1.0.data/purelib/window_capture/events.py +63 -0
  17. window_grabber-0.1.0.data/purelib/window_capture/licenses/minhook.txt +81 -0
  18. window_grabber-0.1.0.data/purelib/window_capture/monitor.py +301 -0
  19. window_grabber-0.1.0.data/purelib/window_capture/options.py +394 -0
  20. window_grabber-0.1.0.data/purelib/window_capture/presentation.py +150 -0
  21. window_grabber-0.1.0.data/purelib/window_capture/py.typed +0 -0
  22. window_grabber-0.1.0.data/purelib/window_capture/recording.py +117 -0
  23. window_grabber-0.1.0.data/purelib/window_capture/results.py +186 -0
  24. window_grabber-0.1.0.dist-info/METADATA +401 -0
  25. window_grabber-0.1.0.dist-info/RECORD +28 -0
  26. window_grabber-0.1.0.dist-info/WHEEL +5 -0
  27. window_grabber-0.1.0.dist-info/entry_points.txt +2 -0
  28. window_grabber-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,54 @@
1
+ """Window capture with a typed Python API and isolated, precompiled Rust workers."""
2
+
3
+ from ._comparison import ChangeOptions
4
+ from ._modes import recommended_capture_mode
5
+ from .monitor import ProcessMonitor, WindowEvent
6
+ from .recording import DirectoryRecorder, record_process
7
+ from .events import CaptureEvent, CaptureEvents
8
+ from ._api import CaptureSession, capture, find_windows, probe
9
+ from .options import (
10
+ CaptureOptions,
11
+ ContentOptions,
12
+ CoordinatorOptions,
13
+ DesktopOptions,
14
+ DiagnosticOptions,
15
+ ImageOptions,
16
+ PreparedSource,
17
+ Match,
18
+ Target,
19
+ ValidationOptions,
20
+ WorkerOptions,
21
+ )
22
+ from .results import CapabilityReport, CapturedImage, CaptureError, CaptureResult, CloseReport
23
+
24
+ __version__ = "0.1.0"
25
+ __all__ = [
26
+ "recommended_capture_mode",
27
+ "DirectoryRecorder",
28
+ "record_process",
29
+ "ChangeOptions",
30
+ "ProcessMonitor",
31
+ "WindowEvent",
32
+ "CaptureEvent",
33
+ "CaptureEvents",
34
+ "capture",
35
+ "find_windows",
36
+ "probe",
37
+ "CaptureSession",
38
+ "Target",
39
+ "Match",
40
+ "PreparedSource",
41
+ "CaptureOptions",
42
+ "ContentOptions",
43
+ "DesktopOptions",
44
+ "DiagnosticOptions",
45
+ "ImageOptions",
46
+ "ValidationOptions",
47
+ "WorkerOptions",
48
+ "CoordinatorOptions",
49
+ "CapturedImage",
50
+ "CaptureResult",
51
+ "CaptureError",
52
+ "CapabilityReport",
53
+ "CloseReport",
54
+ ]
@@ -0,0 +1,120 @@
1
+ import argparse
2
+ import json
3
+
4
+ from . import CaptureOptions, CaptureSession, DesktopOptions, Target, capture, find_windows, probe
5
+ from .results import thaw
6
+ from .recording import DirectoryRecorder, record_process
7
+ from .monitor import ProcessMonitor
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description="Capture a window using the bundled Rust helper")
12
+ parser.add_argument("--monitor", action="store_true")
13
+ parser.add_argument("--interval-seconds", type=float, default=5.0)
14
+ parser.add_argument("--recording-max-bytes", type=int, default=1024 * 1024 * 1024)
15
+ parser.add_argument("--probe", action="store_true")
16
+ parser.add_argument("--find", action="store_true")
17
+ parser.add_argument("--hwnd", type=lambda x: int(x, 0))
18
+ parser.add_argument("--x11-window-id", type=lambda x: int(x, 0))
19
+ parser.add_argument(
20
+ "--select-window", action="store_true", help="Select a Wayland window interactively"
21
+ )
22
+ parser.add_argument("--pid", type=int)
23
+ parser.add_argument(
24
+ "--process-name", action="append", help="Repeat to monitor multiple executables"
25
+ )
26
+ parser.add_argument("--process-path")
27
+ parser.add_argument("--window-class")
28
+ parser.add_argument("--window-title")
29
+ parser.add_argument("--mode", choices=("isolated", "controlled_desktop"))
30
+ parser.add_argument("--allow-resize", action="store_true", default=None)
31
+ parser.add_argument("--desktop", action="store_true", help="Permit temporary desktop changes")
32
+ parser.add_argument("--separate-owned-popups", action="store_true")
33
+ parser.add_argument(
34
+ "--resize-to-fit", action="store_true", help="Permit temporary layout changes"
35
+ )
36
+ parser.add_argument("--timeout-ms", type=int, default=2000)
37
+ parser.add_argument("--output", default="capture.png")
38
+ args = parser.parse_args()
39
+ if sum((args.monitor, args.probe, args.find, args.select_window)) > 1:
40
+ parser.error("Choose only one operation")
41
+ if args.mode and (args.desktop or args.separate_owned_popups or args.resize_to_fit):
42
+ parser.error("--mode replaces the advanced desktop flags; use --allow-resize")
43
+ if args.allow_resize and args.mode != "controlled_desktop":
44
+ parser.error("--allow-resize requires --mode controlled_desktop")
45
+ if args.process_name and len(args.process_name) > 1 and not args.monitor:
46
+ parser.error("Repeated --process-name requires --monitor")
47
+ if args.probe:
48
+ report = probe(timeout_ms=args.timeout_ms)
49
+ print(json.dumps(thaw(report.__dict__), indent=2))
50
+ return 0
51
+ options = CaptureOptions(
52
+ timeout_ms=args.timeout_ms,
53
+ desktop=DesktopOptions(
54
+ enabled=args.desktop,
55
+ separate_owned_popups=args.separate_owned_popups,
56
+ resize_to_fit=args.resize_to_fit,
57
+ ),
58
+ )
59
+ if args.select_window:
60
+ with CaptureSession(
61
+ options=options, mode=args.mode, allow_resize=args.allow_resize
62
+ ) as session:
63
+ source = session.select_window()
64
+ result = session.capture(Target(portal_source=source))
65
+ print(json.dumps(result.metadata(), indent=2))
66
+ if result.accepted:
67
+ result.require_image().save(args.output)
68
+ return 0 if result.accepted else 1
69
+ targets = [
70
+ Target(
71
+ hwnd=args.hwnd,
72
+ x11_window_id=args.x11_window_id,
73
+ pid=args.pid,
74
+ process_name=name,
75
+ process_path=args.process_path,
76
+ window_class=args.window_class,
77
+ window_title=args.window_title,
78
+ )
79
+ for name in (args.process_name or [None])
80
+ ]
81
+ target = targets[0]
82
+ if args.monitor:
83
+ if args.find or args.hwnd or args.x11_window_id:
84
+ parser.error("--monitor requires a process selector")
85
+ if args.output == "capture.png":
86
+ parser.error("--monitor requires --output DIRECTORY")
87
+ try:
88
+ with ProcessMonitor(
89
+ targets,
90
+ options=options,
91
+ allow_desktop_capture=args.desktop,
92
+ mode=args.mode,
93
+ allow_resize=args.allow_resize,
94
+ ) as monitor:
95
+ record_process(
96
+ monitor,
97
+ DirectoryRecorder(args.output, max_bytes=args.recording_max_bytes),
98
+ interval_seconds=args.interval_seconds,
99
+ discovery_timeout_ms=args.timeout_ms,
100
+ )
101
+ except KeyboardInterrupt:
102
+ return 0
103
+ return 0
104
+ if args.find:
105
+ print(json.dumps(thaw(find_windows(target, timeout_ms=args.timeout_ms)), indent=2))
106
+ return 0
107
+ result = capture(
108
+ target,
109
+ options=options,
110
+ mode=args.mode,
111
+ allow_resize=args.allow_resize,
112
+ )
113
+ print(json.dumps(result.metadata(), indent=2))
114
+ if result.accepted:
115
+ result.require_image().save(args.output)
116
+ return 0 if result.accepted else 1
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
@@ -0,0 +1,359 @@
1
+ """Synchronous Python facade over the precompiled Rust coordinator protocol."""
2
+
3
+ import os
4
+ import queue
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from typing import Any, Mapping, Optional, Tuple
9
+
10
+ from ._response import capture_result
11
+ from ._discovery import identity as window_identity
12
+ from ._modes import options_for_mode
13
+ from .events import CaptureEvents
14
+ from ._pool import NativePool
15
+ from . import _transport
16
+ from ._deadline import Failure
17
+ from .options import (
18
+ BACKENDS,
19
+ CaptureOptions,
20
+ CoordinatorOptions,
21
+ PreparedSource,
22
+ Target,
23
+ WorkerOptions,
24
+ integer,
25
+ to_wire,
26
+ )
27
+ from .results import (
28
+ CapabilityReport,
29
+ CaptureError,
30
+ CaptureResult,
31
+ CloseReport,
32
+ freeze,
33
+ validate_metadata,
34
+ validate_capabilities,
35
+ )
36
+
37
+
38
+ class CaptureSession:
39
+ def __init__(
40
+ self,
41
+ *,
42
+ options: Optional[CaptureOptions] = None,
43
+ workers: Optional[WorkerOptions] = None,
44
+ coordinator: Optional[CoordinatorOptions] = None,
45
+ event_capacity: int = 128,
46
+ mode: Optional[str] = None,
47
+ allow_resize: Optional[bool] = None,
48
+ ) -> None:
49
+ self._pid = os.getpid()
50
+ self.options = options_for_mode(options, mode, allow_resize)
51
+ self._mode = mode
52
+ self._allow_resize = allow_resize
53
+ self.workers = WorkerOptions() if workers is None else workers
54
+ if not isinstance(self.options, CaptureOptions) or not isinstance(
55
+ self.workers, WorkerOptions
56
+ ):
57
+ raise ValueError("options and workers require complete typed option objects")
58
+ if coordinator is not None and not isinstance(coordinator, CoordinatorOptions):
59
+ raise ValueError("coordinator requires CoordinatorOptions")
60
+ self.coordinator = coordinator
61
+ self.events = CaptureEvents(event_capacity)
62
+ # Slots are released by the I/O worker, not by a timed-out caller.
63
+ self._pool = NativePool(self.workers.idle_timeout_ms)
64
+ self._slots = threading.BoundedSemaphore(self.workers.max_workers)
65
+ self._lock = threading.Lock()
66
+ self._drained = threading.Condition(self._lock)
67
+ self._tickets = set()
68
+ self._closed = False
69
+ self._recovery = "not_needed"
70
+
71
+ def _check_process(self):
72
+ if self._pid != os.getpid():
73
+ raise RuntimeError(
74
+ "Create a new CaptureSession after fork; inherited sessions cannot be used"
75
+ )
76
+
77
+ def __enter__(self):
78
+ self._check_process()
79
+ return self
80
+
81
+ def __exit__(self, *args):
82
+ self.close(timeout_ms=0)
83
+
84
+ def _request(self, operation, start, timeout_ms, acquire_ms=None, *, _portal=False, **values):
85
+ self._check_process()
86
+ integer("timeout_ms", timeout_ms, 1, 86400000)
87
+ end = start + timeout_ms / 1000.0
88
+ with self._lock:
89
+ if self._closed:
90
+ raise Failure("CANCELLED", "Capture session is closed", "CANCELLED")
91
+ if not self._slots.acquire(blocking=False):
92
+ raise Failure("RESOURCE_LIMIT", "Session worker limit reached")
93
+ ticket = None
94
+ released = False
95
+
96
+ def done():
97
+ nonlocal released
98
+ with self._lock:
99
+ if released:
100
+ return
101
+ released = True
102
+ self._tickets.discard(ticket)
103
+ self._slots.release()
104
+ self._drained.notify_all()
105
+
106
+ try:
107
+ portal = (
108
+ _portal
109
+ or operation == "select"
110
+ or values.get("target", {}).get("portal_source") is not None
111
+ )
112
+ command = _transport.portal_command() if portal else _transport.installed_command()
113
+ request = dict(
114
+ values,
115
+ protocol_version=1,
116
+ operation=operation,
117
+ workers=to_wire(self.workers),
118
+ coordinator=to_wire(self.coordinator),
119
+ )
120
+
121
+ ticket = _transport.Ticket(
122
+ command,
123
+ request,
124
+ end,
125
+ start + (acquire_ms if acquire_ms is not None else timeout_ms) / 1000.0,
126
+ self.workers.max_buffer_bytes,
127
+ values.get("options", {}).get("image", {}).get("max_pixels", 40000000),
128
+ done,
129
+ self._pool,
130
+ )
131
+ with self._lock:
132
+ if self._closed:
133
+ ticket.cancelled.set()
134
+ self._tickets.add(ticket)
135
+ _transport.broker().submit(ticket)
136
+ except BaseException as exc:
137
+ done()
138
+ if isinstance(exc, (RuntimeError, OSError)):
139
+ raise Failure("WORKER_START_FAILED", "Native broker could not start") from None
140
+ raise
141
+ try:
142
+ reply = ticket.answer.get(timeout=max(0, end - time.monotonic()))
143
+ except queue.Empty:
144
+ ticket.cancelled.set()
145
+ raise Failure("DEADLINE_EXCEEDED") from None
146
+ except BaseException:
147
+ ticket.cancelled.set()
148
+ raise
149
+ if isinstance(reply, Failure):
150
+ raise reply
151
+ if time.monotonic() >= end:
152
+ raise Failure("DEADLINE_EXCEEDED")
153
+ meta, payload = reply
154
+ if meta.get("protocol_version") != 1 or meta.get("request_id") != values.get("request_id"):
155
+ raise Failure("INVALID_BUFFER", "Native response identity/version mismatch")
156
+ if meta.get("status") == "FAILED":
157
+ try:
158
+ validate_metadata(meta)
159
+ except ValueError:
160
+ raise Failure("INVALID_BUFFER", "Malformed native failure response") from None
161
+ if operation in ("find", "probe") and payload:
162
+ raise Failure("INVALID_BUFFER", "Unexpected discovery or capability payload")
163
+ return meta, payload
164
+
165
+ def capture(self, target: Target, *, options: Optional[CaptureOptions] = None) -> CaptureResult:
166
+ self._check_process()
167
+ start = time.monotonic()
168
+ if not isinstance(target, Target):
169
+ raise ValueError("target requires Target")
170
+ effective = (
171
+ self.options
172
+ if options is None
173
+ else options_for_mode(options, self._mode, self._allow_resize)
174
+ )
175
+ if not isinstance(effective, CaptureOptions):
176
+ raise ValueError("options requires a complete CaptureOptions")
177
+ request_id = uuid.uuid4().hex
178
+ try:
179
+ meta, payload = self._request(
180
+ "capture",
181
+ start,
182
+ effective.timeout_ms,
183
+ effective.effective_acquire_ms,
184
+ target=to_wire(target),
185
+ options=to_wire(effective),
186
+ request_id=request_id,
187
+ )
188
+ result = capture_result(meta, payload, effective)
189
+ if time.monotonic() >= start + effective.timeout_ms / 1000.0:
190
+ raise Failure("DEADLINE_EXCEEDED")
191
+ except (Failure, ValueError, TypeError, KeyError) as exc:
192
+ failure = (
193
+ exc
194
+ if isinstance(exc, Failure)
195
+ else Failure("INVALID_BUFFER", "Malformed capture response")
196
+ )
197
+ uncertain = effective.desktop.enabled and failure.reason in (
198
+ "DEADLINE_EXCEEDED",
199
+ "WORKER_EXITED",
200
+ "INVALID_BUFFER",
201
+ )
202
+ result = CaptureResult(
203
+ request_id,
204
+ failure.status,
205
+ failure.reason,
206
+ failure.message,
207
+ recovery="pending" if uncertain else "not_needed",
208
+ )
209
+ from dataclasses import replace
210
+
211
+ timing = dict(result.timing, total_ms=(time.monotonic() - start) * 1000)
212
+ result = replace(result, timing=timing)
213
+ if result.recovery in ("pending", "failed"):
214
+ with self._lock:
215
+ self._recovery = result.recovery
216
+ self.events._record(result)
217
+ if not result.accepted and effective.on_failure == "raise":
218
+ raise CaptureError(result)
219
+ return result
220
+
221
+ def find_windows(
222
+ self, target: Target, *, timeout_ms: int = 2000
223
+ ) -> Tuple[Mapping[str, Any], ...]:
224
+ integer("timeout_ms", timeout_ms)
225
+ if not isinstance(target, Target):
226
+ raise ValueError("target requires Target")
227
+ request_id = uuid.uuid4().hex
228
+ try:
229
+ meta, _ = self._request(
230
+ "find", time.monotonic(), timeout_ms, target=to_wire(target), request_id=request_id
231
+ )
232
+ if meta.get("status") == "FAILED":
233
+ raise Failure(meta["reason"], meta.get("message", ""))
234
+ windows = meta["windows"]
235
+ if not isinstance(windows, (list, tuple)) or any(
236
+ not isinstance(window, Mapping) for window in windows
237
+ ):
238
+ raise ValueError("Invalid window list")
239
+ for window in windows:
240
+ window_identity(window)
241
+ return tuple(freeze(window) for window in windows)
242
+ except (Failure, KeyError, TypeError, ValueError) as exc:
243
+ if not isinstance(exc, Failure):
244
+ exc = Failure("INVALID_BUFFER", "Malformed window discovery response")
245
+ raise CaptureError(
246
+ CaptureResult(request_id, "FAILED", exc.reason, exc.message)
247
+ ) from None
248
+
249
+ def prepare(self, *, timeout_ms: int = 5000) -> CapabilityReport:
250
+ return self._probe(timeout_ms)
251
+
252
+ def _probe(self, timeout_ms):
253
+ integer("timeout_ms", timeout_ms)
254
+ start = time.monotonic()
255
+ try:
256
+ meta, _ = self._request("probe", start, timeout_ms, request_id=uuid.uuid4().hex)
257
+ if meta.get("status") == "FAILED":
258
+ raise Failure(meta["reason"], meta.get("message", ""))
259
+ validate_capabilities(meta["backends"], meta.get("environment", {}))
260
+ # Optional Linux libraries live in a separate executable. Probe it
261
+ # under the original deadline and never open a permission dialog.
262
+ try:
263
+ _transport.portal_command()
264
+ except Failure:
265
+ pass
266
+ else:
267
+ try:
268
+ portal, _ = self._request(
269
+ "probe", start, timeout_ms, _portal=True, request_id=uuid.uuid4().hex
270
+ )
271
+ descriptor = portal["backends"]["wayland_portal"]
272
+ validate_capabilities({"wayland_portal": descriptor}, {})
273
+ meta["backends"]["wayland_portal"] = descriptor
274
+ except (Failure, KeyError, TypeError, ValueError) as exc:
275
+ meta["backends"]["wayland_portal"] = {
276
+ "available": False,
277
+ "reason": exc.reason if isinstance(exc, Failure) else "INVALID_BUFFER",
278
+ }
279
+ return CapabilityReport(meta["backends"], meta.get("environment", {}))
280
+ except (Failure, KeyError, TypeError, ValueError) as exc:
281
+ if not isinstance(exc, Failure):
282
+ exc = Failure("INVALID_BUFFER", "Malformed capability response")
283
+ return CapabilityReport(
284
+ {b: {"available": False, "reason": exc.reason} for b in BACKENDS},
285
+ {"reason": exc.reason, "message": exc.message},
286
+ )
287
+
288
+ def select_window(self, *, timeout_ms: Optional[int] = None) -> PreparedSource:
289
+ if timeout_ms is not None:
290
+ integer("timeout_ms", timeout_ms, 1, 86400000)
291
+ request_id = uuid.uuid4().hex
292
+ try:
293
+ meta, _ = self._request(
294
+ "select",
295
+ time.monotonic(),
296
+ timeout_ms or 86400000,
297
+ request_id=request_id,
298
+ options=to_wire(self.options),
299
+ )
300
+ if meta.get("status") == "FAILED":
301
+ raise Failure(meta["reason"], meta.get("message", ""))
302
+ return PreparedSource(**meta["source"])
303
+ except (Failure, ValueError, TypeError, KeyError) as exc:
304
+ failure = (
305
+ exc
306
+ if isinstance(exc, Failure)
307
+ else Failure("INVALID_BUFFER", "Invalid source response")
308
+ )
309
+ raise CaptureError(
310
+ CaptureResult(request_id, "FAILED", failure.reason, failure.message)
311
+ ) from None
312
+
313
+ def close(self, *, timeout_ms: int = 1000) -> CloseReport:
314
+ self._check_process()
315
+ integer("timeout_ms", timeout_ms, 0, 86400000)
316
+ end = time.monotonic() + timeout_ms / 1000.0
317
+ with self._drained:
318
+ self._closed = True
319
+ self._pool.close()
320
+ for ticket in self._tickets:
321
+ ticket.cancelled.set()
322
+ if ticket.request.get("options", {}).get("desktop", {}).get("enabled"):
323
+ self._recovery = "pending"
324
+ # Watchdog owns teardown. A bounded wait releases the session lock.
325
+ while self._tickets:
326
+ remaining = end - time.monotonic()
327
+ if remaining <= 0:
328
+ break
329
+ self._drained.wait(timeout=remaining)
330
+ return CloseReport(True, self._recovery)
331
+
332
+
333
+ def capture(
334
+ target: Target,
335
+ *,
336
+ options: Optional[CaptureOptions] = None,
337
+ mode: Optional[str] = None,
338
+ allow_resize: Optional[bool] = None,
339
+ ) -> CaptureResult:
340
+ with CaptureSession(options=options, mode=mode, allow_resize=allow_resize) as session:
341
+ return session.capture(target)
342
+
343
+
344
+ def find_windows(target: Target, *, timeout_ms: int = 2000) -> Tuple[Mapping[str, Any], ...]:
345
+ with CaptureSession() as session:
346
+ return session.find_windows(target, timeout_ms=timeout_ms)
347
+
348
+
349
+ def probe(*, timeout_ms: int = 2000, check_workers: bool = True) -> CapabilityReport:
350
+ integer("timeout_ms", timeout_ms)
351
+ if type(check_workers) is not bool:
352
+ raise ValueError("check_workers must be a bool")
353
+ if not check_workers:
354
+ return CapabilityReport({b: {"available": None, "reason": "NOT_PROBED"} for b in BACKENDS})
355
+ with CaptureSession() as session:
356
+ return session._probe(timeout_ms)
357
+
358
+
359
+ _capture_result = capture_result
@@ -0,0 +1,117 @@
1
+ """Bounded decoding of our native PNG output and configurable pixel comparison."""
2
+
3
+ from dataclasses import dataclass, field
4
+ import struct
5
+ import zlib
6
+
7
+ from .options import integer
8
+ from .results import CapturedImage
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ChangeOptions:
13
+ """A pixel counts when any RGBA channel differs by more than the tolerance."""
14
+
15
+ channel_tolerance: int = 3
16
+ min_changed_pixels: int = 1
17
+
18
+ def __post_init__(self):
19
+ integer("channel_tolerance", self.channel_tolerance, 0, 255)
20
+ integer("min_changed_pixels", self.min_changed_pixels)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Baseline:
25
+ width: int
26
+ height: int
27
+ color_space: str
28
+ alpha: str
29
+ pixels: bytes = field(repr=False)
30
+
31
+
32
+ def decode(image: CapturedImage) -> Baseline:
33
+ integer("image.width", image.width, 1, 0xFFFFFFFF)
34
+ integer("image.height", image.height, 1, 0xFFFFFFFF)
35
+ length = image.width * image.height * 4
36
+ if image.format == "rgba":
37
+ if len(image.data) != length:
38
+ raise ValueError("RGBA dimensions do not match pixels")
39
+ pixels = image.data
40
+ elif image.format == "png":
41
+ data = image.data
42
+ if data[:8] != b"\x89PNG\r\n\x1a\n":
43
+ raise ValueError("Invalid PNG signature")
44
+ offset, compressed, header, ended = 8, bytearray(), False, False
45
+ while offset + 12 <= len(data):
46
+ size = struct.unpack_from(">I", data, offset)[0]
47
+ kind = data[offset + 4 : offset + 8]
48
+ stop = offset + 8 + size
49
+ if stop + 4 > len(data):
50
+ raise ValueError("Truncated PNG chunk")
51
+ body = data[offset + 8 : stop]
52
+ if zlib.crc32(kind + body) & 0xFFFFFFFF != struct.unpack_from(">I", data, stop)[0]:
53
+ raise ValueError("PNG checksum mismatch")
54
+ if not header:
55
+ expected = struct.pack(">IIBBBBB", image.width, image.height, 8, 6, 0, 0, 0)
56
+ if kind != b"IHDR" or body != expected:
57
+ raise ValueError("Expected native RGBA8 PNG")
58
+ header = True
59
+ elif kind == b"IDAT":
60
+ compressed.extend(body)
61
+ elif kind == b"IEND":
62
+ if body or stop + 4 != len(data):
63
+ raise ValueError("Invalid PNG ending")
64
+ ended = True
65
+ break
66
+ elif kind != b"sRGB" or body != b"\x00":
67
+ raise ValueError("Unexpected native PNG chunk")
68
+ offset = stop + 4
69
+ if not ended:
70
+ raise ValueError("Missing PNG ending")
71
+ decoder = zlib.decompressobj()
72
+ row_size = image.width * 4 + 1
73
+ expected_size = row_size * image.height
74
+ try:
75
+ raw = decoder.decompress(compressed, expected_size + 1)
76
+ except zlib.error as error:
77
+ raise ValueError("Invalid PNG compressed pixels") from error
78
+ if (
79
+ len(raw) != expected_size
80
+ or not decoder.eof
81
+ or decoder.unused_data
82
+ or decoder.unconsumed_tail
83
+ ):
84
+ raise ValueError("PNG decompression exceeds dimensions or is incomplete")
85
+ # The native encoder deliberately emits unfiltered rows. Reject a new
86
+ # encoding contract instead of interpreting filtered bytes as pixels.
87
+ if any(raw[y * row_size] != 0 for y in range(image.height)):
88
+ raise ValueError("Unsupported native PNG row filter")
89
+ pixels = b"".join(raw[y * row_size + 1 : (y + 1) * row_size] for y in range(image.height))
90
+ else:
91
+ raise ValueError("Unsupported image format")
92
+ return Baseline(image.width, image.height, image.color_space, image.alpha, pixels)
93
+
94
+
95
+ def changed(previous: Baseline, current: Baseline, options: ChangeOptions) -> bool:
96
+ if (previous.width, previous.height, previous.color_space, previous.alpha) != (
97
+ current.width,
98
+ current.height,
99
+ current.color_space,
100
+ current.alpha,
101
+ ):
102
+ return True
103
+ if previous.pixels == current.pixels:
104
+ return False
105
+ count = 0
106
+ # Compare packed pixels first; identical pixels need no Python channel loop.
107
+ before = memoryview(previous.pixels).cast("I")
108
+ after = memoryview(current.pixels).cast("I")
109
+ for a, b in zip(before, after):
110
+ if a != b and any(
111
+ abs(((a >> shift) & 255) - ((b >> shift) & 255)) > options.channel_tolerance
112
+ for shift in (0, 8, 16, 24)
113
+ ):
114
+ count += 1
115
+ if count >= options.min_changed_pixels:
116
+ return True
117
+ return False
@@ -0,0 +1,25 @@
1
+ import time
2
+
3
+
4
+ class Failure(Exception):
5
+ def __init__(self, reason, message="", status="FAILED"):
6
+ self.reason = reason
7
+ self.message = message or reason.replace("_", " ").lower()
8
+ self.status = status
9
+ super().__init__(self.message)
10
+
11
+
12
+ class Deadline:
13
+ def __init__(self, end, clock=time.monotonic):
14
+ self.end = end
15
+ self.clock = clock
16
+
17
+ def remaining(self):
18
+ return max(0.0, self.end - self.clock())
19
+
20
+ def check(self, reason="DEADLINE_EXCEEDED"):
21
+ if self.remaining() <= 0:
22
+ raise Failure(reason)
23
+
24
+ def cap(self, milliseconds):
25
+ return Deadline(min(self.end, self.clock() + milliseconds / 1000), self.clock)