diff --git a/scripts/setup_pi.sh b/scripts/setup_pi.sh index ad65357..095416f 100755 --- a/scripts/setup_pi.sh +++ b/scripts/setup_pi.sh @@ -7,7 +7,8 @@ sudo apt-get install -y \ libcap-dev \ python3-prctl \ python3-picamera2 \ - python3-pil + python3-pil \ + ffmpeg echo "Configuring Poetry to use system site-packages..." poetry config virtualenvs.options.system-site-packages true diff --git a/src/app.py b/src/app.py index 3b9e85c..81bee3f 100644 --- a/src/app.py +++ b/src/app.py @@ -1,16 +1,14 @@ import logging -from collections.abc import Generator from datetime import UTC, datetime -from typing import Any -from flask import Flask, Response, jsonify, render_template +from flask import Flask, Response, abort, jsonify, render_template, send_from_directory from src.camera import camera app = Flask(__name__) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.WARNING) @app.get("/heartbeat") @@ -43,17 +41,35 @@ def camera_stop() -> tuple[Response, int]: return jsonify({"status": "stopped"}), 200 -@app.get("/camera/stream") -def camera_stream() -> Response: - def generate() -> Generator[bytes, Any, Any]: - for frame in camera.frames(): - yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") - - return Response( - generate(), - mimetype="multipart/x-mixed-replace; boundary=frame", +@app.get("/camera/status") +def camera_status() -> tuple[Response, int]: + return ( + jsonify( + { + "running": camera.running, + "ready": camera.wait_until_ready(timeout=0), + } + ), + 200, ) +@app.get("/camera/hls/") +def hls_segment(filename: str) -> Response: + hls_dir = camera.hls_dir + if not hls_dir.exists(): + abort(404) + + # set correct MIME types for HLS files + if filename.endswith(".m3u8"): + mimetype = "application/vnd.apple.mpegurl" + elif filename.endswith(".ts"): + mimetype = "video/mp2t" + else: + abort(404) + + return send_from_directory(str(hls_dir), filename, mimetype=mimetype) + + if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) diff --git a/src/camera.py b/src/camera.py index 61687d3..eb27c21 100644 --- a/src/camera.py +++ b/src/camera.py @@ -1,114 +1,166 @@ -import io import logging +import shutil +import subprocess import threading -from collections.abc import Iterator - -try: - from PIL import Image - - PIL_AVAILABLE = True -except ImportError: - PIL_AVAILABLE = False +import time +from pathlib import Path logger = logging.getLogger(__name__) + try: from picamera2 import Picamera2 + from picamera2.encoders import H264Encoder + from picamera2.outputs import FileOutput PICAMERA_AVAILABLE = True except ImportError: PICAMERA_AVAILABLE = False - logger.warning("picamera2 not available — running in mock mode") + +HLS_DIR = Path("/tmp/hls") +SEGMENT_DURATION = 2 # seconds per segment +SEGMENT_COUNT = 5 # segments to keep in playlist +BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs + + +class PipeOutput: + """Accepts H.264 bytes from picamera2 and writes to a subprocess stdin pipe.""" + + def __init__(self, proc: subprocess.Popen[bytes]) -> None: + self._proc = proc + + def write(self, data: bytes) -> None: + if self._proc.stdin: + try: + self._proc.stdin.write(data) + except BrokenPipeError: + pass + + def close(self) -> None: + if self._proc.stdin: + self._proc.stdin.close() class Camera: + def __init__(self) -> None: self._picam: Picamera2 | None = None - self._thread: threading.Thread | None = None - self._frame: bytes = b"" - self._lock = threading.Lock() + self._encoder: H264Encoder | None = None + self._ffmpeg: subprocess.Popen[bytes] | None = None + self._output: PipeOutput | None = None + self._ready_event = threading.Event() + self._watch_thread: threading.Thread | None = None self._stop_event = threading.Event() - self._frame_event = threading.Event() self.running = False def start(self) -> None: if self.running: return + + # prepare HLS output directory + if HLS_DIR.exists(): + shutil.rmtree(HLS_DIR) + HLS_DIR.mkdir(parents=True) + if not PICAMERA_AVAILABLE: logger.info("Mock camera started") self.running = True return + # start ffmpeg: reads raw H.264 from stdin, writes HLS segments + ffmpeg_cmd = [ + "ffmpeg", + "-loglevel", + "warning", + "-f", + "h264", # input is raw H.264 + "-i", + "pipe:0", # read from stdin + "-c:v", + "copy", # no re-encoding — pass through directly + "-hls_time", + str(SEGMENT_DURATION), + "-hls_list_size", + str(SEGMENT_COUNT), + "-hls_flags", + "delete_segments+append_list", + "-hls_segment_filename", + str(HLS_DIR / "seg%03d.ts"), + str(HLS_DIR / "stream.m3u8"), + ] + + self._ffmpeg = subprocess.Popen( + ffmpeg_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self._output = PipeOutput(self._ffmpeg) + + # configure picamera2 with H.264 encoder self._picam = Picamera2() - config = self._picam.create_still_configuration( - main={"size": (1280, 720), "format": "RGB888"}, + config = self._picam.create_video_configuration( + main={"size": (1280, 720)}, ) self._picam.configure(config) - self._picam.start() + self._encoder = H264Encoder(bitrate=BITRATE) + self._picam.start_recording(self._encoder, FileOutput(self._output)) + + # watch for the playlist to appear — signals first segment is ready self._stop_event.clear() - self._frame_event.clear() - self._thread = threading.Thread(target=self._capture_loop, daemon=True) - self._thread.start() + self._ready_event.clear() + self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True) + self._watch_thread.start() self.running = True - logger.info("Camera started") - - def _capture_loop(self) -> None: - assert self._picam is not None - self._stop_event.wait(0.5) - logger.info("Capture loop: starting") + logger.info("Camera started — waiting for first HLS segment") + def _watch_playlist(self) -> None: + """Signal ready once the m3u8 playlist exists and has at least one segment.""" + playlist = HLS_DIR / "stream.m3u8" while not self._stop_event.is_set(): - try: - logger.info("Capture loop: attempting capture_array") - array = self._picam.capture_array("main") - logger.info(f"Capture loop: got array {array.shape}") - buffer = io.BytesIO() - img = Image.fromarray(array) - img.save(buffer, format="JPEG", quality=85) - frame = buffer.getvalue() - logger.info(f"Capture loop: encoded JPEG {len(frame)} bytes") - with self._lock: - self._frame = frame - self._frame_event.set() - logger.info("Capture loop: frame event set") - except Exception as e: - logger.error(f"Capture loop ERROR: {e}", exc_info=True) - self._stop_event.wait(0.1) + if playlist.exists(): + content = playlist.read_text() + if ".ts" in content: + logger.info("HLS playlist ready") + self._ready_event.set() + return + time.sleep(0.25) + + def wait_until_ready(self, timeout: float = 15.0) -> bool: + if not PICAMERA_AVAILABLE: + return True + return self._ready_event.wait(timeout) def stop(self) -> None: if not self.running: return self._stop_event.set() - self._frame_event.set() # unblock any waiting frames() calls - if self._thread: - self._thread.join(timeout=3) + self._ready_event.set() # unblock any waiters + if self._picam: - self._picam.stop() + self._picam.stop_recording() self._picam.close() self._picam = None - self._frame = b"" + + if self._output: + self._output.close() + + if self._ffmpeg: + try: + self._ffmpeg.wait(timeout=5) + except subprocess.TimeoutExpired: + self._ffmpeg.kill() + self._ffmpeg = None + + if HLS_DIR.exists(): + shutil.rmtree(HLS_DIR) + self.running = False logger.info("Camera stopped") - def get_frame(self) -> bytes: - with self._lock: - return self._frame - - def wait_for_first_frame(self, timeout: float = 5.0) -> bool: - """Block until the first frame is captured, or timeout.""" - return self._frame_event.wait(timeout) - - def frames(self) -> Iterator[bytes]: - """Yield JPEG frames as they are captured.""" - # wait for first real frame before yielding anything - if not self.wait_for_first_frame(): - logger.error("Timed out waiting for first frame") - return - while self.running: - frame = self.get_frame() - if frame: - yield frame - self._stop_event.wait(0.033) # ~30fps cap + @property + def hls_dir(self) -> Path: + return HLS_DIR camera = Camera() diff --git a/src/templates/index.html b/src/templates/index.html index 1121e31..6ede01a 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -38,23 +38,30 @@ border: 1px solid #333; border-radius: 8px; overflow: hidden; + position: relative; display: flex; align-items: center; justify-content: center; - color: #555; - font-size: 0.9rem; } - #preview img { + #stream-video { width: 100%; height: 100%; object-fit: cover; display: none; } + #placeholder { + color: #555; + font-size: 0.9rem; + } + .controls { display: flex; gap: 1rem; + align-items: center; + flex-wrap: wrap; + justify-content: center; } button { @@ -94,7 +101,7 @@
Stream not started - Camera stream +
@@ -104,39 +111,96 @@

+ +