529 lines
18 KiB
Rust
529 lines
18 KiB
Rust
use axum::{
|
||
body::Body,
|
||
extract::{Path, Query, State},
|
||
http::{header, StatusCode},
|
||
response::{IntoResponse, Response},
|
||
routing::get,
|
||
Router,
|
||
};
|
||
|
||
use crate::core::db::{schema, PostgresDb};
|
||
|
||
pub fn bbox_routes() -> Router<crate::api::server::AppState> {
|
||
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<String>,
|
||
text: &str,
|
||
origin_x: i32,
|
||
origin_y: i32,
|
||
enable: Option<i32>,
|
||
) -> 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<i32>,
|
||
end: Option<i32>,
|
||
face_uuid: Option<String>,
|
||
duration: Option<f64>,
|
||
}
|
||
|
||
async fn bbox_overlay_video(
|
||
State(state): State<crate::api::server::AppState>,
|
||
Path(file_uuid): Path<String>,
|
||
Query(p): Query<BboxParams>,
|
||
) -> Result<impl IntoResponse, StatusCode> {
|
||
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<i32>, Option<String>)> = 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<String> = 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 = std::process::Command::new("ffmpeg")
|
||
.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::<u64>().unwrap_or(0);
|
||
let end = if parts.len() > 1 && !parts[1].is_empty() {
|
||
parts[1].parse::<u64>().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<crate::api::server::AppState>,
|
||
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||
) -> Result<impl IntoResponse, StatusCode> {
|
||
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+text holding at last detection until next one
|
||
let mut parts: Vec<String> = Vec::new();
|
||
for (i, (frame, x, y, w, h)) in rows.iter().enumerate() {
|
||
// Hold this detection until the next one (or end)
|
||
let next_frame = if i + 1 < rows.len() {
|
||
rows[i + 1].0
|
||
} else {
|
||
// For last detection, extend to duration end
|
||
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: visible from this frame until next detection
|
||
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: same hold behavior
|
||
let label = format!("t{}", trace_id);
|
||
let mut tx = *x + 6;
|
||
let mut ty = *y + 6;
|
||
for ch in label.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 dx = tx + col as i32 * 3;
|
||
let dy = ty + row as i32 * 3;
|
||
parts.push(format!(
|
||
"drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill:enable='between(n,{},{})'",
|
||
dx, dy, start_offset, end_offset - 1
|
||
));
|
||
}
|
||
}
|
||
}
|
||
tx += CHAR_ADVANCE;
|
||
}
|
||
}
|
||
|
||
let vf = if parts.is_empty() {
|
||
"null".to_string()
|
||
} else {
|
||
parts.join(",")
|
||
};
|
||
|
||
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 status = std::process::Command::new("ffmpeg")
|
||
.args([
|
||
"-ss", &seek.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())
|
||
}
|
||
|
||
async fn stream_video(
|
||
State(state): State<crate::api::server::AppState>,
|
||
Path(file_uuid): Path<String>,
|
||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||
request: axum::http::Request<Body>,
|
||
) -> Result<impl IntoResponse, StatusCode> {
|
||
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 = std::process::Command::new("ffmpeg")
|
||
.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<i32>,
|
||
y: Option<i32>,
|
||
w: Option<i32>,
|
||
h: Option<i32>,
|
||
}
|
||
|
||
async fn face_thumbnail(
|
||
State(state): State<crate::api::server::AppState>,
|
||
Path(file_uuid): Path<String>,
|
||
Query(q): Query<ThumbQuery>,
|
||
) -> Result<impl IntoResponse, StatusCode> {
|
||
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 = std::process::Command::new("ffmpeg")
|
||
.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())
|
||
}
|