feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
use crate::core::config::OUTPUT_DIR;
|
||||
use crate::core::db::schema;
|
||||
use crate::core::llm::client::generate_5w1h_summary;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
@@ -115,19 +114,6 @@ pub async fn ingest_rule3(pool: &PgPool, file_uuid: &str) -> Result<usize> {
|
||||
|
||||
let aggregated_text = texts.join(" ");
|
||||
|
||||
// 3. Call LLM for Summary
|
||||
let summary = if !aggregated_text.is_empty() {
|
||||
match generate_5w1h_summary(&aggregated_text).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("LLM Summary failed for scene {}: {}", scene.scene_number, e);
|
||||
"LLM Error".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"No Audio".to_string()
|
||||
};
|
||||
|
||||
info!(
|
||||
"Scene {}: {} -> {} ({} sentences)",
|
||||
scene.scene_number,
|
||||
@@ -168,7 +154,7 @@ pub async fn ingest_rule3(pool: &PgPool, file_uuid: &str) -> Result<usize> {
|
||||
.bind(scene.end_frame as i64)
|
||||
.bind(&metadata)
|
||||
.bind(&aggregated_text)
|
||||
.bind(&summary)
|
||||
.bind(&String::new())
|
||||
.bind(&metadata)
|
||||
.bind(&child_ids)
|
||||
.execute(&mut *tx)
|
||||
|
||||
+4
-345
@@ -1,7 +1,6 @@
|
||||
use crate::core::time::FrameTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ==================== ChunkType ====================
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChunkType {
|
||||
@@ -10,7 +9,6 @@ pub enum ChunkType {
|
||||
Cut,
|
||||
Trace,
|
||||
Story,
|
||||
Visual, // 視覺分片 (Phase 2.1)
|
||||
}
|
||||
|
||||
impl ChunkType {
|
||||
@@ -21,17 +19,15 @@ impl ChunkType {
|
||||
ChunkType::Cut => "cut",
|
||||
ChunkType::Trace => "trace",
|
||||
ChunkType::Story => "story",
|
||||
ChunkType::Visual => "visual",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ChunkRule ====================
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChunkRule {
|
||||
Rule1, // 直接轉換
|
||||
Rule2, // 集合內容
|
||||
Rule1,
|
||||
Rule2,
|
||||
}
|
||||
|
||||
impl ChunkRule {
|
||||
@@ -43,73 +39,6 @@ impl ChunkRule {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 視覺分片相關結構 (Phase 2.1) ====================
|
||||
/// 邊界框
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BoundingBox {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
/// 檢測到的物件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedObject {
|
||||
/// 物件類別名稱
|
||||
pub class_name: String,
|
||||
/// 物件類別 ID
|
||||
pub class_id: u32,
|
||||
/// 信心值 (0.0-1.0)
|
||||
pub confidence: f32,
|
||||
/// 邊界框
|
||||
pub bbox: Option<BoundingBox>,
|
||||
/// 出現次數 (在分片內)
|
||||
pub occurrence: u32,
|
||||
}
|
||||
|
||||
/// 關鍵幀的物件列表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyframeObjects {
|
||||
/// 關鍵幀時間 (秒) - 僅供參考,主要使用 frame_number
|
||||
pub timestamp: f64,
|
||||
/// 關鍵幀幀號 - 主要時間標示
|
||||
pub frame_number: u64,
|
||||
/// 檢測到的物件
|
||||
pub objects: Vec<DetectedObject>,
|
||||
}
|
||||
|
||||
/// 視覺元數據
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VisualMetadata {
|
||||
/// 總物件數量
|
||||
pub object_count: u32,
|
||||
/// 唯一物件類別列表
|
||||
pub unique_classes: Vec<String>,
|
||||
/// 最高信心值
|
||||
pub max_confidence: f32,
|
||||
/// 平均信心值
|
||||
pub avg_confidence: f32,
|
||||
/// 空間密度(每幀平均物件數)
|
||||
pub spatial_density: f32,
|
||||
}
|
||||
|
||||
/// 視覺分片內容
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VisualChunkContent {
|
||||
/// 關鍵幀物件列表,每個關鍵幀包含 frame_number
|
||||
pub keyframe_objects: Vec<KeyframeObjects>,
|
||||
/// 主要物件標籤(出現在大多數幀中的物件)
|
||||
pub dominant_objects: Vec<String>,
|
||||
/// 物件關係 (object1, relationship, object2) - 可選
|
||||
pub object_relationships: Vec<(String, String, String)>,
|
||||
/// 場景描述 - 可選
|
||||
pub scene_description: Option<String>,
|
||||
/// 視覺元數據
|
||||
pub metadata: VisualMetadata,
|
||||
}
|
||||
|
||||
// ==================== Chunk 主結構 ====================
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Chunk {
|
||||
pub file_id: i32,
|
||||
@@ -117,11 +46,8 @@ pub struct Chunk {
|
||||
pub chunk_id: String,
|
||||
pub chunk_type: ChunkType,
|
||||
pub rule: ChunkRule,
|
||||
/// Frames per second (can be fractional, e.g., 29.97, 23.976)
|
||||
pub fps: f64,
|
||||
/// Start frame (0-based) - 主要時間標示
|
||||
pub start_frame: i64,
|
||||
/// End frame (exclusive) - 主要時間標示
|
||||
pub end_frame: i64,
|
||||
pub text_content: Option<String>,
|
||||
pub content: serde_json::Value,
|
||||
@@ -129,13 +55,11 @@ pub struct Chunk {
|
||||
pub vector_id: Option<String>,
|
||||
pub frame_count: i32,
|
||||
pub pre_chunk_ids: Vec<i32>,
|
||||
pub parent_chunk_id: Option<String>, // For parent-child chunk hierarchy
|
||||
pub child_chunk_ids: Vec<String>, // Child chunk IDs (for parent chunks)
|
||||
pub visual_stats: Option<serde_json::Value>,
|
||||
pub parent_chunk_id: Option<String>,
|
||||
pub child_chunk_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// 創建新分片
|
||||
pub fn new(
|
||||
file_id: i32,
|
||||
uuid: String,
|
||||
@@ -166,167 +90,17 @@ impl Chunk {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: None,
|
||||
child_chunk_ids: vec![],
|
||||
visual_stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 創建視覺分片 (Phase 2.1)
|
||||
pub fn new_visual(
|
||||
file_id: i32,
|
||||
uuid: String,
|
||||
chunk_id: String,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
fps: f64,
|
||||
visual_content: VisualChunkContent,
|
||||
) -> Self {
|
||||
let content = serde_json::to_value(&visual_content)
|
||||
.unwrap_or_else(|_| serde_json::json!({"error": "Failed to serialize visual content"}));
|
||||
|
||||
Self::new(
|
||||
file_id,
|
||||
uuid,
|
||||
chunk_id,
|
||||
ChunkType::Visual,
|
||||
ChunkRule::Rule2,
|
||||
start_frame,
|
||||
end_frame,
|
||||
fps,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
/// 從 YOLO 幀創建視覺分片 (Phase 2.1)
|
||||
pub fn from_yolo_frames(
|
||||
file_id: i32,
|
||||
uuid: String,
|
||||
chunk_id: String,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
fps: f64,
|
||||
yolo_frames: Vec<crate::core::processor::yolo::YoloFrame>,
|
||||
) -> Self {
|
||||
// 將 YOLO 幀轉換為關鍵幀物件
|
||||
let keyframe_objects: Vec<KeyframeObjects> = yolo_frames
|
||||
.iter()
|
||||
.map(|frame| {
|
||||
let objects: Vec<DetectedObject> = frame
|
||||
.objects
|
||||
.iter()
|
||||
.map(|obj| DetectedObject {
|
||||
class_name: obj.class_name.clone(),
|
||||
class_id: obj.class_id,
|
||||
confidence: obj.confidence,
|
||||
bbox: Some(BoundingBox {
|
||||
x: obj.x,
|
||||
y: obj.y,
|
||||
width: obj.width,
|
||||
height: obj.height,
|
||||
}),
|
||||
occurrence: 1,
|
||||
})
|
||||
.collect();
|
||||
|
||||
KeyframeObjects {
|
||||
timestamp: frame.timestamp,
|
||||
frame_number: frame.frame,
|
||||
objects,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 計算物件統計
|
||||
let total_objects: u32 = yolo_frames.iter().map(|f| f.objects.len() as u32).sum();
|
||||
|
||||
// 收集所有物件類別
|
||||
let all_classes: Vec<String> = yolo_frames
|
||||
.iter()
|
||||
.flat_map(|f| f.objects.iter().map(|o| o.class_name.clone()))
|
||||
.collect();
|
||||
|
||||
// 獲取唯一類別
|
||||
let unique_classes: Vec<String> = all_classes
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// 計算信心值統計
|
||||
let confidences: Vec<f32> = yolo_frames
|
||||
.iter()
|
||||
.flat_map(|f| f.objects.iter().map(|o| o.confidence))
|
||||
.collect();
|
||||
|
||||
let max_confidence = confidences.iter().copied().fold(0.0f32, f32::max);
|
||||
let avg_confidence = if !confidences.is_empty() {
|
||||
confidences.iter().sum::<f32>() / confidences.len() as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// 計算主要物件(出現在大多數幀中的物件)
|
||||
let mut object_counts = std::collections::HashMap::new();
|
||||
for frame in &yolo_frames {
|
||||
let frame_classes: std::collections::HashSet<_> =
|
||||
frame.objects.iter().map(|o| o.class_name.clone()).collect();
|
||||
for class in frame_classes {
|
||||
*object_counts.entry(class).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut dominant_objects: Vec<String> = object_counts
|
||||
.into_iter()
|
||||
.filter(|(_, count)| *count as f32 / yolo_frames.len() as f32 > 0.5)
|
||||
.map(|(class, _)| class)
|
||||
.collect();
|
||||
dominant_objects.sort();
|
||||
|
||||
// 創建視覺內容
|
||||
let visual_content = VisualChunkContent {
|
||||
keyframe_objects,
|
||||
dominant_objects,
|
||||
object_relationships: vec![], // 可選:後期添加關係檢測
|
||||
scene_description: None, // 可選:後期添加 LLM 生成的場景描述
|
||||
metadata: VisualMetadata {
|
||||
object_count: total_objects,
|
||||
unique_classes,
|
||||
max_confidence,
|
||||
avg_confidence,
|
||||
spatial_density: if yolo_frames.len() > 0 {
|
||||
total_objects as f32 / yolo_frames.len() as f32
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Self::new_visual(
|
||||
file_id,
|
||||
uuid,
|
||||
chunk_id,
|
||||
start_frame,
|
||||
end_frame,
|
||||
fps,
|
||||
visual_content,
|
||||
)
|
||||
}
|
||||
|
||||
/// 將分片轉換為幀時間
|
||||
pub fn to_frame_time(&self) -> FrameTime {
|
||||
// 使用第一個幀作為參考點
|
||||
FrameTime::from_frames(self.start_frame, self.fps)
|
||||
}
|
||||
|
||||
/// 檢查是否是父分片
|
||||
pub fn is_parent(&self) -> bool {
|
||||
self.parent_chunk_id.is_some()
|
||||
}
|
||||
|
||||
/// 從秒數創建新分片(舊版轉換)
|
||||
///
|
||||
/// 這對於從存儲時間為秒的舊系統遷移很有用。
|
||||
/// 幀數通過舍入 `seconds * fps` 計算。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_seconds(
|
||||
file_id: i32,
|
||||
@@ -354,197 +128,82 @@ impl Chunk {
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回開始時間為 `FrameTime`
|
||||
pub fn start_time(&self) -> FrameTime {
|
||||
FrameTime::from_frames(self.start_frame, self.fps)
|
||||
}
|
||||
|
||||
/// 返回結束時間為 `FrameTime`
|
||||
pub fn end_time(&self) -> FrameTime {
|
||||
FrameTime::from_frames(self.end_frame, self.fps)
|
||||
}
|
||||
|
||||
/// 返回持續時間的幀數
|
||||
pub fn duration_frames(&self) -> i64 {
|
||||
self.end_frame - self.start_frame
|
||||
}
|
||||
|
||||
/// 返回持續時間的秒數
|
||||
pub fn duration_seconds(&self) -> f64 {
|
||||
self.duration_frames() as f64 / self.fps
|
||||
}
|
||||
|
||||
/// 將開始時間格式化為 "seconds.frame" (例如:"123.04")
|
||||
pub fn format_start_sec_frame(&self) -> String {
|
||||
self.start_time().format_sec_frame()
|
||||
}
|
||||
|
||||
/// 將結束時間格式化為 "seconds.frame" (例如:"456.15")
|
||||
pub fn format_end_sec_frame(&self) -> String {
|
||||
self.end_time().format_sec_frame()
|
||||
}
|
||||
|
||||
/// 將開始時間格式化為 "HH:MM:SS"
|
||||
pub fn format_start_hms(&self) -> String {
|
||||
self.start_time().format_hms()
|
||||
}
|
||||
|
||||
/// 將結束時間格式化為 "HH:MM:SS"
|
||||
pub fn format_end_hms(&self) -> String {
|
||||
self.end_time().format_hms()
|
||||
}
|
||||
|
||||
/// 將開始時間格式化為 "HH:MM:SS.FF"
|
||||
pub fn format_start_hms_frame(&self) -> String {
|
||||
self.start_time().format_hms_frame()
|
||||
}
|
||||
|
||||
/// 將結束時間格式化為 "HH:MM:SS.FF"
|
||||
pub fn format_end_hms_frame(&self) -> String {
|
||||
self.end_time().format_hms_frame()
|
||||
}
|
||||
|
||||
/// 返回 (start_seconds, end_seconds) 元組用於兼容性
|
||||
///
|
||||
/// 這在遷移期間提供向後兼容性。
|
||||
/// 建議使用 `start_time()` 和 `end_time()` 方法。
|
||||
pub fn time_range_seconds(&self) -> (f64, f64) {
|
||||
(self.start_time().seconds(), self.end_time().seconds())
|
||||
}
|
||||
|
||||
/// 添加元數據
|
||||
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
|
||||
self.metadata = Some(metadata);
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加向量 ID
|
||||
pub fn with_vector_id(mut self, vector_id: String) -> Self {
|
||||
self.vector_id = Some(vector_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加文本內容
|
||||
pub fn with_text_content(mut self, text: String) -> Self {
|
||||
self.text_content = Some(text);
|
||||
self
|
||||
}
|
||||
|
||||
/// 設置幀數
|
||||
pub fn with_frame_count(mut self, count: i32) -> Self {
|
||||
self.frame_count = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// 設置前一個分片 ID
|
||||
pub fn with_pre_chunk_ids(mut self, ids: Vec<i32>) -> Self {
|
||||
self.pre_chunk_ids = ids;
|
||||
self
|
||||
}
|
||||
|
||||
/// 設置父分片 ID
|
||||
pub fn with_parent_chunk_id(mut self, parent_id: String) -> Self {
|
||||
self.parent_chunk_id = Some(parent_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// 設置子分片 ID
|
||||
pub fn with_child_chunk_ids(mut self, child_ids: Vec<String>) -> Self {
|
||||
self.child_chunk_ids = child_ids;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== VisualChunkContent 輔助方法 ====================
|
||||
impl VisualChunkContent {
|
||||
/// 計算兩個 YOLO 幀之間的相似度(基於物件組成)
|
||||
pub fn frame_similarity(
|
||||
frame1: &crate::core::processor::yolo::YoloFrame,
|
||||
frame2: &crate::core::processor::yolo::YoloFrame,
|
||||
) -> f32 {
|
||||
if frame1.objects.is_empty() && frame2.objects.is_empty() {
|
||||
return 1.0; // 兩個空幀完全相似
|
||||
}
|
||||
|
||||
if frame1.objects.is_empty() || frame2.objects.is_empty() {
|
||||
return 0.0; // 一個空一個非空,不相似
|
||||
}
|
||||
|
||||
// 創建物件類別名稱集合
|
||||
let set1: std::collections::HashSet<String> = frame1
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| o.class_name.clone())
|
||||
.collect();
|
||||
let set2: std::collections::HashSet<String> = frame2
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| o.class_name.clone())
|
||||
.collect();
|
||||
|
||||
// 計算 Jaccard 相似度
|
||||
let intersection: Vec<_> = set1.intersection(&set2).collect();
|
||||
let union: Vec<_> = set1.union(&set2).collect();
|
||||
|
||||
if union.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
intersection.len() as f32 / union.len() as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// 獲取視覺分片的摘要(使用關鍵幀的 frame_number)
|
||||
pub fn summary(&self, fps: f64) -> String {
|
||||
if self.keyframe_objects.is_empty() {
|
||||
return "Empty visual chunk".to_string();
|
||||
}
|
||||
|
||||
let first_frame = self.keyframe_objects.first().unwrap().frame_number;
|
||||
let last_frame = self.keyframe_objects.last().unwrap().frame_number;
|
||||
|
||||
// 計算時間(僅供參考)
|
||||
let start_time = if fps > 0.0 {
|
||||
first_frame as f64 / fps
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let end_time = if fps > 0.0 {
|
||||
last_frame as f64 / fps
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let duration = end_time - start_time;
|
||||
let frame_count = self.keyframe_objects.len();
|
||||
|
||||
format!(
|
||||
"Visual chunk: frames {} to {} (duration: {:.1}s, {} frames). Objects: {} total, {} unique. Dominant: {}",
|
||||
first_frame,
|
||||
last_frame,
|
||||
duration,
|
||||
frame_count,
|
||||
self.metadata.object_count,
|
||||
self.metadata.unique_classes.len(),
|
||||
if self.dominant_objects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
self.dominant_objects.join(", ")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 檢查是否包含特定物件類別
|
||||
pub fn contains_object(&self, class_name: &str) -> bool {
|
||||
self.keyframe_objects
|
||||
.iter()
|
||||
.any(|ko| ko.objects.iter().any(|obj| obj.class_name == class_name))
|
||||
}
|
||||
|
||||
/// 獲取信心值高於閾值的所有物件
|
||||
pub fn high_confidence_objects(&self, threshold: f32) -> Vec<&DetectedObject> {
|
||||
self.keyframe_objects
|
||||
.iter()
|
||||
.flat_map(|ko| ko.objects.iter())
|
||||
.filter(|obj| obj.confidence >= threshold)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -56,7 +56,7 @@ pub static REDIS_URL: Lazy<String> = Lazy::new(|| {
|
||||
env::var("REDIS_URL").unwrap_or_else(|_| {
|
||||
let password = env::var("REDIS_PASSWORD").unwrap_or_else(|_| "accusys".to_string());
|
||||
// Format: redis://[:password]@host:port (use default user)
|
||||
format!("redis://:{}@localhost:6379", password)
|
||||
format!("redis://default:{}@localhost:6379", password)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -277,12 +277,14 @@ pub mod llm {
|
||||
}
|
||||
|
||||
/// Ollama embedding endpoint (vector embeddings for text sync).
|
||||
pub static OLLAMA_URL: Lazy<String> =
|
||||
Lazy::new(|| env::var("MOMENTRY_OLLAMA_URL").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string()));
|
||||
pub static OLLAMA_URL: Lazy<String> = Lazy::new(|| {
|
||||
env::var("MOMENTRY_OLLAMA_URL").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string())
|
||||
});
|
||||
|
||||
/// Text embedding server (comic-embed or alternative).
|
||||
pub static EMBED_URL: Lazy<String> =
|
||||
Lazy::new(|| env::var("MOMENTRY_EMBED_URL").unwrap_or_else(|_| "http://127.0.0.1:11436".to_string()));
|
||||
pub static EMBED_URL: Lazy<String> = Lazy::new(|| {
|
||||
env::var("MOMENTRY_EMBED_URL").unwrap_or_else(|_| "http://127.0.0.1:11436".to_string())
|
||||
});
|
||||
|
||||
/// LLM health endpoint.
|
||||
pub static LLM_HEALTH_URL: Lazy<String> = Lazy::new(|| {
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
use anyhow::{Context, Result};
|
||||
use bson::{doc, oid::ObjectId, DateTime as BsonDateTime, Document};
|
||||
use chrono::{DateTime, Utc};
|
||||
use mongodb::{Client, Collection, Database, IndexModel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use uuid::Uuid;
|
||||
|
||||
const COLLECTION_NAME: &str = "identity_merge_history";
|
||||
|
||||
fn bson_doc_to_json(doc: &Document) -> JsonValue {
|
||||
match bson::to_bson(doc) {
|
||||
Ok(bson) => bson.into_relaxed_extjson(),
|
||||
Err(_) => JsonValue::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_to_bson_doc(value: &JsonValue) -> Document {
|
||||
bson::to_document(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn doc_field_to_json(doc: &Document, key: &str) -> JsonValue {
|
||||
doc.get(key)
|
||||
.map(|b| b.clone().into_relaxed_extjson())
|
||||
.unwrap_or(JsonValue::Null)
|
||||
}
|
||||
|
||||
fn json_to_bson(value: &JsonValue) -> bson::Bson {
|
||||
bson::to_bson(value).unwrap_or(bson::Bson::Null)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityMergeHistory {
|
||||
pub id: Option<ObjectId>,
|
||||
pub merge_id: String,
|
||||
pub source_identity: IdentitySnapshot,
|
||||
pub target_identity: TargetIdentitySnapshot,
|
||||
pub aliases_added_to_target: Vec<AliasEntry>,
|
||||
pub metadata_fields_added: Vec<String>,
|
||||
pub faces_transferred: FacesTransferred,
|
||||
pub merge_params: MergeParams,
|
||||
pub merged_at: DateTime<Utc>,
|
||||
pub undo_deadline: DateTime<Utc>,
|
||||
pub undone: bool,
|
||||
pub undone_at: Option<DateTime<Utc>>,
|
||||
pub undone_by: Option<String>,
|
||||
pub undone_snapshot: Option<UndoneSnapshot>,
|
||||
pub undo_expired: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentitySnapshot {
|
||||
pub id: i64,
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub identity_type: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub status: String,
|
||||
pub tmdb_id: Option<i64>,
|
||||
pub tmdb_profile: Option<String>,
|
||||
pub metadata: JsonValue,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub face_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TargetIdentitySnapshot {
|
||||
pub id: i64,
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub metadata_before: JsonValue,
|
||||
pub metadata_after: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AliasEntry {
|
||||
pub name: String,
|
||||
pub locale: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FacesTransferred {
|
||||
pub file_uuid: String,
|
||||
pub face_ids: Vec<String>,
|
||||
pub trace_ids: Vec<i32>,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UndoneSnapshot {
|
||||
pub source_identity_id: i64,
|
||||
pub source_uuid: String,
|
||||
pub source_name: String,
|
||||
pub target_metadata_at_undo: JsonValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MergeParams {
|
||||
pub keep_history: bool,
|
||||
pub cleared_stranger_id: bool,
|
||||
pub performed_by_user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MergeHistoryQuery {
|
||||
pub source_uuid: Option<String>,
|
||||
pub target_uuid: Option<String>,
|
||||
pub merge_id: Option<String>,
|
||||
pub undone: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MergeHistoryEntry {
|
||||
pub merge_id: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub faces_transferred: i64,
|
||||
pub merged_at: DateTime<Utc>,
|
||||
pub undo_deadline: DateTime<Utc>,
|
||||
pub undone: bool,
|
||||
pub undo_expired: bool,
|
||||
}
|
||||
|
||||
impl IdentityMergeHistory {
|
||||
pub fn from_document(doc: &Document) -> Result<Self> {
|
||||
let source = doc
|
||||
.get_document("source_identity")
|
||||
.context("Missing source_identity")?;
|
||||
let target = doc
|
||||
.get_document("target_identity")
|
||||
.context("Missing target_identity")?;
|
||||
let faces = doc
|
||||
.get_document("faces_transferred")
|
||||
.context("Missing faces_transferred")?;
|
||||
let aliases = doc
|
||||
.get_array("aliases_added_to_target")
|
||||
.unwrap_or(&vec![])
|
||||
.clone();
|
||||
let fields = doc
|
||||
.get_array("metadata_fields_added")
|
||||
.unwrap_or(&vec![])
|
||||
.clone();
|
||||
let merge_params_doc = doc
|
||||
.get_document("merge_params")
|
||||
.unwrap_or(&Document::new())
|
||||
.clone();
|
||||
|
||||
let mut parsed_aliases = Vec::new();
|
||||
for a in aliases {
|
||||
if let Some(d) = a.as_document() {
|
||||
parsed_aliases.push(AliasEntry {
|
||||
name: d.get_str("name").unwrap_or("").to_string(),
|
||||
locale: d.get_str("locale").unwrap_or("en").to_string(),
|
||||
source: d.get_str("source").ok().map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut parsed_fields = Vec::new();
|
||||
for f in fields {
|
||||
if let Some(s) = f.as_str() {
|
||||
parsed_fields.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let undone_snapshot = doc.get_document("undone_snapshot").ok().and_then(|d| {
|
||||
let sid = d.get_i64("source_identity_id").unwrap_or(0);
|
||||
let suuid = d.get_str("source_uuid").unwrap_or("").to_string();
|
||||
let sname = d.get_str("source_name").unwrap_or("").to_string();
|
||||
let meta = doc_field_to_json(d, "target_metadata_at_undo");
|
||||
Some(UndoneSnapshot {
|
||||
source_identity_id: sid,
|
||||
source_uuid: suuid,
|
||||
source_name: sname,
|
||||
target_metadata_at_undo: meta,
|
||||
})
|
||||
});
|
||||
|
||||
Ok(IdentityMergeHistory {
|
||||
id: doc.get_object_id("_id").ok(),
|
||||
merge_id: doc.get_str("merge_id").unwrap_or("").to_string(),
|
||||
source_identity: IdentitySnapshot {
|
||||
id: source.get_i64("id").unwrap_or(0),
|
||||
uuid: source.get_str("uuid").unwrap_or("").to_string(),
|
||||
name: source.get_str("name").unwrap_or("").to_string(),
|
||||
identity_type: source.get_str("identity_type").ok().map(|s| s.to_string()),
|
||||
source: source.get_str("source").ok().map(|s| s.to_string()),
|
||||
status: source.get_str("status").unwrap_or("").to_string(),
|
||||
tmdb_id: source.get_i64("tmdb_id").ok(),
|
||||
tmdb_profile: source.get_str("tmdb_profile").ok().map(|s| s.to_string()),
|
||||
metadata: doc_field_to_json(source, "metadata"),
|
||||
created_at: source
|
||||
.get_datetime("created_at")
|
||||
.map(|d| d.to_chrono())
|
||||
.ok(),
|
||||
face_count: source.get_i64("face_count").unwrap_or(0),
|
||||
},
|
||||
target_identity: TargetIdentitySnapshot {
|
||||
id: target.get_i64("id").unwrap_or(0),
|
||||
uuid: target.get_str("uuid").unwrap_or("").to_string(),
|
||||
name: target.get_str("name").unwrap_or("").to_string(),
|
||||
metadata_before: doc_field_to_json(target, "metadata_before"),
|
||||
metadata_after: target
|
||||
.get("metadata_after")
|
||||
.map(|b| b.clone().into_relaxed_extjson()),
|
||||
},
|
||||
aliases_added_to_target: parsed_aliases,
|
||||
metadata_fields_added: parsed_fields,
|
||||
faces_transferred: FacesTransferred {
|
||||
file_uuid: faces.get_str("file_uuid").unwrap_or("").to_string(),
|
||||
face_ids: faces
|
||||
.get_array("face_ids")
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|b| b.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
trace_ids: faces
|
||||
.get_array("trace_ids")
|
||||
.map(|arr| arr.iter().filter_map(|b| b.as_i32()).collect())
|
||||
.unwrap_or_default(),
|
||||
count: faces.get_i64("count").unwrap_or(0),
|
||||
},
|
||||
merge_params: MergeParams {
|
||||
keep_history: merge_params_doc.get_bool("keep_history").unwrap_or(true),
|
||||
cleared_stranger_id: merge_params_doc
|
||||
.get_bool("cleared_stranger_id")
|
||||
.unwrap_or(true),
|
||||
performed_by_user: merge_params_doc
|
||||
.get_str("performed_by_user")
|
||||
.ok()
|
||||
.map(|s| s.to_string()),
|
||||
},
|
||||
merged_at: doc
|
||||
.get_datetime("merged_at")
|
||||
.map(|d| d.to_chrono())
|
||||
.unwrap_or_default(),
|
||||
undo_deadline: doc
|
||||
.get_datetime("undo_deadline")
|
||||
.map(|d| d.to_chrono())
|
||||
.unwrap_or_default(),
|
||||
undone: doc.get_bool("undone").unwrap_or(false),
|
||||
undone_at: doc.get_datetime("undone_at").map(|d| d.to_chrono()).ok(),
|
||||
undone_by: doc.get_str("undone_by").ok().map(|s| s.to_string()),
|
||||
undone_snapshot,
|
||||
undo_expired: doc.get_bool("undo_expired").unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_document(&self) -> Document {
|
||||
let mut doc = doc! {
|
||||
"merge_id": &self.merge_id,
|
||||
"source_identity": {
|
||||
"id": self.source_identity.id as i64,
|
||||
"uuid": &self.source_identity.uuid,
|
||||
"name": &self.source_identity.name,
|
||||
"identity_type": self.source_identity.identity_type.as_deref(),
|
||||
"source": self.source_identity.source.as_deref(),
|
||||
"status": &self.source_identity.status,
|
||||
"tmdb_id": self.source_identity.tmdb_id,
|
||||
"tmdb_profile": self.source_identity.tmdb_profile.as_deref(),
|
||||
"metadata": json_to_bson(&self.source_identity.metadata),
|
||||
"created_at": self.source_identity.created_at
|
||||
.map(|dt| BsonDateTime::from_chrono(dt)),
|
||||
"face_count": self.source_identity.face_count,
|
||||
},
|
||||
"target_identity": {
|
||||
"id": self.target_identity.id as i64,
|
||||
"uuid": &self.target_identity.uuid,
|
||||
"name": &self.target_identity.name,
|
||||
"metadata_before": json_to_bson(&self.target_identity.metadata_before),
|
||||
"metadata_after": self.target_identity.metadata_after.as_ref().map(json_to_bson),
|
||||
},
|
||||
"aliases_added_to_target": self.aliases_added_to_target.iter().map(|a| {
|
||||
doc! {
|
||||
"name": &a.name,
|
||||
"locale": &a.locale,
|
||||
"source": a.source.as_deref(),
|
||||
}
|
||||
}).collect::<Vec<Document>>(),
|
||||
"metadata_fields_added": &self.metadata_fields_added,
|
||||
"faces_transferred": {
|
||||
"file_uuid": &self.faces_transferred.file_uuid,
|
||||
"face_ids": &self.faces_transferred.face_ids,
|
||||
"trace_ids": &self.faces_transferred.trace_ids,
|
||||
"count": self.faces_transferred.count,
|
||||
},
|
||||
"merge_params": {
|
||||
"keep_history": self.merge_params.keep_history,
|
||||
"cleared_stranger_id": self.merge_params.cleared_stranger_id,
|
||||
"performed_by_user": self.merge_params.performed_by_user.as_deref(),
|
||||
},
|
||||
"merged_at": BsonDateTime::from_chrono(self.merged_at),
|
||||
"undo_deadline": BsonDateTime::from_chrono(self.undo_deadline),
|
||||
"undone": self.undone,
|
||||
"undone_at": self.undone_at.map(|dt| BsonDateTime::from_chrono(dt)),
|
||||
"undone_by": self.undone_by.as_deref(),
|
||||
"undone_snapshot": self.undone_snapshot.as_ref().map(|s| {
|
||||
doc! {
|
||||
"source_identity_id": s.source_identity_id,
|
||||
"source_uuid": &s.source_uuid,
|
||||
"source_name": &s.source_name,
|
||||
"target_metadata_at_undo": json_to_bson(&s.target_metadata_at_undo),
|
||||
}
|
||||
}),
|
||||
"undo_expired": self.undo_expired,
|
||||
};
|
||||
|
||||
if let Some(ref oid) = self.id {
|
||||
doc.insert("_id", oid.clone());
|
||||
}
|
||||
|
||||
doc
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityMergeHistoryStore {
|
||||
client: Client,
|
||||
db: Database,
|
||||
collection: Collection<Document>,
|
||||
}
|
||||
|
||||
impl IdentityMergeHistoryStore {
|
||||
pub async fn init() -> Result<Self> {
|
||||
let uri = crate::core::config::MONGODB_URL.as_str();
|
||||
let client = Client::with_uri_str(uri)
|
||||
.await
|
||||
.context("Failed to connect to MongoDB")?;
|
||||
let db_name = crate::core::config::MONGODB_DATABASE.as_str();
|
||||
let db = client.database(db_name);
|
||||
let collection: Collection<Document> = db.collection(COLLECTION_NAME);
|
||||
|
||||
let store = Self {
|
||||
client,
|
||||
db,
|
||||
collection,
|
||||
};
|
||||
|
||||
store.ensure_indexes().await?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
async fn ensure_indexes(&self) -> Result<()> {
|
||||
let merge_id_index = IndexModel::builder()
|
||||
.keys(doc! { "merge_id": 1 })
|
||||
.options(
|
||||
mongodb::options::IndexOptions::builder()
|
||||
.unique(true)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
|
||||
let merged_at_index = IndexModel::builder().keys(doc! { "merged_at": -1 }).build();
|
||||
|
||||
let source_uuid_index = IndexModel::builder()
|
||||
.keys(doc! { "source_identity.uuid": 1 })
|
||||
.build();
|
||||
|
||||
let target_uuid_index = IndexModel::builder()
|
||||
.keys(doc! { "target_identity.uuid": 1 })
|
||||
.build();
|
||||
|
||||
self.collection
|
||||
.create_indexes(
|
||||
[
|
||||
merge_id_index,
|
||||
merged_at_index,
|
||||
source_uuid_index,
|
||||
target_uuid_index,
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to create identity_merge_history indexes")?;
|
||||
|
||||
tracing::info!("MongoDB identity_merge_history indexes ensured");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_merge_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
pub async fn store_merge_history(&self, history: &IdentityMergeHistory) -> Result<()> {
|
||||
let doc = history.to_document();
|
||||
self.collection
|
||||
.insert_one(doc, None)
|
||||
.await
|
||||
.context("Failed to store merge history in MongoDB")?;
|
||||
|
||||
tracing::info!(
|
||||
"Stored merge history: merge_id={}, source={}, target={}, faces={}",
|
||||
history.merge_id,
|
||||
history.source_identity.name,
|
||||
history.target_identity.name,
|
||||
history.faces_transferred.count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_merge_history(&self, merge_id: &str) -> Result<Option<IdentityMergeHistory>> {
|
||||
let filter = doc! { "merge_id": merge_id };
|
||||
let result = self
|
||||
.collection
|
||||
.find_one(filter, None)
|
||||
.await
|
||||
.context("Failed to get merge history from MongoDB")?;
|
||||
|
||||
match result {
|
||||
Some(doc) => {
|
||||
let history = IdentityMergeHistory::from_document(&doc)
|
||||
.context("Failed to parse merge history from MongoDB")?;
|
||||
Ok(Some(history))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_merge_history(
|
||||
&self,
|
||||
query: MergeHistoryQuery,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<(Vec<MergeHistoryEntry>, u64)> {
|
||||
let mut filter = doc! {};
|
||||
|
||||
if let Some(source_uuid) = query.source_uuid {
|
||||
filter.insert("source_identity.uuid", source_uuid);
|
||||
}
|
||||
if let Some(target_uuid) = query.target_uuid {
|
||||
filter.insert("target_identity.uuid", target_uuid);
|
||||
}
|
||||
if let Some(merge_id) = query.merge_id {
|
||||
filter.insert("merge_id", merge_id);
|
||||
}
|
||||
if let Some(undone) = query.undone {
|
||||
filter.insert("undone", undone);
|
||||
}
|
||||
|
||||
let skip = (page - 1) * page_size;
|
||||
let limit = page_size;
|
||||
|
||||
let mut cursor = self
|
||||
.collection
|
||||
.find(filter.clone(), None)
|
||||
.await
|
||||
.context("Failed to query merge history")?;
|
||||
|
||||
let total = self
|
||||
.collection
|
||||
.count_documents(filter, None)
|
||||
.await
|
||||
.context("Failed to count merge history")?;
|
||||
|
||||
let mut results: Vec<MergeHistoryEntry> = Vec::new();
|
||||
let mut count = 0;
|
||||
|
||||
while cursor.advance().await.context("Failed to advance cursor")? {
|
||||
if count >= skip && results.len() < limit as usize {
|
||||
let doc: Document = cursor
|
||||
.deserialize_current()
|
||||
.context("Failed to deserialize")?;
|
||||
|
||||
let merge_id = doc.get_str("merge_id").unwrap_or("").to_string();
|
||||
let source_name = doc
|
||||
.get_document("source_identity")
|
||||
.map(|d| d.get_str("name").unwrap_or("").to_string())
|
||||
.unwrap_or_default();
|
||||
let target_name = doc
|
||||
.get_document("target_identity")
|
||||
.map(|d| d.get_str("name").unwrap_or("").to_string())
|
||||
.unwrap_or_default();
|
||||
let faces_count = doc
|
||||
.get_document("faces_transferred")
|
||||
.map(|d| d.get_i64("count").unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
let merged_at = doc
|
||||
.get_datetime("merged_at")
|
||||
.map(|d| d.to_chrono())
|
||||
.unwrap_or_default();
|
||||
let undo_deadline = doc
|
||||
.get_datetime("undo_deadline")
|
||||
.map(|d| d.to_chrono())
|
||||
.unwrap_or_default();
|
||||
let undone = doc.get_bool("undone").unwrap_or(false);
|
||||
let undo_expired = doc.get_bool("undo_expired").unwrap_or(false);
|
||||
|
||||
results.push(MergeHistoryEntry {
|
||||
merge_id,
|
||||
source_name,
|
||||
target_name,
|
||||
faces_transferred: faces_count,
|
||||
merged_at,
|
||||
undo_deadline,
|
||||
undone,
|
||||
undo_expired,
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok((results, total))
|
||||
}
|
||||
|
||||
pub async fn mark_as_undone(
|
||||
&self,
|
||||
merge_id: &str,
|
||||
undone_by: Option<&str>,
|
||||
undone_snapshot: UndoneSnapshot,
|
||||
) -> Result<()> {
|
||||
let filter = doc! { "merge_id": merge_id };
|
||||
let snapshot_doc = doc! {
|
||||
"source_identity_id": undone_snapshot.source_identity_id,
|
||||
"source_uuid": &undone_snapshot.source_uuid,
|
||||
"source_name": &undone_snapshot.source_name,
|
||||
"target_metadata_at_undo": json_to_bson(&undone_snapshot.target_metadata_at_undo),
|
||||
};
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"undone": true,
|
||||
"undone_at": BsonDateTime::from_chrono(Utc::now()),
|
||||
"undone_by": undone_by,
|
||||
"undone_snapshot": snapshot_doc,
|
||||
}
|
||||
};
|
||||
|
||||
self.collection
|
||||
.update_one(filter, update, None)
|
||||
.await
|
||||
.context("Failed to mark merge as undone")?;
|
||||
|
||||
tracing::info!("Marked merge {} as undone", merge_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mark_as_redone(&self, merge_id: &str, redone_by: Option<&str>) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let new_deadline = now + chrono::Duration::hours(24);
|
||||
let filter = doc! { "merge_id": merge_id };
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"undone": false,
|
||||
"undone_at": bson::Bson::Null,
|
||||
"undone_by": redone_by,
|
||||
"undone_snapshot": bson::Bson::Null,
|
||||
"undo_deadline": BsonDateTime::from_chrono(new_deadline),
|
||||
"undo_expired": false
|
||||
}
|
||||
};
|
||||
|
||||
self.collection
|
||||
.update_one(filter, update, None)
|
||||
.await
|
||||
.context("Failed to mark merge as redone")?;
|
||||
|
||||
tracing::info!(
|
||||
"Marked merge {} as redone (new deadline: {})",
|
||||
merge_id,
|
||||
new_deadline
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_undo_deadline(&self, merge_id: &str) -> Result<bool> {
|
||||
let history = self
|
||||
.get_merge_history(merge_id)
|
||||
.await?
|
||||
.context("Merge history not found")?;
|
||||
|
||||
let now = Utc::now();
|
||||
if now > history.undo_deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn mark_expired_merges(&self) -> Result<u64> {
|
||||
let now = BsonDateTime::from_chrono(Utc::now());
|
||||
let filter = doc! {
|
||||
"undo_deadline": { "$lt": now },
|
||||
"undone": false,
|
||||
"undo_expired": false
|
||||
};
|
||||
let update = doc! { "$set": { "undo_expired": true } };
|
||||
|
||||
let result = self
|
||||
.collection
|
||||
.update_many(filter, update, None)
|
||||
.await
|
||||
.context("Failed to mark expired merges")?;
|
||||
|
||||
let count = result.modified_count;
|
||||
if count > 0 {
|
||||
tracing::info!("Marked {} expired merges", count);
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -32,17 +32,21 @@ pub trait VectorStore: Send + Sync {
|
||||
async fn search(&self, query_vector: &[f32], limit: usize) -> Result<Vec<SearchResult>>;
|
||||
}
|
||||
|
||||
pub mod identity_merge_history;
|
||||
pub mod mongodb_db;
|
||||
pub mod postgres_db;
|
||||
pub mod qdrant_db;
|
||||
pub mod redis_client;
|
||||
pub mod redis_db;
|
||||
pub mod sync_db;
|
||||
|
||||
pub use identity_merge_history::{
|
||||
AliasEntry, FacesTransferred, IdentityMergeHistory, IdentityMergeHistoryStore,
|
||||
IdentitySnapshot, MergeHistoryEntry, MergeHistoryQuery, MergeParams, TargetIdentitySnapshot,
|
||||
UndoneSnapshot,
|
||||
};
|
||||
pub use mongodb_db::MongoDb;
|
||||
pub use postgres_db::{
|
||||
Bm25Result, CandidateRecord, CreateApiKeyConfig, FileIdentityRecord, FileRecord,
|
||||
HybridSearchResult, IdentityChunkRecord, IdentityDetailRecord, IdentityFaceRecord,
|
||||
Bm25Result, CandidateRecord, CreateApiKeyConfig, FileFaceRecord, FileIdentityRecord,
|
||||
FileRecord, HybridSearchResult, IdentityChunkRecord, IdentityDetailRecord, IdentityFaceRecord,
|
||||
IdentityFileRecord, MonitorJob, MonitorJobStats, MonitorJobStatus, PipelineType, PostgresDb,
|
||||
ProcessorJobStatus, ProcessorResult, ProcessorType, ResourceRecord, VideoRecord, VideoStatus,
|
||||
};
|
||||
@@ -52,4 +56,3 @@ pub use redis_client::{
|
||||
ProgressMessage, RedisClient,
|
||||
};
|
||||
pub use redis_db::RedisDb;
|
||||
pub use sync_db::SyncDb;
|
||||
|
||||
@@ -131,7 +131,6 @@ impl MongoDb {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: doc.parent_chunk_id,
|
||||
child_chunk_ids: doc.child_chunk_ids,
|
||||
visual_stats: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -190,7 +189,6 @@ impl MongoDb {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: doc.parent_chunk_id,
|
||||
child_chunk_ids: doc.child_chunk_ids,
|
||||
visual_stats: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -246,7 +244,6 @@ impl MongoDb {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: doc.parent_chunk_id,
|
||||
child_chunk_ids: doc.child_chunk_ids,
|
||||
visual_stats: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
+13
-47
@@ -70,7 +70,7 @@ impl QdrantDb {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let create_url = format!("{}/collections", self.base_url);
|
||||
let create_url = format!("{}/collections/{}", self.base_url, self.collection_name);
|
||||
let body = serde_json::json!({
|
||||
"vectors": {
|
||||
"size": vector_dim,
|
||||
@@ -79,7 +79,7 @@ impl QdrantDb {
|
||||
});
|
||||
|
||||
self.client
|
||||
.post(&create_url)
|
||||
.put(&create_url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
@@ -867,50 +867,6 @@ impl VectorStore for QdrantDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync face embeddings from PostgreSQL to Qdrant for ANN search
|
||||
pub async fn sync_face_embeddings(file_uuid: &str) -> Result<()> {
|
||||
use crate::core::config::DATABASE_URL;
|
||||
use sqlx::Row;
|
||||
|
||||
let pool = sqlx::PgPool::connect(&DATABASE_URL).await?;
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let qdrant: QdrantDb = QdrantDb::new();
|
||||
|
||||
let query = format!(
|
||||
"SELECT id, trace_id, frame_number, embedding FROM {} \
|
||||
WHERE file_uuid = $1 AND embedding IS NOT NULL \
|
||||
AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true)",
|
||||
table
|
||||
);
|
||||
let rows = sqlx::query(&query).bind(file_uuid).fetch_all(&pool).await?;
|
||||
|
||||
let mut count = 0u64;
|
||||
for row in &rows {
|
||||
let id: i32 = row.get(0);
|
||||
let trace_id: Option<i32> = row.get(1);
|
||||
let frame_number: i64 = row.get(2);
|
||||
let embedding: Option<Vec<f32>> = row.get(3);
|
||||
|
||||
if let (Some(emb), Some(tid)) = (embedding, trace_id) {
|
||||
if let Err(e) = qdrant
|
||||
.upsert_face_embedding(id as u64, &emb, file_uuid, tid, frame_number)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Qdrant upsert failed for face {}: {}", id, e);
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"Synced {} face embeddings to Qdrant for {}",
|
||||
count,
|
||||
file_uuid
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_trace_embeddings(file_uuid: &str) -> Result<()> {
|
||||
use crate::core::config::DATABASE_URL;
|
||||
use sqlx::Row;
|
||||
@@ -984,12 +940,22 @@ pub async fn sync_trace_embeddings(file_uuid: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
// Push to Qdrant in batches
|
||||
// Point ID: hash(file_uuid + trace_id) for global uniqueness
|
||||
for chunk in trace_avgs.chunks(500) {
|
||||
let batch: Vec<(u64, &[f32], Option<serde_json::Value>)> = chunk
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let point_id = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(file_uuid.as_bytes());
|
||||
hasher.update(b"_");
|
||||
hasher.update(t.tid.to_string().as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
u64::from_be_bytes(hash[0..8].try_into().unwrap())
|
||||
};
|
||||
(
|
||||
t.tid as u64,
|
||||
point_id,
|
||||
t.avg_emb.as_slice(),
|
||||
Some(serde_json::json!({
|
||||
"trace_id": t.tid,
|
||||
|
||||
@@ -319,7 +319,9 @@ impl RedisClient {
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
let _: usize = conn.publish(&channel, serde_json::to_string(&alert_json)?).await?;
|
||||
let _: usize = conn
|
||||
.publish(&channel, serde_json::to_string(&alert_json)?)
|
||||
.await?;
|
||||
|
||||
tracing::warn!(
|
||||
"Processor alert: {} | {} | {} | {}",
|
||||
|
||||
@@ -78,7 +78,10 @@ impl SyncDb {
|
||||
pub async fn embed_text(&self, text: &str) -> Result<Vec<f32>> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&format!("{}/api/embeddings", crate::core::config::OLLAMA_URL.as_str()))
|
||||
.post(&format!(
|
||||
"{}/api/embeddings",
|
||||
crate::core::config::OLLAMA_URL.as_str()
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"model": "all-minilm",
|
||||
"prompt": text,
|
||||
+13
-6
@@ -78,12 +78,19 @@ impl FrameManager {
|
||||
.and_then(|s| s.strip_suffix(".jpg"))
|
||||
{
|
||||
if let Ok(frame_num) = num_str.parse::<u64>() {
|
||||
let timestamp = frame_num as f64 / fps;
|
||||
frames.push(CachedFrame {
|
||||
path: entry.path(),
|
||||
frame_number: frame_num,
|
||||
timestamp_secs: timestamp,
|
||||
});
|
||||
let frame_path = entry.path();
|
||||
if let Ok(data) = std::fs::read(&frame_path) {
|
||||
if crate::core::thumbnail::validator::is_valid_jpeg(&data) {
|
||||
let timestamp = frame_num as f64 / fps;
|
||||
frames.push(CachedFrame {
|
||||
path: frame_path,
|
||||
frame_number: frame_num,
|
||||
timestamp_secs: timestamp,
|
||||
});
|
||||
} else {
|
||||
info!("[FrameCache] Skipping invalid JPEG: {:?}", frame_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Resu
|
||||
|
||||
let record = sqlx::query_as::<_, crate::core::db::IdentityDetailRecord>(
|
||||
&format!(
|
||||
"SELECT id, uuid::text, name, identity_type, source, status, metadata, reference_data, \
|
||||
"SELECT id::bigint, uuid::text, name, identity_type, source, status, metadata, COALESCE(reference_data, '{{}}'::jsonb) as reference_data, \
|
||||
NULL::real[] as voice_embedding, NULL::real[] as identity_embedding, \
|
||||
face_embedding::real[] as face_embedding, \
|
||||
tmdb_id, tmdb_profile, created_at::timestamptz as created_at, NULL::timestamptz as updated_at \
|
||||
|
||||
@@ -97,6 +97,68 @@ pub fn llm_vision_model() -> String {
|
||||
config::llm::VISION_MODEL.clone()
|
||||
}
|
||||
|
||||
/// Call the vision LLM with text + base64 images. Returns the generated text.
|
||||
pub async fn call_llm_vision(
|
||||
system_prompt: &str,
|
||||
user_text: &str,
|
||||
base64_images: Vec<String>,
|
||||
max_tokens: u32,
|
||||
timeout_secs: u64,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut content_parts: Vec<Value> = vec![json!({"type": "text", "text": user_text})];
|
||||
for img in &base64_images {
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": format!("data:image/jpeg;base64,{}", img)}
|
||||
}));
|
||||
}
|
||||
|
||||
let messages = json!([
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": content_parts}
|
||||
]);
|
||||
|
||||
let req = json!({
|
||||
"model": llm_vision_model(),
|
||||
"messages": messages,
|
||||
"temperature": 0.1,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": false,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let res = client.post(&llm_vision_url()).json(&req).send().await?;
|
||||
if !res.status().is_success() {
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Vision LLM API error: {}", text);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VisionResponse {
|
||||
choices: Vec<VisionChoice>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct VisionChoice {
|
||||
message: VisionMessage,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct VisionMessage {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
let vision_res: VisionResponse = res.json().await?;
|
||||
let content = vision_res
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|c| c.message.content)
|
||||
.unwrap_or_default();
|
||||
Ok(content.trim().to_string())
|
||||
}
|
||||
|
||||
/// Build a tool definition JSON for function calling
|
||||
pub fn make_tool(name: &str, description: &str, properties: Value, required: Vec<&str>) -> ToolDef {
|
||||
ToolDef {
|
||||
@@ -121,9 +183,11 @@ pub async fn call_llm(
|
||||
timeout_secs: u64,
|
||||
) -> anyhow::Result<LlmResponse> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(
|
||||
if timeout_secs > 0 { timeout_secs } else { *config::llm::CHAT_TIMEOUT_SECS },
|
||||
))
|
||||
.timeout(std::time::Duration::from_secs(if timeout_secs > 0 {
|
||||
timeout_secs
|
||||
} else {
|
||||
*config::llm::CHAT_TIMEOUT_SECS
|
||||
}))
|
||||
.build()?;
|
||||
|
||||
let req = ChatRequest {
|
||||
@@ -135,11 +199,7 @@ pub async fn call_llm(
|
||||
tools,
|
||||
};
|
||||
|
||||
let res = client
|
||||
.post(&llm_chat_url())
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
let res = client.post(&llm_chat_url()).json(&req).send().await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
@@ -147,13 +207,17 @@ pub async fn call_llm(
|
||||
}
|
||||
|
||||
let chat_res: ChatResponse = res.json().await?;
|
||||
let choice = chat_res.choices.into_iter().next()
|
||||
let choice = chat_res
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Empty LLM response"))?;
|
||||
|
||||
match choice.finish_reason.as_deref() {
|
||||
Some("tool_calls") => {
|
||||
let calls = choice.message.tool_calls
|
||||
.ok_or_else(|| anyhow::anyhow!("finish_reason=tool_calls but no tool_calls in message"))?;
|
||||
let calls = choice.message.tool_calls.ok_or_else(|| {
|
||||
anyhow::anyhow!("finish_reason=tool_calls but no tool_calls in message")
|
||||
})?;
|
||||
Ok(LlmResponse::ToolCalls(calls))
|
||||
}
|
||||
_ => {
|
||||
@@ -164,16 +228,18 @@ pub async fn call_llm(
|
||||
}
|
||||
|
||||
/// Helper to build the system prompt + user messages
|
||||
pub fn build_conversation(system_prompt: &str, user_query: &str, history: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
let mut messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(system_prompt.to_string()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
},
|
||||
];
|
||||
pub fn build_conversation(
|
||||
system_prompt: &str,
|
||||
user_query: &str,
|
||||
history: Vec<ChatMessage>,
|
||||
) -> Vec<ChatMessage> {
|
||||
let mut messages = vec![ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(system_prompt.to_string()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}];
|
||||
// Add history (user + assistant exchanges)
|
||||
messages.extend(history);
|
||||
// Add current user query
|
||||
|
||||
+38
-12
@@ -18,12 +18,22 @@ pub struct AsrxResult {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AsrxSegment {
|
||||
#[serde(alias = "start")]
|
||||
pub start_time: f64,
|
||||
#[serde(alias = "end")]
|
||||
pub end_time: f64,
|
||||
#[serde(default)]
|
||||
pub start_frame: u64,
|
||||
#[serde(default)]
|
||||
pub end_frame: u64,
|
||||
pub text: String,
|
||||
pub speaker_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub lang_prob: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub quality: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn process_asrx(
|
||||
@@ -32,24 +42,16 @@ pub async fn process_asrx(
|
||||
uuid: Option<&str>,
|
||||
) -> Result<AsrxResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("asrx_processor_custom.py");
|
||||
let script_path = executor.script_path("asrx_processor.py");
|
||||
|
||||
tracing::info!(
|
||||
"[ASRX] Starting speaker diarization (custom): {}",
|
||||
"[ASRX] Starting hybrid speaker diarization: {}",
|
||||
video_path
|
||||
);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[ASRX] Custom script not found, falling back to original");
|
||||
let fallback_path = executor.script_path("asrx_processor.py");
|
||||
if !fallback_path.exists() {
|
||||
tracing::warn!("[ASRX] No script found, returning empty result");
|
||||
return Ok(AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
embeddings: None,
|
||||
});
|
||||
}
|
||||
tracing::error!("[ASRX] Script not found: {:?}", script_path);
|
||||
anyhow::bail!("asrx_processor.py not found");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -65,6 +67,7 @@ pub async fn process_asrx(
|
||||
|
||||
if let Some(u) = uuid {
|
||||
cmd.arg("--uuid").arg(u);
|
||||
cmd.arg("--file-uuid").arg(u);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
@@ -126,6 +129,9 @@ mod tests {
|
||||
end_frame: 75,
|
||||
text: "Hello".to_string(),
|
||||
speaker_id: Some("SPEAKER_00".to_string()),
|
||||
language: None,
|
||||
lang_prob: None,
|
||||
quality: None,
|
||||
}],
|
||||
embeddings: None,
|
||||
};
|
||||
@@ -173,7 +179,27 @@ mod tests {
|
||||
end_frame: 150,
|
||||
text: "Test".to_string(),
|
||||
speaker_id: None,
|
||||
language: None,
|
||||
lang_prob: None,
|
||||
quality: None,
|
||||
};
|
||||
assert!(segment.end_time > segment.start_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asrx_backward_compat_old_format() {
|
||||
let json = r#"{
|
||||
"language": "en",
|
||||
"segments": [
|
||||
{"start": 10.0, "end": 12.5, "text": "Hello", "speaker_id": "SPEAKER_00"}
|
||||
]
|
||||
}"#;
|
||||
let result: AsrxResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.segments.len(), 1);
|
||||
assert_eq!(result.segments[0].start_time, 10.0);
|
||||
assert_eq!(result.segments[0].end_time, 12.5);
|
||||
assert_eq!(result.segments[0].text, "Hello");
|
||||
assert_eq!(result.segments[0].start_frame, 0);
|
||||
assert_eq!(result.segments[0].end_frame, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-12
@@ -43,11 +43,15 @@ pub async fn process_cut(
|
||||
let script_path = executor.script_path("cut_processor.py");
|
||||
|
||||
if !script_path.exists() {
|
||||
return Ok(CutResult {
|
||||
let empty_result = CutResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
scenes: vec![],
|
||||
});
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&empty_result)?;
|
||||
std::fs::write(output_path, &json)
|
||||
.with_context(|| format!("Failed to write {:?}", output_path))?;
|
||||
return Ok(empty_result);
|
||||
}
|
||||
|
||||
executor
|
||||
@@ -127,18 +131,26 @@ fn try_native_cut(video_path: &str) -> Result<CutResult> {
|
||||
.context("Failed to run ffmpeg scene detection")?;
|
||||
|
||||
let stderr_output = String::from_utf8_lossy(&scene_output.stderr);
|
||||
let stdout_output = String::from_utf8_lossy(&scene_output.stdout);
|
||||
let mut scene_times: Vec<f64> = Vec::new();
|
||||
|
||||
// Parse ffmpeg showinfo output for scene changes
|
||||
// Format: [Parsed_showinfo...] pts:123.456 pts_time:123.456 ...
|
||||
for line in stderr_output.lines() {
|
||||
if line.contains("pts_time:") {
|
||||
if let Some(pos) = line.find("pts_time:") {
|
||||
let rest = &line[pos + 9..];
|
||||
let time_str = rest.split_whitespace().next().unwrap_or("");
|
||||
if let Ok(t) = time_str.parse::<f64>() {
|
||||
scene_times.push(t);
|
||||
}
|
||||
// Parse ffprobe output for scene changes (check both stderr and stdout)
|
||||
// Format: pts_time=123.456 or pts_time:123.456
|
||||
for line in stderr_output.lines().chain(stdout_output.lines()) {
|
||||
// Try pts_time= format (standard ffprobe output)
|
||||
if let Some(pos) = line.find("pts_time=") {
|
||||
let rest = &line[pos + 9..];
|
||||
let time_str = rest.split_whitespace().next().unwrap_or("");
|
||||
if let Ok(t) = time_str.parse::<f64>() {
|
||||
scene_times.push(t);
|
||||
}
|
||||
}
|
||||
// Try pts_time: format (showinfo filter output)
|
||||
else if let Some(pos) = line.find("pts_time:") {
|
||||
let rest = &line[pos + 9..];
|
||||
let time_str = rest.split_whitespace().next().unwrap_or("");
|
||||
if let Ok(t) = time_str.parse::<f64>() {
|
||||
scene_times.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ pub mod pose;
|
||||
pub mod scene_classification;
|
||||
pub mod story;
|
||||
pub mod tkg;
|
||||
pub mod visual_chunk;
|
||||
pub mod yolo;
|
||||
|
||||
pub use asr::{process_asr, AsrResult, AsrSegment};
|
||||
@@ -40,5 +39,4 @@ pub use tkg::{
|
||||
build_tkg, query_auto_representative_frame, FrameTraceInfo, MainIdentityInfo,
|
||||
RepresentativeFrameResult, TkgResult,
|
||||
};
|
||||
pub use visual_chunk::{process_visual_chunk, process_visual_chunk_advanced, VisualChunkResult};
|
||||
pub use yolo::{process_yolo, YoloFrame, YoloObject, YoloResult};
|
||||
|
||||
+153
-41
@@ -38,7 +38,10 @@ fn load_face_pose_data(output_dir: &str, file_uuid: &str) -> Result<Vec<FacePose
|
||||
let mut poses = Vec::new();
|
||||
if let Some(frames) = json.get("frames").and_then(|v| v.as_array()) {
|
||||
for frame_entry in frames {
|
||||
let frame_num = frame_entry.get("frame").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let frame_num = frame_entry
|
||||
.get("frame")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
if let Some(faces) = frame_entry.get("faces").and_then(|v| v.as_array()) {
|
||||
for face in faces {
|
||||
let bbox = match face.get("bbox") {
|
||||
@@ -68,7 +71,14 @@ fn load_face_pose_data(output_dir: &str, file_uuid: &str) -> Result<Vec<FacePose
|
||||
|
||||
/// Match a face from face_detections (frame, x, y, w, h) to its pose in face.json
|
||||
/// Uses bbox center distance to find the best match when multiple faces per frame.
|
||||
fn get_pose_for_face(frame: i64, x: f64, y: f64, w: f64, h: f64, poses: &[FacePose]) -> Option<(f64, f64, f64)> {
|
||||
fn get_pose_for_face(
|
||||
frame: i64,
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
poses: &[FacePose],
|
||||
) -> Option<(f64, f64, f64)> {
|
||||
let cx = x + w / 2.0;
|
||||
let cy = y + h / 2.0;
|
||||
let mut best_dist = f64::MAX;
|
||||
@@ -86,8 +96,12 @@ fn get_pose_for_face(frame: i64, x: f64, y: f64, w: f64, h: f64, poses: &[FacePo
|
||||
}
|
||||
|
||||
fn detect_mutual_gaze(
|
||||
bbox_a_x: f64, bbox_a_w: f64, yaw_a: f64,
|
||||
bbox_b_x: f64, bbox_b_w: f64, yaw_b: f64,
|
||||
bbox_a_x: f64,
|
||||
bbox_a_w: f64,
|
||||
yaw_a: f64,
|
||||
bbox_b_x: f64,
|
||||
bbox_b_w: f64,
|
||||
yaw_b: f64,
|
||||
threshold: f64,
|
||||
) -> bool {
|
||||
let cx_a = bbox_a_x + bbox_a_w / 2.0;
|
||||
@@ -138,12 +152,16 @@ struct AsrxSegmentEntry {
|
||||
#[serde(default)]
|
||||
speaker_id: String,
|
||||
#[serde(default)]
|
||||
start_time: f64,
|
||||
start: f64,
|
||||
#[serde(default)]
|
||||
end_time: f64,
|
||||
end: f64,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
start_frame: i64,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
end_frame: i64,
|
||||
}
|
||||
|
||||
@@ -195,7 +213,10 @@ pub struct TkgResult {
|
||||
pub async fn build_tkg(db: &PostgresDb, file_uuid: &str, output_dir: &str) -> Result<TkgResult> {
|
||||
let pool = db.pool();
|
||||
let pose_data = load_face_pose_data(output_dir, file_uuid).unwrap_or_default();
|
||||
tracing::info!("[TKG] Loaded {} pose entries from face.json", pose_data.len());
|
||||
tracing::info!(
|
||||
"[TKG] Loaded {} pose entries from face.json",
|
||||
pose_data.len()
|
||||
);
|
||||
|
||||
let n_face = build_face_trace_nodes(pool, file_uuid, &pose_data).await?;
|
||||
let n_objects = build_yolo_object_nodes(pool, file_uuid, output_dir).await?;
|
||||
@@ -217,7 +238,11 @@ pub async fn build_tkg(db: &PostgresDb, file_uuid: &str, output_dir: &str) -> Re
|
||||
|
||||
// ── Node builders ─────────────────────────────────────────────────
|
||||
|
||||
async fn build_face_trace_nodes(pool: &PgPool, file_uuid: &str, pose_data: &[FacePose]) -> Result<usize> {
|
||||
async fn build_face_trace_nodes(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
pose_data: &[FacePose],
|
||||
) -> Result<usize> {
|
||||
let face_table = t("face_detections");
|
||||
let nodes_table = t("tkg_nodes");
|
||||
|
||||
@@ -257,7 +282,10 @@ async fn build_face_trace_nodes(pool: &PgPool, file_uuid: &str, pose_data: &[Fac
|
||||
// Group by trace_id: trace_id → Vec<(frame, x, y, w, h)>
|
||||
let mut trace_frames: HashMap<i64, Vec<(i64, f64, f64, f64, f64)>> = HashMap::new();
|
||||
for (tid, frame, x, y, w, h) in &frame_rows {
|
||||
trace_frames.entry(*tid).or_default().push((*frame, *x, *y, *w, *h));
|
||||
trace_frames
|
||||
.entry(*tid)
|
||||
.or_default()
|
||||
.push((*frame, *x, *y, *w, *h));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
@@ -274,7 +302,9 @@ async fn build_face_trace_nodes(pool: &PgPool, file_uuid: &str, pose_data: &[Fac
|
||||
|
||||
if let Some(frames) = trace_frames.get(&tid) {
|
||||
for (frame, x, y, w, h) in frames {
|
||||
if let Some((yaw, pitch, roll)) = get_pose_for_face(*frame, *x, *y, *w, *h, pose_data) {
|
||||
if let Some((yaw, pitch, roll)) =
|
||||
get_pose_for_face(*frame, *x, *y, *w, *h, pose_data)
|
||||
{
|
||||
yaw_sum += yaw;
|
||||
pitch_sum += pitch;
|
||||
roll_sum += roll;
|
||||
@@ -284,7 +314,11 @@ async fn build_face_trace_nodes(pool: &PgPool, file_uuid: &str, pose_data: &[Fac
|
||||
}
|
||||
|
||||
let (avg_yaw, avg_pitch, avg_roll) = if pose_count > 0 {
|
||||
(yaw_sum / pose_count as f64, pitch_sum / pose_count as f64, roll_sum / pose_count as f64)
|
||||
(
|
||||
yaw_sum / pose_count as f64,
|
||||
pitch_sum / pose_count as f64,
|
||||
roll_sum / pose_count as f64,
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0, 0.0)
|
||||
};
|
||||
@@ -401,8 +435,44 @@ async fn build_speaker_nodes(pool: &PgPool, file_uuid: &str, output_dir: &str) -
|
||||
let nodes_table = t("tkg_nodes");
|
||||
let mut count = 0;
|
||||
|
||||
// Group segments by speaker_id
|
||||
let mut speaker_segments: HashMap<String, Vec<&AsrxSegmentEntry>> = HashMap::new();
|
||||
for seg in &asrx.segments {
|
||||
speaker_segments
|
||||
.entry(seg.speaker_id.clone())
|
||||
.or_default()
|
||||
.push(seg);
|
||||
}
|
||||
|
||||
for (sid, stat) in &stats {
|
||||
let props = serde_json::json!({ "segment_count": stat.count });
|
||||
let segs = speaker_segments.get(sid);
|
||||
let (full_text, segments_json) = if let Some(seg_list) = segs {
|
||||
let full: String = seg_list
|
||||
.iter()
|
||||
.map(|s| s.text.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let segments: Vec<serde_json::Value> = seg_list
|
||||
.iter()
|
||||
.map(|s| {
|
||||
serde_json::json!({
|
||||
"start": s.start,
|
||||
"end": s.end,
|
||||
"text": s.text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(full, serde_json::Value::Array(segments))
|
||||
} else {
|
||||
(String::new(), serde_json::Value::Array(vec![]))
|
||||
};
|
||||
|
||||
let props = serde_json::json!({
|
||||
"segment_count": stat.count,
|
||||
"segments": segments_json,
|
||||
"full_text": full_text,
|
||||
});
|
||||
|
||||
sqlx::query(&format!(
|
||||
r#"
|
||||
@@ -576,8 +646,8 @@ async fn build_speaker_face_edges(
|
||||
|
||||
// Calculate fps from last segment
|
||||
let last = asrx.segments.last().unwrap();
|
||||
let fps = if last.end_time > 0.0 {
|
||||
last.end_frame as f64 / last.end_time
|
||||
let fps = if last.end > 0.0 {
|
||||
last.end_frame as f64 / last.end
|
||||
} else {
|
||||
30.0
|
||||
};
|
||||
@@ -604,8 +674,8 @@ async fn build_speaker_face_edges(
|
||||
let face_end_sec = *ef as f64 / fps;
|
||||
|
||||
for seg in &asrx.segments {
|
||||
let seg_start = seg.start_time;
|
||||
let seg_end = seg.end_time;
|
||||
let seg_start = seg.start;
|
||||
let seg_end = seg.end;
|
||||
let overlap_start = face_start_sec.max(seg_start);
|
||||
let overlap_end = face_end_sec.min(seg_end);
|
||||
|
||||
@@ -669,7 +739,11 @@ async fn build_speaker_face_edges(
|
||||
Ok(edge_count)
|
||||
}
|
||||
|
||||
async fn build_face_face_edges(pool: &PgPool, file_uuid: &str, pose_data: &[FacePose]) -> Result<usize> {
|
||||
async fn build_face_face_edges(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
pose_data: &[FacePose],
|
||||
) -> Result<usize> {
|
||||
let face_table = t("face_detections");
|
||||
let nodes_table = t("tkg_nodes");
|
||||
let edges_table = t("tkg_edges");
|
||||
@@ -722,8 +796,9 @@ async fn build_face_face_edges(pool: &PgPool, file_uuid: &str, pose_data: &[Face
|
||||
(Some(&(xa, ya, wa, ha)), Some(&(xb, yb, wb, hb))) => {
|
||||
get_pose_for_face(*frame, xa, ya, wa, ha, pose_data)
|
||||
.and_then(|(yaw_a, _, _)| {
|
||||
get_pose_for_face(*frame, xb, yb, wb, hb, pose_data)
|
||||
.map(|(yaw_b, _, _)| detect_mutual_gaze(xa, wa, yaw_a, xb, wb, yaw_b, 0.05))
|
||||
get_pose_for_face(*frame, xb, yb, wb, hb, pose_data).map(|(yaw_b, _, _)| {
|
||||
detect_mutual_gaze(xa, wa, yaw_a, xb, wb, yaw_b, 0.05)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -770,7 +845,11 @@ async fn build_face_face_edges(pool: &PgPool, file_uuid: &str, pose_data: &[Face
|
||||
};
|
||||
|
||||
let frames: Vec<i64> = frame_data.iter().map(|(f, _)| *f).collect();
|
||||
let gaze_frames: Vec<i64> = frame_data.iter().filter(|(_, g)| *g).map(|(f, _)| *f).collect();
|
||||
let gaze_frames: Vec<i64> = frame_data
|
||||
.iter()
|
||||
.filter(|(_, g)| *g)
|
||||
.map(|(f, _)| *f)
|
||||
.collect();
|
||||
let gaze_count = gaze_frames.len() as i64;
|
||||
let has_gaze = gaze_count > 0;
|
||||
|
||||
@@ -793,8 +872,13 @@ async fn build_face_face_edges(pool: &PgPool, file_uuid: &str, pose_data: &[Face
|
||||
}
|
||||
}
|
||||
let (avg_ya, avg_yb) = if gaze_sample > 0 {
|
||||
(yaw_a_sum / gaze_sample as f64, yaw_b_sum / gaze_sample as f64)
|
||||
} else { (0.0, 0.0) };
|
||||
(
|
||||
yaw_a_sum / gaze_sample as f64,
|
||||
yaw_b_sum / gaze_sample as f64,
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"first_frame": frames[0],
|
||||
@@ -902,9 +986,14 @@ pub async fn query_auto_representative_frame(
|
||||
.context("Failed to detect main identities")?;
|
||||
|
||||
let main_ids: Vec<(i32, String, String, i64)> = mains;
|
||||
let main_idents: Vec<MainIdentityInfo> = main_ids.iter().map(|(_, u, n, c)|
|
||||
MainIdentityInfo { identity_uuid: u.clone(), name: n.clone(), face_count: *c }
|
||||
).collect();
|
||||
let main_idents: Vec<MainIdentityInfo> = main_ids
|
||||
.iter()
|
||||
.map(|(_, u, n, c)| MainIdentityInfo {
|
||||
identity_uuid: u.clone(),
|
||||
name: n.clone(),
|
||||
face_count: *c,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let frame_number: Option<i64> = if main_ids.len() >= 2 {
|
||||
let id_a = main_ids[0].0;
|
||||
@@ -915,16 +1004,20 @@ pub async fn query_auto_representative_frame(
|
||||
AND trace_id IS NOT NULL GROUP BY trace_id ORDER BY COUNT(*) DESC LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(id_a)
|
||||
.fetch_optional(pool).await?;
|
||||
.bind(file_uuid)
|
||||
.bind(id_a)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let trace_b: Option<(i32,)> = sqlx::query_as(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND identity_id = $2 \
|
||||
AND trace_id IS NOT NULL GROUP BY trace_id ORDER BY COUNT(*) DESC LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(id_b)
|
||||
.fetch_optional(pool).await?;
|
||||
.bind(file_uuid)
|
||||
.bind(id_b)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match (trace_a, trace_b) {
|
||||
(Some((ta,)), Some((tb,))) => {
|
||||
@@ -940,11 +1033,18 @@ pub async fn query_auto_representative_frame(
|
||||
LIMIT 1",
|
||||
edges_table, nodes_table, nodes_table
|
||||
))
|
||||
.bind(file_uuid).bind(ta).bind(tb)
|
||||
.fetch_optional(pool).await?;
|
||||
.bind(file_uuid)
|
||||
.bind(ta)
|
||||
.bind(tb)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((f,)) = tkg_frame {
|
||||
if f <= half_frame { Some(f) } else { None }
|
||||
if f <= half_frame {
|
||||
Some(f)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT MIN(fd_a.frame_number)::bigint \
|
||||
@@ -954,8 +1054,12 @@ pub async fn query_auto_representative_frame(
|
||||
AND fd_b.identity_id = $3 AND fd_a.frame_number <= $4",
|
||||
fd_table, fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(id_a).bind(id_b).bind(half_frame)
|
||||
.fetch_optional(pool).await?
|
||||
.bind(file_uuid)
|
||||
.bind(id_a)
|
||||
.bind(id_b)
|
||||
.bind(half_frame)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
@@ -976,8 +1080,11 @@ pub async fn query_auto_representative_frame(
|
||||
LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(first_id).bind(half_frame)
|
||||
.fetch_optional(pool).await?
|
||||
.bind(file_uuid)
|
||||
.bind(first_id)
|
||||
.bind(half_frame)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -995,20 +1102,25 @@ pub async fn query_auto_representative_frame(
|
||||
LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(half_frame)
|
||||
.fetch_optional(pool).await?
|
||||
.bind(file_uuid)
|
||||
.bind(half_frame)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let frame_number = frame_number.ok_or_else(|| anyhow::anyhow!("No faces found in this file"))?;
|
||||
let frame_number =
|
||||
frame_number.ok_or_else(|| anyhow::anyhow!("No faces found in this file"))?;
|
||||
|
||||
let face_quality: f64 = sqlx::query_scalar::<_, f64>(&format!(
|
||||
"SELECT COALESCE(MAX((width::float8 * height::float8) * confidence::float8), 0) \
|
||||
FROM {} WHERE file_uuid = $1 AND frame_number = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(frame_number)
|
||||
.fetch_one(pool).await?;
|
||||
.bind(file_uuid)
|
||||
.bind(frame_number)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let traces: Vec<FrameTraceInfo> = sqlx::query_as::<_, (i32, Option<String>, Option<String>, i32, i32, i32, i32, f64)>(&format!(
|
||||
"SELECT fd.trace_id, i.uuid::text, i.name, fd.x, fd.y, fd.width, fd.height, fd.confidence::float8 \
|
||||
|
||||
@@ -1,594 +0,0 @@
|
||||
//! 視覺分片處理器 (Phase 2.2)
|
||||
//!
|
||||
//! 從 YOLO 結果生成視覺分片
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
use super::yolo::{YoloFrame, YoloResult};
|
||||
|
||||
const VISUAL_CHUNK_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// 視覺分片處理結果
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct VisualChunkResult {
|
||||
/// 生成的視覺分片數量
|
||||
pub chunk_count: u32,
|
||||
/// 處理的總幀數
|
||||
pub total_frames: u32,
|
||||
/// 檢測到的總物件數
|
||||
pub total_objects: u32,
|
||||
/// 唯一物件類別數
|
||||
pub unique_classes: u32,
|
||||
/// 生成的視覺分片
|
||||
pub chunks: Vec<crate::core::chunk::Chunk>,
|
||||
}
|
||||
|
||||
/// 從 YOLO 結果生成視覺分片
|
||||
pub async fn process_visual_chunk(
|
||||
file_id: i32,
|
||||
uuid: String,
|
||||
video_path: &str,
|
||||
yolo_result: &YoloResult,
|
||||
chunk_index_offset: u32,
|
||||
fps: f64,
|
||||
) -> Result<VisualChunkResult> {
|
||||
tracing::info!(
|
||||
"[VisualChunk] Starting visual chunk generation for video: {}, {} frames",
|
||||
video_path,
|
||||
yolo_result.frames.len()
|
||||
);
|
||||
|
||||
if yolo_result.frames.is_empty() {
|
||||
tracing::warn!("[VisualChunk] No YOLO frames to process");
|
||||
return Ok(VisualChunkResult {
|
||||
chunk_count: 0,
|
||||
total_frames: 0,
|
||||
total_objects: 0,
|
||||
unique_classes: 0,
|
||||
chunks: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// 策略 1: 固定幀數分片(每 N 幀一個分片)
|
||||
let chunks = create_fixed_frame_chunks(file_id, &uuid, yolo_result, chunk_index_offset, fps);
|
||||
|
||||
// 統計信息
|
||||
let total_objects: u32 = yolo_result
|
||||
.frames
|
||||
.iter()
|
||||
.map(|f| f.objects.len() as u32)
|
||||
.sum();
|
||||
let all_classes: Vec<String> = yolo_result
|
||||
.frames
|
||||
.iter()
|
||||
.flat_map(|f| f.objects.iter().map(|o| o.class_name.clone()))
|
||||
.collect();
|
||||
let unique_classes: u32 = all_classes
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len() as u32;
|
||||
|
||||
tracing::info!(
|
||||
"[VisualChunk] Generated {} visual chunks from {} frames, {} total objects, {} unique classes",
|
||||
chunks.len(),
|
||||
yolo_result.frames.len(),
|
||||
total_objects,
|
||||
unique_classes
|
||||
);
|
||||
|
||||
Ok(VisualChunkResult {
|
||||
chunk_count: chunks.len() as u32,
|
||||
total_frames: yolo_result.frames.len() as u32,
|
||||
total_objects,
|
||||
unique_classes,
|
||||
chunks,
|
||||
})
|
||||
}
|
||||
|
||||
/// 創建固定幀數分片(每 N 幀一個分片)
|
||||
fn create_fixed_frame_chunks(
|
||||
file_id: i32,
|
||||
uuid: &str,
|
||||
yolo_result: &YoloResult,
|
||||
chunk_index_offset: u32,
|
||||
fps: f64,
|
||||
) -> Vec<crate::core::chunk::Chunk> {
|
||||
let mut chunks = Vec::new();
|
||||
|
||||
// 配置:每 30 幀創建一個分片(約 1 秒,如果 fps=30)
|
||||
let frames_per_chunk = 30;
|
||||
let total_frames = yolo_result.frames.len();
|
||||
|
||||
if total_frames == 0 {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
let mut chunk_index = chunk_index_offset;
|
||||
let mut start_idx = 0;
|
||||
|
||||
while start_idx < total_frames {
|
||||
let end_idx = std::cmp::min(start_idx + frames_per_chunk, total_frames);
|
||||
|
||||
// 獲取這個分片的幀
|
||||
let chunk_frames: Vec<YoloFrame> = yolo_result.frames[start_idx..end_idx]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if chunk_frames.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// 計算幀範圍
|
||||
let start_frame = chunk_frames.first().unwrap().frame as i64;
|
||||
let end_frame = chunk_frames.last().unwrap().frame as i64 + 1; // exclusive
|
||||
|
||||
// 創建視覺分片
|
||||
let chunk = crate::core::chunk::Chunk::from_yolo_frames(
|
||||
file_id,
|
||||
uuid.to_string(),
|
||||
format!("vis_{}", chunk_index),
|
||||
start_frame,
|
||||
end_frame,
|
||||
fps,
|
||||
chunk_frames,
|
||||
);
|
||||
|
||||
chunks.push(chunk);
|
||||
|
||||
// 更新索引
|
||||
start_idx = end_idx;
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
/// 基於物件相似度創建分片
|
||||
fn create_similarity_based_chunks(
|
||||
file_id: i32,
|
||||
uuid: &str,
|
||||
yolo_result: &YoloResult,
|
||||
chunk_index_offset: u32,
|
||||
fps: f64,
|
||||
similarity_threshold: f32,
|
||||
min_frames_per_chunk: usize,
|
||||
) -> Vec<crate::core::chunk::Chunk> {
|
||||
let mut chunks = Vec::new();
|
||||
|
||||
if yolo_result.frames.is_empty() {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
let mut current_chunk_frames: Vec<YoloFrame> = Vec::new();
|
||||
let mut chunk_index = chunk_index_offset;
|
||||
let mut current_start_frame = 0;
|
||||
|
||||
for (i, frame) in yolo_result.frames.iter().enumerate() {
|
||||
if current_chunk_frames.is_empty() {
|
||||
current_chunk_frames.push(frame.clone());
|
||||
current_start_frame = frame.frame as i64;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 檢查相似度(簡化版本:檢查物件類別是否相同)
|
||||
let last_frame = current_chunk_frames.last().unwrap();
|
||||
let similarity = calculate_frame_similarity(last_frame, frame);
|
||||
|
||||
if similarity >= similarity_threshold {
|
||||
// 相似度高,加入當前分片
|
||||
current_chunk_frames.push(frame.clone());
|
||||
} else {
|
||||
// 相似度低,創建新分片
|
||||
if current_chunk_frames.len() >= min_frames_per_chunk {
|
||||
let end_frame = current_chunk_frames.last().unwrap().frame as i64 + 1;
|
||||
|
||||
let chunk = crate::core::chunk::Chunk::from_yolo_frames(
|
||||
file_id,
|
||||
uuid.to_string(),
|
||||
format!("vis_{}", chunk_index),
|
||||
current_start_frame,
|
||||
end_frame,
|
||||
fps,
|
||||
current_chunk_frames.clone(),
|
||||
);
|
||||
|
||||
chunks.push(chunk);
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
// 開始新的分片
|
||||
current_chunk_frames = vec![frame.clone()];
|
||||
current_start_frame = frame.frame as i64;
|
||||
}
|
||||
}
|
||||
|
||||
// 處理最後一個分片
|
||||
if current_chunk_frames.len() >= min_frames_per_chunk {
|
||||
let end_frame = current_chunk_frames.last().unwrap().frame as i64 + 1;
|
||||
|
||||
let chunk = crate::core::chunk::Chunk::from_yolo_frames(
|
||||
file_id,
|
||||
uuid.to_string(),
|
||||
format!("vis_{}", chunk_index),
|
||||
current_start_frame,
|
||||
end_frame,
|
||||
fps,
|
||||
current_chunk_frames,
|
||||
);
|
||||
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
/// 計算兩個幀之間的相似度(基於物件類別)
|
||||
fn calculate_frame_similarity(frame1: &YoloFrame, frame2: &YoloFrame) -> f32 {
|
||||
if frame1.objects.is_empty() && frame2.objects.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
if frame1.objects.is_empty() || frame2.objects.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let set1: std::collections::HashSet<String> = frame1
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| o.class_name.clone())
|
||||
.collect();
|
||||
let set2: std::collections::HashSet<String> = frame2
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| o.class_name.clone())
|
||||
.collect();
|
||||
|
||||
let intersection: Vec<_> = set1.intersection(&set2).collect();
|
||||
let union: Vec<_> = set1.union(&set2).collect();
|
||||
|
||||
if union.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
intersection.len() as f32 / union.len() as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用 Python 腳本生成視覺分片(進階版本)
|
||||
pub async fn process_visual_chunk_advanced(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<VisualChunkResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("visual_chunk_processor.py");
|
||||
|
||||
tracing::info!(
|
||||
"[VisualChunk] Starting advanced visual chunk generation: {}",
|
||||
video_path
|
||||
);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[VisualChunk] Script not found, using basic generation");
|
||||
// 這裡可以回退到基本生成方法
|
||||
return Ok(VisualChunkResult {
|
||||
chunk_count: 0,
|
||||
total_frames: 0,
|
||||
total_objects: 0,
|
||||
unique_classes: 0,
|
||||
chunks: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let yolo_path = uuid.map(|u| {
|
||||
std::path::PathBuf::from(crate::core::config::OUTPUT_DIR.as_str())
|
||||
.join(format!("{}.yolo.json", u))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
let args: &[&str] = if let Some(ref yp) = yolo_path {
|
||||
&[video_path, output_path, "--yolo-result", yp]
|
||||
} else {
|
||||
&[video_path, output_path]
|
||||
};
|
||||
let result = match executor
|
||||
.run(
|
||||
"visual_chunk_processor.py",
|
||||
args,
|
||||
uuid,
|
||||
"VisualChunk",
|
||||
Some(VISUAL_CHUNK_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => match std::fs::read_to_string(output_path) {
|
||||
Ok(json_str) => match serde_json::from_str::<VisualChunkResult>(&json_str) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[VisualChunk] Failed to parse output ({}), returning empty",
|
||||
e
|
||||
);
|
||||
VisualChunkResult::default()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[VisualChunk] Failed to read output ({}), returning empty",
|
||||
e
|
||||
);
|
||||
VisualChunkResult::default()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[VisualChunk] Failed to run script ({}), returning empty",
|
||||
e
|
||||
);
|
||||
VisualChunkResult::default()
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"[VisualChunk] Advanced generation result: {} chunks, {} frames",
|
||||
result.chunk_count,
|
||||
result.total_frames
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calculate_frame_similarity() {
|
||||
use crate::core::processor::yolo::{YoloFrame, YoloObject};
|
||||
|
||||
let frame1 = YoloFrame {
|
||||
frame: 0,
|
||||
timestamp: 0.0,
|
||||
objects: vec![
|
||||
YoloObject {
|
||||
class_name: "person".to_string(),
|
||||
class_id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 50,
|
||||
height: 100,
|
||||
confidence: 0.95,
|
||||
},
|
||||
YoloObject {
|
||||
class_name: "car".to_string(),
|
||||
class_id: 2,
|
||||
x: 300,
|
||||
y: 150,
|
||||
width: 80,
|
||||
height: 60,
|
||||
confidence: 0.87,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let frame2 = YoloFrame {
|
||||
frame: 1,
|
||||
timestamp: 0.033,
|
||||
objects: vec![
|
||||
YoloObject {
|
||||
class_name: "person".to_string(),
|
||||
class_id: 0,
|
||||
x: 110,
|
||||
y: 210,
|
||||
width: 52,
|
||||
height: 102,
|
||||
confidence: 0.92,
|
||||
},
|
||||
YoloObject {
|
||||
class_name: "car".to_string(),
|
||||
class_id: 2,
|
||||
x: 310,
|
||||
y: 155,
|
||||
width: 82,
|
||||
height: 62,
|
||||
confidence: 0.85,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let frame3 = YoloFrame {
|
||||
frame: 2,
|
||||
timestamp: 0.066,
|
||||
objects: vec![YoloObject {
|
||||
class_name: "dog".to_string(),
|
||||
class_id: 16,
|
||||
x: 150,
|
||||
y: 250,
|
||||
width: 40,
|
||||
height: 60,
|
||||
confidence: 0.78,
|
||||
}],
|
||||
};
|
||||
|
||||
// 相同物件的幀應該高度相似
|
||||
let similarity_same = calculate_frame_similarity(&frame1, &frame2);
|
||||
assert!((similarity_same - 1.0).abs() < 0.001);
|
||||
|
||||
// 不同物件的幀應該不相似
|
||||
let similarity_diff = calculate_frame_similarity(&frame1, &frame3);
|
||||
assert!((similarity_diff - 0.0).abs() < 0.001);
|
||||
|
||||
// 空幀應該完全相似
|
||||
let empty_frame = YoloFrame {
|
||||
frame: 3,
|
||||
timestamp: 0.1,
|
||||
objects: vec![],
|
||||
};
|
||||
let similarity_empty = calculate_frame_similarity(&empty_frame, &empty_frame);
|
||||
assert!((similarity_empty - 1.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_fixed_frame_chunks() {
|
||||
use crate::core::processor::yolo::{YoloFrame, YoloObject, YoloResult};
|
||||
|
||||
// 創建測試 YOLO 結果(60 幀,每幀都有物件)
|
||||
let mut frames = Vec::new();
|
||||
for i in 0..60 {
|
||||
frames.push(YoloFrame {
|
||||
frame: i as u64,
|
||||
timestamp: i as f64 / 30.0, // 假設 fps=30
|
||||
objects: vec![YoloObject {
|
||||
class_name: "person".to_string(),
|
||||
class_id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 50,
|
||||
height: 100,
|
||||
confidence: 0.9,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let yolo_result = YoloResult {
|
||||
frame_count: 60,
|
||||
fps: 30.0,
|
||||
frames,
|
||||
};
|
||||
|
||||
let chunks = create_fixed_frame_chunks(1, "test-uuid", &yolo_result, 0, 30.0);
|
||||
|
||||
// 60 幀,每 30 幀一個分片,應該有 2 個分片
|
||||
assert_eq!(chunks.len(), 2);
|
||||
|
||||
// 檢查第一個分片
|
||||
let first_chunk = &chunks[0];
|
||||
assert_eq!(
|
||||
first_chunk.chunk_type,
|
||||
crate::core::chunk::ChunkType::Visual
|
||||
);
|
||||
assert_eq!(first_chunk.start_frame, 0);
|
||||
assert_eq!(first_chunk.end_frame, 30); // exclusive
|
||||
assert_eq!(first_chunk.frame_count, 30);
|
||||
|
||||
// 檢查第二個分片
|
||||
let second_chunk = &chunks[1];
|
||||
assert_eq!(
|
||||
second_chunk.chunk_type,
|
||||
crate::core::chunk::ChunkType::Visual
|
||||
);
|
||||
assert_eq!(second_chunk.start_frame, 30);
|
||||
assert_eq!(second_chunk.end_frame, 60); // exclusive
|
||||
assert_eq!(second_chunk.frame_count, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_similarity_based_chunks() {
|
||||
use crate::core::processor::yolo::{YoloFrame, YoloObject, YoloResult};
|
||||
|
||||
// 創建測試 YOLO 結果
|
||||
let frames = vec![
|
||||
YoloFrame {
|
||||
// 幀 0-4: 都有 person 和 car
|
||||
frame: 0,
|
||||
timestamp: 0.0,
|
||||
objects: vec![
|
||||
YoloObject {
|
||||
class_name: "person".to_string(),
|
||||
class_id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 50,
|
||||
height: 100,
|
||||
confidence: 0.9,
|
||||
},
|
||||
YoloObject {
|
||||
class_name: "car".to_string(),
|
||||
class_id: 2,
|
||||
x: 300,
|
||||
y: 150,
|
||||
width: 80,
|
||||
height: 60,
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
YoloFrame {
|
||||
// 幀 1
|
||||
frame: 1,
|
||||
timestamp: 0.033,
|
||||
objects: vec![
|
||||
YoloObject {
|
||||
class_name: "person".to_string(),
|
||||
class_id: 0,
|
||||
x: 110,
|
||||
y: 210,
|
||||
width: 52,
|
||||
height: 102,
|
||||
confidence: 0.88,
|
||||
},
|
||||
YoloObject {
|
||||
class_name: "car".to_string(),
|
||||
class_id: 2,
|
||||
x: 310,
|
||||
y: 155,
|
||||
width: 82,
|
||||
height: 62,
|
||||
confidence: 0.78,
|
||||
},
|
||||
],
|
||||
},
|
||||
YoloFrame {
|
||||
// 幀 5-9: 只有 dog
|
||||
frame: 5,
|
||||
timestamp: 0.166,
|
||||
objects: vec![YoloObject {
|
||||
class_name: "dog".to_string(),
|
||||
class_id: 16,
|
||||
x: 150,
|
||||
y: 250,
|
||||
width: 40,
|
||||
height: 60,
|
||||
confidence: 0.7,
|
||||
}],
|
||||
},
|
||||
YoloFrame {
|
||||
// 幀 6
|
||||
frame: 6,
|
||||
timestamp: 0.2,
|
||||
objects: vec![YoloObject {
|
||||
class_name: "dog".to_string(),
|
||||
class_id: 16,
|
||||
x: 155,
|
||||
y: 255,
|
||||
width: 42,
|
||||
height: 62,
|
||||
confidence: 0.68,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let yolo_result = YoloResult {
|
||||
frame_count: 7,
|
||||
fps: 30.0,
|
||||
frames,
|
||||
};
|
||||
|
||||
let chunks = create_similarity_based_chunks(
|
||||
1,
|
||||
"test-uuid",
|
||||
&yolo_result,
|
||||
0,
|
||||
30.0,
|
||||
0.5, // similarity threshold
|
||||
2, // min frames per chunk
|
||||
);
|
||||
|
||||
// 應該有 2 個分片:一個是 person+car,一個是 dog
|
||||
assert_eq!(chunks.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod validator;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
pub const JPEG_MIN_SIZE: usize = 100;
|
||||
pub const JPEG_SOI_MARKER: [u8; 3] = [0xFF, 0xD8, 0xFF];
|
||||
pub const JPEG_EOI_MARKER: [u8; 2] = [0xFF, 0xD9];
|
||||
|
||||
pub fn validate_jpeg(data: &[u8]) -> Result<()> {
|
||||
if data.len() < JPEG_MIN_SIZE {
|
||||
bail!(
|
||||
"JPEG too small: {} bytes (minimum {})",
|
||||
data.len(),
|
||||
JPEG_MIN_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
if data[0..3] != JPEG_SOI_MARKER {
|
||||
bail!(
|
||||
"Invalid JPEG header: expected {:02X?}, got {:02X?}",
|
||||
JPEG_SOI_MARKER,
|
||||
&data[0..3]
|
||||
);
|
||||
}
|
||||
|
||||
if data[data.len() - 2..] != JPEG_EOI_MARKER {
|
||||
bail!(
|
||||
"Incomplete JPEG: missing EOI marker, got {:02X?}",
|
||||
&data[data.len() - 2..]
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_valid_jpeg(data: &[u8]) -> bool {
|
||||
validate_jpeg(data).is_ok()
|
||||
}
|
||||
|
||||
pub fn jpeg_size_ok(data: &[u8]) -> bool {
|
||||
data.len() >= JPEG_MIN_SIZE
|
||||
}
|
||||
|
||||
pub fn jpeg_header_ok(data: &[u8]) -> bool {
|
||||
data.len() >= 3 && data[0..3] == JPEG_SOI_MARKER
|
||||
}
|
||||
|
||||
pub fn jpeg_footer_ok(data: &[u8]) -> bool {
|
||||
data.len() >= 2 && data[data.len() - 2..] == JPEG_EOI_MARKER
|
||||
}
|
||||
|
||||
pub fn validate_frame(frame: i64, total_frames: i64) -> Result<()> {
|
||||
if frame < 0 {
|
||||
bail!("Frame number cannot be negative: {}", frame);
|
||||
}
|
||||
if frame > total_frames {
|
||||
bail!("Frame {} exceeds total frames {}", frame, total_frames);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_crop(
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
video_width: i32,
|
||||
video_height: i32,
|
||||
) -> Result<()> {
|
||||
if x < 0 || y < 0 || w <= 0 || h <= 0 {
|
||||
bail!(
|
||||
"Invalid crop parameters: x={}, y={}, w={}, h={} (must be positive)",
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h
|
||||
);
|
||||
}
|
||||
if x + w > video_width {
|
||||
bail!(
|
||||
"Crop width exceeds video: x+w={} > video_width={}",
|
||||
x + w,
|
||||
video_width
|
||||
);
|
||||
}
|
||||
if y + h > video_height {
|
||||
bail!(
|
||||
"Crop height exceeds video: y+h={} > video_height={}",
|
||||
y + h,
|
||||
video_height
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_jpeg_valid() {
|
||||
let valid_jpeg = vec![
|
||||
0xFF, 0xD8, 0xFF, // SOI marker
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B,
|
||||
0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29,
|
||||
0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45,
|
||||
0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53,
|
||||
0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0xFF,
|
||||
0xD9, // EOI marker
|
||||
];
|
||||
assert!(validate_jpeg(&valid_jpeg).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_jpeg_too_small() {
|
||||
let small_data = vec![0xFF, 0xD8, 0xFF, 0xFF, 0xD9];
|
||||
assert!(validate_jpeg(&small_data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_jpeg_invalid_header() {
|
||||
let invalid_header = vec![
|
||||
0x00, 0x00, 0x00, // wrong header
|
||||
0x00, 0x01, 0x02, 0x03, 0xFF, 0xD9,
|
||||
];
|
||||
assert!(validate_jpeg(&invalid_header).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_jpeg_missing_footer() {
|
||||
let missing_footer = vec![0xFF, 0xD8, 0xFF, 0x00, 0x01, 0x02, 0x03];
|
||||
assert!(validate_jpeg(&missing_footer).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_frame_valid() {
|
||||
assert!(validate_frame(500, 1000).is_ok());
|
||||
assert!(validate_frame(0, 1000).is_ok());
|
||||
assert!(validate_frame(1000, 1000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_frame_exceeds() {
|
||||
assert!(validate_frame(1001, 1000).is_err());
|
||||
assert!(validate_frame(-1, 1000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_crop_valid() {
|
||||
assert!(validate_crop(100, 100, 200, 200, 1920, 1080).is_ok());
|
||||
assert!(validate_crop(0, 0, 1920, 1080, 1920, 1080).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_crop_exceeds_width() {
|
||||
assert!(validate_crop(1800, 100, 200, 200, 1920, 1080).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_crop_exceeds_height() {
|
||||
assert!(validate_crop(100, 900, 200, 200, 1920, 1080).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_crop_negative() {
|
||||
assert!(validate_crop(-1, 100, 200, 200, 1920, 1080).is_err());
|
||||
assert!(validate_crop(100, -1, 200, 200, 1920, 1080).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_jpeg() {
|
||||
let valid_jpeg = vec![
|
||||
0xFF, 0xD8, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
||||
0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
||||
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26,
|
||||
0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34,
|
||||
0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42,
|
||||
0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50,
|
||||
0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E,
|
||||
0x5F, 0xFF, 0xD9,
|
||||
];
|
||||
assert!(is_valid_jpeg(&valid_jpeg));
|
||||
assert!(!is_valid_jpeg(&[0xFF, 0xD8, 0xFF, 0xFF, 0xD9])); // too small
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jpeg_helpers() {
|
||||
let valid_jpeg = vec![
|
||||
0xFF, 0xD8, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
||||
0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
||||
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26,
|
||||
0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34,
|
||||
0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42,
|
||||
0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50,
|
||||
0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E,
|
||||
0x5F, 0xFF, 0xD9,
|
||||
];
|
||||
assert!(jpeg_size_ok(&valid_jpeg));
|
||||
assert!(jpeg_header_ok(&valid_jpeg));
|
||||
assert!(jpeg_footer_ok(&valid_jpeg));
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -91,22 +91,21 @@ async fn upsert_identities_from_disk(
|
||||
{
|
||||
Ok(identity_file) => {
|
||||
let identities_table = crate::core::db::schema::table_name("identities");
|
||||
let uuid_clean = identity_file.identity_uuid.replace('-', "");
|
||||
let result = sqlx::query(&format!(
|
||||
"INSERT INTO {} (uuid, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata) \
|
||||
VALUES ($1::uuid, $2, 'people', 'tmdb', 'confirmed', $3, $4, $5::jsonb) \
|
||||
VALUES (gen_random_uuid(), $1, 'people', 'tmdb', 'confirmed', $2, $3, $4::jsonb) \
|
||||
ON CONFLICT (tmdb_id) WHERE tmdb_id IS NOT NULL DO UPDATE SET \
|
||||
uuid = COALESCE({}.uuid, $1::uuid), \
|
||||
tmdb_profile = COALESCE(EXCLUDED.tmdb_profile, {}.tmdb_profile), \
|
||||
metadata = {}.metadata || $5::jsonb",
|
||||
identities_table, identities_table, identities_table, identities_table
|
||||
))
|
||||
.bind(&identity_file.identity_uuid)
|
||||
.bind(&identity_file.name)
|
||||
.bind(identity_file.tmdb_id)
|
||||
.bind(&identity_file.tmdb_profile)
|
||||
.bind(&identity_file.metadata)
|
||||
.execute(db.pool())
|
||||
.await;
|
||||
metadata = jsonb_deep_merge({}.metadata, $4::jsonb)",
|
||||
identities_table, identities_table, identities_table
|
||||
))
|
||||
.bind(&identity_file.name)
|
||||
.bind(identity_file.tmdb_id)
|
||||
.bind(&identity_file.tmdb_profile)
|
||||
.bind(&identity_file.metadata)
|
||||
.execute(db.pool())
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
@@ -226,7 +225,7 @@ pub async fn create_identities_from_data(
|
||||
VALUES ($1, 'people', 'tmdb', 'confirmed', $2, $3, $4::jsonb) \
|
||||
ON CONFLICT (tmdb_id) WHERE tmdb_id IS NOT NULL DO UPDATE SET \
|
||||
tmdb_profile = COALESCE(EXCLUDED.tmdb_profile, {}.tmdb_profile), \
|
||||
metadata = {}.metadata || $4::jsonb \
|
||||
metadata = jsonb_deep_merge({}.metadata, $4::jsonb) \
|
||||
RETURNING uuid",
|
||||
identities_table, identities_table, identities_table
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user