Skip to content

Commit a32dccd

Browse files
authored
Merge pull request #2 from OptimusGREEN/copilot/fix-mjpeg-stream-octoprint
Replace ffmpeg one-shot HTTP server with Python MJPEG proxy
2 parents 8a3aa9a + 40ae652 commit a32dccd

5 files changed

Lines changed: 217 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/

Dockerfile

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
FROM alpine:3.19
2-
RUN apk add --no-cache ffmpeg
3-
COPY entrypoint.sh /entrypoint.sh
4-
RUN chmod +x /entrypoint.sh
5-
ENTRYPOINT ["/entrypoint.sh"]
2+
RUN apk add --no-cache ffmpeg python3
3+
COPY server.py /server.py
4+
ENTRYPOINT ["python3", "/server.py"]

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,44 @@
11
# printcam-proxy
2-
rtsp to mjpeg proxy for octoprint
2+
3+
RTSP to MJPEG proxy for OctoPrint.
4+
5+
Converts an RTSP camera stream into an MJPEG HTTP stream that OctoPrint can use.
6+
7+
## Endpoints
8+
9+
| Path | Description |
10+
|------|-------------|
11+
| `/?action=stream` | MJPEG stream (multipart/x-mixed-replace) |
12+
| `/?action=snapshot` | Single JPEG snapshot |
13+
| `/` | Alias for `/?action=stream` |
14+
15+
## Configuration
16+
17+
Set via environment variables:
18+
19+
| Variable | Default | Description |
20+
|----------|---------|-------------|
21+
| `RTSP_URL` | `rtsp://10.79.80.238:554/unicast` | Source RTSP stream URL |
22+
| `PORT` | `8889` | HTTP port to listen on |
23+
| `FPS` | `15` | Output frame rate |
24+
| `QUALITY` | `5` | JPEG quality (2–31, lower = better) |
25+
26+
## Usage
27+
28+
```yaml
29+
services:
30+
printcam-proxy:
31+
image: ghcr.io/optimusgreen/printcam-proxy:latest
32+
container_name: printcam-proxy
33+
restart: unless-stopped
34+
network_mode: host
35+
environment:
36+
- RTSP_URL=rtsp://<camera-ip>:554/unicast
37+
- PORT=8889
38+
- FPS=15
39+
- QUALITY=5
40+
```
41+
42+
In OctoPrint, set:
43+
- **Stream URL**: `http://<host>:8889/?action=stream`
44+
- **Snapshot URL**: `http://<host>:8889/?action=snapshot`

docker-compose.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,9 @@ services:
33
image: ghcr.io/optimusgreen/printcam-proxy:latest
44
container_name: printcam-proxy
55
restart: unless-stopped
6-
network_mode: host
6+
network_mode: host
7+
environment:
8+
- RTSP_URL=rtsp://10.79.80.238:554/unicast
9+
- PORT=8889
10+
- FPS=15
11+
- QUALITY=5

server.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
"""MJPEG proxy: converts an RTSP stream to an MJPEG HTTP stream for OctoPrint."""
3+
4+
import os
5+
import queue
6+
import subprocess
7+
import threading
8+
import time
9+
from http.server import BaseHTTPRequestHandler
10+
from socketserver import ThreadingMixIn, TCPServer
11+
12+
RTSP_URL = os.environ.get("RTSP_URL", "rtsp://10.79.80.238:554/unicast")
13+
PORT = int(os.environ.get("PORT", "8889"))
14+
FPS = int(os.environ.get("FPS", "15"))
15+
QUALITY = int(os.environ.get("QUALITY", "5"))
16+
17+
BOUNDARY = b"frame"
18+
19+
_frame_lock = threading.Lock()
20+
_latest_frame = None
21+
22+
_clients_lock = threading.Lock()
23+
_client_queues: list = []
24+
25+
26+
def _broadcast(frame: bytes) -> None:
27+
global _latest_frame
28+
with _frame_lock:
29+
_latest_frame = frame
30+
with _clients_lock:
31+
for q in _client_queues[:]:
32+
try:
33+
q.put_nowait(frame)
34+
except queue.Full:
35+
pass # drop stale frame for slow clients
36+
37+
38+
def capture_loop() -> None:
39+
"""Run ffmpeg in a loop, parse JPEG frames and broadcast to all clients."""
40+
cmd = [
41+
"ffmpeg",
42+
"-rtsp_transport", "tcp",
43+
"-i", RTSP_URL,
44+
"-c:v", "mjpeg",
45+
"-q:v", str(QUALITY),
46+
"-r", str(FPS),
47+
"-f", "image2pipe",
48+
"pipe:1",
49+
]
50+
while True:
51+
proc = None
52+
try:
53+
proc = subprocess.Popen(
54+
cmd,
55+
stdout=subprocess.PIPE,
56+
stderr=subprocess.DEVNULL,
57+
)
58+
buf = bytearray()
59+
while True:
60+
chunk = proc.stdout.read(65536)
61+
if not chunk:
62+
break
63+
buf.extend(chunk)
64+
# Extract complete JPEG frames delimited by SOI/EOI markers
65+
while True:
66+
start = buf.find(b"\xff\xd8")
67+
if start == -1:
68+
buf = bytearray()
69+
break
70+
end = buf.find(b"\xff\xd9", start + 2)
71+
if end == -1:
72+
# Keep partial frame; discard leading garbage
73+
if start > 0:
74+
del buf[:start]
75+
break
76+
frame = bytes(buf[start : end + 2])
77+
del buf[: end + 2]
78+
_broadcast(frame)
79+
except Exception as exc:
80+
print(f"[capture] error: {exc}", flush=True)
81+
finally:
82+
if proc is not None:
83+
try:
84+
proc.kill()
85+
proc.wait()
86+
except Exception:
87+
pass
88+
time.sleep(2) # brief pause before reconnecting
89+
90+
91+
class MJPEGHandler(BaseHTTPRequestHandler):
92+
def log_message(self, fmt, *args): # silence default request log
93+
pass
94+
95+
def do_GET(self):
96+
if self.path in ("/", "/?action=stream"):
97+
self._serve_stream()
98+
elif self.path == "/?action=snapshot":
99+
self._serve_snapshot()
100+
else:
101+
self.send_error(404)
102+
103+
def _serve_stream(self):
104+
self.send_response(200)
105+
self.send_header(
106+
"Content-Type",
107+
f"multipart/x-mixed-replace; boundary=--{BOUNDARY.decode()}",
108+
)
109+
self.send_header("Cache-Control", "no-cache")
110+
self.send_header("Pragma", "no-cache")
111+
self.end_headers()
112+
113+
q: queue.Queue = queue.Queue(maxsize=2) # small queue keeps latency low; stale frames are dropped
114+
with _clients_lock:
115+
_client_queues.append(q)
116+
try:
117+
while True:
118+
try:
119+
frame = q.get(timeout=10)
120+
except queue.Empty:
121+
continue
122+
try:
123+
self.wfile.write(
124+
b"--" + BOUNDARY + b"\r\n"
125+
b"Content-Type: image/jpeg\r\n\r\n"
126+
+ frame
127+
+ b"\r\n"
128+
)
129+
self.wfile.flush()
130+
except Exception:
131+
break
132+
finally:
133+
with _clients_lock:
134+
try:
135+
_client_queues.remove(q)
136+
except ValueError:
137+
pass
138+
139+
def _serve_snapshot(self):
140+
with _frame_lock:
141+
frame = _latest_frame
142+
if frame is None:
143+
self.send_error(503, "No frame available yet")
144+
return
145+
self.send_response(200)
146+
self.send_header("Content-Type", "image/jpeg")
147+
self.send_header("Content-Length", str(len(frame)))
148+
self.send_header("Cache-Control", "no-cache")
149+
self.end_headers()
150+
self.wfile.write(frame)
151+
152+
153+
class ThreadedHTTPServer(ThreadingMixIn, TCPServer):
154+
allow_reuse_address = True
155+
daemon_threads = True
156+
157+
158+
if __name__ == "__main__":
159+
threading.Thread(target=capture_loop, daemon=True).start()
160+
print(f"RTSP source: {RTSP_URL}", flush=True)
161+
print(f"Stream URL: http://0.0.0.0:{PORT}/?action=stream", flush=True)
162+
print(f"Snapshot URL: http://0.0.0.0:{PORT}/?action=snapshot", flush=True)
163+
server = ThreadedHTTPServer(("0.0.0.0", PORT), MJPEGHandler)
164+
server.serve_forever()

0 commit comments

Comments
 (0)