capturekit 0.1.0__py3-none-any.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 (41) hide show
  1. capturekit/__init__.py +164 -0
  2. capturekit/_backends/__init__.py +0 -0
  3. capturekit/_backends/base.py +89 -0
  4. capturekit/_backends/linux/__init__.py +1 -0
  5. capturekit/_backends/linux/_xlib.py +655 -0
  6. capturekit/_backends/linux/api.py +160 -0
  7. capturekit/_backends/linux/pulse.py +464 -0
  8. capturekit/_backends/linux/x11.py +720 -0
  9. capturekit/_backends/windows/__init__.py +0 -0
  10. capturekit/_backends/windows/_com.py +350 -0
  11. capturekit/_backends/windows/_d3d11.py +307 -0
  12. capturekit/_backends/windows/_win32.py +420 -0
  13. capturekit/_backends/windows/_winrt.py +162 -0
  14. capturekit/_backends/windows/api.py +214 -0
  15. capturekit/_backends/windows/cursor.py +66 -0
  16. capturekit/_backends/windows/dxgi_duplication.py +310 -0
  17. capturekit/_backends/windows/wasapi.py +570 -0
  18. capturekit/_backends/windows/wgc.py +398 -0
  19. capturekit/_pipeline/__init__.py +1 -0
  20. capturekit/_pipeline/aligner.py +177 -0
  21. capturekit/_pipeline/clock.py +47 -0
  22. capturekit/_pipeline/frame_queue.py +120 -0
  23. capturekit/_pipeline/mixer.py +80 -0
  24. capturekit/_pipeline/timeline.py +133 -0
  25. capturekit/_platform.py +24 -0
  26. capturekit/_version.py +1 -0
  27. capturekit/_worker.py +100 -0
  28. capturekit/capabilities.py +79 -0
  29. capturekit/capture.py +141 -0
  30. capturekit/config.py +85 -0
  31. capturekit/encoding/__init__.py +5 -0
  32. capturekit/encoding/ffmpeg.py +356 -0
  33. capturekit/encoding/pipes.py +212 -0
  34. capturekit/errors.py +109 -0
  35. capturekit/py.typed +0 -0
  36. capturekit/recording.py +940 -0
  37. capturekit/sources.py +124 -0
  38. capturekit/types.py +211 -0
  39. capturekit-0.1.0.dist-info/METADATA +112 -0
  40. capturekit-0.1.0.dist-info/RECORD +41 -0
  41. capturekit-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,120 @@
1
+ """Bounded frame queue with duplicate-slot merging, plus a buffer pool.
2
+
3
+ Only *new* frames hold buffers and count against the capacity. A duplicate
4
+ slot ("repeat the previous frame") is a counter merged into the queue tail,
5
+ so the pacer never blocks and memory stays bounded even when the encoder
6
+ falls behind.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import collections
12
+ import threading
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ Buffer = npt.NDArray[np.uint8]
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class FrameItem:
23
+ buffer: Buffer
24
+ timestamp_ns: int
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class DupItem:
29
+ count: int
30
+
31
+
32
+ QueueItem = FrameItem | DupItem
33
+
34
+
35
+ class FrameQueue:
36
+ def __init__(self, capacity: int) -> None:
37
+ self._capacity = capacity
38
+ self._items: collections.deque[QueueItem] = collections.deque()
39
+ self._frames = 0
40
+ self._closed = False
41
+ self._cond = threading.Condition()
42
+ self.max_depth = 0
43
+
44
+ def put_frame(self, item: FrameItem) -> bool:
45
+ """Enqueue a new frame. Returns False (not enqueued) if full."""
46
+ with self._cond:
47
+ if self._closed:
48
+ return False
49
+ if self._frames >= self._capacity:
50
+ return False
51
+ self._items.append(item)
52
+ self._frames += 1
53
+ self.max_depth = max(self.max_depth, self._frames)
54
+ self._cond.notify()
55
+ return True
56
+
57
+ def put_dup(self, count: int = 1) -> None:
58
+ with self._cond:
59
+ if self._closed or count <= 0:
60
+ return
61
+ if self._items and isinstance(self._items[-1], DupItem):
62
+ self._items[-1].count += count
63
+ else:
64
+ self._items.append(DupItem(count))
65
+ self._cond.notify()
66
+
67
+ def get(self, timeout_s: float | None = None) -> QueueItem | None:
68
+ """Next item, or ``None`` when closed and drained (or timed out)."""
69
+ with self._cond:
70
+ if not self._cond.wait_for(lambda: self._items or self._closed, timeout=timeout_s):
71
+ return None
72
+ if not self._items:
73
+ return None
74
+ item = self._items.popleft()
75
+ if isinstance(item, FrameItem):
76
+ self._frames -= 1
77
+ return item
78
+
79
+ def close(self) -> None:
80
+ """No more puts; consumers drain remaining items then get ``None``."""
81
+ with self._cond:
82
+ self._closed = True
83
+ self._cond.notify_all()
84
+
85
+ def drain(self) -> list[QueueItem]:
86
+ with self._cond:
87
+ items = list(self._items)
88
+ self._items.clear()
89
+ self._frames = 0
90
+ return items
91
+
92
+ @property
93
+ def depth(self) -> int:
94
+ with self._cond:
95
+ return self._frames
96
+
97
+
98
+ class BufferPool:
99
+ """Recycles fixed-shape frame buffers to avoid per-frame large allocations."""
100
+
101
+ def __init__(self, shape: tuple[int, int, int], max_buffers: int) -> None:
102
+ self.shape = shape
103
+ self._max = max_buffers
104
+ self._free: list[Buffer] = []
105
+ self._allocated = 0
106
+ self._lock = threading.Lock()
107
+
108
+ def acquire(self) -> Buffer | None:
109
+ """A free buffer, or ``None`` if ``max_buffers`` are all in use."""
110
+ with self._lock:
111
+ if self._free:
112
+ return self._free.pop()
113
+ if self._allocated >= self._max:
114
+ return None
115
+ self._allocated += 1
116
+ return np.zeros(self.shape, dtype=np.uint8)
117
+
118
+ def release(self, buf: Buffer) -> None:
119
+ with self._lock:
120
+ self._free.append(buf)
@@ -0,0 +1,80 @@
1
+ """Mixes several aligned sample streams into one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections import deque
7
+ from collections.abc import Callable
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+
12
+ Samples = npt.NDArray[np.float32]
13
+
14
+
15
+ class SampleFifo:
16
+ def __init__(self, channels: int) -> None:
17
+ self.channels = channels
18
+ self._blocks: deque[Samples] = deque()
19
+ self.size = 0
20
+
21
+ def push(self, block: Samples) -> None:
22
+ if block.shape[0]:
23
+ self._blocks.append(block)
24
+ self.size += int(block.shape[0])
25
+
26
+ def pop(self, n: int) -> Samples:
27
+ out = np.empty((n, self.channels), dtype=np.float32)
28
+ filled = 0
29
+ while filled < n:
30
+ blk = self._blocks[0]
31
+ take = min(n - filled, blk.shape[0])
32
+ out[filled : filled + take] = blk[:take]
33
+ filled += take
34
+ if take == blk.shape[0]:
35
+ self._blocks.popleft()
36
+ else:
37
+ self._blocks[0] = blk[take:]
38
+ self.size -= n
39
+ return out
40
+
41
+
42
+ class AudioMixer:
43
+ """Sums N aligned inputs; emits only samples available on all live inputs.
44
+
45
+ Inputs marked finished count as silence so one ended source cannot stall
46
+ the mix. Output is clipped to [-1, 1].
47
+ """
48
+
49
+ def __init__(self, inputs: int, channels: int, sink: Callable[[Samples], None]) -> None:
50
+ self._fifos = [SampleFifo(channels) for _ in range(inputs)]
51
+ self._finished = [False] * inputs
52
+ self._sink = sink
53
+ self._lock = threading.Lock()
54
+ self.channels = channels
55
+
56
+ def push(self, index: int, block: Samples) -> None:
57
+ with self._lock:
58
+ self._fifos[index].push(block)
59
+ self._emit_locked(final=False)
60
+
61
+ def finish_input(self, index: int) -> None:
62
+ with self._lock:
63
+ self._finished[index] = True
64
+ self._emit_locked(final=all(self._finished))
65
+
66
+ def _emit_locked(self, final: bool) -> None:
67
+ live = [f.size for f, done in zip(self._fifos, self._finished, strict=True) if not done]
68
+ if live and not final:
69
+ n = min(live)
70
+ else:
71
+ n = max((f.size for f in self._fifos), default=0)
72
+ if n <= 0:
73
+ return
74
+ mix = np.zeros((n, self.channels), dtype=np.float32)
75
+ for f in self._fifos:
76
+ take = min(n, f.size)
77
+ if take:
78
+ mix[:take] += f.pop(take)
79
+ np.clip(mix, -1.0, 1.0, out=mix)
80
+ self._sink(mix)
@@ -0,0 +1,133 @@
1
+ """Pause-aware mapping between wall-clock time and media time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class _Segment:
11
+ wall_start: int
12
+ media_start: int
13
+ wall_end: int | None = None
14
+
15
+
16
+ class Timeline:
17
+ """Maps capturekit-clock nanoseconds to media nanoseconds.
18
+
19
+ Media time starts at 0 when :meth:`start` is called and does not advance
20
+ while paused. All methods are thread-safe.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self._lock = threading.Lock()
25
+ self._cond = threading.Condition(self._lock)
26
+ self._segments: list[_Segment] = []
27
+ self._stopped = False
28
+
29
+ # -- state changes -------------------------------------------------
30
+ def start(self, wall_ns: int) -> None:
31
+ with self._cond:
32
+ if self._segments:
33
+ raise RuntimeError("timeline already started")
34
+ self._segments.append(_Segment(wall_ns, 0))
35
+ self._cond.notify_all()
36
+
37
+ def pause(self, wall_ns: int) -> int:
38
+ """Pause at ``wall_ns``; returns the media time at the pause."""
39
+ with self._cond:
40
+ seg = self._active()
41
+ if seg is None:
42
+ raise RuntimeError("timeline not running")
43
+ wall_ns = max(wall_ns, seg.wall_start)
44
+ seg.wall_end = wall_ns
45
+ return seg.media_start + (wall_ns - seg.wall_start)
46
+
47
+ def resume(self, wall_ns: int) -> None:
48
+ with self._cond:
49
+ if not self._segments or self._segments[-1].wall_end is None:
50
+ raise RuntimeError("timeline not paused")
51
+ last = self._segments[-1]
52
+ assert last.wall_end is not None
53
+ media = last.media_start + (last.wall_end - last.wall_start)
54
+ self._segments.append(_Segment(max(wall_ns, last.wall_end), media))
55
+ self._cond.notify_all()
56
+
57
+ def stop(self, wall_ns: int) -> int:
58
+ """Stop at ``wall_ns`` (pausing if running); returns final media time."""
59
+ with self._cond:
60
+ self._stopped = True
61
+ self._cond.notify_all()
62
+ if not self._segments:
63
+ return 0
64
+ seg = self._segments[-1]
65
+ if seg.wall_end is None:
66
+ seg.wall_end = max(wall_ns, seg.wall_start)
67
+ return seg.media_start + (seg.wall_end - seg.wall_start)
68
+
69
+ # -- queries -------------------------------------------------------
70
+ def _active(self) -> _Segment | None:
71
+ if self._segments and self._segments[-1].wall_end is None:
72
+ return self._segments[-1]
73
+ return None
74
+
75
+ @property
76
+ def generation(self) -> int:
77
+ """Number of recording segments so far (increments on every resume)."""
78
+ with self._lock:
79
+ return len(self._segments)
80
+
81
+ @property
82
+ def started(self) -> bool:
83
+ with self._lock:
84
+ return bool(self._segments)
85
+
86
+ @property
87
+ def paused(self) -> bool:
88
+ return self.is_paused()
89
+
90
+ def is_paused(self) -> bool:
91
+ """Method form of :attr:`paused` (re-evaluated on each call; another
92
+ thread may pause or resume at any time)."""
93
+ with self._lock:
94
+ return bool(self._segments) and self._segments[-1].wall_end is not None
95
+
96
+ @property
97
+ def stopped(self) -> bool:
98
+ with self._lock:
99
+ return self._stopped
100
+
101
+ def media_ns(self, wall_ns: int) -> int | None:
102
+ """Media time of ``wall_ns`` or ``None`` if it falls outside recording."""
103
+ with self._lock:
104
+ for seg in reversed(self._segments):
105
+ if wall_ns >= seg.wall_start:
106
+ if seg.wall_end is not None and wall_ns >= seg.wall_end:
107
+ return None
108
+ return seg.media_start + (wall_ns - seg.wall_start)
109
+ return None
110
+
111
+ def media_now(self, wall_now: int) -> int:
112
+ """Media time reached at ``wall_now`` (clamped while paused)."""
113
+ with self._lock:
114
+ if not self._segments:
115
+ return 0
116
+ seg = self._segments[-1]
117
+ end = wall_now if seg.wall_end is None else min(wall_now, seg.wall_end)
118
+ return seg.media_start + max(0, end - seg.wall_start)
119
+
120
+ def wall_for_media(self, media_ns: int) -> int | None:
121
+ """Wall time at which the *active* segment reaches ``media_ns``."""
122
+ with self._lock:
123
+ seg = self._active()
124
+ if seg is None:
125
+ return None
126
+ return seg.wall_start + (media_ns - seg.media_start)
127
+
128
+ def wait_running(self, timeout_s: float) -> bool:
129
+ """Block until the timeline is running (not paused) or stopped."""
130
+ with self._cond:
131
+ return self._cond.wait_for(
132
+ lambda: self._stopped or self._active() is not None, timeout=timeout_s
133
+ )
@@ -0,0 +1,24 @@
1
+ """Lazy selection of the platform backend module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from types import ModuleType
7
+
8
+ from .errors import UnsupportedPlatformError
9
+
10
+
11
+ def backend() -> ModuleType:
12
+ """Return the platform ``api`` module (imported on first use)."""
13
+ plat: str = sys.platform # a variable, so type checkers keep every branch reachable
14
+ if plat == "win32":
15
+ from ._backends.windows import api as win_api
16
+
17
+ return win_api
18
+ if plat.startswith("linux"):
19
+ from ._backends.linux import api as linux_api
20
+
21
+ return linux_api
22
+ raise UnsupportedPlatformError(
23
+ f"capturekit supports Windows 10/11 and Linux X11; {sys.platform!r} is not supported"
24
+ )
capturekit/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
capturekit/_worker.py ADDED
@@ -0,0 +1,100 @@
1
+ """A single thread that owns a video backend and executes calls on it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import threading
7
+ from collections.abc import Callable
8
+ from concurrent.futures import Future
9
+ from concurrent.futures import TimeoutError as FuturesTimeout
10
+ from typing import Any, TypeVar
11
+
12
+ from . import _platform
13
+ from ._backends.base import VideoBackend
14
+ from .errors import CaptureError, CaptureTimeoutError
15
+ from .sources import VideoSource
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ class BackendThread:
21
+ """Creates, starts and drives a backend on one dedicated thread.
22
+
23
+ Native video APIs (COM/WinRT on Windows, an Xlib ``Display`` on Linux) are
24
+ used from this thread only; callers interact via :meth:`call`.
25
+ """
26
+
27
+ def __init__(self, source: VideoSource, name: str = "capturekit-video") -> None:
28
+ self._source = source
29
+ self._calls: queue.Queue[tuple[Callable[[VideoBackend], Any], Future[Any]] | None] = (
30
+ queue.Queue()
31
+ )
32
+ self._ready: Future[VideoBackend] = Future()
33
+ self._thread = threading.Thread(target=self._run, name=name, daemon=True)
34
+ self._closed = threading.Event()
35
+
36
+ def start(self, timeout_s: float = 15.0) -> VideoBackend:
37
+ self._thread.start()
38
+ try:
39
+ return self._ready.result(timeout_s)
40
+ except FuturesTimeout:
41
+ # The backend may still finish starting later: make it exit and
42
+ # close itself instead of leaking a live capture session.
43
+ self._calls.put(None)
44
+ raise CaptureTimeoutError(
45
+ f"capture backend did not start within {timeout_s}s"
46
+ ) from None
47
+
48
+ def _run(self) -> None:
49
+ api = _platform.backend()
50
+ try:
51
+ with api.thread_context():
52
+ backend: VideoBackend = api.open_video(self._source)
53
+ try:
54
+ backend.start()
55
+ except BaseException as exc:
56
+ backend.close()
57
+ self._ready.set_exception(exc)
58
+ return
59
+ self._ready.set_result(backend)
60
+ try:
61
+ while True:
62
+ item = self._calls.get()
63
+ if item is None:
64
+ break
65
+ fn, fut = item
66
+ if not fut.set_running_or_notify_cancel():
67
+ continue
68
+ try:
69
+ fut.set_result(fn(backend))
70
+ except BaseException as exc:
71
+ fut.set_exception(exc)
72
+ finally:
73
+ backend.close()
74
+ except BaseException as exc:
75
+ if not self._ready.done():
76
+ self._ready.set_exception(exc)
77
+ finally:
78
+ self._closed.set()
79
+ # Never leave a caller waiting on a call that will not run.
80
+ while True:
81
+ try:
82
+ pending = self._calls.get_nowait()
83
+ except queue.Empty:
84
+ break
85
+ if pending is not None and not pending[1].done():
86
+ pending[1].set_exception(CaptureError("capture was closed"))
87
+
88
+ def call(self, fn: Callable[[VideoBackend], T], timeout_s: float | None = None) -> T:
89
+ if self._closed.is_set() or not self._thread.is_alive():
90
+ raise CaptureError("capture is closed")
91
+ fut: Future[T] = Future()
92
+ self._calls.put((fn, fut))
93
+ if self._closed.is_set() and not fut.done(): # raced with shutdown
94
+ fut.set_exception(CaptureError("capture was closed"))
95
+ return fut.result(timeout_s)
96
+
97
+ def stop(self, timeout_s: float = 10.0) -> None:
98
+ if self._thread.is_alive():
99
+ self._calls.put(None)
100
+ self._thread.join(timeout_s)
@@ -0,0 +1,79 @@
1
+ """Programmatic capability discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ class Support(enum.Enum):
10
+ #: Implemented and tested on this platform.
11
+ SUPPORTED = "supported"
12
+ #: Implemented; limited testing or known gaps (see ``reason``).
13
+ EXPERIMENTAL = "experimental"
14
+ #: Implemented for this platform, but this machine/OS build/server lacks
15
+ #: a requirement (``reason`` says which).
16
+ UNAVAILABLE = "unavailable"
17
+ #: Not implemented or out of scope.
18
+ UNSUPPORTED = "unsupported"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Capability:
23
+ status: Support
24
+ reason: str = ""
25
+
26
+ def __bool__(self) -> bool:
27
+ return self.status in (Support.SUPPORTED, Support.EXPERIMENTAL)
28
+
29
+
30
+ def supported(reason: str = "") -> Capability:
31
+ return Capability(Support.SUPPORTED, reason)
32
+
33
+
34
+ def experimental(reason: str) -> Capability:
35
+ return Capability(Support.EXPERIMENTAL, reason)
36
+
37
+
38
+ def unavailable(reason: str) -> Capability:
39
+ return Capability(Support.UNAVAILABLE, reason)
40
+
41
+
42
+ def unsupported(reason: str) -> Capability:
43
+ return Capability(Support.UNSUPPORTED, reason)
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Capabilities:
48
+ platform: str
49
+ os_version: str
50
+ display_server: str | None
51
+ monitor_capture: Capability
52
+ window_capture: Capability
53
+ window_capture_borderless: Capability
54
+ window_capture_minimized: Capability
55
+ cursor_capture: Capability
56
+ microphone_audio: Capability
57
+ system_audio: Capability
58
+ process_audio: Capability
59
+ audio_session_enumeration: Capability
60
+ recording: Capability
61
+ encoders: tuple[str, ...] = ()
62
+ notes: tuple[str, ...] = field(default_factory=tuple)
63
+
64
+ def summary(self) -> str:
65
+ lines = [
66
+ f"capturekit on {self.platform} {self.os_version} "
67
+ f"(display server: {self.display_server or 'n/a'})"
68
+ ]
69
+ for name in (
70
+ "monitor_capture", "window_capture", "window_capture_borderless",
71
+ "window_capture_minimized", "cursor_capture", "microphone_audio", "system_audio",
72
+ "process_audio", "audio_session_enumeration", "recording",
73
+ ): # fmt: skip
74
+ cap: Capability = getattr(self, name)
75
+ lines.append(f" {name:28s} {cap.status.value:12s} {cap.reason}")
76
+ if self.encoders:
77
+ lines.append(f" validated encoders: {', '.join(self.encoders)}")
78
+ lines.extend(f" note: {n}" for n in self.notes)
79
+ return "\n".join(lines)
capturekit/capture.py ADDED
@@ -0,0 +1,141 @@
1
+ """Frame capture without recording."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Iterator
7
+ from types import TracebackType
8
+
9
+ import numpy as np
10
+
11
+ from ._backends.base import AcquiredFrame, VideoBackend
12
+ from ._pipeline.clock import NS_PER_S, now_ns, sleep_until
13
+ from ._worker import BackendThread
14
+ from .errors import CaptureTimeoutError
15
+ from .sources import MonitorSource, VideoSource, WindowSource
16
+ from .types import Frame, Monitor, Rect, Window
17
+
18
+
19
+ class ScreenCapture:
20
+ """Capture frames from a monitor or a window.
21
+
22
+ Example::
23
+
24
+ with ScreenCapture(MonitorSource(0)) as cap:
25
+ frame = cap.grab() # newest frame (BGRA numpy array)
26
+ for frame in cap.frames(fps=30, max_frames=90):
27
+ ...
28
+
29
+ Thread-safe; all native work happens on an internal backend thread.
30
+ """
31
+
32
+ def __init__(self, source: VideoSource) -> None:
33
+ self.source = source
34
+ self._worker: BackendThread | None = None
35
+ self._backend_name = ""
36
+ self._last: Frame | None = None
37
+ self._index = 0
38
+ self._lock = threading.Lock()
39
+
40
+ # -- lifecycle -----------------------------------------------------------
41
+ def open(self) -> ScreenCapture:
42
+ with self._lock:
43
+ self._open_locked()
44
+ return self
45
+
46
+ def _open_locked(self) -> None:
47
+ if self._worker is None:
48
+ worker = BackendThread(self.source, name="capturekit-capture")
49
+ backend = worker.start()
50
+ self._backend_name = backend.info.name
51
+ self._worker = worker
52
+
53
+ def close(self) -> None:
54
+ with self._lock:
55
+ if self._worker is not None:
56
+ self._worker.stop()
57
+ self._worker = None
58
+
59
+ def __enter__(self) -> ScreenCapture:
60
+ return self.open()
61
+
62
+ def __exit__(
63
+ self, et: type[BaseException] | None, ev: BaseException | None, tb: TracebackType | None
64
+ ) -> None:
65
+ self.close()
66
+
67
+ @property
68
+ def backend_name(self) -> str:
69
+ return self._backend_name
70
+
71
+ # -- capture ---------------------------------------------------------------
72
+ def _acquire(self, timeout_s: float) -> AcquiredFrame | None:
73
+ if self._worker is None:
74
+ self._open_locked()
75
+ assert self._worker is not None
76
+
77
+ def fn(b: VideoBackend) -> AcquiredFrame | None:
78
+ return b.acquire(None, timeout_s)
79
+
80
+ return self._worker.call(fn, timeout_s + 10)
81
+
82
+ def grab(self, timeout: float = 2.0, *, new_only: bool = False) -> Frame:
83
+ """Return the newest frame.
84
+
85
+ If nothing changed since the previous call, a copy of the previous
86
+ frame is returned (unless ``new_only``). Raises
87
+ :class:`CaptureTimeoutError` if no frame is available in ``timeout``.
88
+ """
89
+ with self._lock:
90
+ first_wait = timeout if (self._last is None or new_only) else 0.0
91
+ acq = self._acquire(first_wait)
92
+ if acq is None:
93
+ if self._last is None or new_only:
94
+ raise CaptureTimeoutError(f"no frame within {timeout:.2f}s")
95
+ last = self._last
96
+ return Frame(last.data.copy(), last.timestamp_ns, last.index, last.content_size)
97
+ frame = Frame(acq.data, acq.timestamp_ns, self._index, acq.content_size)
98
+ self._index += 1
99
+ self._last = frame
100
+ return Frame(frame.data.copy(), frame.timestamp_ns, frame.index, frame.content_size)
101
+
102
+ def frames(
103
+ self, fps: float | None = 30.0, *, max_frames: int | None = None, timeout: float = 2.0
104
+ ) -> Iterator[Frame]:
105
+ """Iterate frames.
106
+
107
+ With ``fps`` frames are produced at absolute deadlines (repeating the
108
+ previous image when nothing changed). With ``fps=None`` only new
109
+ frames are yielded, as fast as the source produces them.
110
+ """
111
+ count = 0
112
+ if fps is None:
113
+ while max_frames is None or count < max_frames:
114
+ yield self.grab(timeout, new_only=True)
115
+ count += 1
116
+ return
117
+ period = NS_PER_S / fps
118
+ t0 = now_ns()
119
+ n = 0
120
+ while max_frames is None or count < max_frames:
121
+ sleep_until(t0 + int(n * period))
122
+ yield self.grab(timeout)
123
+ count += 1
124
+ # Skip deadlines already missed (slow consumer) instead of bursting.
125
+ n = max(n + 1, int((now_ns() - t0) / period))
126
+
127
+
128
+ def screenshot(
129
+ target: Monitor | Window | int | None = None,
130
+ region: Rect | None = None,
131
+ *,
132
+ timeout: float = 2.0,
133
+ ) -> np.ndarray:
134
+ """One-shot BGRA screenshot of a monitor (default primary) or window."""
135
+ src: VideoSource
136
+ if isinstance(target, Window):
137
+ src = WindowSource(target, region=region)
138
+ else:
139
+ src = MonitorSource(target, region=region)
140
+ with ScreenCapture(src) as cap:
141
+ return cap.grab(timeout).data