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.
- capturekit/__init__.py +164 -0
- capturekit/_backends/__init__.py +0 -0
- capturekit/_backends/base.py +89 -0
- capturekit/_backends/linux/__init__.py +1 -0
- capturekit/_backends/linux/_xlib.py +655 -0
- capturekit/_backends/linux/api.py +160 -0
- capturekit/_backends/linux/pulse.py +464 -0
- capturekit/_backends/linux/x11.py +720 -0
- capturekit/_backends/windows/__init__.py +0 -0
- capturekit/_backends/windows/_com.py +350 -0
- capturekit/_backends/windows/_d3d11.py +307 -0
- capturekit/_backends/windows/_win32.py +420 -0
- capturekit/_backends/windows/_winrt.py +162 -0
- capturekit/_backends/windows/api.py +214 -0
- capturekit/_backends/windows/cursor.py +66 -0
- capturekit/_backends/windows/dxgi_duplication.py +310 -0
- capturekit/_backends/windows/wasapi.py +570 -0
- capturekit/_backends/windows/wgc.py +398 -0
- capturekit/_pipeline/__init__.py +1 -0
- capturekit/_pipeline/aligner.py +177 -0
- capturekit/_pipeline/clock.py +47 -0
- capturekit/_pipeline/frame_queue.py +120 -0
- capturekit/_pipeline/mixer.py +80 -0
- capturekit/_pipeline/timeline.py +133 -0
- capturekit/_platform.py +24 -0
- capturekit/_version.py +1 -0
- capturekit/_worker.py +100 -0
- capturekit/capabilities.py +79 -0
- capturekit/capture.py +141 -0
- capturekit/config.py +85 -0
- capturekit/encoding/__init__.py +5 -0
- capturekit/encoding/ffmpeg.py +356 -0
- capturekit/encoding/pipes.py +212 -0
- capturekit/errors.py +109 -0
- capturekit/py.typed +0 -0
- capturekit/recording.py +940 -0
- capturekit/sources.py +124 -0
- capturekit/types.py +211 -0
- capturekit-0.1.0.dist-info/METADATA +112 -0
- capturekit-0.1.0.dist-info/RECORD +41 -0
- 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
|
+
)
|
capturekit/_platform.py
ADDED
|
@@ -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
|