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
capturekit/config.py ADDED
@@ -0,0 +1,85 @@
1
+ """Recording configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ class AudioMixMode(enum.Enum):
10
+ #: One track per audio source (default).
11
+ SEPARATE = "separate"
12
+ #: A single track containing the sum of all sources.
13
+ MIXED = "mixed"
14
+ #: Separate tracks followed by a mixed track.
15
+ BOTH = "both"
16
+
17
+
18
+ class TargetLostPolicy(enum.Enum):
19
+ #: Stop and finalise the recording, report via ``on_error``.
20
+ STOP = "stop"
21
+ #: Keep recording, repeating the last frame (window closed / device gone).
22
+ CONTINUE = "continue"
23
+
24
+
25
+ class Container(enum.Enum):
26
+ MKV = "matroska"
27
+ MP4 = "mp4"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class EncoderConfig:
32
+ """Video/audio encoder settings.
33
+
34
+ ``encoder``: ``"auto"`` probes hardware encoders (NVENC → QSV → AMF) and
35
+ falls back to ``libx264``. Explicit FFmpeg encoder names are accepted
36
+ (``"h264_nvenc"``, ``"libx264"``, ...). ``fallback`` allows trying the next
37
+ candidate when an explicitly requested encoder fails validation.
38
+
39
+ ``bitrate`` in bits/s; ``None`` uses constant-quality mode with
40
+ ``quality`` (CRF/CQ, lower is better, default 23).
41
+ """
42
+
43
+ encoder: str = "auto"
44
+ codec: str = "h264"
45
+ bitrate: int | None = None
46
+ quality: int = 23
47
+ preset: str | None = None
48
+ keyframe_interval_s: float = 2.0
49
+ fallback: bool = True
50
+ audio_codec: str = "aac"
51
+ audio_bitrate: int = 192_000
52
+ ffmpeg_path: str | None = None
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class RecordingConfig:
57
+ """Recording settings.
58
+
59
+ ``output_size``: fixed output resolution ``(w, h)``. ``None`` uses the
60
+ source size at start. When a window is resized during recording its
61
+ content is placed at the top-left and padded with black / cropped.
62
+ Odd sizes are rounded down to even (required for 4:2:0 encoding).
63
+
64
+ ``queue_frames`` bounds raw frames waiting for the encoder (memory ≈
65
+ ``queue_frames * w * h * 4`` bytes).
66
+ """
67
+
68
+ fps: float = 30.0
69
+ output_size: tuple[int, int] | None = None
70
+ container: Container | None = None
71
+ encoder: EncoderConfig = field(default_factory=EncoderConfig)
72
+ audio_mix: AudioMixMode = AudioMixMode.SEPARATE
73
+ audio_sample_rate: int = 48_000
74
+ queue_frames: int = 8
75
+ overwrite: bool = False
76
+ on_target_lost: TargetLostPolicy = TargetLostPolicy.STOP
77
+ start_timeout_s: float = 10.0
78
+
79
+ def __post_init__(self) -> None:
80
+ if not (1.0 <= self.fps <= 240.0):
81
+ raise ValueError(f"fps must be within 1..240, got {self.fps}")
82
+ if self.queue_frames < 2:
83
+ raise ValueError("queue_frames must be >= 2")
84
+ if self.output_size is not None and (self.output_size[0] < 2 or self.output_size[1] < 2):
85
+ raise ValueError(f"invalid output_size {self.output_size}")
@@ -0,0 +1,5 @@
1
+ """Encoding through FFmpeg subprocesses."""
2
+
3
+ from .ffmpeg import EncoderProbe, ffmpeg_version, find_ffmpeg, probe_encoder, select_encoder
4
+
5
+ __all__ = ["EncoderProbe", "ffmpeg_version", "find_ffmpeg", "probe_encoder", "select_encoder"]
@@ -0,0 +1,356 @@
1
+ """FFmpeg discovery, encoder validation, command construction and process control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from ..config import Container, EncoderConfig
16
+ from ..errors import EncoderError, EncoderNotAvailableError
17
+
18
+ #: Order used by ``encoder="auto"`` (hardware first).
19
+ AUTO_H264_ORDER = ("h264_nvenc", "h264_qsv", "h264_amf", "libx264", "h264_mf")
20
+ HARDWARE_ENCODERS = {
21
+ "h264_nvenc",
22
+ "h264_qsv",
23
+ "h264_amf",
24
+ "h264_mf",
25
+ "h264_vaapi",
26
+ "hevc_nvenc",
27
+ "hevc_qsv",
28
+ "hevc_amf",
29
+ }
30
+ #: Encoders that accept BGRA input directly (colour conversion on the GPU).
31
+ BGRA_INPUT_ENCODERS = {"h264_nvenc", "hevc_nvenc", "av1_nvenc", "libx264rgb"}
32
+
33
+ _CREATE_NO_WINDOW = 0x08000000 if sys.platform == "win32" else 0
34
+
35
+
36
+ def find_ffmpeg(explicit: str | None = None) -> str:
37
+ """Locate an FFmpeg executable.
38
+
39
+ Order: ``explicit`` → ``$CAPTUREKIT_FFMPEG`` → ``PATH`` → the optional
40
+ ``imageio-ffmpeg`` bundled binary.
41
+ """
42
+ for cand in (explicit, os.environ.get("CAPTUREKIT_FFMPEG")):
43
+ if cand:
44
+ if Path(cand).is_file():
45
+ return str(Path(cand).resolve())
46
+ raise EncoderNotAvailableError(f"FFmpeg not found at {cand!r}")
47
+ found = _which_no_cwd("ffmpeg")
48
+ if found:
49
+ return found
50
+ try:
51
+ import imageio_ffmpeg
52
+
53
+ exe = imageio_ffmpeg.get_ffmpeg_exe()
54
+ if exe and Path(exe).is_file():
55
+ return str(exe)
56
+ except Exception:
57
+ pass
58
+ raise EncoderNotAvailableError(
59
+ "FFmpeg executable not found: install FFmpeg, set CAPTUREKIT_FFMPEG, or "
60
+ "`pip install capturekit[ffmpeg]`"
61
+ )
62
+
63
+
64
+ def _which_no_cwd(name: str) -> str | None:
65
+ """``shutil.which`` restricted to absolute PATH entries.
66
+
67
+ On Windows ``shutil.which`` also searches the current directory first,
68
+ which would execute a planted ``ffmpeg.exe`` from e.g. a downloads folder.
69
+ """
70
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
71
+ dirs = [
72
+ d
73
+ for d in os.environ.get("PATH", "").split(os.pathsep)
74
+ if d and os.path.isabs(d) and os.path.normcase(os.path.abspath(d)) != cwd
75
+ ]
76
+ found = shutil.which(name, path=os.pathsep.join(dirs)) if dirs else None
77
+ if found and os.path.normcase(os.path.dirname(os.path.abspath(found))) == cwd:
78
+ return None
79
+ return str(Path(found).resolve()) if found else None
80
+
81
+
82
+ def _run(args: Sequence[str], timeout: float) -> subprocess.CompletedProcess[str]:
83
+ return subprocess.run(
84
+ list(args), capture_output=True, text=True, timeout=timeout,
85
+ stdin=subprocess.DEVNULL, creationflags=_CREATE_NO_WINDOW, check=False,
86
+ ) # fmt: skip
87
+
88
+
89
+ def ffmpeg_version(ffmpeg: str) -> str:
90
+ out = _run([ffmpeg, "-hide_banner", "-version"], 15).stdout
91
+ return out.splitlines()[0] if out else "unknown"
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class EncoderProbe:
96
+ name: str
97
+ usable: bool
98
+ hardware: bool
99
+ reason: str = ""
100
+
101
+
102
+ _probe_cache: dict[tuple[str, float, str], EncoderProbe] = {}
103
+ _probe_lock = threading.Lock()
104
+
105
+
106
+ def probe_encoder(ffmpeg: str, encoder: str) -> EncoderProbe:
107
+ """Validate an encoder by actually initialising it (a 3-frame encode).
108
+
109
+ ``-encoders`` listing is not enough: e.g. ``h264_qsv`` is listed in most
110
+ Windows builds but fails without an Intel GPU.
111
+ """
112
+ key = (ffmpeg, os.path.getmtime(ffmpeg), encoder)
113
+ with _probe_lock:
114
+ if key in _probe_cache:
115
+ return _probe_cache[key]
116
+ pix = (
117
+ "bgra"
118
+ if encoder in BGRA_INPUT_ENCODERS
119
+ else ("nv12" if encoder in HARDWARE_ENCODERS else "yuv420p")
120
+ )
121
+ args = [
122
+ ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "error",
123
+ "-f", "lavfi", "-i", "color=c=black:s=256x256:r=30",
124
+ "-frames:v", "3", "-pix_fmt", pix, "-c:v", encoder, "-f", "null", "-",
125
+ ] # fmt: skip
126
+ try:
127
+ res = _run(args, 30)
128
+ ok = res.returncode == 0
129
+ reason = "" if ok else (res.stderr.strip().splitlines() or ["failed"])[-1]
130
+ except subprocess.TimeoutExpired:
131
+ ok, reason = False, "probe timed out"
132
+ probe = EncoderProbe(encoder, ok, encoder in HARDWARE_ENCODERS, reason)
133
+ with _probe_lock:
134
+ _probe_cache[key] = probe
135
+ return probe
136
+
137
+
138
+ def select_encoder(ffmpeg: str, cfg: EncoderConfig, size: tuple[int, int]) -> str:
139
+ """Choose a validated encoder according to ``cfg``.
140
+
141
+ Raises :class:`EncoderNotAvailableError` with every rejection reason when
142
+ nothing works (never silently picks something the caller excluded).
143
+ """
144
+ if cfg.encoder == "auto":
145
+ candidates: list[str] = list(AUTO_H264_ORDER)
146
+ else:
147
+ candidates = [cfg.encoder]
148
+ if cfg.fallback:
149
+ candidates += [e for e in AUTO_H264_ORDER if e != cfg.encoder]
150
+ reasons = []
151
+ for enc in candidates:
152
+ # NVENC rejects tiny frames on some GPU generations.
153
+ if enc.endswith("_nvenc") and (size[0] < 160 or size[1] < 64):
154
+ reasons.append(f"{enc}: frame {size} below NVENC minimum")
155
+ continue
156
+ probe = probe_encoder(ffmpeg, enc)
157
+ if probe.usable:
158
+ return enc
159
+ reasons.append(f"{enc}: {probe.reason}")
160
+ raise EncoderNotAvailableError("no usable video encoder:\n " + "\n ".join(reasons))
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class AudioInput:
165
+ path: str # pipe path FFmpeg reads from
166
+ sample_rate: int
167
+ channels: int
168
+ title: str
169
+
170
+
171
+ def container_for(output: str, explicit: Container | None) -> Container:
172
+ if explicit is not None:
173
+ return explicit
174
+ ext = Path(output).suffix.lower()
175
+ if ext in (".mp4", ".m4v", ".mov"):
176
+ return Container.MP4
177
+ return Container.MKV
178
+
179
+
180
+ def _video_codec_args(encoder: str, cfg: EncoderConfig, fps: float) -> list[str]:
181
+ gop = max(1, round(cfg.keyframe_interval_s * fps))
182
+ args = ["-c:v", encoder, "-g", str(gop)]
183
+ br = cfg.bitrate
184
+ if encoder.endswith("_nvenc"):
185
+ args += ["-preset", cfg.preset or "p4"]
186
+ if br:
187
+ args += [
188
+ "-rc",
189
+ "vbr",
190
+ "-b:v",
191
+ str(br),
192
+ "-maxrate",
193
+ str(int(br * 1.5)),
194
+ "-bufsize",
195
+ str(br * 2),
196
+ ]
197
+ else:
198
+ args += ["-rc", "vbr", "-cq", str(cfg.quality), "-b:v", "0"]
199
+ elif encoder.endswith("_qsv"):
200
+ args += ["-pix_fmt", "nv12", "-preset", cfg.preset or "veryfast"]
201
+ args += ["-b:v", str(br)] if br else ["-global_quality", str(cfg.quality)]
202
+ elif encoder.endswith("_amf"):
203
+ args += ["-pix_fmt", "nv12", "-quality", cfg.preset or "balanced"]
204
+ if br:
205
+ args += ["-rc", "vbr_peak", "-b:v", str(br), "-maxrate", str(int(br * 1.5))]
206
+ else:
207
+ args += ["-rc", "cqp", "-qp_i", str(cfg.quality), "-qp_p", str(cfg.quality)]
208
+ elif encoder == "h264_mf":
209
+ args += ["-pix_fmt", "nv12", "-b:v", str(br or 8_000_000)]
210
+ else: # libx264 and other software encoders
211
+ args += ["-pix_fmt", "yuv420p", "-preset", cfg.preset or "veryfast"]
212
+ if br:
213
+ args += ["-b:v", str(br), "-maxrate", str(int(br * 1.5)), "-bufsize", str(br * 2)]
214
+ else:
215
+ args += ["-crf", str(cfg.quality)]
216
+ return args
217
+
218
+
219
+ def build_command(
220
+ ffmpeg: str,
221
+ *,
222
+ output: str,
223
+ container: Container,
224
+ encoder: str | None,
225
+ cfg: EncoderConfig,
226
+ video_size: tuple[int, int] | None,
227
+ fps: float,
228
+ audio_inputs: Sequence[AudioInput] = (),
229
+ overwrite: bool = False,
230
+ ) -> list[str]:
231
+ """Build the FFmpeg argument list (never passed through a shell).
232
+
233
+ The output is given as ``file:<absolute path>`` so names starting with
234
+ ``-`` or containing ``proto:`` prefixes cannot be misinterpreted.
235
+ """
236
+ args = [ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "warning", "-nostats"]
237
+ args.append("-y" if overwrite else "-n")
238
+ inputs = 0
239
+ if video_size is not None:
240
+ w, h = video_size
241
+ args += [
242
+ "-thread_queue_size", "64", "-probesize", "32", "-analyzeduration", "0",
243
+ "-f", "rawvideo", "-pix_fmt", "bgra", "-video_size", f"{w}x{h}",
244
+ "-framerate", _fps_str(fps), "-i", "pipe:0",
245
+ ] # fmt: skip
246
+ inputs += 1
247
+ for a in audio_inputs:
248
+ args += [
249
+ "-thread_queue_size", "1024", "-probesize", "32", "-analyzeduration", "0",
250
+ "-f", "f32le", "-ar", str(a.sample_rate), "-ac", str(a.channels), "-i", a.path,
251
+ ] # fmt: skip
252
+ inputs += 1
253
+ for i in range(inputs):
254
+ args += ["-map", f"{i}"]
255
+ if video_size is not None:
256
+ assert encoder is not None
257
+ # No colour-space override: NVENC's internal RGB->YUV and swscale's
258
+ # default both use BT.601, which is what the encoders tag.
259
+ args += _video_codec_args(encoder, cfg, fps)
260
+ if audio_inputs:
261
+ acodec = cfg.audio_codec
262
+ args += ["-c:a", acodec]
263
+ if acodec not in ("flac", "pcm_f32le", "pcm_s16le"):
264
+ args += ["-b:a", str(cfg.audio_bitrate)]
265
+ for i, a in enumerate(audio_inputs):
266
+ args += [f"-metadata:s:a:{i}", f"title={a.title}"]
267
+ if container is Container.MP4:
268
+ # Fragmented MP4 stays playable if the process dies mid-recording.
269
+ args += ["-f", "mp4", "-movflags", "+frag_keyframe+empty_moov+default_base_moof"]
270
+ else:
271
+ args += ["-f", "matroska"]
272
+ args.append("file:" + str(Path(output).resolve()))
273
+ return args
274
+
275
+
276
+ def _fps_str(fps: float) -> str:
277
+ if abs(fps - round(fps)) < 1e-9:
278
+ return str(round(fps))
279
+ # NTSC-style rates such as 29.97 → 30000/1001
280
+ for base in (24, 30, 60, 120):
281
+ if abs(fps - base * 1000 / 1001) < 1e-3:
282
+ return f"{base * 1000}/1001"
283
+ return f"{fps:.6f}"
284
+
285
+
286
+ class FFmpegProcess:
287
+ """A running FFmpeg process with continuous stderr draining."""
288
+
289
+ def __init__(self, args: list[str]) -> None:
290
+ self.args = args
291
+ self._stderr_tail: collections.deque[str] = collections.deque(maxlen=200)
292
+ try:
293
+ self.proc = subprocess.Popen(
294
+ args,
295
+ stdin=subprocess.PIPE,
296
+ stdout=subprocess.DEVNULL,
297
+ stderr=subprocess.PIPE,
298
+ creationflags=_CREATE_NO_WINDOW,
299
+ bufsize=0,
300
+ )
301
+ except OSError as exc:
302
+ raise EncoderNotAvailableError(f"failed to start FFmpeg: {exc}") from exc
303
+ self._stderr_thread = threading.Thread(
304
+ target=self._drain, name="capturekit-ffmpeg-stderr", daemon=True
305
+ )
306
+ self._stderr_thread.start()
307
+
308
+ def _drain(self) -> None:
309
+ assert self.proc.stderr is not None
310
+ for raw in iter(self.proc.stderr.readline, b""):
311
+ self._stderr_tail.append(raw.decode("utf-8", "replace").rstrip())
312
+
313
+ @property
314
+ def stderr_tail(self) -> str:
315
+ return "\n".join(self._stderr_tail)
316
+
317
+ def write(self, data: memoryview | bytes) -> None:
318
+ """Write all of ``data`` to stdin (raises EncoderError if FFmpeg died)."""
319
+ stdin = self.proc.stdin
320
+ assert stdin is not None
321
+ mv = memoryview(data).cast("B")
322
+ try:
323
+ while mv:
324
+ mv = mv[stdin.write(mv) :]
325
+ except (BrokenPipeError, OSError, ValueError) as exc:
326
+ self.proc.poll()
327
+ raise EncoderError(
328
+ f"FFmpeg stopped accepting video ({exc})",
329
+ stderr_tail=self.stderr_tail,
330
+ returncode=self.proc.returncode,
331
+ ) from exc
332
+
333
+ def close_stdin(self) -> None:
334
+ if self.proc.stdin is not None and not self.proc.stdin.closed:
335
+ try:
336
+ self.proc.stdin.close()
337
+ except OSError:
338
+ pass
339
+
340
+ def wait(self, timeout: float) -> int:
341
+ """Wait for exit; terminate/kill on timeout. Returns the exit code."""
342
+ try:
343
+ return self.proc.wait(timeout=timeout)
344
+ except subprocess.TimeoutExpired:
345
+ self.proc.terminate()
346
+ try:
347
+ return self.proc.wait(timeout=5)
348
+ except subprocess.TimeoutExpired:
349
+ self.proc.kill()
350
+ return self.proc.wait(timeout=5)
351
+ finally:
352
+ self._stderr_thread.join(timeout=2)
353
+
354
+ def kill(self) -> None:
355
+ if self.proc.poll() is None:
356
+ self.proc.kill()
@@ -0,0 +1,212 @@
1
+ """Extra FFmpeg inputs: Windows named pipes and POSIX FIFOs.
2
+
3
+ The pipe is created before FFmpeg starts; a writer thread connects (blocking
4
+ until FFmpeg opens it) and streams data. ``abort()`` unblocks a writer stuck in
5
+ connect by connecting to the pipe ourselves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import uuid
15
+
16
+ from ..errors import EncoderError
17
+
18
+
19
+ class OutputPipe:
20
+ path: str
21
+
22
+ def connect(self) -> None:
23
+ raise NotImplementedError
24
+
25
+ def write(self, data: bytes | memoryview) -> None:
26
+ raise NotImplementedError
27
+
28
+ def close(self) -> None:
29
+ raise NotImplementedError
30
+
31
+ def abort(self) -> None:
32
+ raise NotImplementedError
33
+
34
+
35
+ if sys.platform == "win32":
36
+ import ctypes
37
+ from ctypes import byref, c_void_p
38
+ from ctypes import wintypes as wt
39
+
40
+ _k32 = ctypes.WinDLL("kernel32", use_last_error=True)
41
+ _adv = ctypes.WinDLL("advapi32", use_last_error=True)
42
+ _k32.CreateNamedPipeW.argtypes = (
43
+ wt.LPCWSTR,
44
+ wt.DWORD,
45
+ wt.DWORD,
46
+ wt.DWORD,
47
+ wt.DWORD,
48
+ wt.DWORD,
49
+ wt.DWORD,
50
+ c_void_p,
51
+ )
52
+ _k32.CreateNamedPipeW.restype = wt.HANDLE
53
+ _k32.ConnectNamedPipe.argtypes = (wt.HANDLE, c_void_p)
54
+ _k32.ConnectNamedPipe.restype = wt.BOOL
55
+ _k32.WriteFile.argtypes = (wt.HANDLE, c_void_p, wt.DWORD, ctypes.POINTER(wt.DWORD), c_void_p)
56
+ _k32.WriteFile.restype = wt.BOOL
57
+ _k32.FlushFileBuffers.argtypes = (wt.HANDLE,)
58
+ _k32.DisconnectNamedPipe.argtypes = (wt.HANDLE,)
59
+ _k32.CloseHandle.argtypes = (wt.HANDLE,)
60
+ _k32.CreateFileW.argtypes = (
61
+ wt.LPCWSTR,
62
+ wt.DWORD,
63
+ wt.DWORD,
64
+ c_void_p,
65
+ wt.DWORD,
66
+ wt.DWORD,
67
+ wt.HANDLE,
68
+ )
69
+ _k32.CreateFileW.restype = wt.HANDLE
70
+ _k32.LocalFree.argtypes = (c_void_p,)
71
+ _adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = (
72
+ wt.LPCWSTR,
73
+ wt.DWORD,
74
+ ctypes.POINTER(c_void_p),
75
+ c_void_p,
76
+ )
77
+ _adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wt.BOOL
78
+
79
+ class SECURITY_ATTRIBUTES(ctypes.Structure):
80
+ _fields_ = [
81
+ ("nLength", wt.DWORD),
82
+ ("lpSecurityDescriptor", c_void_p),
83
+ ("bInheritHandle", wt.BOOL),
84
+ ]
85
+
86
+ INVALID_HANDLE_VALUE = wt.HANDLE(-1).value
87
+ PIPE_ACCESS_OUTBOUND = 0x2
88
+ FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000
89
+ PIPE_TYPE_BYTE = 0x0
90
+ PIPE_WAIT = 0x0
91
+ PIPE_REJECT_REMOTE_CLIENTS = 0x8
92
+ ERROR_PIPE_CONNECTED = 535
93
+ GENERIC_READ = 0x80000000
94
+ OPEN_EXISTING = 3
95
+ #: Owner (current user), SYSTEM and Administrators only — other local
96
+ #: users cannot connect and read the recorded audio.
97
+ _PIPE_SDDL = "D:P(A;;GA;;;OW)(A;;GA;;;SY)(A;;GA;;;BA)"
98
+
99
+ class NamedPipe(OutputPipe):
100
+ def __init__(self, label: str, buffer_size: int = 1 << 20) -> None:
101
+ self.path = rf"\\.\pipe\capturekit-{uuid.uuid4().hex}-{label}"
102
+ sd = c_void_p()
103
+ if not _adv.ConvertStringSecurityDescriptorToSecurityDescriptorW(
104
+ self._sddl(), 1, byref(sd), None
105
+ ):
106
+ raise EncoderError(f"security descriptor failed ({ctypes.get_last_error()})")
107
+ sa = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), sd, False)
108
+ try:
109
+ self._h = _k32.CreateNamedPipeW(
110
+ self.path,
111
+ PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE,
112
+ PIPE_TYPE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
113
+ 1, buffer_size, buffer_size, 0, byref(sa),
114
+ ) # fmt: skip
115
+ finally:
116
+ _k32.LocalFree(sd)
117
+ if self._h in (None, INVALID_HANDLE_VALUE):
118
+ raise EncoderError(f"CreateNamedPipe failed ({ctypes.get_last_error()})")
119
+ self._lock = threading.Lock()
120
+ self._connected = False
121
+ self._closed = False
122
+
123
+ @staticmethod
124
+ def _sddl() -> str:
125
+ return _PIPE_SDDL
126
+
127
+ def connect(self) -> None:
128
+ ok = _k32.ConnectNamedPipe(self._h, None)
129
+ if not ok:
130
+ err = ctypes.get_last_error()
131
+ if err != ERROR_PIPE_CONNECTED:
132
+ raise EncoderError(f"ConnectNamedPipe failed ({err})")
133
+ self._connected = True
134
+
135
+ def write(self, data: bytes | memoryview) -> None:
136
+ mv = memoryview(data).cast("B")
137
+ written = wt.DWORD()
138
+ while mv:
139
+ chunk = mv[: 1 << 20]
140
+ buf = (
141
+ (ctypes.c_char * len(chunk)).from_buffer_copy(chunk)
142
+ if chunk.readonly
143
+ else (ctypes.c_char * len(chunk)).from_buffer(chunk)
144
+ )
145
+ if not _k32.WriteFile(self._h, buf, len(chunk), byref(written), None):
146
+ raise EncoderError(
147
+ f"audio pipe write failed ({ctypes.get_last_error()}); "
148
+ "FFmpeg closed its input"
149
+ )
150
+ mv = mv[written.value :]
151
+
152
+ def close(self) -> None:
153
+ with self._lock:
154
+ if self._closed:
155
+ return
156
+ self._closed = True
157
+ # Plain CloseHandle: the client still reads buffered data, then
158
+ # gets EOF. FlushFileBuffers would block until FFmpeg reads
159
+ # everything (forever if FFmpeg is stalled).
160
+ _k32.CloseHandle(self._h)
161
+
162
+ def abort(self) -> None:
163
+ """Unblock a pending ``connect()`` by connecting as a client."""
164
+ h = _k32.CreateFileW(self.path, GENERIC_READ, 0, None, OPEN_EXISTING, 0, None)
165
+ if h not in (None, INVALID_HANDLE_VALUE):
166
+ _k32.CloseHandle(h)
167
+
168
+ def create_pipe(label: str) -> OutputPipe:
169
+ return NamedPipe(label)
170
+
171
+ else:
172
+
173
+ class Fifo(OutputPipe):
174
+ _dir: str | None = None
175
+
176
+ def __init__(self, label: str) -> None:
177
+ if Fifo._dir is None or not os.path.isdir(Fifo._dir):
178
+ Fifo._dir = tempfile.mkdtemp(prefix="capturekit-") # mode 0700
179
+ self.path = os.path.join(Fifo._dir, f"{uuid.uuid4().hex}-{label}.f32")
180
+ os.mkfifo(self.path, 0o600)
181
+ self._fd: int | None = None
182
+
183
+ def connect(self) -> None:
184
+ self._fd = os.open(self.path, os.O_WRONLY) # blocks until FFmpeg opens it
185
+
186
+ def write(self, data: bytes | memoryview) -> None:
187
+ assert self._fd is not None
188
+ mv = memoryview(data).cast("B")
189
+ try:
190
+ while mv:
191
+ mv = mv[os.write(self._fd, mv) :]
192
+ except BrokenPipeError as exc:
193
+ raise EncoderError("FFmpeg closed its audio input") from exc
194
+
195
+ def close(self) -> None:
196
+ if self._fd is not None:
197
+ os.close(self._fd)
198
+ self._fd = None
199
+ try:
200
+ os.unlink(self.path)
201
+ except FileNotFoundError:
202
+ pass
203
+
204
+ def abort(self) -> None:
205
+ try:
206
+ fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
207
+ os.close(fd)
208
+ except OSError:
209
+ pass
210
+
211
+ def create_pipe(label: str) -> OutputPipe:
212
+ return Fifo(label)