OctoPrint-BitBang 0.1.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.
- octoprint_bitbang/__init__.py +499 -0
- octoprint_bitbang/__main__.py +2 -0
- octoprint_bitbang/app.py +86 -0
- octoprint_bitbang/camera.py +117 -0
- octoprint_bitbang/flip_track.py +59 -0
- octoprint_bitbang/index.html +16 -0
- octoprint_bitbang/octoprint_adapter.py +103 -0
- octoprint_bitbang/pi_camera_track.py +48 -0
- octoprint_bitbang/pi_h264_source.py +149 -0
- octoprint_bitbang/static/favicon.png +0 -0
- octoprint_bitbang/static/js/bitbang.js +249 -0
- octoprint_bitbang/templates/bitbang_navbar.jinja2 +29 -0
- octoprint_bitbang/templates/bitbang_settings.jinja2 +144 -0
- octoprint_bitbang/usb_camera_source.py +71 -0
- octoprint_bitbang-0.1.2.dist-info/METADATA +152 -0
- octoprint_bitbang-0.1.2.dist-info/RECORD +20 -0
- octoprint_bitbang-0.1.2.dist-info/WHEEL +5 -0
- octoprint_bitbang-0.1.2.dist-info/entry_points.txt +5 -0
- octoprint_bitbang-0.1.2.dist-info/licenses/LICENSE +21 -0
- octoprint_bitbang-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Camera auto-detection for OctoPrint BitBang.
|
|
2
|
+
|
|
3
|
+
Detects available camera sources in priority order:
|
|
4
|
+
1. picamera2 CSI camera (Pi hardware, software encode in aiortc)
|
|
5
|
+
2. USB webcam via V4L2/dshow/avfoundation (software encode)
|
|
6
|
+
3. None (HTTP-only mode, no crash)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect_camera(logger=None):
|
|
13
|
+
"""Detect best available camera source.
|
|
14
|
+
|
|
15
|
+
Returns dict with keys: type, device, format, options
|
|
16
|
+
Or None if no camera found.
|
|
17
|
+
"""
|
|
18
|
+
log = logger.info if logger else lambda msg: print(f"[camera] {msg}")
|
|
19
|
+
|
|
20
|
+
# 1. picamera2 (Raspberry Pi CSI)
|
|
21
|
+
source = _try_picamera2()
|
|
22
|
+
if source:
|
|
23
|
+
log("Found Pi CSI camera via picamera2")
|
|
24
|
+
return source
|
|
25
|
+
|
|
26
|
+
# 2. USB webcam (platform-specific)
|
|
27
|
+
source = _try_usb_webcam()
|
|
28
|
+
if source:
|
|
29
|
+
log(f"Found USB webcam: {source['device']}")
|
|
30
|
+
return source
|
|
31
|
+
|
|
32
|
+
log("No camera found - running in HTTP-only mode")
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _try_picamera2():
|
|
37
|
+
"""Check if picamera2 is available (Raspberry Pi CSI camera)."""
|
|
38
|
+
try:
|
|
39
|
+
from picamera2 import Picamera2
|
|
40
|
+
cam = Picamera2()
|
|
41
|
+
cam.close()
|
|
42
|
+
return {"type": "picamera2"}
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _try_usb_webcam():
|
|
48
|
+
"""Check for USB webcam (platform-specific)."""
|
|
49
|
+
if sys.platform == 'darwin':
|
|
50
|
+
return {
|
|
51
|
+
"type": "usb",
|
|
52
|
+
"device": "0:none",
|
|
53
|
+
"format": "avfoundation",
|
|
54
|
+
"options": {"framerate": "30", "video_size": "640x480"},
|
|
55
|
+
}
|
|
56
|
+
elif sys.platform == 'win32':
|
|
57
|
+
camera = _find_windows_camera()
|
|
58
|
+
if camera:
|
|
59
|
+
return {
|
|
60
|
+
"type": "usb",
|
|
61
|
+
"device": f"video={camera}",
|
|
62
|
+
"format": "dshow",
|
|
63
|
+
"options": {"framerate": "30", "video_size": "640x480"},
|
|
64
|
+
}
|
|
65
|
+
return None
|
|
66
|
+
else:
|
|
67
|
+
# Linux: check /dev/video0 through /dev/video3
|
|
68
|
+
import os
|
|
69
|
+
for i in range(4):
|
|
70
|
+
dev = f"/dev/video{i}"
|
|
71
|
+
if os.path.exists(dev):
|
|
72
|
+
if _is_v4l2_capture(dev):
|
|
73
|
+
return {
|
|
74
|
+
"type": "usb",
|
|
75
|
+
"device": dev,
|
|
76
|
+
"format": "v4l2",
|
|
77
|
+
"options": {"framerate": "30", "video_size": "640x480"},
|
|
78
|
+
}
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_v4l2_capture(device):
|
|
83
|
+
"""Check if a V4L2 device is a video capture device (not encoder/decoder).
|
|
84
|
+
|
|
85
|
+
Skips V4L2 M2M devices like /dev/video10, /dev/video11 on Raspberry Pi.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
import fcntl
|
|
89
|
+
import struct
|
|
90
|
+
VIDIOC_QUERYCAP = 0x80685600
|
|
91
|
+
with open(device, 'rb') as f:
|
|
92
|
+
buf = bytearray(104)
|
|
93
|
+
fcntl.ioctl(f, VIDIOC_QUERYCAP, buf)
|
|
94
|
+
capabilities = struct.unpack_from('<I', buf, 84)[0]
|
|
95
|
+
V4L2_CAP_VIDEO_CAPTURE = 0x00000001
|
|
96
|
+
return bool(capabilities & V4L2_CAP_VIDEO_CAPTURE)
|
|
97
|
+
except Exception:
|
|
98
|
+
# If we can't probe, assume it's a capture device
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_windows_camera():
|
|
103
|
+
"""Discover first available camera on Windows via PowerShell."""
|
|
104
|
+
import subprocess
|
|
105
|
+
try:
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
['powershell', '-Command',
|
|
108
|
+
'Get-PnpDevice -Class Camera -Status OK | '
|
|
109
|
+
'Select-Object -ExpandProperty FriendlyName'],
|
|
110
|
+
capture_output=True, text=True, timeout=5
|
|
111
|
+
)
|
|
112
|
+
name = result.stdout.strip().split('\n')[0].strip()
|
|
113
|
+
if name:
|
|
114
|
+
return name
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""MediaStreamTrack wrapper that hflip/vflips frames via PyAV.
|
|
2
|
+
|
|
3
|
+
Used for USB webcams where flip can't be done at the sensor level.
|
|
4
|
+
aiortc's MediaPlayer already decodes frames to av.VideoFrame before
|
|
5
|
+
handing them to the H.264 encoder, so we intercept there and flip
|
|
6
|
+
in-place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from aiortc import MediaStreamTrack
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FlippedTrack(MediaStreamTrack):
|
|
13
|
+
kind = "video"
|
|
14
|
+
|
|
15
|
+
def __init__(self, source, hflip=False, vflip=False):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._source = source
|
|
18
|
+
self._hflip = bool(hflip)
|
|
19
|
+
self._vflip = bool(vflip)
|
|
20
|
+
self._graph = None
|
|
21
|
+
self._buffer_src = None
|
|
22
|
+
self._buffer_sink = None
|
|
23
|
+
|
|
24
|
+
def _init_graph(self, frame):
|
|
25
|
+
# Build a PyAV filter graph matching the first frame's format.
|
|
26
|
+
# Chaining hflip and vflip gives us a 180° rotation when both are set.
|
|
27
|
+
from av.filter import Graph
|
|
28
|
+
|
|
29
|
+
graph = Graph()
|
|
30
|
+
src = graph.add_buffer(template=frame)
|
|
31
|
+
last = src
|
|
32
|
+
if self._hflip:
|
|
33
|
+
n = graph.add("hflip")
|
|
34
|
+
last.link_to(n)
|
|
35
|
+
last = n
|
|
36
|
+
if self._vflip:
|
|
37
|
+
n = graph.add("vflip")
|
|
38
|
+
last.link_to(n)
|
|
39
|
+
last = n
|
|
40
|
+
sink = graph.add("buffersink")
|
|
41
|
+
last.link_to(sink)
|
|
42
|
+
graph.configure()
|
|
43
|
+
self._graph = graph
|
|
44
|
+
self._buffer_src = src
|
|
45
|
+
self._buffer_sink = sink
|
|
46
|
+
|
|
47
|
+
async def recv(self):
|
|
48
|
+
frame = await self._source.recv()
|
|
49
|
+
if not (self._hflip or self._vflip):
|
|
50
|
+
return frame
|
|
51
|
+
if self._graph is None:
|
|
52
|
+
self._init_graph(frame)
|
|
53
|
+
self._buffer_src.push(frame)
|
|
54
|
+
return self._buffer_sink.pull()
|
|
55
|
+
|
|
56
|
+
def stop(self):
|
|
57
|
+
super().stop()
|
|
58
|
+
if self._source and hasattr(self._source, "stop"):
|
|
59
|
+
self._source.stop()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<style>
|
|
5
|
+
body { font-family: sans-serif; text-align: center; padding: 20px; }
|
|
6
|
+
video { width: 100%; max-width: 640px; background: #000; }
|
|
7
|
+
#status { color: #666; margin-top: 10px; }
|
|
8
|
+
</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<h2>OctoPrint BitBang Prototype</h2>
|
|
12
|
+
<video data-bitbang-stream="camera" autoplay playsinline muted></video>
|
|
13
|
+
<p id="status">Camera stream + HTTP tunnel active</p>
|
|
14
|
+
<p>In the real plugin, OctoPrint's full UI loads here via the BitBang HTTP tunnel.</p>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""OctoPrint BitBang adapter - extends BitBangASGI with camera video track.
|
|
2
|
+
|
|
3
|
+
Subclasses BitBangASGI to add a camera video track alongside async HTTP
|
|
4
|
+
reverse proxy. Fully async -- no WSGI thread pool.
|
|
5
|
+
Camera source is auto-detected or explicitly configured.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from bitbang import BitBangASGI
|
|
9
|
+
from aiortc.contrib.media import MediaRelay
|
|
10
|
+
|
|
11
|
+
from .camera import detect_camera
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def force_h264(pc, sender):
|
|
15
|
+
"""Force H.264 codec on a transceiver so aiortc doesn't negotiate VP8."""
|
|
16
|
+
from aiortc.rtcrtpsender import RTCRtpSender
|
|
17
|
+
h264 = [c for c in RTCRtpSender.getCapabilities("video").codecs
|
|
18
|
+
if c.name == "H264"]
|
|
19
|
+
for t in pc.getTransceivers():
|
|
20
|
+
if t.sender is sender:
|
|
21
|
+
t.setCodecPreferences(h264)
|
|
22
|
+
break
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OctoPrintBitBang(BitBangASGI):
|
|
26
|
+
"""BitBang adapter with camera video for OctoPrint remote access.
|
|
27
|
+
|
|
28
|
+
Extends BitBangASGI to capture video from the best available camera
|
|
29
|
+
source and share it with all connected clients using MediaRelay.
|
|
30
|
+
Falls back to HTTP-only mode if no camera is found.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, app, camera_source=None, ws_target=None, **kwargs):
|
|
34
|
+
super().__init__(app, **kwargs)
|
|
35
|
+
self.ws_target = ws_target # host:port for WebSocket bridging
|
|
36
|
+
self.relay = MediaRelay()
|
|
37
|
+
self.player = None
|
|
38
|
+
self._init_camera(camera_source)
|
|
39
|
+
|
|
40
|
+
def _init_camera(self, camera_source):
|
|
41
|
+
"""Initialize camera from explicit source or auto-detect."""
|
|
42
|
+
source = camera_source or detect_camera()
|
|
43
|
+
if not source:
|
|
44
|
+
print("No camera - running in HTTP-only mode")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if source["type"] == "usb":
|
|
48
|
+
# USB webcam (software H.264 encode via aiortc)
|
|
49
|
+
try:
|
|
50
|
+
from .usb_camera_source import UsbCameraSource
|
|
51
|
+
self.player = UsbCameraSource(
|
|
52
|
+
device=source["device"],
|
|
53
|
+
format=source.get("format"),
|
|
54
|
+
options=source.get("options", {}),
|
|
55
|
+
brightness=source.get("brightness", 0),
|
|
56
|
+
flip_horizontal=source.get("flip_horizontal", False),
|
|
57
|
+
flip_vertical=source.get("flip_vertical", False),
|
|
58
|
+
)
|
|
59
|
+
print(f"Opened USB camera: {source['device']}")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Warning: Could not open camera '{source['device']}': {e}")
|
|
62
|
+
|
|
63
|
+
elif source["type"] == "picamera2":
|
|
64
|
+
# Pi CSI camera - picamera2 H264Encoder (hw on Pi 4, sw on Pi 5)
|
|
65
|
+
# emits Annex-B H.264 that aiortc packetizes without re-encoding.
|
|
66
|
+
try:
|
|
67
|
+
from .pi_h264_source import PiH264Track
|
|
68
|
+
size = source.get("size", (640, 480))
|
|
69
|
+
framerate = source.get("framerate", 30)
|
|
70
|
+
bitrate = source.get("bitrate", 4_000_000)
|
|
71
|
+
brightness = source.get("brightness", 0)
|
|
72
|
+
self.player = PiH264Track(
|
|
73
|
+
size=size, framerate=framerate, bitrate=bitrate,
|
|
74
|
+
brightness=brightness,
|
|
75
|
+
flip_horizontal=source.get("flip_horizontal", False),
|
|
76
|
+
flip_vertical=source.get("flip_vertical", False),
|
|
77
|
+
)
|
|
78
|
+
print(f"Opened Pi CSI camera via H264Encoder ({size[0]}x{size[1]}@{framerate})")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(f"Warning: Could not open Pi CSI camera: {e}")
|
|
81
|
+
|
|
82
|
+
def setup_peer_connection(self, pc, client_id):
|
|
83
|
+
"""Add camera video track to peer connection."""
|
|
84
|
+
if self.player and self.player.video:
|
|
85
|
+
sender = pc.addTrack(self.relay.subscribe(self.player.video))
|
|
86
|
+
force_h264(pc, sender)
|
|
87
|
+
print(f"Added camera video track for {client_id}")
|
|
88
|
+
|
|
89
|
+
def get_stream_metadata(self):
|
|
90
|
+
"""Return stream name for video track."""
|
|
91
|
+
if self.player and self.player.video:
|
|
92
|
+
return {"0": "camera"}
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
async def close(self):
|
|
96
|
+
"""Close peer connections and media player."""
|
|
97
|
+
await super().close()
|
|
98
|
+
if self.player:
|
|
99
|
+
if hasattr(self.player, "stop"):
|
|
100
|
+
self.player.stop()
|
|
101
|
+
elif self.player.video:
|
|
102
|
+
self.player.video.stop()
|
|
103
|
+
self.player = None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""aiortc video track backed by picamera2 (Raspberry Pi CSI camera).
|
|
2
|
+
|
|
3
|
+
Captures frames from picamera2 and yields them as aiortc VideoFrames.
|
|
4
|
+
aiortc handles software H.264 encoding downstream.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import fractions
|
|
9
|
+
|
|
10
|
+
from aiortc import MediaStreamTrack
|
|
11
|
+
from av import VideoFrame
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PiCameraTrack(MediaStreamTrack):
|
|
15
|
+
kind = "video"
|
|
16
|
+
|
|
17
|
+
def __init__(self, size=(640, 480), framerate=30):
|
|
18
|
+
super().__init__()
|
|
19
|
+
from picamera2 import Picamera2
|
|
20
|
+
|
|
21
|
+
self.picam2 = Picamera2()
|
|
22
|
+
config = self.picam2.create_video_configuration(
|
|
23
|
+
main={"size": size, "format": "RGB888"}
|
|
24
|
+
)
|
|
25
|
+
self.picam2.configure(config)
|
|
26
|
+
self.picam2.start()
|
|
27
|
+
|
|
28
|
+
self.framerate = framerate
|
|
29
|
+
self._timestamp = 0
|
|
30
|
+
self._time_base = fractions.Fraction(1, 90000)
|
|
31
|
+
self._ticks_per_frame = int(90000 / framerate)
|
|
32
|
+
|
|
33
|
+
async def recv(self):
|
|
34
|
+
loop = asyncio.get_event_loop()
|
|
35
|
+
array = await loop.run_in_executor(None, self.picam2.capture_array)
|
|
36
|
+
frame = VideoFrame.from_ndarray(array, format="bgr24")
|
|
37
|
+
frame.pts = self._timestamp
|
|
38
|
+
frame.time_base = self._time_base
|
|
39
|
+
self._timestamp += self._ticks_per_frame
|
|
40
|
+
return frame
|
|
41
|
+
|
|
42
|
+
def stop(self):
|
|
43
|
+
super().stop()
|
|
44
|
+
try:
|
|
45
|
+
self.picam2.stop()
|
|
46
|
+
self.picam2.close()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""picamera2 H264Encoder → aiortc MediaStreamTrack passthrough.
|
|
2
|
+
|
|
3
|
+
Hardware H.264 on Pi 4 (V4L2 M2M at /dev/video11), software (LibavH264Encoder)
|
|
4
|
+
on Pi 5. picamera2 delivers Annex-B NAL units plus a microsecond timestamp
|
|
5
|
+
via Output.outputframe(); we wrap each as an av.Packet with pts set, so
|
|
6
|
+
aiortc's H264Payloader.pack() packetizes into RTP without re-encoding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import io
|
|
11
|
+
from fractions import Fraction
|
|
12
|
+
|
|
13
|
+
import av
|
|
14
|
+
from aiortc import MediaStreamTrack
|
|
15
|
+
from picamera2.outputs import Output
|
|
16
|
+
|
|
17
|
+
# picamera2's V4L2Encoder reports timestamps as integer microseconds
|
|
18
|
+
# (seconds * 1_000_000 + microseconds from V4L2 buffer timestamp).
|
|
19
|
+
_TIME_BASE = Fraction(1, 1_000_000)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _QueueOutput(Output):
|
|
23
|
+
"""picamera2 Output that turns each encoded frame into an av.Packet
|
|
24
|
+
and enqueues it on a provided asyncio.Queue (thread-safely)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, loop, queue):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._loop = loop
|
|
29
|
+
self._queue = queue
|
|
30
|
+
|
|
31
|
+
def outputframe(self, frame, keyframe=True, timestamp=None, packet=None, audio=False):
|
|
32
|
+
if timestamp is None:
|
|
33
|
+
return
|
|
34
|
+
data = frame if isinstance(frame, (bytes, bytearray)) else bytes(frame)
|
|
35
|
+
pkt = av.Packet(data)
|
|
36
|
+
pkt.pts = timestamp
|
|
37
|
+
pkt.dts = timestamp
|
|
38
|
+
pkt.time_base = _TIME_BASE
|
|
39
|
+
pkt.is_keyframe = keyframe
|
|
40
|
+
self._loop.call_soon_threadsafe(self._try_put, pkt)
|
|
41
|
+
|
|
42
|
+
def _try_put(self, pkt):
|
|
43
|
+
# Drop oldest on overflow so live stream doesn't stall.
|
|
44
|
+
if self._queue.full():
|
|
45
|
+
try:
|
|
46
|
+
self._queue.get_nowait()
|
|
47
|
+
except asyncio.QueueEmpty:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
self._queue.put_nowait(pkt)
|
|
51
|
+
except asyncio.QueueFull:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PiH264Track(MediaStreamTrack):
|
|
56
|
+
"""aiortc video track backed by picamera2's H264Encoder."""
|
|
57
|
+
|
|
58
|
+
kind = "video"
|
|
59
|
+
|
|
60
|
+
def __init__(self, size=(640, 480), framerate=30, bitrate=1_500_000,
|
|
61
|
+
brightness=0, flip_horizontal=False, flip_vertical=False):
|
|
62
|
+
super().__init__()
|
|
63
|
+
from picamera2 import Picamera2
|
|
64
|
+
from picamera2.encoders import H264Encoder
|
|
65
|
+
from libcamera import Transform
|
|
66
|
+
|
|
67
|
+
self._size = size
|
|
68
|
+
self._framerate = framerate
|
|
69
|
+
self._brightness = brightness # -100..100, scaled to picamera2's -1.0..1.0
|
|
70
|
+
|
|
71
|
+
self.picam2 = Picamera2()
|
|
72
|
+
config = self.picam2.create_video_configuration(
|
|
73
|
+
main={"size": size, "format": "YUV420"},
|
|
74
|
+
transform=Transform(hflip=bool(flip_horizontal), vflip=bool(flip_vertical)),
|
|
75
|
+
controls={
|
|
76
|
+
"FrameRate": float(framerate),
|
|
77
|
+
"Brightness": self._brightness / 100.0,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
self.picam2.configure(config)
|
|
81
|
+
# Start the camera up front (no encoder yet) so capture_image works
|
|
82
|
+
# before any WebRTC client connects — needed for timelapse snapshots.
|
|
83
|
+
self.picam2.start()
|
|
84
|
+
|
|
85
|
+
# Let the encoder pick its own GOP length (V4L2 default is ~60
|
|
86
|
+
# frames on Pi 4). repeat=True → SPS/PPS before each IDR so late-
|
|
87
|
+
# joining WebRTC subscribers can start decoding when one arrives.
|
|
88
|
+
self.encoder = H264Encoder(
|
|
89
|
+
bitrate=bitrate,
|
|
90
|
+
repeat=True,
|
|
91
|
+
profile="baseline",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self._encoder_started = False
|
|
95
|
+
self._queue: asyncio.Queue | None = None
|
|
96
|
+
self._output: _QueueOutput | None = None
|
|
97
|
+
|
|
98
|
+
def _ensure_started(self):
|
|
99
|
+
if self._encoder_started:
|
|
100
|
+
return
|
|
101
|
+
self._queue = asyncio.Queue(maxsize=30)
|
|
102
|
+
self._output = _QueueOutput(asyncio.get_running_loop(), self._queue)
|
|
103
|
+
self.picam2.start_encoder(self.encoder, self._output)
|
|
104
|
+
self._encoder_started = True
|
|
105
|
+
|
|
106
|
+
async def recv(self):
|
|
107
|
+
self._ensure_started()
|
|
108
|
+
return await self._queue.get()
|
|
109
|
+
|
|
110
|
+
def set_brightness(self, value):
|
|
111
|
+
"""Update brightness live. `value` is -100..100 (mapped to -1.0..1.0)."""
|
|
112
|
+
self._brightness = max(-100, min(100, int(value)))
|
|
113
|
+
self.picam2.set_controls({"Brightness": self._brightness / 100.0})
|
|
114
|
+
|
|
115
|
+
def capture_snapshot(self):
|
|
116
|
+
"""Return a JPEG snapshot grabbed straight from the camera. Safe to
|
|
117
|
+
call concurrently with the H.264 encoder — picamera2/libcamera
|
|
118
|
+
delivers a separate frame to the application without disturbing
|
|
119
|
+
the encoder pipeline. Uses request.save's YUV420→JPEG fast path
|
|
120
|
+
(simplejpeg.encode_jpeg_yuv_planes) so we never go through PIL,
|
|
121
|
+
which doesn't support a YUV420 mode."""
|
|
122
|
+
buf = io.BytesIO()
|
|
123
|
+
request = self.picam2.capture_request()
|
|
124
|
+
try:
|
|
125
|
+
request.save("main", buf, format="jpeg")
|
|
126
|
+
finally:
|
|
127
|
+
request.release()
|
|
128
|
+
return buf.getvalue()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def video(self):
|
|
132
|
+
# MediaPlayer-shaped interface so the adapter can treat us like one.
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def stop(self):
|
|
136
|
+
super().stop()
|
|
137
|
+
try:
|
|
138
|
+
if self._encoder_started:
|
|
139
|
+
self.picam2.stop_encoder()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
try:
|
|
143
|
+
self.picam2.stop()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
try:
|
|
147
|
+
self.picam2.close()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
Binary file
|