heldar_kernel/services/
clip.rs1use 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 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 let result = tokio::time::timeout(Duration::from_secs(180), cmd.output()).await;
129 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 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}