diff --git a/src/api/media_api.rs b/src/api/media_api.rs index f8d7f21..d1c54e2 100644 --- a/src/api/media_api.rs +++ b/src/api/media_api.rs @@ -114,12 +114,36 @@ fn render_text( #[derive(Debug, serde::Deserialize)] struct BboxParams { + // Legacy (deprecated): single param, frames start: Option, end: Option, + // Explicit params: input either or both + start_frame: Option, + end_frame: Option, + start_time: Option, + end_time: Option, face_uuid: Option, duration: Option, } +/// Resolve (start_frame, end_frame) from dual input. +/// Priority: start_frame/end_frame > start/end > start_time/end_time. +/// If only time is given, convert via fps. +fn resolve_frame_range( + start_frame: Option, end_frame: Option, + start: Option, end: Option, + start_time: Option, end_time: Option, + fps: f64, +) -> (i32, i32) { + if let (Some(sf), Some(ef)) = (start_frame.or(start), end_frame.or(end)) { + return (sf, ef); + } + if let (Some(st), Some(et)) = (start_time, end_time) { + return ((st * fps) as i32, (et * fps) as i32); + } + (0, i32::MAX) +} + async fn bbox_overlay_video( State(state): State, Path(file_uuid): Path, @@ -136,8 +160,6 @@ async fn bbox_overlay_video( .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); @@ -152,6 +174,8 @@ async fn bbox_overlay_video( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .unwrap_or(24.0); + let (start_f, end_f) = resolve_frame_range(p.start_frame, p.end_frame, p.start, p.end, p.start_time, p.end_time, fps); + let start_sec = start_f as f64 / fps; // Get face bboxes @@ -487,11 +511,30 @@ async fn stream_video( 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; + // Chunk extraction with dual time/frame params + let start_time_param = params.get("start_time").and_then(|v| v.parse::().ok()); + let end_time_param = params.get("end_time").and_then(|v| v.parse::().ok()); + let start_frame_param = params.get("start_frame").and_then(|v| v.parse::().ok()); + let end_frame_param = params.get("end_frame").and_then(|v| v.parse::().ok()); + let start_legacy = params.get("start").and_then(|v| v.parse::().ok()); + let end_legacy = params.get("end").and_then(|v| v.parse::().ok()); + + let has_range = start_frame_param.is_some() || start_time_param.is_some() || start_legacy.is_some(); + + if has_range { + let (start_sec, dur) = if let (Some(sf), Some(ef)) = (start_frame_param, end_frame_param) { + 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); + (sf / _fps, (ef - sf) / _fps) + } else if let (Some(st), Some(et)) = (start_time_param, end_time_param) { + (st, et - st) + } else if let (Some(s), Some(e)) = (start_legacy, end_legacy) { + (s, e - s) + } else { + return Err(StatusCode::BAD_REQUEST); + }; if dur <= 0.0 { return Err(StatusCode::BAD_REQUEST); } @@ -499,29 +542,15 @@ async fn stream_video( 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, - ]) + .args(["-ss", &start_sec.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 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") @@ -530,6 +559,7 @@ async fn stream_video( .unwrap()); } + // Full file streaming with range request support let file_size = src.metadata().map(|m| m.len()).unwrap_or(0); let content_type = "video/mp4"; diff --git a/src/api/trace_agent_api.rs b/src/api/trace_agent_api.rs index 74e580c..ad8cc30 100644 --- a/src/api/trace_agent_api.rs +++ b/src/api/trace_agent_api.rs @@ -36,10 +36,10 @@ struct TracesRequest { struct TraceInfo { trace_id: i32, face_count: i64, - first_frame: i32, - last_frame: i32, - first_sec: f64, - last_sec: f64, + start_frame: i32, + end_frame: i32, + start_time: f64, + end_time: f64, duration_sec: f64, avg_confidence: f64, sample_face_id: Option, @@ -49,6 +49,7 @@ struct TraceInfo { struct TracesResponse { success: bool, file_uuid: String, + fps: f64, total_traces: i64, total_faces: i64, page: i64, @@ -73,8 +74,8 @@ async fn list_traces_sorted( let order_clause = match sort { "face_count" => "face_count DESC", - "duration" => "(MAX(frame_number) - MIN(frame_number)) DESC", - _ => "first_frame ASC", + "duration" => "duration_sec DESC", + _ => "start_frame ASC", }; let fps: f64 = @@ -87,12 +88,13 @@ async fn list_traces_sorted( .unwrap_or(24.0); let query = format!( - "SELECT tt.*, fd.id AS sample_face_id, {} AS video_fps FROM ( + "SELECT tt.*, fd.id AS sample_face_id FROM ( SELECT trace_id, COUNT(*) AS face_count, - MIN(frame_number) AS first_frame, - MAX(frame_number) AS last_frame, - AVG(confidence) AS avg_confidence + MIN(frame_number) AS start_frame, + MAX(frame_number) AS end_frame, + (MAX(frame_number) - MIN(frame_number))::float8 AS duration_sec, + AVG(confidence)::float8 AS avg_confidence FROM {} WHERE file_uuid = $1 AND trace_id IS NOT NULL AND confidence >= $5 AND confidence <= $6 @@ -106,13 +108,12 @@ async fn list_traces_sorted( WHERE trace_id = tt.trace_id AND file_uuid = $1 ORDER BY confidence DESC LIMIT 1 ) fd ON true", - crate::core::db::schema::table_name("videos"), crate::core::db::schema::table_name("face_detections"), order_clause, crate::core::db::schema::table_name("face_detections"), ); - let rows: Vec<(i32, i64, i32, i32, f64, f64, f64, f64, Option)> = + let rows: Vec<(i32, i64, i32, i32, f64, f64, Option)> = sqlx::query_as(&query) .bind(&file_uuid) .bind(min_faces) @@ -126,16 +127,16 @@ async fn list_traces_sorted( let traces: Vec = rows .into_iter() - .map(|(tid, fc, ff, lf, fs, ls, dur, conf, fid)| TraceInfo { + .map(|(tid, fc, sf, ef, dur, conf, fid)| TraceInfo { trace_id: tid, face_count: fc, - first_frame: ff, - last_frame: lf, - first_sec: fs, - last_sec: ls, - duration_sec: dur, + start_frame: sf, + end_frame: ef, + start_time: sf as f64 / fps, + end_time: ef as f64 / fps, + duration_sec: dur / fps, avg_confidence: conf, - sample_face_id: fid, + sample_face_id: fid.map(|v| v.to_string()), }) .collect(); @@ -151,6 +152,7 @@ async fn list_traces_sorted( Ok(Json(TracesResponse { success: true, file_uuid, + fps, total_traces, total_faces, page, @@ -174,7 +176,9 @@ struct TraceFacesQuery { struct TraceFaceItem { id: i32, start_frame: i32, + end_frame: i32, start_time: f64, + end_time: f64, x: Option, y: Option, width: Option, @@ -188,6 +192,7 @@ struct TraceFacesResponse { success: bool, file_uuid: String, trace_id: i32, + fps: f64, total: i64, faces: Vec, } @@ -274,10 +279,13 @@ async fn list_trace_faces( let mid_w = lerp_i32(prev.4, *w, t); let mid_h = lerp_i32(prev.5, *h, t); let mid_frame = prev_frame + mid; + let mt = (mid_frame as f64 / fps * 10.0).round() / 10.0; faces.push(TraceFaceItem { id: 0, start_frame: mid_frame, - start_time: (mid_frame as f64 / fps * 10.0).round() / 10.0, + end_frame: mid_frame, + start_time: mt, + end_time: mt, x: mid_x, y: mid_y, width: mid_w, @@ -291,10 +299,13 @@ async fn list_trace_faces( // Add the real detection let frame_val = *frame; + let ft = (frame_val as f64 / fps * 10.0).round() / 10.0; faces.push(TraceFaceItem { id: *id, start_frame: frame_val, - start_time: (frame_val as f64 / fps * 10.0).round() / 10.0, + end_frame: frame_val, + start_time: ft, + end_time: ft, x: *x, y: *y, width: *w, @@ -314,6 +325,7 @@ async fn list_trace_faces( success: true, file_uuid, trace_id, + fps, total, faces, }))