streamcraft 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.
@@ -0,0 +1,52 @@
1
+ """
2
+ streamcraft — Friendly Python utilities for GStreamer.
3
+
4
+ Sits on top of the standard gi.repository GStreamer bindings,
5
+ reducing boilerplate while keeping all GStreamer concepts visible.
6
+
7
+ Quick start:
8
+ from streamcraft import PipelineBuilder
9
+
10
+ pipeline, elems = (
11
+ PipelineBuilder()
12
+ .element("v4l2src", name="src", device="/dev/video0")
13
+ .caps("image/jpeg,width=1280,height=720,framerate=30/1")
14
+ .element("queue", max_size_buffers=3, leaky=2)
15
+ .element("jpegdec")
16
+ .element("videoconvert")
17
+ .element("x264enc", tune="zerolatency", speed_preset="ultrafast", bitrate=2000)
18
+ .element("h264parse", config_interval=1)
19
+ .element("rtph264pay", pt=96)
20
+ .build()
21
+ )
22
+
23
+ # The pipeline is a real Gst.Pipeline — do anything with it
24
+ encoder = elems["encoder"] # or pipeline.get_by_name("encoder")
25
+ pipeline.set_state(Gst.State.PLAYING)
26
+ """
27
+
28
+ from .pipeline import PipelineBuilder
29
+ from .devices import (
30
+ require_elements,
31
+ check_v4l2_device,
32
+ list_v4l2_devices,
33
+ V4L2PTZCamera,
34
+ PTZStatus,
35
+ ControlRange,
36
+ )
37
+ from .webrtc import WebRTCSession
38
+
39
+ __all__ = [
40
+ # Pipeline building
41
+ "PipelineBuilder",
42
+ # Device utilities
43
+ "require_elements",
44
+ "check_v4l2_device",
45
+ "list_v4l2_devices",
46
+ "V4L2PTZCamera",
47
+ "PTZStatus",
48
+ "ControlRange",
49
+ # WebRTC session management
50
+ "WebRTCSession",
51
+ ]
52
+ __version__ = "0.1.0"
streamcraft/devices.py ADDED
@@ -0,0 +1,517 @@
1
+ """
2
+ streamcraft.devices — Device detection and camera control for GStreamer pipelines.
3
+
4
+ This module solves two distinct but related problems that come up early in
5
+ every GStreamer project:
6
+
7
+ 1. "Does this machine have what my pipeline needs?" — checked via
8
+ require_elements() and check_v4l2_device() before you try to build.
9
+
10
+ 2. "I have a PTZ camera; how do I control it?" — answered by V4L2PTZCamera,
11
+ which auto-detects a camera's capabilities and presents a clean API.
12
+
13
+ The design philosophy is the same as the rest of streamcraft: we sit on top of
14
+ the standard tools (v4l2-ctl, GStreamer's ElementFactory) without hiding them.
15
+ If you need to go deeper, the underlying commands and APIs are still there.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ from dataclasses import dataclass, field
24
+ from typing import Dict, List, Optional, Tuple
25
+
26
+ import gi
27
+ gi.require_version("Gst", "1.0")
28
+ from gi.repository import Gst
29
+
30
+ Gst.init(None)
31
+
32
+
33
+ # ─────────────────────────────────────────────────────────────────────────────
34
+ # Part 1: Environment / dependency checking
35
+ #
36
+ # These functions answer the question "can I even start?" before you spend
37
+ # time building a pipeline that will fail in a confusing way at runtime.
38
+ # ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ def require_elements(*factory_names: str) -> None:
41
+ """
42
+ Assert that all named GStreamer element factories are available on this system.
43
+
44
+ Call this near the top of your program, before building any pipelines.
45
+ If anything is missing, you get a clear error message with the exact
46
+ apt-get command to fix it — rather than a cryptic None or segfault
47
+ later when the missing element is actually needed.
48
+
49
+ Args:
50
+ *factory_names: One or more GStreamer element factory names, e.g.:
51
+ require_elements("webrtcbin", "x264enc", "alsasrc", "rtph264pay")
52
+
53
+ Raises:
54
+ EnvironmentError: If one or more elements are not found, with a
55
+ grouped message listing all missing elements and install hints.
56
+
57
+ Example:
58
+ # Check everything your pipeline needs in one shot at startup
59
+ require_elements(
60
+ "webrtcbin", "v4l2src", "jpegdec", "x264enc",
61
+ "h264parse", "rtph264pay", "alsasrc", "opusenc", "rtpopuspay"
62
+ )
63
+ """
64
+ missing = [
65
+ name for name in factory_names
66
+ if Gst.ElementFactory.find(name) is None
67
+ ]
68
+
69
+ if not missing:
70
+ return # All good — fast path, no output
71
+
72
+ # Build a helpful error message. We group the missing elements by which
73
+ # plugin package typically provides them, so the user knows what to install.
74
+ _PLUGIN_HINTS = {
75
+ "v4l2src": "gstreamer1.0-plugins-good",
76
+ "jpegdec": "gstreamer1.0-plugins-good",
77
+ "rtpvp8pay": "gstreamer1.0-plugins-good",
78
+ "rtpopuspay": "gstreamer1.0-plugins-good",
79
+ "alsasrc": "gstreamer1.0-alsa",
80
+ "alsasink": "gstreamer1.0-alsa",
81
+ "pulsesrc": "gstreamer1.0-pulseaudio",
82
+ "pulsesink": "gstreamer1.0-pulseaudio",
83
+ "audioconvert": "gstreamer1.0-plugins-base",
84
+ "audioresample": "gstreamer1.0-plugins-base",
85
+ "videoconvert": "gstreamer1.0-plugins-base",
86
+ "videoscale": "gstreamer1.0-plugins-base",
87
+ "opusenc": "gstreamer1.0-plugins-base",
88
+ "vorbisenc": "gstreamer1.0-plugins-base",
89
+ "theoraenc": "gstreamer1.0-plugins-base",
90
+ "x264enc": "gstreamer1.0-plugins-ugly",
91
+ "x265enc": "gstreamer1.0-plugins-ugly",
92
+ "lamemp3enc": "gstreamer1.0-plugins-ugly",
93
+ "webrtcbin": "gstreamer1.0-plugins-bad",
94
+ "dtlssrtpdec": "gstreamer1.0-plugins-bad",
95
+ "srtpdec": "gstreamer1.0-plugins-bad",
96
+ "h264parse": "gstreamer1.0-plugins-bad",
97
+ "decodebin": "gstreamer1.0-plugins-base",
98
+ "decodebin3": "gstreamer1.0-plugins-base",
99
+ "avdec_h264": "gstreamer1.0-libav",
100
+ "avdec_h265": "gstreamer1.0-libav",
101
+ }
102
+
103
+ # Collect suggested packages (deduped, preserving order)
104
+ seen_pkgs: Dict[str, bool] = {}
105
+ for name in missing:
106
+ pkg = _PLUGIN_HINTS.get(name)
107
+ if pkg and pkg not in seen_pkgs:
108
+ seen_pkgs[pkg] = True
109
+
110
+ lines = [
111
+ f"Missing GStreamer elements: {', '.join(missing)}",
112
+ "",
113
+ "These elements were not found on this system.",
114
+ "Verify each one with: gst-inspect-1.0 <element-name>",
115
+ ]
116
+
117
+ if seen_pkgs:
118
+ lines += [
119
+ "",
120
+ "Suggested packages to install:",
121
+ " sudo apt update && sudo apt install -y " + " ".join(seen_pkgs),
122
+ ]
123
+ else:
124
+ lines += [
125
+ "",
126
+ "Install the relevant GStreamer plugin packages for your distribution.",
127
+ "On Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-{base,good,bad,ugly}",
128
+ ]
129
+
130
+ raise EnvironmentError("\n".join(lines))
131
+
132
+
133
+ def check_v4l2_device(device: str = "/dev/video0") -> Tuple[bool, str]:
134
+ """
135
+ Check whether a V4L2 video device exists and is readable.
136
+
137
+ This is a lightweight check — it verifies the device file exists and
138
+ that a 1-buffer test pipeline can open it. It does NOT try to capture
139
+ a full frame, so it returns quickly.
140
+
141
+ Args:
142
+ device: The device path, e.g. "/dev/video0".
143
+
144
+ Returns:
145
+ A (success, message) tuple. success is True if the device is usable.
146
+ message is a human-readable description of what was found or what
147
+ went wrong — suitable for logging or displaying to the user.
148
+
149
+ Example:
150
+ ok, msg = check_v4l2_device("/dev/video0")
151
+ if not ok:
152
+ print(f"Camera not available: {msg}")
153
+ sys.exit(1)
154
+ print(msg) # e.g. "Device /dev/video0 is accessible"
155
+ """
156
+ if not os.path.exists(device):
157
+ return False, f"Device {device!r} does not exist"
158
+
159
+ if not os.access(device, os.R_OK):
160
+ return False, (
161
+ f"Device {device!r} exists but is not readable. "
162
+ f"Try: sudo usermod -aG video $USER (then re-login)"
163
+ )
164
+
165
+ # Attempt a 1-buffer pipeline to confirm the device actually opens.
166
+ # This catches "device busy" and driver errors that file permissions can't detect.
167
+ test_pipe = None
168
+ try:
169
+ test_pipe = Gst.parse_launch(
170
+ f"v4l2src device={device} num-buffers=1 ! fakesink"
171
+ )
172
+ ret = test_pipe.set_state(Gst.State.PLAYING)
173
+ if ret == Gst.StateChangeReturn.FAILURE:
174
+ return False, (
175
+ f"Device {device!r} could not be opened by GStreamer. "
176
+ f"It may be in use by another process."
177
+ )
178
+ return True, f"Device {device!r} is accessible"
179
+ except Exception as exc:
180
+ return False, f"Device {device!r} test failed: {exc}"
181
+ finally:
182
+ if test_pipe is not None:
183
+ test_pipe.set_state(Gst.State.NULL)
184
+
185
+
186
+ def list_v4l2_devices() -> List[str]:
187
+ """
188
+ Return a list of V4L2 video device paths available on this system.
189
+
190
+ Scans /dev/video* and returns paths that exist. Does not verify that
191
+ each device is actually openable — use check_v4l2_device() for that.
192
+
193
+ Returns:
194
+ A sorted list of device paths, e.g. ["/dev/video0", "/dev/video2"].
195
+ Returns an empty list if no devices are found.
196
+ """
197
+ import glob
198
+ return sorted(glob.glob("/dev/video*"))
199
+
200
+
201
+ # ─────────────────────────────────────────────────────────────────────────────
202
+ # Part 2: PTZ camera control
203
+ #
204
+ # V4L2PTZCamera wraps v4l2-ctl to control pan, tilt, and zoom on any
205
+ # camera that exposes those controls via the V4L2 extended controls API.
206
+ # Tested with Obsbot Tail Air, Obsbot Meet 4K, Logitech PTZ Pro, and similar.
207
+ # ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ @dataclass
210
+ class ControlRange:
211
+ """The valid range for a single PTZ control axis, as reported by the camera."""
212
+ min: int
213
+ max: int
214
+ step: int
215
+ current: int
216
+
217
+ def clamp(self, value: int) -> int:
218
+ """Clamp a value to this range, snapping to the nearest valid step."""
219
+ clamped = max(self.min, min(self.max, value))
220
+ # Snap to nearest valid step from the minimum
221
+ if self.step > 0:
222
+ offset = clamped - self.min
223
+ snapped = self.min + round(offset / self.step) * self.step
224
+ return max(self.min, min(self.max, snapped))
225
+ return clamped
226
+
227
+ @property
228
+ def span(self) -> int:
229
+ """The total range from min to max."""
230
+ return self.max - self.min
231
+
232
+
233
+ @dataclass
234
+ class PTZStatus:
235
+ """A snapshot of the camera's current state, including all ranges."""
236
+ available: bool
237
+ device: str
238
+ pan: int = 0
239
+ tilt: int = 0
240
+ zoom: int = 0
241
+ pan_range: Optional[ControlRange] = None
242
+ tilt_range: Optional[ControlRange] = None
243
+ zoom_range: Optional[ControlRange] = None
244
+
245
+ def to_dict(self) -> dict:
246
+ """Serialize to a plain dict — convenient for sending over WebSocket."""
247
+ return {
248
+ "available": self.available,
249
+ "device": self.device,
250
+ "pan": self.pan,
251
+ "tilt": self.tilt,
252
+ "zoom": self.zoom,
253
+ "ranges": {
254
+ "pan": {"min": self.pan_range.min, "max": self.pan_range.max} if self.pan_range else None,
255
+ "tilt": {"min": self.tilt_range.min, "max": self.tilt_range.max} if self.tilt_range else None,
256
+ "zoom": {"min": self.zoom_range.min, "max": self.zoom_range.max} if self.zoom_range else None,
257
+ },
258
+ }
259
+
260
+
261
+ class V4L2PTZCamera:
262
+ """
263
+ Control pan, tilt, and zoom on any V4L2 camera that supports those controls.
264
+
265
+ On creation, the class uses v4l2-ctl to auto-detect which controls the
266
+ camera exposes and what ranges are valid for each. This means the same
267
+ class works with any PTZ camera without you needing to hardcode min/max
268
+ values per camera model.
269
+
270
+ All set_*() methods silently clamp values to the camera's valid range,
271
+ so callers don't need to guard against out-of-bounds values. If a control
272
+ is not available on the connected camera, the method does nothing and
273
+ returns False.
274
+
275
+ If the camera is not connected, all operations are no-ops. This is
276
+ intentional — it allows code that uses PTZ control to start successfully
277
+ even when no PTZ camera is plugged in, and add the camera later.
278
+
279
+ Example:
280
+ cam = V4L2PTZCamera("/dev/video0")
281
+
282
+ if cam.available:
283
+ print(cam.status)
284
+ cam.set_pan(90_000) # tilt right (units are arc-seconds * 100)
285
+ cam.set_tilt(-50_000) # tilt down
286
+ cam.set_zoom(200) # 2x zoom (if zoom range is 100–400)
287
+ cam.reset() # back to center, minimum zoom
288
+
289
+ # Or set everything at once:
290
+ cam.set_ptz(pan=0, tilt=0, zoom=100)
291
+ """
292
+
293
+ # Default fallback ranges used when auto-detection fails or a control
294
+ # isn't listed. These are typical values for mid-range PTZ cameras.
295
+ _DEFAULT_PAN_MIN = -468_000
296
+ _DEFAULT_PAN_MAX = 468_000
297
+ _DEFAULT_TILT_MIN = -324_000
298
+ _DEFAULT_TILT_MAX = 324_000
299
+ _DEFAULT_ZOOM_MIN = 100
300
+ _DEFAULT_ZOOM_MAX = 400
301
+
302
+ def __init__(self, device: str = "/dev/video0"):
303
+ self.device = device
304
+ self.available = False
305
+
306
+ # Ranges — populated by _detect_ranges(), defaulted here so that
307
+ # all attributes always exist even if detection fails
308
+ self.pan_range = ControlRange(self._DEFAULT_PAN_MIN, self._DEFAULT_PAN_MAX, 3600, 0)
309
+ self.tilt_range = ControlRange(self._DEFAULT_TILT_MIN, self._DEFAULT_TILT_MAX, 3600, 0)
310
+ self.zoom_range = ControlRange(self._DEFAULT_ZOOM_MIN, self._DEFAULT_ZOOM_MAX, 1, self._DEFAULT_ZOOM_MIN)
311
+
312
+ # Current values — updated on every successful set_*() call
313
+ self._pan = 0
314
+ self._tilt = 0
315
+ self._zoom = self._DEFAULT_ZOOM_MIN
316
+
317
+ self._check_availability()
318
+ if self.available:
319
+ self._detect_ranges()
320
+
321
+ # ── Public API ────────────────────────────────────────────────────────────
322
+
323
+ @property
324
+ def pan(self) -> int:
325
+ return self._pan
326
+
327
+ @property
328
+ def tilt(self) -> int:
329
+ return self._tilt
330
+
331
+ @property
332
+ def zoom(self) -> int:
333
+ return self._zoom
334
+
335
+ @property
336
+ def status(self) -> PTZStatus:
337
+ """Return the current camera state as a PTZStatus dataclass."""
338
+ return PTZStatus(
339
+ available=self.available,
340
+ device=self.device,
341
+ pan=self._pan,
342
+ tilt=self._tilt,
343
+ zoom=self._zoom,
344
+ pan_range=self.pan_range,
345
+ tilt_range=self.tilt_range,
346
+ zoom_range=self.zoom_range,
347
+ )
348
+
349
+ def set_pan(self, value: int) -> bool:
350
+ """
351
+ Set the pan position. Value is clamped to the camera's valid range.
352
+ Units are camera-specific (typically arc-seconds × 100 for most PTZ cameras).
353
+
354
+ Returns True on success, False if the camera is unavailable or the
355
+ control failed.
356
+ """
357
+ if not self.available:
358
+ return False
359
+ value = self.pan_range.clamp(value)
360
+ if self._set_ctrl("pan_absolute", value):
361
+ self._pan = value
362
+ return True
363
+ return False
364
+
365
+ def set_tilt(self, value: int) -> bool:
366
+ """
367
+ Set the tilt position. Value is clamped to the camera's valid range.
368
+
369
+ Returns True on success, False if the camera is unavailable or the
370
+ control failed.
371
+ """
372
+ if not self.available:
373
+ return False
374
+ value = self.tilt_range.clamp(value)
375
+ if self._set_ctrl("tilt_absolute", value):
376
+ self._tilt = value
377
+ return True
378
+ return False
379
+
380
+ def set_zoom(self, value: int) -> bool:
381
+ """
382
+ Set the zoom level. Value is clamped to the camera's valid range.
383
+
384
+ Returns True on success, False if the camera is unavailable or the
385
+ control failed.
386
+ """
387
+ if not self.available:
388
+ return False
389
+ value = self.zoom_range.clamp(value)
390
+ if self._set_ctrl("zoom_absolute", value):
391
+ self._zoom = value
392
+ return True
393
+ return False
394
+
395
+ def set_ptz(self, pan: int, tilt: int, zoom: int) -> bool:
396
+ """
397
+ Set pan, tilt, and zoom in one call. All values are clamped.
398
+
399
+ Returns True only if all three controls succeeded.
400
+ """
401
+ ok_pan = self.set_pan(pan)
402
+ ok_tilt = self.set_tilt(tilt)
403
+ ok_zoom = self.set_zoom(zoom)
404
+ return ok_pan and ok_tilt and ok_zoom
405
+
406
+ def reset(self) -> bool:
407
+ """Move to center position (pan=0, tilt=0) at minimum zoom."""
408
+ return self.set_ptz(0, 0, self.zoom_range.min)
409
+
410
+ # ── Internal helpers ──────────────────────────────────────────────────────
411
+
412
+ def _check_availability(self) -> None:
413
+ """Use v4l2-ctl --info to verify the device is reachable."""
414
+ try:
415
+ result = subprocess.run(
416
+ ["v4l2-ctl", "-d", self.device, "--info"],
417
+ capture_output=True, text=True, timeout=2,
418
+ )
419
+ self.available = (result.returncode == 0)
420
+ except FileNotFoundError:
421
+ # v4l2-ctl is not installed
422
+ self.available = False
423
+ except Exception:
424
+ self.available = False
425
+
426
+ def _detect_ranges(self) -> None:
427
+ """
428
+ Parse v4l2-ctl --list-ctrls to find valid ranges for pan, tilt, zoom.
429
+
430
+ The output looks like:
431
+ pan_absolute 0x009a0908 (int) : min=-522000 max=522000 step=3600 default=0 value=0
432
+ tilt_absolute 0x009a0909 (int) : min=-270000 max=270000 step=3600 default=0 value=0
433
+ zoom_absolute 0x009a090d (int) : min=100 max=400 step=10 default=100 value=100
434
+
435
+ We parse min, max, step, and current value for each control we care
436
+ about and store them in ControlRange instances.
437
+ """
438
+ try:
439
+ result = subprocess.run(
440
+ ["v4l2-ctl", "-d", self.device, "--list-ctrls"],
441
+ capture_output=True, text=True, timeout=2,
442
+ )
443
+ if result.returncode != 0:
444
+ return
445
+
446
+ for line in result.stdout.splitlines():
447
+ line = line.strip()
448
+
449
+ if "pan_absolute" in line:
450
+ parsed = _parse_v4l2_ctrl_line(line)
451
+ if parsed:
452
+ self.pan_range = parsed
453
+ self._pan = parsed.current
454
+
455
+ elif "tilt_absolute" in line:
456
+ parsed = _parse_v4l2_ctrl_line(line)
457
+ if parsed:
458
+ self.tilt_range = parsed
459
+ self._tilt = parsed.current
460
+
461
+ elif "zoom_absolute" in line:
462
+ parsed = _parse_v4l2_ctrl_line(line)
463
+ if parsed:
464
+ self.zoom_range = parsed
465
+ self._zoom = parsed.current
466
+
467
+ except Exception:
468
+ # If detection fails for any reason, we keep the defaults.
469
+ # This is a deliberate silent failure — PTZ detection failing
470
+ # should not crash the application.
471
+ pass
472
+
473
+ def _set_ctrl(self, control_name: str, value: int) -> bool:
474
+ """
475
+ Run v4l2-ctl --set-ctrl to apply a single control value.
476
+ Returns True on success, False on any failure.
477
+ """
478
+ try:
479
+ result = subprocess.run(
480
+ ["v4l2-ctl", "-d", self.device,
481
+ "--set-ctrl", f"{control_name}={value}"],
482
+ capture_output=True, text=True, timeout=1,
483
+ )
484
+ return result.returncode == 0
485
+ except Exception:
486
+ return False
487
+
488
+
489
+ def _parse_v4l2_ctrl_line(line: str) -> Optional[ControlRange]:
490
+ """
491
+ Parse a single line of v4l2-ctl --list-ctrls output into a ControlRange.
492
+
493
+ The format is:
494
+ <name> <hex_id> (<type>) : min=<N> max=<N> step=<N> default=<N> value=<N>
495
+
496
+ We only need min, max, step, and the current value. Returns None if
497
+ any required field is missing or unparseable.
498
+ """
499
+ fields: Dict[str, int] = {}
500
+ for token in line.split():
501
+ for key in ("min", "max", "step", "value"):
502
+ if token.startswith(f"{key}="):
503
+ try:
504
+ fields[key] = int(token.split("=", 1)[1])
505
+ except ValueError:
506
+ pass
507
+
508
+ required = ("min", "max", "step", "value")
509
+ if not all(k in fields for k in required):
510
+ return None
511
+
512
+ return ControlRange(
513
+ min=fields["min"],
514
+ max=fields["max"],
515
+ step=fields["step"],
516
+ current=fields["value"],
517
+ )