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
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,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)
|