use axum::{ body::Body, extract::{Path, Query, State}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::get, Router, }; use once_cell::sync::Lazy; use crate::core::db::{schema, PostgresDb}; static FFMPEG: Lazy = Lazy::new(|| { std::env::var("MOMENTRY_FFMPEG").unwrap_or_else(|_| { let full = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg"; if std::path::Path::new(full).exists() { full.to_string() } else { "ffmpeg".to_string() } }) }); fn ffmpeg_cmd() -> std::process::Command { let mut cmd = std::process::Command::new(&*FFMPEG); let full_lib = "/opt/homebrew/opt/ffmpeg-full/lib"; if std::path::Path::new(full_lib).exists() { cmd.env("DYLD_LIBRARY_PATH", full_lib); } cmd } pub fn bbox_routes() -> Router { Router::new() .route( "/api/v1/file/:file_uuid/video/bbox", get(bbox_overlay_video), ) .route( "/api/v1/file/:file_uuid/trace/:trace_id/video", get(trace_video), ) .route("/api/v1/file/:file_uuid/video", get(stream_video)) .route("/api/v1/file/:file_uuid/thumbnail", get(face_thumbnail)) } /// 5×7 bitmap font — each character 5 wide × 7 tall /// Encoding: col 0=0x10, col 1=0x08, col 2=0x04, col 3=0x02, col 4=0x01 fn bitmap_char(c: char) -> [u8; 7] { match c.to_ascii_lowercase() { '0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E], '1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E], '2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F], '3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E], '4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02], '5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E], '6' => [0x0E, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x0E], '7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10], '8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E], '9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E], 'a' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], 'b' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E], 'c' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E], 'd' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C], 'e' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F], 'f' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10], 't' => [0x04, 0x04, 0x1F, 0x04, 0x04, 0x04, 0x06], '_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F], ' ' => [0x00; 7], _ => [0x00; 7], } } /// Width of one character in pixels (5 cols × 3px/dot = 15px) const CHAR_W: i32 = 5 * 3; /// Spacing between characters (px) const CHAR_GAP: i32 = 4; /// Total advance per character const CHAR_ADVANCE: i32 = CHAR_W + CHAR_GAP; fn render_text( parts: &mut Vec, text: &str, origin_x: i32, origin_y: i32, enable: Option, ) -> i32 { let mut px = origin_x; for ch in text.chars() { let bm = bitmap_char(ch); for (row, bits) in bm.iter().enumerate() { for col in 0..5 { if bits & (1 << (4 - col)) != 0 { let x = px + col as i32 * 3; let y = origin_y + row as i32 * 3; if let Some(offset) = enable { parts.push(format!( "drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill:enable='eq(n,{})'", x, y, offset )); } else { parts.push(format!( "drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill", x, y )); } } } } px += CHAR_ADVANCE; } px } #[derive(Debug, serde::Deserialize)] struct BboxParams { start: Option, end: Option, face_uuid: Option, duration: Option, } async fn bbox_overlay_video( State(state): State, Path(file_uuid): Path, Query(p): Query, ) -> Result { let videos_table = schema::table_name("videos"); let row: Option<(String,)> = sqlx::query_as(&format!( "SELECT file_path FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (video_path,) = row.ok_or(StatusCode::NOT_FOUND)?; let start_f = p.start.unwrap_or(0); let end_f = p.end.unwrap_or(i32::MAX); let face_fuid = p.face_uuid.as_deref().unwrap_or(&file_uuid); let duration = p.duration.unwrap_or(10.0); // Get FPS let fps: f64 = sqlx::query_scalar(&format!( "SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .unwrap_or(24.0); let start_sec = start_f as f64 / fps; // Get face bboxes let face_table = schema::table_name("face_detections"); let rows: Vec<(i32, i32, i32, i32, i32, Option, Option)> = sqlx::query_as( &format!("SELECT frame_number, x, y, width, height, trace_id, face_id FROM {} WHERE file_uuid = $1 AND frame_number BETWEEN $2 AND $3 ORDER BY frame_number", face_table) ) .bind(face_fuid).bind(start_f).bind(end_f) .fetch_all(state.db.pool()).await .unwrap_or_else(|e| { tracing::error!("bbox query error: {}", e); vec![] }); // Build filters let mut parts: Vec = Vec::new(); let mut is_first = true; for (frame, x, y, w, h, trace_id, _) in &rows { let text = format!("t{}", trace_id.unwrap_or(0)); if is_first { is_first = false; // Persistent bbox: thin pale red border parts.push(format!( "drawbox=x={}:y={}:w={}:h={}:color=red@0.3:thickness=4", x, y, w, h )); // Always-on text: top-left of bbox with padding let tx = *x + 6; let ty = *y + 6; render_text(&mut parts, &text, tx, ty, None); } else { let offset = frame - start_f; // Per-frame bbox: thick bright red parts.push(format!( "drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='eq(n,{})'", x, y, w, h, offset )); // Per-frame text let tx = *x + 6; let ty = *y + 6; render_text(&mut parts, &text, tx, ty, Some(offset)); } } let vf = if parts.is_empty() { "null".to_string() } else { parts.join(",") }; let tmp = std::env::temp_dir().join(format!("bbox_{}.mp4", uuid::Uuid::new_v4())); let tmp_str = tmp.to_str().unwrap_or("").to_string(); let status = ffmpeg_cmd() .args([ "-ss", &start_sec.to_string(), "-i", &video_path, "-t", &duration.to_string(), "-vf", &vf, "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28", "-an", "-movflags", "+faststart", "-y", &tmp_str, ]) .status() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if !status.success() { let _ = std::fs::remove_file(&tmp); return Err(StatusCode::INTERNAL_SERVER_ERROR); } let data = tokio::fs::read(&tmp) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let _ = std::fs::remove_file(&tmp); Ok(Response::builder() .header(header::CONTENT_TYPE, "video/mp4") .header(header::CONTENT_LENGTH, data.len()) .body(Body::from(data)) .unwrap()) } fn parse_range(range: &str, file_size: u64) -> (u64, u64) { let r = range.trim_start_matches("bytes="); let parts: Vec<&str> = r.split('-').collect(); let start = parts[0].parse::().unwrap_or(0); let end = if parts.len() > 1 && !parts[1].is_empty() { parts[1].parse::().unwrap_or(file_size - 1) } else { file_size - 1 }; (start.min(file_size - 1), end.min(file_size - 1)) } async fn trace_video( State(state): State, Path((file_uuid, trace_id)): Path<(String, i32)>, Query(params): Query>, ) -> Result { use axum::http::header; let videos_table = schema::table_name("videos"); let row: Option<(String,)> = sqlx::query_as(&format!( "SELECT file_path FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (video_path,) = row.ok_or(StatusCode::NOT_FOUND)?; let fps: f64 = sqlx::query_scalar(&format!( "SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .unwrap_or(24.0); // Get all detections for this trace_id let face_table = schema::table_name("face_detections"); let rows: Vec<(i32, i32, i32, i32, i32)> = sqlx::query_as(&format!( "SELECT frame_number, x, y, width, height FROM {} WHERE file_uuid = $1 AND trace_id = $2 ORDER BY frame_number", face_table )) .bind(&file_uuid).bind(trace_id) .fetch_all(state.db.pool()).await .unwrap_or_else(|e| { tracing::error!("trace query error: {}", e); vec![] }); if rows.is_empty() { return Err(StatusCode::NOT_FOUND); } let first_frame = rows[0].0; let last_frame = rows[rows.len() - 1].0; let start_sec = first_frame as f64 / fps; let padding = params.get("padding").and_then(|s| s.parse().ok()).unwrap_or(2.0); let duration = (last_frame - first_frame) as f64 / fps + padding * 2.0; let seek = (start_sec - padding).max(0.0); // Build filters: bbox+drawtext (1 filter + 1 drawtext per detection) let mut parts: Vec = Vec::new(); for (i, (frame, x, y, w, h)) in rows.iter().enumerate() { let next_frame = if i + 1 < rows.len() { rows[i + 1].0 } else { last_frame + (padding * fps) as i32 }; let start_offset = frame - first_frame + (padding * fps) as i32; let end_offset = next_frame - first_frame + (padding * fps) as i32; // Bbox parts.push(format!( "drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='between(n,{},{})'", x, y, w, h, start_offset, end_offset - 1 )); // Text label (drawtext, 1 filter vs ~175 bitmap drawboxes) parts.push(format!( "drawtext=text='{}':x={}:y={}:fontsize=20:fontcolor=white:box=1:boxcolor=red@0.8:enable='between(n,{},{})'", trace_id, x + 4, y + 4, start_offset, end_offset - 1 )); } // Write filter to temp file to bypass ARG_MAX let filter_text = parts.join(","); let filter_file = std::env::temp_dir().join(format!("vf_{}.txt", uuid::Uuid::new_v4())); let _ = std::fs::write(&filter_file, &filter_text); let filter_path = filter_file.to_str().unwrap_or(""); let tmp = std::env::temp_dir().join(format!("trace_{}.mp4", uuid::Uuid::new_v4())); let tmp_str = tmp.to_str().unwrap_or("").to_string(); let result = ffmpeg_cmd() .args([ "-ss", &seek.to_string(), "-i", &video_path, "-t", &duration.to_string(), "-/filter_complex", &filter_path, "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28", "-an", "-movflags", "+faststart", "-y", &tmp_str, ]) .output() .map_err(|e| { tracing::error!("ffmpeg spawn: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; if !result.status.success() { let stderr = String::from_utf8_lossy(&result.stderr); tracing::error!("ffmpeg failed: {}", &stderr[..stderr.len().min(300)]); let _ = std::fs::remove_file(&filter_file); let _ = std::fs::remove_file(&tmp); return Err(StatusCode::INTERNAL_SERVER_ERROR); } let data = tokio::fs::read(&tmp) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let _ = std::fs::remove_file(&filter_file); let _ = std::fs::remove_file(&tmp); Ok(Response::builder() .header(header::CONTENT_TYPE, "video/mp4") .header(header::CONTENT_LENGTH, data.len()) .body(Body::from(data)) .unwrap()) } async fn stream_video( State(state): State, Path(file_uuid): Path, Query(params): Query>, request: axum::http::Request, ) -> Result { use tokio::io::{AsyncReadExt, AsyncSeekExt}; let videos_table = schema::table_name("videos"); let row: Option<(String,)> = sqlx::query_as(&format!( "SELECT file_path FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?; let src = std::path::PathBuf::from(&file_path); if !src.exists() { return Err(StatusCode::NOT_FOUND); } // Chunk extraction with start/end params if let (Some(s), Some(e)) = (params.get("start"), params.get("end")) { let start: f64 = s.parse().unwrap_or(0.0); let end: f64 = e.parse().unwrap_or(0.0); let dur = end - start; if dur <= 0.0 { return Err(StatusCode::BAD_REQUEST); } let tmp = std::env::temp_dir().join(format!("chunk_{}.mp4", uuid::Uuid::new_v4())); let tmp_str = tmp.to_str().unwrap_or("").to_string(); let status = ffmpeg_cmd() .args([ "-ss", &start.to_string(), "-i", &file_path, "-t", &dur.to_string(), "-c", "copy", "-movflags", "+faststart", "-y", &tmp_str, ]) .status() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if !status.success() { let _ = std::fs::remove_file(&tmp); return Err(StatusCode::INTERNAL_SERVER_ERROR); } let data = tokio::fs::read(&tmp) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let _ = std::fs::remove_file(&tmp); return Ok(Response::builder() .header(header::CONTENT_TYPE, "video/mp4") .header(header::CONTENT_LENGTH, data.len()) .body(Body::from(data)) .unwrap()); } let file_size = src.metadata().map(|m| m.len()).unwrap_or(0); let content_type = "video/mp4"; let range_hdr = request .headers() .get(header::RANGE) .and_then(|v| v.to_str().ok()); if let Some(range_str) = range_hdr { let (start, end) = parse_range(range_str, file_size); let length = end - start + 1; let mut file = tokio::fs::File::open(&src) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; file.seek(std::io::SeekFrom::Start(start)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let take = file.take(length); let stream = tokio_util::io::ReaderStream::new(take); let body = Body::from_stream(stream); Ok(Response::builder() .status(StatusCode::PARTIAL_CONTENT) .header(header::CONTENT_TYPE, content_type) .header( header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, file_size), ) .header(header::CONTENT_LENGTH, length) .body(body) .unwrap()) } else { let file = tokio::fs::File::open(&src) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let stream = tokio_util::io::ReaderStream::new(file); let body = Body::from_stream(stream); Ok(Response::builder() .header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_LENGTH, file_size) .header(header::ACCEPT_RANGES, "bytes") .body(body) .unwrap()) } } #[derive(Debug, serde::Deserialize)] struct ThumbQuery { frame: i64, x: Option, y: Option, w: Option, h: Option, } async fn face_thumbnail( State(state): State, Path(file_uuid): Path, Query(q): Query, ) -> Result { let videos_table = schema::table_name("videos"); let row: Option<(String,)> = sqlx::query_as(&format!( "SELECT file_path FROM {} WHERE file_uuid = $1", videos_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?; let select = format!("select=eq(n\\,{})", q.frame); let vf = if let (Some(x), Some(y), Some(w), Some(h)) = (q.x, q.y, q.w, q.h) { format!("{},crop={}:{}:{}:{}", select, w, h, x, y) } else { select }; let output = ffmpeg_cmd() .args([ "-i", &file_path, "-vf", &vf, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "-", ]) .output() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if !output.status.success() { return Err(StatusCode::INTERNAL_SERVER_ERROR); } Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "image/jpeg") .header(header::CACHE_CONTROL, "public, max-age=86400") .body(Body::from(output.stdout)) .unwrap()) }