Files
momentry_core/src/api/media_api.rs
2026-05-08 00:48:15 +08:00

529 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}