feat: trace video normal/debug mode — normal=raw, debug=bbox+frame+identity+cut
This commit is contained in:
@@ -261,26 +261,18 @@ async fn trace_video(
|
|||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
|
|
||||||
|
let mode = params.get("mode").map(|s| s.as_str()).unwrap_or("debug");
|
||||||
|
|
||||||
let videos_table = schema::table_name("videos");
|
let videos_table = schema::table_name("videos");
|
||||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
let row: Option<(String, f64, i32, i32)> = sqlx::query_as(&format!(
|
||||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
"SELECT file_path, COALESCE(fps, 24.0), COALESCE(width, 0), COALESCE(height, 0) FROM {} WHERE file_uuid = $1",
|
||||||
videos_table
|
videos_table
|
||||||
))
|
))
|
||||||
.bind(&file_uuid)
|
.bind(&file_uuid)
|
||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
let (video_path,) = row.ok_or(StatusCode::NOT_FOUND)?;
|
let (video_path, fps, _width, _height) = 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
|
// Get all detections for this trace_id
|
||||||
let face_table = schema::table_name("face_detections");
|
let face_table = schema::table_name("face_detections");
|
||||||
@@ -305,45 +297,82 @@ async fn trace_video(
|
|||||||
.unwrap_or(2.0);
|
.unwrap_or(2.0);
|
||||||
let duration = (last_frame - first_frame) as f64 / fps + padding * 2.0;
|
let duration = (last_frame - first_frame) as f64 / fps + padding * 2.0;
|
||||||
let seek = (start_sec - padding).max(0.0);
|
let seek = (start_sec - padding).max(0.0);
|
||||||
|
|
||||||
|
// === NORMAL MODE: raw video without overlays ===
|
||||||
|
if mode == "normal" {
|
||||||
|
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(),
|
||||||
|
"-c", "copy", "-an", "-y", &tmp_str])
|
||||||
|
.output()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
if !result.status.success() {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DEBUG MODE: with overlays ===
|
||||||
let total_frames = (duration * fps).round() as i32;
|
let total_frames = (duration * fps).round() as i32;
|
||||||
let pad_frames = (padding * fps) as i32;
|
let pad_frames = (padding * fps) as i32;
|
||||||
|
|
||||||
// Build filters: bbox+drawtext (1 filter + 1 drawtext per detection)
|
// Query identity info for this trace
|
||||||
|
let identity_name: String = sqlx::query_scalar(&format!(
|
||||||
|
"SELECT COALESCE(i.name, 'unknown') FROM {} fd LEFT JOIN dev.identities i ON i.id = fd.identity_id WHERE fd.file_uuid = $1 AND fd.trace_id = $2 LIMIT 1",
|
||||||
|
face_table
|
||||||
|
))
|
||||||
|
.bind(&file_uuid).bind(trace_id)
|
||||||
|
.fetch_optional(state.db.pool()).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Query cut_id for the first frame
|
||||||
|
let cut_id: i32 = sqlx::query_scalar(
|
||||||
|
"SELECT scene_number FROM public.cut WHERE file_uuid = $1 AND start_frame <= $2 AND end_frame >= $2 LIMIT 1"
|
||||||
|
)
|
||||||
|
.bind(&file_uuid).bind(first_frame)
|
||||||
|
.fetch_optional(state.db.pool()).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// Global frame number overlay (top-left corner, always visible)
|
// Top-left info block
|
||||||
let frame_offset = first_frame as i64 - (padding * fps) as i64;
|
let frame_offset = first_frame as i64 - (padding * fps) as i64;
|
||||||
let global_frame_text = format!(
|
let info_lines = format!(
|
||||||
"drawtext=text='Frame\\: %{{eif:n+{}:d}}':fontsize=16:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=10",
|
"drawtext=text='Trace: {} Cut: {} {}':fontsize=14:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=12",
|
||||||
frame_offset
|
trace_id, cut_id, identity_name
|
||||||
);
|
);
|
||||||
parts.push(global_frame_text);
|
parts.push(info_lines);
|
||||||
|
parts.push(format!(
|
||||||
|
"drawtext=text='Frame: %{{eif:n+{}:d}} UUID: {}':fontsize=14:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=34",
|
||||||
|
frame_offset, file_uuid
|
||||||
|
));
|
||||||
|
|
||||||
for (i, (frame, x, y, w, h)) in rows.iter().enumerate() {
|
for (i, (frame, x, y, w, h)) in rows.iter().enumerate() {
|
||||||
let start_off = frame - first_frame + pad_frames;
|
let start_off = frame - first_frame + pad_frames;
|
||||||
// End at next detection, or at the last frame of the video (not beyond)
|
|
||||||
let end_off = if i + 1 < rows.len() {
|
let end_off = if i + 1 < rows.len() {
|
||||||
rows[i + 1].0 - first_frame + pad_frames
|
rows[i + 1].0 - first_frame + pad_frames
|
||||||
} else {
|
} else {
|
||||||
total_frames // last bbox ends with video, no extra padding
|
total_frames
|
||||||
};
|
};
|
||||||
// Bbox
|
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='between(n,{},{})'",
|
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='between(n,{},{})'",
|
||||||
x,
|
x, y, w, h, start_off, end_off - 1
|
||||||
y,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
start_off,
|
|
||||||
end_off - 1
|
|
||||||
));
|
));
|
||||||
// Text label (drawtext, 1 filter vs ~175 bitmap drawboxes)
|
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"drawtext=text='{}':x={}:y={}:fontsize=20:fontcolor=white:box=1:boxcolor=red@0.8:enable='between(n,{},{})'",
|
"drawtext=text='{}':x={}:y={}:fontsize=20:fontcolor=white:box=1:boxcolor=red@0.8:enable='between(n,{},{})'",
|
||||||
trace_id, x + 4, y + 4, start_off, end_off - 1
|
trace_id, x + 4, y + 4, start_off, end_off - 1
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write filter to temp file to bypass ARG_MAX
|
|
||||||
let filter_text = parts.join(",");
|
let filter_text = parts.join(",");
|
||||||
let filter_file = std::env::temp_dir().join(format!("vf_{}.txt", uuid::Uuid::new_v4()));
|
let filter_file = std::env::temp_dir().join(format!("vf_{}.txt", uuid::Uuid::new_v4()));
|
||||||
let _ = std::fs::write(&filter_file, &filter_text);
|
let _ = std::fs::write(&filter_file, &filter_text);
|
||||||
@@ -352,33 +381,12 @@ async fn trace_video(
|
|||||||
let tmp = std::env::temp_dir().join(format!("trace_{}.mp4", uuid::Uuid::new_v4()));
|
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 tmp_str = tmp.to_str().unwrap_or("").to_string();
|
||||||
let result = ffmpeg_cmd()
|
let result = ffmpeg_cmd()
|
||||||
.args([
|
.args(["-ss", &seek.to_string(), "-i", &video_path, "-t", &duration.to_string(),
|
||||||
"-ss",
|
"-/filter_complex", &filter_path,
|
||||||
&seek.to_string(),
|
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
|
||||||
"-i",
|
"-c:a", "aac", "-movflags", "+faststart", "-y", &tmp_str])
|
||||||
&video_path,
|
|
||||||
"-t",
|
|
||||||
&duration.to_string(),
|
|
||||||
"-/filter_complex",
|
|
||||||
&filter_path,
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
"ultrafast",
|
|
||||||
"-crf",
|
|
||||||
"28",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart",
|
|
||||||
"-y",
|
|
||||||
&tmp_str,
|
|
||||||
])
|
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| {
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
tracing::error!("ffmpeg spawn: {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
if !result.status.success() {
|
if !result.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||||
tracing::error!("ffmpeg failed: {}", &stderr[..stderr.len().min(300)]);
|
tracing::error!("ffmpeg failed: {}", &stderr[..stderr.len().min(300)]);
|
||||||
@@ -387,9 +395,7 @@ async fn trace_video(
|
|||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = tokio::fs::read(&tmp)
|
let data = tokio::fs::read(&tmp).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
let _ = std::fs::remove_file(&filter_file);
|
let _ = std::fs::remove_file(&filter_file);
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
|
|||||||
Reference in New Issue
Block a user