Adding recording (#29)
Reviewed-on: #29 Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com> Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
This commit was merged in pull request #29.
This commit is contained in:
+194
-15
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,6 +22,7 @@ except ImportError:
|
||||
PICAMERA_AVAILABLE = False
|
||||
|
||||
HLS_DIR = Path("/tmp/hls")
|
||||
RECORDINGS_DIR = Path("/var/lib/birdcam/recordings")
|
||||
SEGMENT_DURATION = 2 # seconds per segment
|
||||
SEGMENT_COUNT = 5 # segments to keep in playlist
|
||||
BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs
|
||||
@@ -47,13 +51,21 @@ class Camera:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._picam: Picamera2 | None = None
|
||||
self._encoder: H264Encoder | None = None
|
||||
self._stream_encoder: H264Encoder | None = None
|
||||
self._record_encoder: H264Encoder | None = None
|
||||
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
||||
self._record_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.running = False
|
||||
self.recording = False
|
||||
self._current_recording_path: Path | None = None
|
||||
self._recording_started_at: datetime | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
if self.running:
|
||||
@@ -65,7 +77,7 @@ class Camera:
|
||||
HLS_DIR.mkdir(parents=True)
|
||||
|
||||
if not PICAMERA_AVAILABLE:
|
||||
logger.info("Mock camera started")
|
||||
logger.info("Mock camera started (picamera2 not available)")
|
||||
self.running = True
|
||||
return
|
||||
|
||||
@@ -75,11 +87,11 @@ class Camera:
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-f",
|
||||
"h264", # input is raw H.264
|
||||
"h264",
|
||||
"-i",
|
||||
"pipe:0", # read from stdin
|
||||
"pipe:0",
|
||||
"-c:v",
|
||||
"copy", # no re-encoding — pass through directly
|
||||
"copy",
|
||||
"-hls_time",
|
||||
str(SEGMENT_DURATION),
|
||||
"-hls_list_size",
|
||||
@@ -99,7 +111,6 @@ class Camera:
|
||||
)
|
||||
self._output = PipeOutput(self._ffmpeg)
|
||||
|
||||
# configure picamera2 with H.264 encoder
|
||||
self._picam = Picamera2()
|
||||
config = self._picam.create_video_configuration(
|
||||
main={"size": (1280, 720)},
|
||||
@@ -107,19 +118,18 @@ class Camera:
|
||||
self._picam.configure(config)
|
||||
self._picam.set_controls(
|
||||
{
|
||||
"Brightness": 0.1, # -1.0 to 1.0, default 0.0
|
||||
"Contrast": 1.1, # 0.0 to 32.0, default 1.0
|
||||
"Saturation": 1.1, # 0.0 to 32.0, default 1.0
|
||||
"Sharpness": 1.0, # 0.0 to 16.0, default 1.0
|
||||
"AwbEnable": True, # auto white balance
|
||||
"AeEnable": True, # auto exposure
|
||||
"Brightness": 0.1,
|
||||
"Contrast": 1.1,
|
||||
"Saturation": 1.1,
|
||||
"Sharpness": 1.0,
|
||||
"AwbEnable": True,
|
||||
"AeEnable": True,
|
||||
}
|
||||
)
|
||||
self._encoder = H264Encoder(bitrate=BITRATE)
|
||||
self._stream_encoder = H264Encoder(bitrate=BITRATE)
|
||||
buffered = io.BufferedWriter(self._output)
|
||||
self._picam.start_recording(self._encoder, FileOutput(buffered))
|
||||
self._picam.start_recording(self._stream_encoder, FileOutput(buffered))
|
||||
|
||||
# watch for the playlist to appear — signals first segment is ready
|
||||
self._stop_event.clear()
|
||||
self._ready_event.clear()
|
||||
self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
|
||||
@@ -147,6 +157,11 @@ class Camera:
|
||||
def stop(self) -> None:
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
# stop any active recording first
|
||||
if self.recording:
|
||||
self.stop_recording()
|
||||
|
||||
self._stop_event.set()
|
||||
self._ready_event.set() # unblock any waiters
|
||||
|
||||
@@ -174,6 +189,170 @@ class Camera:
|
||||
self.running = False
|
||||
logger.info("Camera stopped")
|
||||
|
||||
# ── Recording ────────────────────────────────────────────────────────────
|
||||
|
||||
def start_recording(self) -> Path:
|
||||
"""
|
||||
Start recording to an MP4 file. Can run independently of or
|
||||
simultaneously with the HLS stream. Returns the output file path.
|
||||
"""
|
||||
with self._lock:
|
||||
if self.recording:
|
||||
raise RuntimeError("Already recording")
|
||||
|
||||
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
||||
output_path = RECORDINGS_DIR / f"recording_{timestamp}.mp4"
|
||||
|
||||
if not PICAMERA_AVAILABLE:
|
||||
# mock mode — create an empty placeholder file
|
||||
output_path.touch()
|
||||
self._current_recording_path = output_path
|
||||
self._recording_started_at = datetime.now(UTC)
|
||||
self.recording = True
|
||||
logger.info("Mock recording started: %s", output_path)
|
||||
return output_path
|
||||
|
||||
if self._picam is None:
|
||||
# camera not yet streaming — start it temporarily in record-only mode
|
||||
self._picam = Picamera2()
|
||||
config = self._picam.create_video_configuration(
|
||||
main={"size": (1280, 720)},
|
||||
)
|
||||
self._picam.configure(config)
|
||||
self._picam.set_controls(
|
||||
{
|
||||
"Brightness": 0.1,
|
||||
"Contrast": 1.1,
|
||||
"Saturation": 1.1,
|
||||
"Sharpness": 1.0,
|
||||
"AwbEnable": True,
|
||||
"AeEnable": True,
|
||||
}
|
||||
)
|
||||
|
||||
# pipe raw H.264 → ffmpeg → MP4 container
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-f",
|
||||
"h264",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
self._record_ffmpeg = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
record_pipe = PipeOutput(self._record_ffmpeg)
|
||||
record_buffered = io.BufferedWriter(record_pipe)
|
||||
|
||||
self._record_encoder = H264Encoder(bitrate=BITRATE)
|
||||
|
||||
if self.running:
|
||||
# camera already running for streaming — add a second encoder output
|
||||
self._picam.start_encoder(
|
||||
self._record_encoder,
|
||||
FileOutput(record_buffered),
|
||||
)
|
||||
else:
|
||||
# no stream active — start camera just for recording
|
||||
self._picam.start_recording(
|
||||
self._record_encoder,
|
||||
FileOutput(record_buffered),
|
||||
)
|
||||
|
||||
self._current_recording_path = output_path
|
||||
self._recording_started_at = datetime.now(UTC)
|
||||
self.recording = True
|
||||
logger.info("Recording started: %s", output_path)
|
||||
return output_path
|
||||
|
||||
def stop_recording(self) -> Path | None:
|
||||
"""Stop the active recording and finalise the MP4 file."""
|
||||
with self._lock:
|
||||
if not self.recording:
|
||||
return None
|
||||
|
||||
path = self._current_recording_path
|
||||
|
||||
if PICAMERA_AVAILABLE and self._record_encoder is not None:
|
||||
if self.running:
|
||||
# streaming still active — only stop the recording encoder
|
||||
self._picam.stop_encoder(self._record_encoder) # type: ignore[union-attr]
|
||||
else:
|
||||
# recording-only mode — stop the whole camera
|
||||
if self._picam:
|
||||
self._picam.stop_recording()
|
||||
self._picam.close()
|
||||
self._picam = None
|
||||
|
||||
self._record_encoder = None
|
||||
|
||||
if self._record_ffmpeg:
|
||||
if self._record_ffmpeg.stdin:
|
||||
self._record_ffmpeg.stdin.close()
|
||||
try:
|
||||
self._record_ffmpeg.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._record_ffmpeg.kill()
|
||||
self._record_ffmpeg = None
|
||||
|
||||
self.recording = False
|
||||
self._current_recording_path = None
|
||||
self._recording_started_at = None
|
||||
logger.info("Recording stopped: %s", path)
|
||||
return path
|
||||
|
||||
def recording_status(self) -> dict[str, object]:
|
||||
"""Return current recording state for the API."""
|
||||
return {
|
||||
"recording": self.recording,
|
||||
"path": (
|
||||
str(self._current_recording_path)
|
||||
if self._current_recording_path
|
||||
else None
|
||||
),
|
||||
"started_at": (
|
||||
self._recording_started_at.isoformat()
|
||||
if self._recording_started_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def list_recordings(self) -> list[dict[str, object]]:
|
||||
"""Return metadata for all saved recordings, newest first."""
|
||||
if not RECORDINGS_DIR.exists():
|
||||
return []
|
||||
|
||||
recordings = []
|
||||
for f in sorted(RECORDINGS_DIR.glob("recording_*.mp4"), reverse=True):
|
||||
stat = f.stat()
|
||||
recordings.append(
|
||||
{
|
||||
"filename": f.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"created_at": datetime.fromtimestamp(
|
||||
stat.st_ctime, tz=UTC
|
||||
).isoformat(),
|
||||
"duration_seconds": None, # could be derived via ffprobe if needed
|
||||
}
|
||||
)
|
||||
return recordings
|
||||
|
||||
# ── Properties ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def hls_dir(self) -> Path:
|
||||
return HLS_DIR
|
||||
|
||||
Reference in New Issue
Block a user