Skip to main content

heldar_kernel/services/
snapshot.rs

1//! Snapshot extraction: a single JPEG frame either from recorded footage at a timestamp,
2//! or live from the camera stream right now.
3
4use std::process::Stdio;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use tokio::process::Command;
9
10use crate::camera_url;
11use crate::error::{AppError, AppResult};
12use crate::models::{Camera, Segment};
13use crate::state::AppState;
14
15/// Extract one frame from recorded footage at `at`.
16pub async fn snapshot_at(
17    state: &AppState,
18    camera_id: &str,
19    at: DateTime<Utc>,
20) -> AppResult<Vec<u8>> {
21    let seg: Option<Segment> = sqlx::query_as::<_, Segment>(
22        "SELECT * FROM segments
23         WHERE camera_id = ? AND start_time <= ? AND end_time >= ?
24         ORDER BY start_time DESC LIMIT 1",
25    )
26    .bind(camera_id)
27    .bind(at)
28    .bind(at)
29    .fetch_optional(&state.pool)
30    .await?;
31    let seg = seg.ok_or_else(|| AppError::NotFound("no footage at that timestamp".into()))?;
32
33    // Read-lock the source segment so retention can't delete it out from under ffmpeg (TOCTOU).
34    let seg_ids = vec![seg.id.clone()];
35    crate::repo::set_segments_locked(&state.pool, &seg_ids, true).await;
36
37    let outcome: AppResult<Vec<u8>> = async {
38        let offset = ((at - seg.start_time).num_milliseconds() as f64 / 1000.0).max(0.0);
39        let mut cmd = Command::new(&state.cfg.ffmpeg_bin);
40        cmd.kill_on_drop(true)
41            .args(["-hide_banner", "-loglevel", "error"])
42            .args(["-ss", &format!("{offset:.3}")])
43            .arg("-i")
44            .arg(&seg.path)
45            .args([
46                "-frames:v",
47                "1",
48                "-q:v",
49                "3",
50                "-f",
51                "image2",
52                "-c:v",
53                "mjpeg",
54                "pipe:1",
55            ])
56            .stdin(Stdio::null())
57            .stdout(Stdio::piped())
58            .stderr(Stdio::piped());
59
60        let out = tokio::time::timeout(Duration::from_secs(20), cmd.output())
61            .await
62            .map_err(|_| AppError::Other(anyhow::anyhow!("snapshot timed out")))?
63            .map_err(|e| AppError::Other(e.into()))?;
64
65        if !out.status.success() || out.stdout.is_empty() {
66            return Err(AppError::Other(anyhow::anyhow!(
67                "snapshot failed: {}",
68                String::from_utf8_lossy(&out.stderr).trim()
69            )));
70        }
71        Ok(out.stdout)
72    }
73    .await;
74
75    crate::repo::set_segments_locked(&state.pool, &seg_ids, false).await;
76    outcome
77}
78
79/// Grab one frame live from the camera stream (sub-stream preferred).
80pub async fn snapshot_live(state: &AppState, camera_id: &str) -> AppResult<Vec<u8>> {
81    let cam: Option<Camera> = sqlx::query_as::<_, Camera>("SELECT * FROM cameras WHERE id = ?")
82        .bind(camera_id)
83        .fetch_optional(&state.pool)
84        .await?;
85    let cam = cam.ok_or_else(|| AppError::NotFound(format!("camera {camera_id} not found")))?;
86    let url = camera_url::stream_url(&cam, "sub")
87        .or_else(|| camera_url::record_url(&cam))
88        .ok_or_else(|| AppError::BadRequest("camera has no stream URL".into()))?;
89
90    let mut cmd = Command::new(&state.cfg.ffmpeg_bin);
91    cmd.kill_on_drop(true)
92        .args([
93            "-hide_banner",
94            "-loglevel",
95            "error",
96            "-rtsp_transport",
97            "tcp",
98            "-timeout",
99            "10000000",
100        ])
101        .arg("-i")
102        .arg(&url)
103        .args([
104            "-frames:v",
105            "1",
106            "-q:v",
107            "3",
108            "-f",
109            "image2",
110            "-c:v",
111            "mjpeg",
112            "pipe:1",
113        ])
114        .stdin(Stdio::null())
115        .stdout(Stdio::piped())
116        .stderr(Stdio::piped());
117
118    let out = tokio::time::timeout(Duration::from_secs(20), cmd.output())
119        .await
120        .map_err(|_| AppError::Other(anyhow::anyhow!("live snapshot timed out")))?
121        .map_err(|e| AppError::Other(e.into()))?;
122
123    if !out.status.success() || out.stdout.is_empty() {
124        // Mask credentials that ffmpeg echoes back in the RTSP URL on failure.
125        let stderr = String::from_utf8_lossy(&out.stderr);
126        return Err(AppError::Other(anyhow::anyhow!(
127            "live snapshot failed: {}",
128            camera_url::mask_url(stderr.trim())
129        )));
130    }
131    Ok(out.stdout)
132}