Modifying output to use H.264.
This commit is contained in:
@@ -7,7 +7,8 @@ sudo apt-get install -y \
|
|||||||
libcap-dev \
|
libcap-dev \
|
||||||
python3-prctl \
|
python3-prctl \
|
||||||
python3-picamera2 \
|
python3-picamera2 \
|
||||||
python3-pil
|
python3-pil \
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
echo "Configuring Poetry to use system site-packages..."
|
echo "Configuring Poetry to use system site-packages..."
|
||||||
poetry config virtualenvs.options.system-site-packages true
|
poetry config virtualenvs.options.system-site-packages true
|
||||||
|
|||||||
42
src/app.py
42
src/app.py
@@ -1,16 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import UTC, datetime
|
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
|
from src.camera import camera
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/heartbeat")
|
@app.get("/heartbeat")
|
||||||
@@ -43,17 +41,35 @@ def camera_stop() -> tuple[Response, int]:
|
|||||||
return jsonify({"status": "stopped"}), 200
|
return jsonify({"status": "stopped"}), 200
|
||||||
|
|
||||||
|
|
||||||
@app.get("/camera/stream")
|
@app.get("/camera/status")
|
||||||
def camera_stream() -> Response:
|
def camera_status() -> tuple[Response, int]:
|
||||||
def generate() -> Generator[bytes, Any, Any]:
|
return (
|
||||||
for frame in camera.frames():
|
jsonify(
|
||||||
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
|
{
|
||||||
|
"running": camera.running,
|
||||||
return Response(
|
"ready": camera.wait_until_ready(timeout=0),
|
||||||
generate(),
|
}
|
||||||
mimetype="multipart/x-mixed-replace; boundary=frame",
|
),
|
||||||
|
200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/camera/hls/<path:filename>")
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
|
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
|
||||||
|
|||||||
184
src/camera.py
184
src/camera.py
@@ -1,114 +1,166 @@
|
|||||||
import io
|
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Iterator
|
import time
|
||||||
|
from pathlib import Path
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
PIL_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
PIL_AVAILABLE = False
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from picamera2 import Picamera2
|
from picamera2 import Picamera2
|
||||||
|
from picamera2.encoders import H264Encoder
|
||||||
|
from picamera2.outputs import FileOutput
|
||||||
|
|
||||||
PICAMERA_AVAILABLE = True
|
PICAMERA_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
PICAMERA_AVAILABLE = False
|
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:
|
class Camera:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._picam: Picamera2 | None = None
|
self._picam: Picamera2 | None = None
|
||||||
self._thread: threading.Thread | None = None
|
self._encoder: H264Encoder | None = None
|
||||||
self._frame: bytes = b""
|
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
||||||
self._lock = threading.Lock()
|
self._output: PipeOutput | None = None
|
||||||
|
self._ready_event = threading.Event()
|
||||||
|
self._watch_thread: threading.Thread | None = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._frame_event = threading.Event()
|
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self.running:
|
if self.running:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# prepare HLS output directory
|
||||||
|
if HLS_DIR.exists():
|
||||||
|
shutil.rmtree(HLS_DIR)
|
||||||
|
HLS_DIR.mkdir(parents=True)
|
||||||
|
|
||||||
if not PICAMERA_AVAILABLE:
|
if not PICAMERA_AVAILABLE:
|
||||||
logger.info("Mock camera started")
|
logger.info("Mock camera started")
|
||||||
self.running = True
|
self.running = True
|
||||||
return
|
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()
|
self._picam = Picamera2()
|
||||||
config = self._picam.create_still_configuration(
|
config = self._picam.create_video_configuration(
|
||||||
main={"size": (1280, 720), "format": "RGB888"},
|
main={"size": (1280, 720)},
|
||||||
)
|
)
|
||||||
self._picam.configure(config)
|
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._stop_event.clear()
|
||||||
self._frame_event.clear()
|
self._ready_event.clear()
|
||||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
|
||||||
self._thread.start()
|
self._watch_thread.start()
|
||||||
self.running = True
|
self.running = True
|
||||||
logger.info("Camera started")
|
logger.info("Camera started — waiting for first HLS segment")
|
||||||
|
|
||||||
def _capture_loop(self) -> None:
|
|
||||||
assert self._picam is not None
|
|
||||||
self._stop_event.wait(0.5)
|
|
||||||
logger.info("Capture loop: starting")
|
|
||||||
|
|
||||||
|
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():
|
while not self._stop_event.is_set():
|
||||||
try:
|
if playlist.exists():
|
||||||
logger.info("Capture loop: attempting capture_array")
|
content = playlist.read_text()
|
||||||
array = self._picam.capture_array("main")
|
if ".ts" in content:
|
||||||
logger.info(f"Capture loop: got array {array.shape}")
|
logger.info("HLS playlist ready")
|
||||||
buffer = io.BytesIO()
|
self._ready_event.set()
|
||||||
img = Image.fromarray(array)
|
return
|
||||||
img.save(buffer, format="JPEG", quality=85)
|
time.sleep(0.25)
|
||||||
frame = buffer.getvalue()
|
|
||||||
logger.info(f"Capture loop: encoded JPEG {len(frame)} bytes")
|
def wait_until_ready(self, timeout: float = 15.0) -> bool:
|
||||||
with self._lock:
|
if not PICAMERA_AVAILABLE:
|
||||||
self._frame = frame
|
return True
|
||||||
self._frame_event.set()
|
return self._ready_event.wait(timeout)
|
||||||
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)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if not self.running:
|
if not self.running:
|
||||||
return
|
return
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
self._frame_event.set() # unblock any waiting frames() calls
|
self._ready_event.set() # unblock any waiters
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=3)
|
|
||||||
if self._picam:
|
if self._picam:
|
||||||
self._picam.stop()
|
self._picam.stop_recording()
|
||||||
self._picam.close()
|
self._picam.close()
|
||||||
self._picam = None
|
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
|
self.running = False
|
||||||
logger.info("Camera stopped")
|
logger.info("Camera stopped")
|
||||||
|
|
||||||
def get_frame(self) -> bytes:
|
@property
|
||||||
with self._lock:
|
def hls_dir(self) -> Path:
|
||||||
return self._frame
|
return HLS_DIR
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
camera = Camera()
|
camera = Camera()
|
||||||
|
|||||||
@@ -38,23 +38,30 @@
|
|||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #555;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview img {
|
#stream-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#placeholder {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -94,7 +101,7 @@
|
|||||||
|
|
||||||
<div id="preview">
|
<div id="preview">
|
||||||
<span id="placeholder">Stream not started</span>
|
<span id="placeholder">Stream not started</span>
|
||||||
<img id="stream-img" alt="Camera stream" />
|
<video id="stream-video" autoplay muted playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
@@ -104,39 +111,96 @@
|
|||||||
|
|
||||||
<p id="status"></p>
|
<p id="status"></p>
|
||||||
|
|
||||||
|
<!-- hls.js from CDN -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const btnStart = document.getElementById("btn-start");
|
const btnStart = document.getElementById("btn-start");
|
||||||
const btnStop = document.getElementById("btn-stop");
|
const btnStop = document.getElementById("btn-stop");
|
||||||
const streamImg = document.getElementById("stream-img");
|
const video = document.getElementById("stream-video");
|
||||||
const placeholder = document.getElementById("placeholder");
|
const placeholder = document.getElementById("placeholder");
|
||||||
const status = document.getElementById("status");
|
const status = document.getElementById("status");
|
||||||
|
|
||||||
|
let hls = null;
|
||||||
|
|
||||||
async function postAction(url) {
|
async function postAction(url) {
|
||||||
const res = await fetch(url, { method: "POST" });
|
const res = await fetch(url, { method: "POST" });
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForReady(maxAttempts = 40) {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
const res = await fetch("/camera/status");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ready) return true;
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHls() {
|
||||||
|
const src = "/camera/hls/stream.m3u8";
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls({
|
||||||
|
lowLatencyMode: false,
|
||||||
|
backBufferLength: 10,
|
||||||
|
});
|
||||||
|
hls.loadSource(src);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
console.error("HLS fatal error:", data);
|
||||||
|
status.textContent = "Stream error — try restarting";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
// Safari native HLS support
|
||||||
|
video.src = src;
|
||||||
|
} else {
|
||||||
|
status.textContent = "HLS not supported in this browser";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.style.display = "block";
|
||||||
|
placeholder.style.display = "none";
|
||||||
|
video.play().catch(e => console.warn("Autoplay blocked:", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHls() {
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy();
|
||||||
|
hls = null;
|
||||||
|
}
|
||||||
|
video.pause();
|
||||||
|
video.src = "";
|
||||||
|
video.style.display = "none";
|
||||||
|
placeholder.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
btnStart.addEventListener("click", async () => {
|
btnStart.addEventListener("click", async () => {
|
||||||
btnStart.disabled = true;
|
btnStart.disabled = true;
|
||||||
status.textContent = "Starting…";
|
status.textContent = "Starting camera…";
|
||||||
|
|
||||||
await postAction("/camera/start");
|
await postAction("/camera/start");
|
||||||
|
|
||||||
// small delay to let camera initialise
|
status.textContent = "Waiting for first segment…";
|
||||||
await new Promise(r => setTimeout(r, 500));
|
const ready = await waitForReady();
|
||||||
|
|
||||||
streamImg.src = "/camera/stream";
|
if (!ready) {
|
||||||
streamImg.style.display = "block";
|
status.textContent = "Camera timed out — check Pi logs";
|
||||||
placeholder.style.display = "none";
|
btnStart.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startHls();
|
||||||
btnStop.disabled = false;
|
btnStop.disabled = false;
|
||||||
status.textContent = "Streaming";
|
status.textContent = "Streaming (H.264 / HLS)";
|
||||||
});
|
});
|
||||||
|
|
||||||
btnStop.addEventListener("click", async () => {
|
btnStop.addEventListener("click", async () => {
|
||||||
btnStop.disabled = true;
|
btnStop.disabled = true;
|
||||||
status.textContent = "Stopping…";
|
status.textContent = "Stopping…";
|
||||||
streamImg.style.display = "none";
|
stopHls();
|
||||||
streamImg.src = "";
|
|
||||||
placeholder.style.display = "block";
|
|
||||||
await postAction("/camera/stop");
|
await postAction("/camera/stop");
|
||||||
btnStart.disabled = false;
|
btnStart.disabled = false;
|
||||||
status.textContent = "Stream stopped";
|
status.textContent = "Stream stopped";
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ def client() -> Generator[FlaskClient, Any, Any]:
|
|||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_status_code(client: FlaskClient) -> None:
|
||||||
|
response = client.get("/heartbeat")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_camera_start(client: FlaskClient) -> None:
|
def test_camera_start(client: FlaskClient) -> None:
|
||||||
response = client.post("/camera/start")
|
response = client.post("/camera/start")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -27,6 +32,19 @@ def test_camera_stop(client: FlaskClient) -> None:
|
|||||||
assert response.get_json()["status"] == "stopped"
|
assert response.get_json()["status"] == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_status(client: FlaskClient) -> None:
|
||||||
|
response = client.get("/camera/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "running" in data
|
||||||
|
assert "ready" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_hls_404_when_not_running(client: FlaskClient) -> None:
|
||||||
|
response = client.get("/camera/hls/stream.m3u8")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_index_page(client: FlaskClient) -> None:
|
def test_index_page(client: FlaskClient) -> None:
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user