Skip to main content

heldar_kernel/services/
clip.rs

1//! Evidence clip export: concatenates the segments overlapping a time range and trims to
2//! the requested window with `-c copy` (no re-encode). Keyframe-aligned (Stage 0 precision).
3
4use std::process::Stdio;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9use tokio::process::Command;
10use uuid::Uuid;
11
12use crate::error::{AppError, AppResult};
13use crate::models::Segment;
14use crate::state::AppState;
15
16const MAX_CLIP_SECONDS: f64 = 3600.0;
17
18#[derive(Debug, Serialize)]
19pub struct ClipResult {
20    pub id: String,
21    pub camera_id: String,
22    pub filename: String,
23    pub url: String,
24    pub from: DateTime<Utc>,
25    pub to: DateTime<Utc>,
26    pub requested_seconds: f64,
27    pub size_bytes: u64,
28    pub segment_count: usize,
29}
30
31pub async fn export_clip(
32    state: &AppState,
33    camera_id: &str,
34    from: DateTime<Utc>,
35    to: DateTime<Utc>,
36) -> AppResult<ClipResult> {
37    if to <= from {
38        return Err(AppError::BadRequest("`to` must be after `from`".into()));
39    }
40    let requested = (to - from).num_milliseconds() as f64 / 1000.0;
41    if requested > MAX_CLIP_SECONDS {
42        return Err(AppError::BadRequest(format!(
43            "clip too long ({requested:.0}s); max {MAX_CLIP_SECONDS:.0}s"
44        )));
45    }
46
47    let camera_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM cameras WHERE id = ?")
48        .bind(camera_id)
49        .fetch_optional(&state.pool)
50        .await?;
51    if camera_exists.is_none() {
52        return Err(AppError::NotFound(format!("camera {camera_id} not found")));
53    }
54
55    let segments: Vec<Segment> = sqlx::query_as::<_, Segment>(
56        "SELECT * FROM segments
57         WHERE camera_id = ? AND start_time < ? AND end_time > ?
58         ORDER BY start_time ASC",
59    )
60    .bind(camera_id)
61    .bind(to)
62    .bind(from)
63    .fetch_all(&state.pool)
64    .await?;
65    if segments.is_empty() {
66        return Err(AppError::NotFound(
67            "no recorded footage in the requested range".into(),
68        ));
69    }
70
71    tokio::fs::create_dir_all(&state.cfg.clips_dir)
72        .await
73        .map_err(|e| AppError::Other(e.into()))?;
74
75    let id = format!("clip_{}", Uuid::new_v4().simple());
76    let filename = format!("{id}.mp4");
77    let out_path = state.cfg.clips_dir.join(&filename);
78    let list_path = state.cfg.clips_dir.join(format!("{id}.txt"));
79
80    // Read-lock the source segments so the retention sweeper can't delete them out from under ffmpeg
81    // mid-export (TOCTOU). Released on EVERY outcome below.
82    let seg_ids: Vec<String> = segments.iter().map(|s| s.id.clone()).collect();
83    crate::repo::set_segments_locked(&state.pool, &seg_ids, true).await;
84
85    let size_outcome: AppResult<u64> = async {
86        let mut list = String::new();
87        for s in &segments {
88            let escaped = s.path.replace('\'', "'\\''");
89            list.push_str(&format!("file '{escaped}'\n"));
90        }
91        tokio::fs::write(&list_path, list)
92            .await
93            .map_err(|e| AppError::Other(e.into()))?;
94
95        let first_start = segments[0].start_time;
96        let ss = ((from - first_start).num_milliseconds() as f64 / 1000.0).max(0.0);
97
98        let mut cmd = Command::new(&state.cfg.ffmpeg_bin);
99        cmd.kill_on_drop(true)
100            .args([
101                "-hide_banner",
102                "-loglevel",
103                "error",
104                "-f",
105                "concat",
106                "-safe",
107                "0",
108            ])
109            .arg("-i")
110            .arg(&list_path)
111            .args(["-ss", &format!("{ss:.3}")])
112            .args(["-t", &format!("{requested:.3}")])
113            .args([
114                "-c",
115                "copy",
116                "-avoid_negative_ts",
117                "make_zero",
118                "-movflags",
119                "+faststart",
120            ])
121            .arg(&out_path)
122            .stdin(Stdio::null())
123            .stdout(Stdio::null())
124            .stderr(Stdio::piped());
125
126        // Remux of even an hour of footage is fast; bound it so a hung/cancelled job can't wedge the
127        // request or orphan ffmpeg (kill_on_drop kills the child when the timed-out future is dropped).
128        let result = tokio::time::timeout(Duration::from_secs(180), cmd.output()).await;
129        // Always remove the temp concat list, on every outcome.
130        let _ = tokio::fs::remove_file(&list_path).await;
131
132        let out = match result {
133            Err(_) => {
134                let _ = tokio::fs::remove_file(&out_path).await;
135                return Err(AppError::Other(anyhow::anyhow!("clip export timed out")));
136            }
137            Ok(Err(e)) => {
138                let _ = tokio::fs::remove_file(&out_path).await;
139                return Err(AppError::Other(e.into()));
140            }
141            Ok(Ok(out)) => out,
142        };
143
144        if !out.status.success() {
145            let _ = tokio::fs::remove_file(&out_path).await;
146            return Err(AppError::Other(anyhow::anyhow!(
147                "ffmpeg clip export failed: {}",
148                String::from_utf8_lossy(&out.stderr).trim()
149            )));
150        }
151
152        Ok(tokio::fs::metadata(&out_path)
153            .await
154            .map(|m| m.len())
155            .unwrap_or(0))
156    }
157    .await;
158
159    // Release the read-lock on every outcome, then surface any error.
160    crate::repo::set_segments_locked(&state.pool, &seg_ids, false).await;
161    let size_bytes = size_outcome?;
162
163    Ok(ClipResult {
164        id,
165        camera_id: camera_id.to_string(),
166        url: format!("/media/clips/{filename}"),
167        filename,
168        from,
169        to,
170        requested_seconds: requested,
171        size_bytes,
172        segment_count: segments.len(),
173    })
174}