feat: media API (video/bbox/thumbnail), UUID unification, dot matrix text, portal fixes, API dictionary V1.3
This commit is contained in:
402
src/api/media_api.rs
Normal file
402
src/api/media_api.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
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/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 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())
|
||||
}
|
||||
Reference in New Issue
Block a user