capture-helper 0.2.2__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.
- capture_helper/__init__.py +81 -0
- capture_helper/api.py +310 -0
- capture_helper/camera.py +326 -0
- capture_helper/cli_argparse.py +382 -0
- capture_helper/cli_click.py +271 -0
- capture_helper/mcp.py +90 -0
- capture_helper/mic.py +285 -0
- capture_helper/sources.py +402 -0
- capture_helper-0.2.2.dist-info/METADATA +193 -0
- capture_helper-0.2.2.dist-info/RECORD +14 -0
- capture_helper-0.2.2.dist-info/WHEEL +5 -0
- capture_helper-0.2.2.dist-info/entry_points.txt +4 -0
- capture_helper-0.2.2.dist-info/licenses/LICENSE +29 -0
- capture_helper-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capture Helper
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
OBS-inspired (no GUI) **capture + process + publish** library for the
|
|
6
|
+
AI Helpers stack. v0.1.0 ships the **INPUT layer**: cross-platform
|
|
7
|
+
device enumeration + selection (cameras / microphones) and a pair of
|
|
8
|
+
iterators that bridge those devices to the rest of the suite's
|
|
9
|
+
contracts.
|
|
10
|
+
|
|
11
|
+
What ships in v0.1.0
|
|
12
|
+
--------------------
|
|
13
|
+
- :class:`SourceKind` — literal ``"camera"`` | ``"microphone"``.
|
|
14
|
+
- :class:`Source` — typed dict describing one device.
|
|
15
|
+
- :class:`MicFrame` — typed dict for one PCM frame (mirrors
|
|
16
|
+
:class:`podcast_helper.streaming.PcmFrame`).
|
|
17
|
+
- :func:`list_sources` — best-effort cross-platform device enumeration
|
|
18
|
+
via ``ffmpeg -list_devices`` (avfoundation / v4l2 / dshow / pulse /
|
|
19
|
+
alsa). Returns ``[]`` rather than raising on unsupported platforms.
|
|
20
|
+
- :func:`pick_source` — pick the first device of a given kind matching
|
|
21
|
+
optional ``name_substring`` / ``index`` filters.
|
|
22
|
+
- :func:`iter_camera_frames` — synchronous generator yielding
|
|
23
|
+
``(H, W, 3)`` BGR uint8 numpy arrays — same shape and dtype as
|
|
24
|
+
:func:`video_helper.extract_frames`.
|
|
25
|
+
- :func:`iter_mic_audio` — async generator yielding
|
|
26
|
+
:class:`MicFrame` — same shape as
|
|
27
|
+
:func:`podcast_helper.extract_audio_stream`.
|
|
28
|
+
|
|
29
|
+
What lands next
|
|
30
|
+
---------------
|
|
31
|
+
See :doc:`README` roadmap. v0.2.0 brings screen / window capture and a
|
|
32
|
+
basic filter chain; v0.3.0 brings multi-source mixing; v0.4.0 brings
|
|
33
|
+
RTMP / HLS / Icecast publish.
|
|
34
|
+
|
|
35
|
+
Usage example
|
|
36
|
+
-------------
|
|
37
|
+
>>> import asyncio, capture_helper as ch
|
|
38
|
+
>>> cam = ch.pick_source("camera")
|
|
39
|
+
>>> for frame in ch.iter_camera_frames(cam, output_width=640, output_height=360,
|
|
40
|
+
... fps=30, max_frames=300):
|
|
41
|
+
... # frame.shape == (360, 640, 3), dtype uint8, BGR.
|
|
42
|
+
... do_something(frame)
|
|
43
|
+
>>>
|
|
44
|
+
>>> async def listen():
|
|
45
|
+
... mic = ch.pick_source("microphone")
|
|
46
|
+
... async for f in ch.iter_mic_audio(mic, target_sample_rate=16000, frame_ms=20):
|
|
47
|
+
... # f["pcm"].shape == (320,) — 20ms @ 16kHz mono.
|
|
48
|
+
... await asr.feed(f["pcm"])
|
|
49
|
+
>>> asyncio.run(listen())
|
|
50
|
+
|
|
51
|
+
Author
|
|
52
|
+
------
|
|
53
|
+
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# Package-level attribution — kept here so tooling that reads
|
|
57
|
+
# ``capture_helper.__author__`` gets the canonical value. Email lives
|
|
58
|
+
# ONLY here (never in module-level docstrings) to keep source files
|
|
59
|
+
# scrapeable without leaking the mailbox to search engines.
|
|
60
|
+
__author__ = "Warith Harchaoui, Ph.D."
|
|
61
|
+
__email__ = "warithmetics@deraison.ai"
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
# types
|
|
65
|
+
"SourceKind",
|
|
66
|
+
"Source",
|
|
67
|
+
"MicFrame",
|
|
68
|
+
# device enumeration + selection
|
|
69
|
+
"list_sources",
|
|
70
|
+
"pick_source",
|
|
71
|
+
# low-level helper exposed because some callers want to splice the
|
|
72
|
+
# per-OS ffmpeg input args into their own pipelines.
|
|
73
|
+
"ffmpeg_input_args",
|
|
74
|
+
# live capture iterators
|
|
75
|
+
"iter_camera_frames",
|
|
76
|
+
"iter_mic_audio",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
from .camera import iter_camera_frames
|
|
80
|
+
from .mic import MicFrame, iter_mic_audio
|
|
81
|
+
from .sources import Source, SourceKind, ffmpeg_input_args, list_sources, pick_source
|
capture_helper/api.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capture Helper — FastAPI HTTP surface.
|
|
3
|
+
|
|
4
|
+
Exposes every public function in :mod:`capture_helper` as an HTTP
|
|
5
|
+
endpoint so `capture-helper` can be dropped behind any reverse proxy
|
|
6
|
+
and consumed by other services. Kept intentionally minimal:
|
|
7
|
+
|
|
8
|
+
- Query / form parameters for device selection.
|
|
9
|
+
- ``JSONResponse`` for device enumeration / picking / input-args (small
|
|
10
|
+
structured payloads).
|
|
11
|
+
- ``StreamingResponse`` (ZIP) for camera snapshots — one download per
|
|
12
|
+
call with the raw ``.bgr24`` frames inside.
|
|
13
|
+
- ``FileResponse`` for the WAV output from ``/capture/mic``.
|
|
14
|
+
- ``BackgroundTasks`` cleans temp files after the response has been
|
|
15
|
+
streamed — no leftover garbage on disk.
|
|
16
|
+
|
|
17
|
+
Install the extra to get the runtime dependencies::
|
|
18
|
+
|
|
19
|
+
pip install 'capture-helper[api]'
|
|
20
|
+
|
|
21
|
+
Then run the app with any ASGI server::
|
|
22
|
+
|
|
23
|
+
uvicorn capture_helper.api:app --host 0.0.0.0 --port 8000
|
|
24
|
+
|
|
25
|
+
Usage Example
|
|
26
|
+
-------------
|
|
27
|
+
>>> # Start the server:
|
|
28
|
+
>>> # uvicorn capture_helper.api:app --reload
|
|
29
|
+
>>> # List devices:
|
|
30
|
+
>>> # curl http://localhost:8000/sources
|
|
31
|
+
>>> # Pick a camera by name:
|
|
32
|
+
>>> # curl 'http://localhost:8000/pick?kind=camera&name=FaceTime'
|
|
33
|
+
>>> # Grab 10 frames at 320x240 into a ZIP:
|
|
34
|
+
>>> # curl -o frames.zip \\
|
|
35
|
+
>>> # 'http://localhost:8000/capture/camera?output_width=320&output_height=240&max_frames=10'
|
|
36
|
+
>>> # Record 3s of mic PCM to a WAV:
|
|
37
|
+
>>> # curl -o mic.wav 'http://localhost:8000/capture/mic?seconds=3'
|
|
38
|
+
>>> # Full OpenAPI docs at http://localhost:8000/docs
|
|
39
|
+
|
|
40
|
+
Author
|
|
41
|
+
------
|
|
42
|
+
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import asyncio
|
|
48
|
+
import io
|
|
49
|
+
import shutil
|
|
50
|
+
import tempfile
|
|
51
|
+
import wave
|
|
52
|
+
import zipfile
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Optional
|
|
55
|
+
|
|
56
|
+
import numpy as np
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
|
|
60
|
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
61
|
+
except ImportError as exc: # pragma: no cover
|
|
62
|
+
raise ImportError(
|
|
63
|
+
"The FastAPI HTTP surface requires the [api] extra. "
|
|
64
|
+
"Install with: pip install 'capture-helper[api]'"
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
from . import (
|
|
68
|
+
ffmpeg_input_args,
|
|
69
|
+
iter_camera_frames,
|
|
70
|
+
iter_mic_audio,
|
|
71
|
+
list_sources,
|
|
72
|
+
pick_source,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# App factory + shared plumbing
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
app = FastAPI(
|
|
82
|
+
title="Capture Helper API",
|
|
83
|
+
description=(
|
|
84
|
+
"HTTP surface for the capture-helper INPUT layer: enumerate cameras / "
|
|
85
|
+
"microphones, resolve one device, print the ffmpeg input argv, and "
|
|
86
|
+
"short-form live capture (camera snapshots as ZIP of raw bgr24 frames, "
|
|
87
|
+
"mic → WAV)."
|
|
88
|
+
),
|
|
89
|
+
version="0.2.2",
|
|
90
|
+
docs_url="/docs",
|
|
91
|
+
redoc_url="/redoc",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cleanup(*paths: Path | str) -> None:
|
|
96
|
+
"""Best-effort cleanup — never let a tidy-up failure kill a response."""
|
|
97
|
+
for p in paths:
|
|
98
|
+
try:
|
|
99
|
+
path = Path(p)
|
|
100
|
+
if path.is_dir():
|
|
101
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
102
|
+
elif path.exists():
|
|
103
|
+
path.unlink(missing_ok=True)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _new_tmpdir() -> Path:
|
|
109
|
+
"""Create a request-scoped temp directory under the system temp root."""
|
|
110
|
+
return Path(tempfile.mkdtemp(prefix="capture-helper-"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _zip_folder(folder: Path) -> io.BytesIO:
|
|
114
|
+
"""Bundle ``folder``'s contents into an in-memory ZIP for streaming."""
|
|
115
|
+
buf = io.BytesIO()
|
|
116
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
117
|
+
for p in folder.rglob("*"):
|
|
118
|
+
if p.is_file():
|
|
119
|
+
zf.write(p, arcname=p.relative_to(folder))
|
|
120
|
+
buf.seek(0)
|
|
121
|
+
return buf
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Meta
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.get("/health", tags=["meta"])
|
|
130
|
+
def health() -> dict:
|
|
131
|
+
"""Simple liveness probe — no dependency check, just proves the app is up."""
|
|
132
|
+
return {"status": "ok"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Reads — device enumeration + selection
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.get("/sources", tags=["reads"])
|
|
141
|
+
def sources(
|
|
142
|
+
kind: Optional[str] = Query(
|
|
143
|
+
None,
|
|
144
|
+
pattern="^(camera|microphone)$",
|
|
145
|
+
description="Filter to one kind; omit to list both.",
|
|
146
|
+
),
|
|
147
|
+
) -> JSONResponse:
|
|
148
|
+
"""Enumerate available capture devices as JSON."""
|
|
149
|
+
return JSONResponse(list_sources(kind))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.get("/pick", tags=["reads"])
|
|
153
|
+
def pick(
|
|
154
|
+
kind: str = Query(..., pattern="^(camera|microphone)$"),
|
|
155
|
+
name: Optional[str] = Query(None, description="Case-insensitive substring."),
|
|
156
|
+
index: Optional[int] = Query(None, description="Exact index match."),
|
|
157
|
+
) -> JSONResponse:
|
|
158
|
+
"""Resolve a single capture device by kind / name / index."""
|
|
159
|
+
try:
|
|
160
|
+
return JSONResponse(pick_source(kind, name_substring=name, index=index))
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
# ``pick_source`` raises ``ValueError`` when no device matches
|
|
163
|
+
# — surface that as a proper HTTP 404 rather than a 500.
|
|
164
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.get("/input-args", tags=["reads"])
|
|
168
|
+
def input_args(
|
|
169
|
+
kind: str = Query(..., pattern="^(camera|microphone)$"),
|
|
170
|
+
name: Optional[str] = Query(None, description="Case-insensitive substring."),
|
|
171
|
+
index: Optional[int] = Query(None, description="Exact index match."),
|
|
172
|
+
) -> JSONResponse:
|
|
173
|
+
"""Print the ffmpeg ``-f DRIVER -i SPEC`` argv fragment for a resolved device."""
|
|
174
|
+
try:
|
|
175
|
+
src = pick_source(kind, name_substring=name, index=index)
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
178
|
+
return JSONResponse({"argv": ffmpeg_input_args(src)})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Actions — live capture
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.get("/capture/camera", tags=["actions"])
|
|
187
|
+
def capture_camera(
|
|
188
|
+
background: BackgroundTasks,
|
|
189
|
+
name: Optional[str] = Query(None),
|
|
190
|
+
index: Optional[int] = Query(None),
|
|
191
|
+
width: Optional[int] = Query(None),
|
|
192
|
+
height: Optional[int] = Query(None),
|
|
193
|
+
fps: Optional[float] = Query(None),
|
|
194
|
+
output_width: Optional[int] = Query(None),
|
|
195
|
+
output_height: Optional[int] = Query(None),
|
|
196
|
+
pad_color: str = Query("black"),
|
|
197
|
+
max_frames: int = Query(30, ge=1, le=10_000),
|
|
198
|
+
):
|
|
199
|
+
"""Grab ``max_frames`` frames from the selected camera. Response is a ZIP."""
|
|
200
|
+
try:
|
|
201
|
+
src = pick_source("camera", name_substring=name, index=index)
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
204
|
+
|
|
205
|
+
tmp = _new_tmpdir()
|
|
206
|
+
frames_dir = tmp / "frames"
|
|
207
|
+
frames_dir.mkdir()
|
|
208
|
+
|
|
209
|
+
# ``iter_camera_frames`` is a synchronous generator; we consume it
|
|
210
|
+
# inline. The library already tears the ffmpeg subprocess down in
|
|
211
|
+
# a ``finally`` block, so an early return here is safe.
|
|
212
|
+
for i, frame in enumerate(
|
|
213
|
+
iter_camera_frames(
|
|
214
|
+
src,
|
|
215
|
+
width=width,
|
|
216
|
+
height=height,
|
|
217
|
+
fps=fps,
|
|
218
|
+
output_width=output_width,
|
|
219
|
+
output_height=output_height,
|
|
220
|
+
pad_color=pad_color,
|
|
221
|
+
max_frames=max_frames,
|
|
222
|
+
)
|
|
223
|
+
):
|
|
224
|
+
(frames_dir / f"frame_{i:06d}.bgr24").write_bytes(frame.tobytes())
|
|
225
|
+
|
|
226
|
+
buf = _zip_folder(frames_dir)
|
|
227
|
+
background.add_task(_cleanup, tmp)
|
|
228
|
+
return StreamingResponse(
|
|
229
|
+
buf,
|
|
230
|
+
media_type="application/zip",
|
|
231
|
+
headers={"Content-Disposition": 'attachment; filename="frames.zip"'},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _record_mic_to_wav(
|
|
236
|
+
src: dict,
|
|
237
|
+
out_path: Path,
|
|
238
|
+
*,
|
|
239
|
+
target_sample_rate: int,
|
|
240
|
+
to_mono: bool,
|
|
241
|
+
frame_ms: int,
|
|
242
|
+
max_frames: int,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Consume :func:`iter_mic_audio` into a WAV file — shared with the CLIs."""
|
|
245
|
+
chunks: list[np.ndarray] = []
|
|
246
|
+
async for frame in iter_mic_audio(
|
|
247
|
+
src,
|
|
248
|
+
target_sample_rate=target_sample_rate,
|
|
249
|
+
to_mono=to_mono,
|
|
250
|
+
frame_ms=frame_ms,
|
|
251
|
+
max_frames=max_frames,
|
|
252
|
+
):
|
|
253
|
+
chunks.append(frame["pcm"])
|
|
254
|
+
if not chunks:
|
|
255
|
+
# Fall through — writes an empty WAV rather than raising, so
|
|
256
|
+
# the client always gets a valid file back.
|
|
257
|
+
pcm16 = np.zeros(0, dtype="<i2")
|
|
258
|
+
n_channels = 1
|
|
259
|
+
else:
|
|
260
|
+
audio = np.concatenate(chunks, axis=0)
|
|
261
|
+
if audio.ndim == 1:
|
|
262
|
+
n_channels = 1
|
|
263
|
+
interleaved = audio
|
|
264
|
+
else:
|
|
265
|
+
n_channels = audio.shape[1]
|
|
266
|
+
interleaved = audio.reshape(-1)
|
|
267
|
+
pcm16 = np.clip(interleaved * 32767.0, -32768.0, 32767.0).astype("<i2")
|
|
268
|
+
|
|
269
|
+
with wave.open(str(out_path), "wb") as wf:
|
|
270
|
+
wf.setnchannels(n_channels)
|
|
271
|
+
wf.setsampwidth(2)
|
|
272
|
+
wf.setframerate(target_sample_rate)
|
|
273
|
+
wf.writeframes(pcm16.tobytes())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.get("/capture/mic", tags=["actions"])
|
|
277
|
+
def capture_mic(
|
|
278
|
+
background: BackgroundTasks,
|
|
279
|
+
name: Optional[str] = Query(None),
|
|
280
|
+
index: Optional[int] = Query(None),
|
|
281
|
+
seconds: float = Query(3.0, gt=0, le=600),
|
|
282
|
+
sample_rate: int = Query(16000, gt=0),
|
|
283
|
+
frame_ms: int = Query(20, gt=0),
|
|
284
|
+
mono: bool = Query(True),
|
|
285
|
+
):
|
|
286
|
+
"""Record ``seconds`` s of PCM from the selected microphone into a WAV file."""
|
|
287
|
+
try:
|
|
288
|
+
src = pick_source("microphone", name_substring=name, index=index)
|
|
289
|
+
except ValueError as exc:
|
|
290
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
291
|
+
|
|
292
|
+
tmp = _new_tmpdir()
|
|
293
|
+
out_path = tmp / "mic.wav"
|
|
294
|
+
frames_per_sec = max(1, 1000 // frame_ms)
|
|
295
|
+
max_frames = int(seconds * frames_per_sec)
|
|
296
|
+
# ``asyncio.run`` in a sync endpoint drives the async iterator to
|
|
297
|
+
# completion. FastAPI's own event loop is not involved for this
|
|
298
|
+
# short blocking call.
|
|
299
|
+
asyncio.run(
|
|
300
|
+
_record_mic_to_wav(
|
|
301
|
+
src,
|
|
302
|
+
out_path,
|
|
303
|
+
target_sample_rate=sample_rate,
|
|
304
|
+
to_mono=mono,
|
|
305
|
+
frame_ms=frame_ms,
|
|
306
|
+
max_frames=max_frames,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
background.add_task(_cleanup, tmp)
|
|
310
|
+
return FileResponse(str(out_path), filename=out_path.name, media_type="audio/wav")
|