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.
@@ -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")