chore: backup before migration to new repo
This commit is contained in:
@@ -30,14 +30,20 @@ pub async fn api_key_validation(
|
||||
tracing::info!("[MIDDLEWARE] Path: {:?}", request.uri().path());
|
||||
|
||||
let headers = request.headers();
|
||||
tracing::info!(
|
||||
"[MIDDLEWARE] Headers: {:?}",
|
||||
headers.keys().collect::<Vec<_>>()
|
||||
);
|
||||
tracing::info!("[MIDDLEWARE] All headers: {:?}", headers);
|
||||
|
||||
let api_key = match extract_api_key(headers) {
|
||||
Ok(key) => {
|
||||
tracing::info!("[MIDDLEWARE] API key extracted, length: {}", key.len());
|
||||
if key.len() > 8 {
|
||||
tracing::info!(
|
||||
"[MIDDLEWARE] Key value: {}...{}",
|
||||
&key[..4],
|
||||
&key[key.len() - 4..]
|
||||
);
|
||||
} else {
|
||||
tracing::info!("[MIDDLEWARE] Key value: ****");
|
||||
}
|
||||
key
|
||||
}
|
||||
Err(status) => {
|
||||
@@ -59,7 +65,10 @@ pub async fn api_key_validation(
|
||||
r
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!("[MIDDLEWARE] API key not found in database");
|
||||
tracing::warn!(
|
||||
"[MIDDLEWARE] API key NOT FOUND in database for hash: {}",
|
||||
&key_hash[..16]
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(axum::body::Body::empty())
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
pub mod face_recognition;
|
||||
pub mod identities;
|
||||
pub mod identity_binding;
|
||||
pub mod middleware;
|
||||
pub mod n8n_search;
|
||||
pub mod person_identity;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod universal_search;
|
||||
pub mod visual_chunk_search;
|
||||
pub mod who;
|
||||
|
||||
pub use server::start_server;
|
||||
|
||||
1667
src/api/server.rs
1667
src/api/server.rs
File diff suppressed because it is too large
Load Diff
41
src/core/cache/keys.rs
vendored
41
src/core/cache/keys.rs
vendored
@@ -10,6 +10,8 @@ pub const KEY_PREFIX_VIDEO: &str = "video:";
|
||||
pub const KEY_PREFIX_SEARCH: &str = "search:";
|
||||
pub const KEY_PREFIX_SEARCH_HYBRID: &str = "search:hybrid:";
|
||||
pub const KEY_PREFIX_SEARCH_N8N: &str = "search:n8n:";
|
||||
pub const KEY_PREFIX_SEARCH_BM25: &str = "search:bm25:";
|
||||
pub const KEY_PREFIX_SEARCH_N8N_BM25: &str = "search:n8n:bm25:";
|
||||
pub const KEY_HEALTH: &str = "health:basic";
|
||||
|
||||
pub fn videos_list(page: usize, limit: usize) -> String {
|
||||
@@ -32,6 +34,14 @@ pub fn n8n_search(query_hash: &str) -> String {
|
||||
format!("{}{}", KEY_PREFIX_SEARCH_N8N, query_hash)
|
||||
}
|
||||
|
||||
pub fn bm25_search(query_hash: &str) -> String {
|
||||
format!("{}{}", KEY_PREFIX_SEARCH_BM25, query_hash)
|
||||
}
|
||||
|
||||
pub fn n8n_bm25_search(query_hash: &str) -> String {
|
||||
format!("{}{}", KEY_PREFIX_SEARCH_N8N_BM25, query_hash)
|
||||
}
|
||||
|
||||
pub fn health() -> String {
|
||||
KEY_HEALTH.to_string()
|
||||
}
|
||||
@@ -48,6 +58,17 @@ pub fn search_prefix() -> String {
|
||||
format!("^{}", KEY_PREFIX_SEARCH)
|
||||
}
|
||||
|
||||
pub const KEY_PREFIX_VISUAL_SEARCH: &str = "search:visual:";
|
||||
pub const CATEGORY_VISUAL_SEARCH: &str = "visual_search";
|
||||
|
||||
pub fn visual_search(uuid: &str, criteria_hash: &str) -> String {
|
||||
format!("{}{}:{}", KEY_PREFIX_VISUAL_SEARCH, uuid, criteria_hash)
|
||||
}
|
||||
|
||||
pub fn visual_search_prefix() -> String {
|
||||
format!("^{}", KEY_PREFIX_VISUAL_SEARCH)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -78,8 +99,28 @@ mod tests {
|
||||
assert_eq!(n8n_search("hash123"), "search:n8n:hash123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bm25_search() {
|
||||
assert_eq!(bm25_search("hash123"), "search:bm25:hash123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_n8n_bm25_search() {
|
||||
assert_eq!(n8n_bm25_search("hash123"), "search:n8n:bm25:hash123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health() {
|
||||
assert_eq!(health(), "health:basic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_search() {
|
||||
assert_eq!(visual_search("abc123", "hash"), "search:visual:abc123:hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_search_prefix() {
|
||||
assert_eq!(visual_search_prefix(), "^search:visual:");
|
||||
}
|
||||
}
|
||||
|
||||
4
src/core/cache/mongo_cache.rs
vendored
4
src/core/cache/mongo_cache.rs
vendored
@@ -136,6 +136,10 @@ impl MongoCache {
|
||||
self.settings.ttl_video_meta
|
||||
}
|
||||
|
||||
pub fn ttl_visual_search(&self) -> u64 {
|
||||
self.settings.ttl_search // Reuse search TTL
|
||||
}
|
||||
|
||||
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(None);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
pub mod rule1_ingest;
|
||||
pub mod rule3_ingest;
|
||||
pub mod splitter;
|
||||
pub mod types;
|
||||
|
||||
pub use rule1_ingest::ingest_rule1;
|
||||
pub use rule3_ingest::ingest_rule3;
|
||||
pub use splitter::{AsrSegment, ChunkSplitter};
|
||||
pub use types::{Chunk, ChunkType};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -8,7 +9,8 @@ pub enum ChunkType {
|
||||
Sentence,
|
||||
Cut,
|
||||
Trace,
|
||||
Story, // Parent chunk from story analysis
|
||||
Story,
|
||||
Visual, // 視覺分片 (Phase 2.1)
|
||||
}
|
||||
|
||||
impl ChunkType {
|
||||
@@ -19,10 +21,12 @@ 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 {
|
||||
@@ -39,6 +43,73 @@ 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,
|
||||
@@ -49,9 +120,9 @@ pub struct Chunk {
|
||||
pub rule: ChunkRule,
|
||||
/// Frames per second (can be fractional, e.g., 29.97, 23.976)
|
||||
pub fps: f64,
|
||||
/// Start frame (0-based)
|
||||
/// Start frame (0-based) - 主要時間標示
|
||||
pub start_frame: i64,
|
||||
/// End frame (exclusive)
|
||||
/// End frame (exclusive) - 主要時間標示
|
||||
pub end_frame: i64,
|
||||
pub text_content: Option<String>,
|
||||
pub content: serde_json::Value,
|
||||
@@ -61,17 +132,11 @@ pub struct Chunk {
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// Creates a new chunk from frame counts.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `start_frame` - Start frame (0-based)
|
||||
/// * `end_frame` - End frame (exclusive)
|
||||
/// * `fps` - Frames per second (can be fractional)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// 創建新分片
|
||||
pub fn new(
|
||||
file_id: i32,
|
||||
uuid: String,
|
||||
@@ -83,11 +148,13 @@ impl Chunk {
|
||||
fps: f64,
|
||||
content: serde_json::Value,
|
||||
) -> Self {
|
||||
let chunk_id = format!("{}_{:04}", chunk_type.as_str(), chunk_index);
|
||||
let frame_count = (end_frame - start_frame) as i32;
|
||||
let chunk_id = format!("{}_{}", uuid, chunk_index);
|
||||
|
||||
Self {
|
||||
file_id,
|
||||
uuid,
|
||||
chunk_id: chunk_id.clone(),
|
||||
chunk_id,
|
||||
chunk_index,
|
||||
chunk_type,
|
||||
rule,
|
||||
@@ -98,17 +165,171 @@ impl Chunk {
|
||||
content,
|
||||
metadata: None,
|
||||
vector_id: None,
|
||||
frame_count: 0,
|
||||
frame_count,
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: None,
|
||||
child_chunk_ids: vec![],
|
||||
visual_stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new chunk from seconds (legacy conversion).
|
||||
/// 創建視覺分片 (Phase 2.1)
|
||||
pub fn new_visual(
|
||||
file_id: i32,
|
||||
uuid: String,
|
||||
chunk_index: u32,
|
||||
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_index,
|
||||
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_index: u32,
|
||||
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_index,
|
||||
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()
|
||||
}
|
||||
|
||||
/// 從秒數創建新分片(舊版轉換)
|
||||
///
|
||||
/// This is useful for migrating from older systems that store time as seconds.
|
||||
/// The frame counts are calculated by rounding `seconds * fps`.
|
||||
/// 這對於從存儲時間為秒的舊系統遷移很有用。
|
||||
/// 幀數通過舍入 `seconds * fps` 計算。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_seconds(
|
||||
file_id: i32,
|
||||
@@ -136,104 +357,197 @@ impl Chunk {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the start time as a `FrameTime`.
|
||||
/// 返回開始時間為 `FrameTime`
|
||||
pub fn start_time(&self) -> FrameTime {
|
||||
FrameTime::from_frames(self.start_frame, self.fps)
|
||||
}
|
||||
|
||||
/// Returns the end time as a `FrameTime`.
|
||||
/// 返回結束時間為 `FrameTime`
|
||||
pub fn end_time(&self) -> FrameTime {
|
||||
FrameTime::from_frames(self.end_frame, self.fps)
|
||||
}
|
||||
|
||||
/// Returns the duration in frames.
|
||||
/// 返回持續時間的幀數
|
||||
pub fn duration_frames(&self) -> i64 {
|
||||
self.end_frame - self.start_frame
|
||||
}
|
||||
|
||||
/// Returns the duration in seconds.
|
||||
/// 返回持續時間的秒數
|
||||
pub fn duration_seconds(&self) -> f64 {
|
||||
self.duration_frames() as f64 / self.fps
|
||||
}
|
||||
|
||||
/// Formats the start time as "seconds.frame" (e.g., "123.04").
|
||||
/// 將開始時間格式化為 "seconds.frame" (例如:"123.04")
|
||||
pub fn format_start_sec_frame(&self) -> String {
|
||||
self.start_time().format_sec_frame()
|
||||
}
|
||||
|
||||
/// Formats the end time as "seconds.frame" (e.g., "456.15").
|
||||
/// 將結束時間格式化為 "seconds.frame" (例如:"456.15")
|
||||
pub fn format_end_sec_frame(&self) -> String {
|
||||
self.end_time().format_sec_frame()
|
||||
}
|
||||
|
||||
/// Formats the start time as "HH:MM:SS".
|
||||
/// 將開始時間格式化為 "HH:MM:SS"
|
||||
pub fn format_start_hms(&self) -> String {
|
||||
self.start_time().format_hms()
|
||||
}
|
||||
|
||||
/// Formats the end time as "HH:MM:SS".
|
||||
/// 將結束時間格式化為 "HH:MM:SS"
|
||||
pub fn format_end_hms(&self) -> String {
|
||||
self.end_time().format_hms()
|
||||
}
|
||||
|
||||
/// Formats the start time as "HH:MM:SS.FF".
|
||||
/// 將開始時間格式化為 "HH:MM:SS.FF"
|
||||
pub fn format_start_hms_frame(&self) -> String {
|
||||
self.start_time().format_hms_frame()
|
||||
}
|
||||
|
||||
/// Formats the end time as "HH:MM:SS.FF".
|
||||
/// 將結束時間格式化為 "HH:MM:SS.FF"
|
||||
pub fn format_end_hms_frame(&self) -> String {
|
||||
self.end_time().format_hms_frame()
|
||||
}
|
||||
|
||||
/// Returns a tuple of (start_seconds, end_seconds) for compatibility.
|
||||
/// 返回 (start_seconds, end_seconds) 元組用於兼容性
|
||||
///
|
||||
/// This is provided for backward compatibility during migration.
|
||||
/// Prefer using `start_time()` and `end_time()` methods.
|
||||
/// 這在遷移期間提供向後兼容性。
|
||||
/// 建議使用 `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
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_parent_chunk(&self) -> bool {
|
||||
!self.child_chunk_ids.is_empty()
|
||||
// ==================== 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
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_child_chunk(&self) -> bool {
|
||||
self.parent_chunk_id.is_some()
|
||||
/// 獲取視覺分片的摘要(使用關鍵幀的 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,3 +164,29 @@ pub mod cache {
|
||||
.unwrap_or(3600)
|
||||
});
|
||||
}
|
||||
|
||||
pub mod llm {
|
||||
use super::*;
|
||||
|
||||
pub static SUMMARY_URL: Lazy<String> = Lazy::new(|| {
|
||||
env::var("MOMENTRY_LLM_SUMMARY_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8081/v1/chat/completions".to_string())
|
||||
});
|
||||
|
||||
pub static SUMMARY_MODEL: Lazy<String> = Lazy::new(|| {
|
||||
env::var("MOMENTRY_LLM_SUMMARY_MODEL").unwrap_or_else(|_| "gemma4".to_string())
|
||||
});
|
||||
|
||||
pub static SUMMARY_TIMEOUT_SECS: Lazy<u64> = Lazy::new(|| {
|
||||
env::var("MOMENTRY_LLM_SUMMARY_TIMEOUT")
|
||||
.unwrap_or_else(|_| "120".to_string())
|
||||
.parse()
|
||||
.unwrap_or(120)
|
||||
});
|
||||
|
||||
pub static SUMMARY_ENABLED: Lazy<bool> = Lazy::new(|| {
|
||||
env::var("MOMENTRY_LLM_SUMMARY_ENABLED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::core::chunk::types::{Chunk, ChunkRule, ChunkType};
|
||||
|
||||
pub struct MongoDb {
|
||||
base_url: String,
|
||||
database: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -53,7 +54,8 @@ impl MongoDb {
|
||||
pub fn new() -> Self {
|
||||
let base_url =
|
||||
std::env::var("MONGODB_URL").unwrap_or_else(|_| "http://localhost:27017".to_string());
|
||||
Self { base_url }
|
||||
let database = crate::core::config::MONGODB_DATABASE.clone();
|
||||
Self { base_url, database }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ impl MongoDb {
|
||||
let doc: ChunkDocument = chunk.clone().into();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let url = format!("{}/momentry/chunks", self.base_url);
|
||||
let url = format!("{}/{}/chunks", self.base_url, self.database);
|
||||
|
||||
client
|
||||
.post(&url)
|
||||
@@ -83,8 +85,8 @@ impl MongoDb {
|
||||
pub async fn get_chunks_by_uuid(&self, uuid: &str) -> Result<Vec<Chunk>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/momentry/chunks?filter={{\"uuid\":\"{}\"}}",
|
||||
self.base_url, uuid
|
||||
"{}/{}/chunks?filter={{\"uuid\":\"{}\"}}",
|
||||
self.base_url, self.database, uuid
|
||||
);
|
||||
|
||||
let response = client
|
||||
@@ -131,6 +133,7 @@ impl MongoDb {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: doc.parent_chunk_id,
|
||||
child_chunk_ids: doc.child_chunk_ids,
|
||||
visual_stats: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -141,8 +144,8 @@ impl MongoDb {
|
||||
pub async fn search_text(&self, query: &str) -> Result<Vec<Chunk>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/momentry/chunks?filter={{\"$text\":{{\"$search\":\"{}\"}}}}",
|
||||
self.base_url, query
|
||||
"{}/{}/chunks?filter={{\"$text\":{{\"$search\":\"{}\"}}}}",
|
||||
self.base_url, self.database, query
|
||||
);
|
||||
|
||||
let response = client
|
||||
@@ -189,6 +192,7 @@ impl MongoDb {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: doc.parent_chunk_id,
|
||||
child_chunk_ids: doc.child_chunk_ids,
|
||||
visual_stats: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -198,7 +202,7 @@ impl MongoDb {
|
||||
|
||||
pub async fn get_all_chunks(&self) -> Result<Vec<Chunk>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/momentry/chunks", self.base_url);
|
||||
let url = format!("{}/{}/chunks", self.base_url, self.database);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
@@ -244,6 +248,7 @@ impl MongoDb {
|
||||
pre_chunk_ids: vec![],
|
||||
parent_chunk_id: doc.parent_chunk_id,
|
||||
child_chunk_ids: doc.child_chunk_ids,
|
||||
visual_stats: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -128,7 +128,7 @@ impl QdrantDb {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
point_id_str.hash(&mut hasher);
|
||||
let point_id = hasher.finish() as u64;
|
||||
let point_id = hasher.finish();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"points": [{
|
||||
@@ -171,7 +171,7 @@ impl QdrantDb {
|
||||
));
|
||||
}
|
||||
|
||||
tracing::debug!("Qdrant response: {}", response_text);
|
||||
tracing::debug!("Qdrant upsert response status: {}", status);
|
||||
tracing::info!("Successfully upserted vector for chunk: {}", chunk_id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -257,6 +257,101 @@ impl QdrantDb {
|
||||
Ok(search_results)
|
||||
}
|
||||
|
||||
pub async fn search_collections(
|
||||
&self,
|
||||
query_vector: &[f32],
|
||||
collections: &[&str],
|
||||
limit: usize,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let mut handles = Vec::new();
|
||||
for &collection in collections {
|
||||
let url = format!("{}/collections/{}/points/search", self.base_url, collection);
|
||||
let client = self.client.clone();
|
||||
let api_key = self.api_key.clone();
|
||||
let query_vec = query_vector.to_vec();
|
||||
let body = serde_json::json!({
|
||||
"vector": query_vec,
|
||||
"limit": limit * 2, // Fetch more from each to account for overlaps
|
||||
"with_payload": true
|
||||
});
|
||||
handles.push(async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("api-key", &api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let resp_text = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Failed to read response".to_string());
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QdrantSearchResult {
|
||||
result: Vec<QdrantPoint>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct QdrantPoint {
|
||||
#[allow(dead_code)]
|
||||
id: serde_json::Value,
|
||||
score: f64,
|
||||
payload: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
if let Ok(result) = serde_json::from_str::<QdrantSearchResult>(&resp_text) {
|
||||
let results: Vec<SearchResult> = result
|
||||
.result
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let uuid = r
|
||||
.payload
|
||||
.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let chunk_id = r
|
||||
.payload
|
||||
.get("chunk_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
SearchResult {
|
||||
uuid,
|
||||
chunk_id,
|
||||
score: r.score as f32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok::<Vec<SearchResult>, anyhow::Error>(results)
|
||||
} else {
|
||||
Ok::<Vec<SearchResult>, anyhow::Error>(Vec::new())
|
||||
}
|
||||
}
|
||||
_ => Ok::<Vec<SearchResult>, anyhow::Error>(Vec::new()),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let results = futures_util::future::join_all(handles).await;
|
||||
let mut merged: Vec<SearchResult> = results
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
// Sort by score descending
|
||||
merged.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
|
||||
// Deduplicate by chunk_id + uuid
|
||||
merged.dedup_by_key(|r| (r.chunk_id.clone(), r.uuid.clone()));
|
||||
// Truncate to limit
|
||||
merged.truncate(limit);
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
pub async fn search_in_uuid(
|
||||
&self,
|
||||
query_vector: &[f32],
|
||||
|
||||
@@ -4,9 +4,15 @@ pub mod chunk;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod embedding;
|
||||
pub mod ingestion;
|
||||
pub mod llm;
|
||||
pub mod overlay;
|
||||
pub mod person_identity;
|
||||
pub mod probe;
|
||||
pub mod processor;
|
||||
pub mod storage;
|
||||
pub mod text;
|
||||
pub mod thumbnail;
|
||||
pub mod time;
|
||||
pub mod tmdb;
|
||||
pub mod worker;
|
||||
|
||||
@@ -28,16 +28,23 @@ pub async fn process_asrx(
|
||||
uuid: Option<&str>,
|
||||
) -> Result<AsrxResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("asrx_processor.py");
|
||||
let script_path = executor.script_path("asrx_processor_custom.py");
|
||||
|
||||
tracing::info!("[ASRX] Starting speaker diarization: {}", video_path);
|
||||
tracing::info!(
|
||||
"[ASRX] Starting speaker diarization (custom): {}",
|
||||
video_path
|
||||
);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[ASRX] Script not found, returning empty result");
|
||||
return Ok(AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
});
|
||||
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![],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(executor.python_path());
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod ocr;
|
||||
pub mod pose;
|
||||
pub mod scene_classification;
|
||||
pub mod story;
|
||||
pub mod visual_chunk;
|
||||
pub mod yolo;
|
||||
|
||||
pub use asr::{process_asr, AsrResult, AsrSegment};
|
||||
@@ -28,4 +29,5 @@ pub use scene_classification::{
|
||||
process_scene_classification, SceneClassificationResult, ScenePrediction, SceneSegment,
|
||||
};
|
||||
pub use story::{process_story, StoryChildChunk, StoryParentChunk, StoryResult, StoryStats};
|
||||
pub use visual_chunk::{process_visual_chunk, process_visual_chunk_advanced, VisualChunkResult};
|
||||
pub use yolo::{process_yolo, YoloFrame, YoloObject, YoloResult};
|
||||
|
||||
@@ -4,6 +4,8 @@ pub mod api;
|
||||
|
||||
pub mod ui;
|
||||
|
||||
pub mod watcher;
|
||||
|
||||
pub mod worker;
|
||||
|
||||
pub use core::cache::{keys, MongoCache, RedisCache};
|
||||
@@ -13,6 +15,10 @@ pub use core::db::{
|
||||
VideoStatus,
|
||||
};
|
||||
pub use core::embedding::Embedder;
|
||||
pub use core::person_identity::{
|
||||
ChunkPersonInfo, PersonAppearance, PersonIdentity, PersonIdentityResponse, PersonMatch,
|
||||
PersonStatistics, PersonTimelineEntry, PersonTimelineResponse,
|
||||
};
|
||||
pub use core::probe::ProbeResult;
|
||||
pub use core::storage::file_manager::FileManager;
|
||||
pub use core::storage::output_dir::OutputDir;
|
||||
|
||||
133
src/main.rs
133
src/main.rs
@@ -1805,6 +1805,64 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// Read Pose JSON (optional)
|
||||
let pose_path = format!("{}.pose.json", uuid);
|
||||
let pose_result = match std::fs::read_to_string(&pose_path) {
|
||||
Ok(pose_json) => match serde_json::from_str::<
|
||||
momentry_core::core::processor::pose::PoseResult,
|
||||
>(&pose_json)
|
||||
{
|
||||
Ok(result) => {
|
||||
println!("Loaded Pose: {} frames", result.frames.len());
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to parse Pose JSON: {}. Skipping Pose.", e);
|
||||
momentry_core::core::processor::pose::PoseResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Warning: Pose file not found. Skipping Pose.");
|
||||
momentry_core::core::processor::pose::PoseResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read ASRX JSON (optional)
|
||||
let asrx_path = format!("{}.asrx.json", uuid);
|
||||
let asrx_result = match std::fs::read_to_string(&asrx_path) {
|
||||
Ok(asrx_json) => match serde_json::from_str::<
|
||||
momentry_core::core::processor::asrx::AsrxResult,
|
||||
>(&asrx_json)
|
||||
{
|
||||
Ok(result) => {
|
||||
println!("Loaded ASRX: {} segments", result.segments.len());
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to parse ASRX JSON: {}. Skipping ASRX.", e);
|
||||
momentry_core::core::processor::asrx::AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Warning: ASRX file not found. Skipping ASRX.");
|
||||
momentry_core::core::processor::asrx::AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Store pre_chunks (from ASR, CUT) ==========
|
||||
|
||||
println!("\nStoring pre_chunks...");
|
||||
@@ -1922,12 +1980,21 @@ async fn main() -> Result<()> {
|
||||
face_by_frame.insert(frame.frame, frame.clone());
|
||||
}
|
||||
|
||||
// Store frames (merge data from YOLO, OCR, Face)
|
||||
let mut pose_by_frame: std::collections::HashMap<
|
||||
u64,
|
||||
momentry_core::core::processor::pose::PoseFrame,
|
||||
> = std::collections::HashMap::new();
|
||||
for frame in &pose_result.frames {
|
||||
pose_by_frame.insert(frame.frame, frame.clone());
|
||||
}
|
||||
|
||||
// Store frames (merge data from YOLO, OCR, Face, Pose)
|
||||
let mut all_frames: Vec<u64> = frame_data
|
||||
.keys()
|
||||
.cloned()
|
||||
.chain(ocr_by_frame.keys().cloned())
|
||||
.chain(face_by_frame.keys().cloned())
|
||||
.chain(pose_by_frame.keys().cloned())
|
||||
.collect();
|
||||
all_frames.sort();
|
||||
all_frames.dedup();
|
||||
@@ -1937,6 +2004,7 @@ async fn main() -> Result<()> {
|
||||
let yolo_frame = frame_data.get(frame_num);
|
||||
let ocr_frame = ocr_by_frame.get(frame_num);
|
||||
let face_frame = face_by_frame.get(frame_num);
|
||||
let pose_frame = pose_by_frame.get(frame_num);
|
||||
|
||||
let frame = momentry_core::core::db::postgres_db::Frame {
|
||||
id: 0,
|
||||
@@ -1947,6 +2015,7 @@ async fn main() -> Result<()> {
|
||||
yolo_objects: yolo_frame.map(|f| serde_json::json!(&f.objects)),
|
||||
ocr_results: ocr_frame.map(|f| serde_json::json!(&f.texts)),
|
||||
face_results: face_frame.map(|f| serde_json::json!(&f.faces)),
|
||||
pose_results: pose_frame.map(|f| serde_json::json!(&f.persons)),
|
||||
frame_path: None,
|
||||
created_at: String::new(),
|
||||
};
|
||||
@@ -1960,10 +2029,33 @@ async fn main() -> Result<()> {
|
||||
println!("\nCreating chunks...");
|
||||
|
||||
// Rule 1: Direct conversion (sentence pre_chunk -> sentence chunk)
|
||||
// Merge ASRX speaker_id by time overlap
|
||||
let mut sentence_chunks = Vec::new();
|
||||
for (i, seg) in asr_result.segments.iter().enumerate() {
|
||||
let pre_chunk_id = asr_pre_chunk_ids.get(i).copied().unwrap_or(0);
|
||||
let chunk = Chunk::from_seconds(
|
||||
|
||||
// Find matching ASRX segment by time overlap
|
||||
let speaker_id = asrx_result
|
||||
.segments
|
||||
.iter()
|
||||
.find(|ax| {
|
||||
// Overlap: ASRX segment overlaps with ASR segment
|
||||
ax.start <= seg.end && ax.end >= seg.start
|
||||
})
|
||||
.and_then(|ax| ax.speaker_id.clone());
|
||||
|
||||
let content = if let Some(ref sid) = speaker_id {
|
||||
serde_json::json!({
|
||||
"text": seg.text,
|
||||
"speaker_id": sid,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"text": seg.text,
|
||||
})
|
||||
};
|
||||
|
||||
let mut chunk = Chunk::from_seconds(
|
||||
file_id as i32,
|
||||
uuid.clone(),
|
||||
i as u32,
|
||||
@@ -1972,15 +2064,40 @@ async fn main() -> Result<()> {
|
||||
seg.start,
|
||||
seg.end,
|
||||
fps,
|
||||
serde_json::json!({
|
||||
"text": seg.text,
|
||||
}),
|
||||
content,
|
||||
)
|
||||
.with_text_content(seg.text.clone())
|
||||
.with_pre_chunk_ids(vec![pre_chunk_id as i32]);
|
||||
|
||||
// Add ASRX metadata if available
|
||||
if speaker_id.is_some() {
|
||||
chunk = chunk.with_metadata(serde_json::json!({
|
||||
"language": asr_result.language,
|
||||
"language_probability": asr_result.language_probability,
|
||||
"speaker_matched": true,
|
||||
}));
|
||||
}
|
||||
|
||||
sentence_chunks.push(chunk);
|
||||
}
|
||||
|
||||
if !asrx_result.segments.is_empty() {
|
||||
let matched = sentence_chunks
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.content
|
||||
.get("speaker_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
println!(
|
||||
" ASRX merge: {}/{} sentence chunks matched to speakers",
|
||||
matched,
|
||||
sentence_chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 1: CUT chunks
|
||||
let mut cut_chunks = Vec::new();
|
||||
for (i, scene) in cut_result.scenes.iter().enumerate() {
|
||||
@@ -2235,7 +2352,7 @@ async fn main() -> Result<()> {
|
||||
// Get list of videos to process
|
||||
let videos_to_process = if uuid == "all" {
|
||||
// Get all videos
|
||||
let videos = pg.list_videos().await?;
|
||||
let videos = pg.list_videos(10000, 0).await?.0;
|
||||
videos.into_iter().map(|v| v.uuid).collect::<Vec<_>>()
|
||||
} else {
|
||||
// Process single video
|
||||
@@ -2486,7 +2603,7 @@ async fn main() -> Result<()> {
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Video not found: {}", uuid))?]
|
||||
} else {
|
||||
db.list_videos().await?
|
||||
db.list_videos(10000, 0).await?.0
|
||||
};
|
||||
|
||||
let output_dir = std::path::PathBuf::from("thumbnails");
|
||||
@@ -2520,7 +2637,7 @@ async fn main() -> Result<()> {
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Video not found: {}", u))?]
|
||||
} else {
|
||||
db.list_videos().await?
|
||||
db.list_videos(10000, 0).await?.0
|
||||
};
|
||||
|
||||
println!("\n╔══════════════════════════════════════════════════════════════════════════════════╗");
|
||||
|
||||
@@ -5,6 +5,21 @@ use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_API_URL: &str = "http://localhost:3002";
|
||||
|
||||
const DEV_API_URL: &str = "http://localhost:3003";
|
||||
|
||||
fn get_api_url() -> String {
|
||||
std::env::var("MOMENTRY_API_URL").unwrap_or_else(|_| {
|
||||
std::env::var("MOMENTRY_SERVER_PORT")
|
||||
.ok()
|
||||
.map(|port| format!("http://localhost:{}", port))
|
||||
.unwrap_or_else(|| DEFAULT_API_URL.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn get_api_key() -> Option<String> {
|
||||
std::env::var("MOMENTRY_API_KEY").ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiClient {
|
||||
client: Client,
|
||||
@@ -83,7 +98,7 @@ pub struct VideosResponse {
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new() -> Self {
|
||||
let url = std::env::var("MOMENTRY_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string());
|
||||
let url = get_api_url();
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_url: url,
|
||||
@@ -103,7 +118,11 @@ impl ApiClient {
|
||||
let request = RegisterRequest {
|
||||
path: path.to_string(),
|
||||
};
|
||||
let response = self.client.post(&url).json(&request).send().await?;
|
||||
let mut request_builder = self.client.post(&url).json(&request);
|
||||
if let Some(key) = get_api_key() {
|
||||
request_builder = request_builder.header("X-API-Key", key);
|
||||
}
|
||||
let response = request_builder.send().await?;
|
||||
let status = response.status();
|
||||
let result = response.json::<RegisterResponse>().await?;
|
||||
if !status.is_success() {
|
||||
@@ -124,7 +143,11 @@ impl ApiClient {
|
||||
limit,
|
||||
uuid: uuid.map(|s| s.to_string()),
|
||||
};
|
||||
let response = self.client.post(&url).json(&request).send().await?;
|
||||
let mut request_builder = self.client.post(&url).json(&request);
|
||||
if let Some(key) = get_api_key() {
|
||||
request_builder = request_builder.header("X-API-Key", key);
|
||||
}
|
||||
let response = request_builder.send().await?;
|
||||
let status = response.status();
|
||||
let result = response.json::<SearchResponse>().await?;
|
||||
if !status.is_success() {
|
||||
@@ -135,18 +158,30 @@ impl ApiClient {
|
||||
|
||||
pub async fn lookup_video(&self, uuid: &str) -> Result<LookupResponse> {
|
||||
let url = format!("{}/api/v1/lookup?uuid={}", self.base_url, uuid);
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let mut request = self.client.get(&url);
|
||||
if let Some(key) = get_api_key() {
|
||||
request = request.header("X-API-Key", key);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
let result = response.json::<LookupResponse>().await?;
|
||||
if !status.is_success() {
|
||||
if status == 200 {
|
||||
let result = response.json::<LookupResponse>().await?;
|
||||
if result.uuid.is_empty() {
|
||||
anyhow::bail!("影片不存在: {}", uuid);
|
||||
}
|
||||
Ok(result)
|
||||
} else {
|
||||
anyhow::bail!("API request failed with status: {}", status);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn list_videos(&self) -> Result<Vec<VideoInfo>> {
|
||||
let url = format!("{}/api/v1/videos", self.base_url);
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let mut request = self.client.get(&url);
|
||||
if let Some(key) = get_api_key() {
|
||||
request = request.header("X-API-Key", key);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
let result = response.json::<VideosResponse>().await?;
|
||||
if !status.is_success() {
|
||||
|
||||
@@ -397,6 +397,29 @@ fn format_time(seconds: f64) -> String {
|
||||
format!("{:02}:{:02}:{:02}.{:02}", hours, minutes, secs, millis)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_video_duration(video_path: &str) -> f64 {
|
||||
let output = std::process::Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
video_path,
|
||||
])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let duration_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
duration_str.parse::<f64>().unwrap_or(0.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_video_uuid(video_path: &str) -> Option<String> {
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
@@ -510,9 +533,714 @@ fn run_player(_video_path: &str, _video_uuid: Option<String>) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "player")]
|
||||
fn run_player(_video_path: &str, _video_uuid: Option<String>) -> Result<()> {
|
||||
println!("Player not available - SDL2 not configured");
|
||||
println!("Playing: {} (UUID: {:?})", _video_path, _video_uuid);
|
||||
fn run_player(video_path: &str, video_uuid: Option<String>) -> Result<()> {
|
||||
run_player_with_sdl2(video_path, video_uuid)
|
||||
}
|
||||
|
||||
#[cfg(feature = "player")]
|
||||
fn run_player_with_sdl2(video_path: &str, video_uuid: Option<String>) -> Result<()> {
|
||||
use sdl2::event::Event;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::pixels::PixelFormatEnum;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
println!("\n=== 🎬 SDL2 Video Player ===");
|
||||
println!("File: {}", video_path);
|
||||
println!("UUID: {:?}", video_uuid);
|
||||
|
||||
let sdl_context = sdl2::init().map_err(|e| anyhow::anyhow!("SDL init failed: {}", e))?;
|
||||
let video_subsystem = sdl_context
|
||||
.video()
|
||||
.map_err(|e| anyhow::anyhow!("Video init failed: {}", e))?;
|
||||
|
||||
let width = 1280u32;
|
||||
let height = 720u32;
|
||||
|
||||
let window = video_subsystem
|
||||
.window("Momentry Player", width, height)
|
||||
.position_centered()
|
||||
.resizable()
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("Window creation failed: {}", e))?;
|
||||
|
||||
let mut canvas = window
|
||||
.into_canvas()
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("Canvas creation failed: {}", e))?;
|
||||
|
||||
let texture_creator = canvas.texture_creator();
|
||||
let mut texture = texture_creator
|
||||
.create_texture_streaming(PixelFormatEnum::RGB24, width as u32, height as u32)
|
||||
.map_err(|e| anyhow::anyhow!("Texture creation failed: {}", e))?;
|
||||
|
||||
let ffmpeg_path = if cfg!(target_os = "macos") {
|
||||
"/opt/homebrew/bin/ffmpeg"
|
||||
} else {
|
||||
"ffmpeg"
|
||||
};
|
||||
|
||||
let mut ffmpeg = std::process::Command::new(ffmpeg_path)
|
||||
.args([
|
||||
"-i",
|
||||
video_path,
|
||||
"-vf",
|
||||
&format!(
|
||||
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2",
|
||||
width, height, width, height
|
||||
),
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"-r",
|
||||
"30",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to start ffmpeg: {}", e))?;
|
||||
|
||||
let stdout = ffmpeg
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let frame_size = (width * height * 3) as usize;
|
||||
let mut frame_buffer = vec![0u8; frame_size];
|
||||
|
||||
let playing = Arc::new(AtomicBool::new(true));
|
||||
let playing_clone = playing.clone();
|
||||
|
||||
let mut event_pump = sdl_context
|
||||
.event_pump()
|
||||
.map_err(|e| anyhow::anyhow!("Event pump failed: {}", e))?;
|
||||
|
||||
let mut asr_overlay = asr_overlay::AsrOverlay::new();
|
||||
let _ = asr_overlay.load_from_file(video_path);
|
||||
println!("ASR Overlay initialized: {}", !asr_overlay.is_empty());
|
||||
|
||||
let video_duration = get_video_duration(video_path);
|
||||
println!("Video duration: {:.1}s", video_duration);
|
||||
|
||||
let mut frame_count = 0u64;
|
||||
let frame_duration = Duration::from_millis(33);
|
||||
let mut paused = false;
|
||||
let mut current_time = 0.0;
|
||||
let mut seek_request: Option<f64> = None;
|
||||
let fps = 30.0;
|
||||
|
||||
let mut asr_overlay_visible = false;
|
||||
|
||||
println!("Playing... (Press SPACE to pause, Q/ESC to quit, ←/→ to seek, A to toggle ASR, F for fullscreen)");
|
||||
|
||||
loop {
|
||||
let frame_start = Instant::now();
|
||||
|
||||
// Handle seek by restarting ffmpeg
|
||||
if let Some(seek_pos) = seek_request {
|
||||
seek_request = None;
|
||||
println!("\n⏩ Seeking to {:.1}s...", seek_pos);
|
||||
|
||||
// Kill old ffmpeg and restart with seek position
|
||||
let _ = ffmpeg.kill();
|
||||
|
||||
ffmpeg = std::process::Command::new(ffmpeg_path)
|
||||
.args([
|
||||
"-ss", &format!("{:.2}", seek_pos),
|
||||
"-i", video_path,
|
||||
"-vf", &format!(
|
||||
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2",
|
||||
width, height, width, height
|
||||
),
|
||||
"-pix_fmt", "rgb24",
|
||||
"-r", "30",
|
||||
"-f", "rawvideo",
|
||||
"-",
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to restart ffmpeg: {}", e))?;
|
||||
|
||||
let stdout = ffmpeg
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
|
||||
reader = BufReader::new(stdout);
|
||||
current_time = seek_pos;
|
||||
println!("▶ Resumed at {:.1}s", current_time);
|
||||
}
|
||||
|
||||
for event in event_pump.poll_iter() {
|
||||
match event {
|
||||
Event::Quit { .. } => {
|
||||
println!("\n👋 Quitting player");
|
||||
playing_clone.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
Event::KeyDown { keycode, .. } => match keycode {
|
||||
Some(Keycode::Q) | Some(Keycode::Escape) => {
|
||||
println!("\n👋 Quitting player");
|
||||
playing_clone.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
Some(Keycode::Space) => {
|
||||
paused = !paused;
|
||||
println!("{}", if paused { "⏸ Paused" } else { "▶ Playing" });
|
||||
}
|
||||
Some(Keycode::Left) => {
|
||||
let new_time = (current_time - 10.0).max(0.0);
|
||||
seek_request = Some(new_time);
|
||||
println!("⏪ Seek to {:.1}s", new_time);
|
||||
}
|
||||
Some(Keycode::Right) => {
|
||||
let new_time = current_time + 10.0;
|
||||
seek_request = Some(new_time);
|
||||
println!("⏩ Seek to {:.1}s", new_time);
|
||||
}
|
||||
Some(Keycode::Up) => {
|
||||
let new_time = (current_time - 60.0).max(0.0);
|
||||
seek_request = Some(new_time);
|
||||
println!("⏪ Seek to {:.1}s (1min)", new_time);
|
||||
}
|
||||
Some(Keycode::Down) => {
|
||||
let new_time = current_time + 60.0;
|
||||
seek_request = Some(new_time);
|
||||
println!("⏩ Seek to {:.1}s (+1min)", new_time);
|
||||
}
|
||||
Some(Keycode::A) => {
|
||||
// Toggle ASR Visibility
|
||||
asr_overlay_visible = !asr_overlay_visible;
|
||||
println!(
|
||||
"{}",
|
||||
if asr_overlay_visible {
|
||||
"🔊 ASR ON"
|
||||
} else {
|
||||
"🔇 ASR OFF"
|
||||
}
|
||||
);
|
||||
}
|
||||
Some(Keycode::F) => {
|
||||
println!("📺 Toggle fullscreen (not implemented in basic SDL2)");
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !playing_clone.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
if paused {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update ASR text based on current time
|
||||
if !asr_overlay.is_empty() {
|
||||
asr_overlay.update(current_time);
|
||||
}
|
||||
|
||||
match reader.read_exact(&mut frame_buffer) {
|
||||
Ok(_) => {
|
||||
texture
|
||||
.update(None, &frame_buffer, (width * 3) as usize)
|
||||
.map_err(|e| anyhow::anyhow!("Texture update failed: {}", e))?;
|
||||
|
||||
// Draw everything
|
||||
canvas.clear();
|
||||
|
||||
canvas
|
||||
.copy(&texture, None, None)
|
||||
.map_err(|e| anyhow::anyhow!("Render failed: {}", e))?;
|
||||
|
||||
// Draw ASR Text if visible and available
|
||||
if asr_overlay_visible && !asr_overlay.get_text().is_empty() {
|
||||
// Placeholder: Cannot use TTF functions directly here without font context.
|
||||
// For now, just printing to console to verify timing.
|
||||
// In a real implementation, load font and draw text here.
|
||||
println!("[ASR] {:.1}s: {}", current_time, asr_overlay.get_text());
|
||||
}
|
||||
|
||||
// Draw progress bar at bottom - gray background, green progress
|
||||
use sdl2::rect::Rect;
|
||||
let progress = if video_duration > 0.0 {
|
||||
(current_time / video_duration).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let bar_width = ((width as f64) * progress) as u32;
|
||||
|
||||
canvas.set_draw_color(sdl2::pixels::Color::RGB(50, 50, 50)); // Background
|
||||
let _ = canvas.fill_rect(Rect::new(0, height as i32 - 15, width, 5));
|
||||
if bar_width > 0 {
|
||||
canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 200, 0)); // Progress
|
||||
let _ = canvas.fill_rect(Rect::new(0, height as i32 - 15, bar_width, 5));
|
||||
}
|
||||
// Reset draw color to black for next frame
|
||||
canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 0, 0));
|
||||
|
||||
canvas.present();
|
||||
|
||||
frame_count += 1;
|
||||
current_time += 1.0 / fps;
|
||||
|
||||
let elapsed = frame_start.elapsed();
|
||||
if elapsed < frame_duration {
|
||||
thread::sleep(frame_duration - elapsed);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
println!(
|
||||
"\n📽️ End of video ({} frames, {:.1}s)",
|
||||
frame_count, current_time
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ffmpeg.kill();
|
||||
println!("✅ Playback finished (total: {:.1}s)", current_time);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_local_mode(external_player: &str) -> Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
// Find video path - skip all flags and get the first non-flag argument after them
|
||||
let video_path = args
|
||||
.iter()
|
||||
.skip(1) // Skip binary name
|
||||
.skip_while(|a| a.starts_with('-')) // Skip flags
|
||||
.next()
|
||||
.cloned();
|
||||
|
||||
let video_path = match video_path {
|
||||
Some(p) if !p.is_empty() => p,
|
||||
_ => {
|
||||
println!("Local Mode - Play local video files");
|
||||
println!("=====================================\n");
|
||||
print!("Enter video file path: ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let path = input.trim().to_string();
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("No video path provided");
|
||||
}
|
||||
path
|
||||
}
|
||||
};
|
||||
|
||||
if !Path::new(&video_path).exists() {
|
||||
anyhow::bail!("File not found: {}", video_path);
|
||||
}
|
||||
|
||||
println!("\nUsing external player: {}", external_player);
|
||||
println!("Playing: {}", video_path);
|
||||
|
||||
match external_player {
|
||||
"vlc" => {
|
||||
std::process::Command::new("open")
|
||||
.arg("-a")
|
||||
.arg("VLC")
|
||||
.arg(&video_path)
|
||||
.spawn()?;
|
||||
println!("✅ Opened with VLC");
|
||||
}
|
||||
"mpv" => {
|
||||
std::process::Command::new("mpv").arg(&video_path).spawn()?;
|
||||
println!("✅ Opened with mpv");
|
||||
}
|
||||
"ffplay" => {
|
||||
std::process::Command::new("ffplay")
|
||||
.arg("-autoexit")
|
||||
.arg(&video_path)
|
||||
.spawn()?;
|
||||
println!("✅ Opened with ffplay");
|
||||
}
|
||||
"sdl2" => {
|
||||
#[cfg(feature = "player")]
|
||||
return run_player_with_sdl2(&video_path, None);
|
||||
#[cfg(not(feature = "player"))]
|
||||
{
|
||||
println!("SDL2 player not enabled. Rebuild with --features player");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::process::Command::new(external_player)
|
||||
.arg(&video_path)
|
||||
.spawn()?;
|
||||
println!("✅ Opened with {}", external_player);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_online_mode() -> Result<()> {
|
||||
println!("\n===========================================");
|
||||
println!(" 🎬 Online Mode - Momentry");
|
||||
println!("===========================================\n");
|
||||
|
||||
let client = ApiClient::new();
|
||||
println!("Connected to API: {}", client.base_url());
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
loop {
|
||||
println!("\n┌─────────────────────────────────────────┐");
|
||||
println!("│ Online Mode Menu │");
|
||||
println!("├─────────────────────────────────────────┤");
|
||||
println!("│ [1] List Videos - 列出所有影片 │");
|
||||
println!("│ [2] Search - RAG 搜尋影片內容 │");
|
||||
println!("│ [3] Play - 播放影片 │");
|
||||
println!("│ [4] Lookup - 查詢影片資訊 │");
|
||||
println!("│ [q] Quit - 離開 │");
|
||||
println!("└─────────────────────────────────────────┘");
|
||||
print!("\n請選擇: ");
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let choice = input.trim();
|
||||
|
||||
match choice {
|
||||
"1" => {
|
||||
println!("\n=== 📋 影片列表 ===");
|
||||
match rt.block_on(client.list_videos()) {
|
||||
Ok(videos) => {
|
||||
if videos.is_empty() {
|
||||
println!("沒有找到任何影片");
|
||||
} else {
|
||||
println!("\n共 {} 部影片:\n", videos.len());
|
||||
for (i, v) in videos.iter().enumerate() {
|
||||
let duration = format!(
|
||||
"{}:{:02}",
|
||||
(v.duration / 60.0) as u32,
|
||||
(v.duration % 60.0) as u32
|
||||
);
|
||||
println!(
|
||||
" [{}] {} | {} | {}x{} | {}",
|
||||
i + 1,
|
||||
v.file_name,
|
||||
v.uuid.chars().take(8).collect::<String>(),
|
||||
v.width,
|
||||
v.height,
|
||||
duration
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("取得影片列表失敗: {}", e),
|
||||
}
|
||||
}
|
||||
"2" => {
|
||||
println!("\n=== 🔍 RAG 搜尋 ===");
|
||||
print!("輸入搜尋關鍵字: ");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let query = input.trim().to_string();
|
||||
if query.is_empty() {
|
||||
println!("搜尋關鍵字不能為空");
|
||||
continue;
|
||||
}
|
||||
|
||||
print!("限定特定影片?(y/N): ");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let limit_uuid = if input.trim().to_lowercase() == "y" {
|
||||
print!("輸入影片 UUID: ");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Some(input.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
println!("\n搜尋中...");
|
||||
match rt.block_on(client.search_chunks(&query, limit_uuid.as_deref(), Some(10))) {
|
||||
Ok(response) => {
|
||||
if response.results.is_empty() {
|
||||
println!("沒有找到結果");
|
||||
continue;
|
||||
}
|
||||
println!("\n找到 {} 個結果:\n", response.results.len());
|
||||
for (i, r) in response.results.iter().enumerate() {
|
||||
let time_range = format!(
|
||||
"{:02}:{:02} - {:02}:{:02}",
|
||||
(r.start_time / 60.0) as u32,
|
||||
(r.start_time % 60.0) as u32,
|
||||
(r.end_time / 60.0) as u32,
|
||||
(r.end_time % 60.0) as u32
|
||||
);
|
||||
let text_preview = if r.text.len() > 50 {
|
||||
format!("{}...", &r.text[..50])
|
||||
} else {
|
||||
r.text.clone()
|
||||
};
|
||||
println!(
|
||||
" [{}] {} | {} | {:.2} | {}",
|
||||
i + 1,
|
||||
time_range,
|
||||
r.uuid.chars().take(8).collect::<String>(),
|
||||
r.score,
|
||||
text_preview
|
||||
);
|
||||
}
|
||||
|
||||
let mut current_player: Option<std::process::Child> = None;
|
||||
|
||||
loop {
|
||||
if let Some(ref mut child) = current_player {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
println!("播放器已結束");
|
||||
current_player = None;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 還在執行中
|
||||
}
|
||||
Err(e) => {
|
||||
println!("檢查播放器狀態失敗:{}", e);
|
||||
current_player = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print!(
|
||||
"\n選擇播放 (1-{}) 或 q 離開 (kill player), L 重新顯示列表:",
|
||||
response.results.len()
|
||||
);
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let selection = input.trim();
|
||||
let selection_lower = selection.to_lowercase();
|
||||
if selection_lower == "q" {
|
||||
if let Some(ref mut child) = current_player {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
println!("已終止播放器");
|
||||
current_player = None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if selection_lower == "l" {
|
||||
println!("\n搜尋結果:");
|
||||
for (i, r) in response.results.iter().enumerate() {
|
||||
let time_range = format!(
|
||||
"{:02}:{:02} - {:02}:{:02}",
|
||||
(r.start_time / 60.0) as u32,
|
||||
(r.start_time % 60.0) as u32,
|
||||
(r.end_time / 60.0) as u32,
|
||||
(r.end_time % 60.0) as u32
|
||||
);
|
||||
let text_preview = if r.text.len() > 50 {
|
||||
format!("{}...", &r.text[..50])
|
||||
} else {
|
||||
r.text.clone()
|
||||
};
|
||||
println!(
|
||||
" [{}] {} | {} | {:.2} | {}",
|
||||
i + 1,
|
||||
time_range,
|
||||
r.uuid.chars().take(8).collect::<String>(),
|
||||
r.score,
|
||||
text_preview
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Ok(idx) = selection.parse::<usize>() {
|
||||
if idx > 0 && idx <= response.results.len() {
|
||||
let selected = &response.results[idx - 1];
|
||||
println!("\n播放:{} - {}", selected.uuid, selected.text);
|
||||
|
||||
if let Some(ref mut child) = current_player {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
println!("已終止前一個播放器");
|
||||
}
|
||||
|
||||
match rt.block_on(client.lookup_video(&selected.uuid)) {
|
||||
Ok(info) => {
|
||||
if let Some(path) = &info.file_path {
|
||||
if std::path::Path::new(path).exists() {
|
||||
let start_sec =
|
||||
(selected.start_time as f64) - 2.0;
|
||||
let end_sec = (selected.end_time as f64) + 2.0;
|
||||
println!(
|
||||
"開啟:{} (從 {:.0} 到 {:.0} 秒,A-B 循環)",
|
||||
path, start_sec, end_sec
|
||||
);
|
||||
println!("提示:mpv 視窗中按 c/C 切換循環,q 離開,Space 暫停");
|
||||
current_player = Some(
|
||||
std::process::Command::new("mpv")
|
||||
.arg(format!(
|
||||
"--start={:.2}",
|
||||
start_sec.max(0.0)
|
||||
))
|
||||
.arg(format!(
|
||||
"--ab-loop-a={:.2}",
|
||||
start_sec.max(0.0)
|
||||
))
|
||||
.arg(format!("--ab-loop-b={:.2}", end_sec))
|
||||
.arg("--input-commands=bind c ab-loop; bind C ab-loop")
|
||||
.arg(path)
|
||||
.spawn()?
|
||||
);
|
||||
} else {
|
||||
println!("錯誤:檔案不存在:{}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("查詢失敗:{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("搜尋失敗:{}", e),
|
||||
}
|
||||
}
|
||||
"4" => {
|
||||
println!("\n=== 🔎 查詢影片 ===");
|
||||
print!("輸入影片 UUID (直接 Enter 從列表選擇): ");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let uuid = input.trim();
|
||||
|
||||
if uuid.is_empty() {
|
||||
println!("載入影片列表...");
|
||||
match rt.block_on(client.list_videos()) {
|
||||
Ok(videos) => {
|
||||
if videos.is_empty() {
|
||||
println!("沒有影片");
|
||||
continue;
|
||||
}
|
||||
println!("\n選擇影片:");
|
||||
for (i, v) in videos.iter().enumerate() {
|
||||
println!(" [{}] {} ({})", i + 1, v.file_name, v.uuid);
|
||||
}
|
||||
print!("\n選擇編號:");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if let Ok(idx) = input.trim().parse::<usize>() {
|
||||
if idx > 0 && idx <= videos.len() {
|
||||
let selected = &videos[idx - 1];
|
||||
println!("\n查詢中...");
|
||||
match rt.block_on(client.lookup_video(&selected.uuid)) {
|
||||
Ok(info) => {
|
||||
println!("\n✓ 找到影片:");
|
||||
println!(" UUID: {}", info.uuid);
|
||||
if let Some(path) = &info.file_path {
|
||||
println!(" 路徑:{}", path);
|
||||
}
|
||||
if let Some(name) = &info.file_name {
|
||||
println!(" 名稱:{}", name);
|
||||
}
|
||||
if let Some(dur) = info.duration {
|
||||
println!(" 時長:{:.2}s", dur);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("查詢失敗:{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("取得影片列表失敗:{}", e),
|
||||
}
|
||||
} else {
|
||||
println!("\n查詢中...");
|
||||
match rt.block_on(client.lookup_video(uuid)) {
|
||||
Ok(info) => {
|
||||
println!("\n✓ 找到影片:");
|
||||
println!(" UUID: {}", info.uuid);
|
||||
if let Some(path) = &info.file_path {
|
||||
println!(" 路徑:{}", path);
|
||||
}
|
||||
if let Some(name) = &info.file_name {
|
||||
println!(" 名稱:{}", name);
|
||||
}
|
||||
if let Some(dur) = info.duration {
|
||||
println!(" 時長:{:.2}s", dur);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("查詢失敗:{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
"3" => {
|
||||
println!("\n=== ▶ 播放影片 ===");
|
||||
print!("輸入影片 UUID (直接 Enter 從列表選擇): ");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let uuid = input.trim();
|
||||
|
||||
if uuid.is_empty() {
|
||||
println!("載入影片列表...");
|
||||
match rt.block_on(client.list_videos()) {
|
||||
Ok(videos) => {
|
||||
if videos.is_empty() {
|
||||
println!("沒有影片");
|
||||
continue;
|
||||
}
|
||||
println!("\n選擇影片:");
|
||||
for (i, v) in videos.iter().enumerate() {
|
||||
println!(" [{}] {} ({})", i + 1, v.file_name, v.uuid);
|
||||
}
|
||||
print!("\n選擇編號:");
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if let Ok(idx) = input.trim().parse::<usize>() {
|
||||
if idx > 0 && idx <= videos.len() {
|
||||
let selected = &videos[idx - 1];
|
||||
println!("\n播放: {}", selected.file_path);
|
||||
if std::path::Path::new(&selected.file_path).exists() {
|
||||
std::process::Command::new("mpv")
|
||||
.arg(&selected.file_path)
|
||||
.spawn()?;
|
||||
} else {
|
||||
println!("錯誤:檔案不存在:{}", selected.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("取得影片列表失敗:{}", e),
|
||||
}
|
||||
} else {
|
||||
match rt.block_on(client.lookup_video(uuid)) {
|
||||
Ok(info) => {
|
||||
if let Some(path) = &info.file_path {
|
||||
println!("開啟: {}", path);
|
||||
if std::path::Path::new(path).exists() {
|
||||
std::process::Command::new("mpv").arg(path).spawn()?;
|
||||
} else {
|
||||
println!("錯誤:檔案不存在:{}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("查詢失敗:{}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
"q" | "Q" => {
|
||||
println!("\n👋 再見!");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("無效選項");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -523,17 +1251,37 @@ fn main() -> Result<()> {
|
||||
let should_download = args.iter().any(|a| a == "-d" || a == "--download");
|
||||
let show_selector = args.iter().any(|a| a == "-s" || a == "--selector");
|
||||
let test_api_mode = args.iter().any(|a| a == "-t" || a == "--test-api");
|
||||
let local_mode = args.iter().any(|a| a == "-l" || a == "--local");
|
||||
let online_mode = args.iter().any(|a| a == "-o" || a == "--online");
|
||||
|
||||
// Get external player choice
|
||||
let external_player = args
|
||||
.iter()
|
||||
.position(|a| a == "-p" || a == "--player")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "vlc".to_string());
|
||||
|
||||
// API Testing Mode
|
||||
if test_api_mode {
|
||||
return run_api_test_mode();
|
||||
}
|
||||
|
||||
// If --selector flag is provided, show video selector
|
||||
// If --selector flag is provided, show video selector (online mode)
|
||||
if show_selector {
|
||||
return run_selector();
|
||||
}
|
||||
|
||||
// If --online or -o is provided, run online mode
|
||||
if online_mode {
|
||||
return run_online_mode();
|
||||
}
|
||||
|
||||
// If --local or -l is provided, run local mode with external player
|
||||
if local_mode {
|
||||
return run_local_mode(&external_player);
|
||||
}
|
||||
|
||||
let video_path = if args.len() < 2 || (should_download && args.len() < 3) {
|
||||
println!("Video Player\n============\nEnter video path or YouTube URL:");
|
||||
let mut input = String::new();
|
||||
|
||||
@@ -4,6 +4,7 @@ use futures_util::StreamExt;
|
||||
use std::path::Path;
|
||||
use std::str;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use momentry_core::core::api_key::{ApiKeyService, ApiKeyType};
|
||||
use momentry_core::core::chunk::types::{Chunk, ChunkRule, ChunkType};
|
||||
@@ -1813,6 +1814,64 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// Read Pose JSON (optional)
|
||||
let pose_path = format!("{}.pose.json", uuid);
|
||||
let pose_result = match std::fs::read_to_string(&pose_path) {
|
||||
Ok(pose_json) => match serde_json::from_str::<
|
||||
momentry_core::core::processor::pose::PoseResult,
|
||||
>(&pose_json)
|
||||
{
|
||||
Ok(result) => {
|
||||
println!("Loaded Pose: {} frames", result.frames.len());
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to parse Pose JSON: {}. Skipping Pose.", e);
|
||||
momentry_core::core::processor::pose::PoseResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Warning: Pose file not found. Skipping Pose.");
|
||||
momentry_core::core::processor::pose::PoseResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read ASRX JSON (optional)
|
||||
let asrx_path = format!("{}.asrx.json", uuid);
|
||||
let asrx_result = match std::fs::read_to_string(&asrx_path) {
|
||||
Ok(asrx_json) => match serde_json::from_str::<
|
||||
momentry_core::core::processor::asrx::AsrxResult,
|
||||
>(&asrx_json)
|
||||
{
|
||||
Ok(result) => {
|
||||
println!("Loaded ASRX: {} segments", result.segments.len());
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to parse ASRX JSON: {}. Skipping ASRX.", e);
|
||||
momentry_core::core::processor::asrx::AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("Warning: ASRX file not found. Skipping ASRX.");
|
||||
momentry_core::core::processor::asrx::AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Store pre_chunks (from ASR, CUT) ==========
|
||||
|
||||
println!("\nStoring pre_chunks...");
|
||||
@@ -1930,12 +1989,21 @@ async fn main() -> Result<()> {
|
||||
face_by_frame.insert(frame.frame, frame.clone());
|
||||
}
|
||||
|
||||
// Store frames (merge data from YOLO, OCR, Face)
|
||||
let mut pose_by_frame: std::collections::HashMap<
|
||||
u64,
|
||||
momentry_core::core::processor::pose::PoseFrame,
|
||||
> = std::collections::HashMap::new();
|
||||
for frame in &pose_result.frames {
|
||||
pose_by_frame.insert(frame.frame, frame.clone());
|
||||
}
|
||||
|
||||
// Store frames (merge data from YOLO, OCR, Face, Pose)
|
||||
let mut all_frames: Vec<u64> = frame_data
|
||||
.keys()
|
||||
.cloned()
|
||||
.chain(ocr_by_frame.keys().cloned())
|
||||
.chain(face_by_frame.keys().cloned())
|
||||
.chain(pose_by_frame.keys().cloned())
|
||||
.collect();
|
||||
all_frames.sort();
|
||||
all_frames.dedup();
|
||||
@@ -1945,6 +2013,7 @@ async fn main() -> Result<()> {
|
||||
let yolo_frame = frame_data.get(frame_num);
|
||||
let ocr_frame = ocr_by_frame.get(frame_num);
|
||||
let face_frame = face_by_frame.get(frame_num);
|
||||
let pose_frame = pose_by_frame.get(frame_num);
|
||||
|
||||
let frame = momentry_core::core::db::postgres_db::Frame {
|
||||
id: 0,
|
||||
@@ -1955,6 +2024,7 @@ async fn main() -> Result<()> {
|
||||
yolo_objects: yolo_frame.map(|f| serde_json::json!(&f.objects)),
|
||||
ocr_results: ocr_frame.map(|f| serde_json::json!(&f.texts)),
|
||||
face_results: face_frame.map(|f| serde_json::json!(&f.faces)),
|
||||
pose_results: pose_frame.map(|f| serde_json::json!(&f.persons)),
|
||||
frame_path: None,
|
||||
created_at: String::new(),
|
||||
};
|
||||
@@ -1968,10 +2038,30 @@ async fn main() -> Result<()> {
|
||||
println!("\nCreating chunks...");
|
||||
|
||||
// Rule 1: Direct conversion (sentence pre_chunk -> sentence chunk)
|
||||
// Merge ASRX speaker_id by time overlap
|
||||
let mut sentence_chunks = Vec::new();
|
||||
for (i, seg) in asr_result.segments.iter().enumerate() {
|
||||
let pre_chunk_id = asr_pre_chunk_ids.get(i).copied().unwrap_or(0);
|
||||
let chunk = Chunk::from_seconds(
|
||||
|
||||
// Find matching ASRX segment by time overlap
|
||||
let speaker_id = asrx_result
|
||||
.segments
|
||||
.iter()
|
||||
.find(|ax| ax.start <= seg.end && ax.end >= seg.start)
|
||||
.and_then(|ax| ax.speaker_id.clone());
|
||||
|
||||
let content = if let Some(ref sid) = speaker_id {
|
||||
serde_json::json!({
|
||||
"text": seg.text,
|
||||
"speaker_id": sid,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"text": seg.text,
|
||||
})
|
||||
};
|
||||
|
||||
let mut chunk = Chunk::from_seconds(
|
||||
file_id as i32,
|
||||
uuid.clone(),
|
||||
i as u32,
|
||||
@@ -1980,15 +2070,39 @@ async fn main() -> Result<()> {
|
||||
seg.start,
|
||||
seg.end,
|
||||
fps,
|
||||
serde_json::json!({
|
||||
"text": seg.text,
|
||||
}),
|
||||
content,
|
||||
)
|
||||
.with_text_content(seg.text.clone())
|
||||
.with_pre_chunk_ids(vec![pre_chunk_id as i32]);
|
||||
|
||||
if speaker_id.is_some() {
|
||||
chunk = chunk.with_metadata(serde_json::json!({
|
||||
"language": asr_result.language,
|
||||
"language_probability": asr_result.language_probability,
|
||||
"speaker_matched": true,
|
||||
}));
|
||||
}
|
||||
|
||||
sentence_chunks.push(chunk);
|
||||
}
|
||||
|
||||
if !asrx_result.segments.is_empty() {
|
||||
let matched = sentence_chunks
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.content
|
||||
.get("speaker_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
println!(
|
||||
" ASRX merge: {}/{} sentence chunks matched to speakers",
|
||||
matched,
|
||||
sentence_chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 1: CUT chunks
|
||||
let mut cut_chunks = Vec::new();
|
||||
for (i, scene) in cut_result.scenes.iter().enumerate() {
|
||||
@@ -2405,6 +2519,20 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
Commands::Server { host, port } => {
|
||||
// Start Auto-Ingest Watcher
|
||||
info!("Starting Auto-Ingest Watcher...");
|
||||
let _watcher = match momentry_core::watcher::run_watcher().await {
|
||||
Ok(w) => {
|
||||
info!("Auto-Ingest Watcher started successfully.");
|
||||
Some(w)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to start Auto-Ingest Watcher: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
// The watcher is kept alive by '_watcher' variable until the server stops.
|
||||
|
||||
let port = port.unwrap_or_else(|| *momentry_core::core::config::SERVER_PORT);
|
||||
momentry_core::api::start_server(&host, port).await?;
|
||||
Ok(())
|
||||
@@ -2461,13 +2589,13 @@ async fn main() -> Result<()> {
|
||||
Commands::Thumbnails { uuid, count } => {
|
||||
let db = PostgresDb::init().await?;
|
||||
|
||||
let videos = if let Some(ref uuid) = uuid {
|
||||
let videos = if let Some(ref u) = uuid {
|
||||
vec![db
|
||||
.get_video_by_uuid(uuid)
|
||||
.get_video_by_uuid(u)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Video not found: {}", uuid))?]
|
||||
.ok_or_else(|| anyhow::anyhow!("Video not found: {}", u))?]
|
||||
} else {
|
||||
db.list_videos().await?
|
||||
db.list_videos(10000, 0).await?.0
|
||||
};
|
||||
|
||||
let output_dir = std::path::PathBuf::from("thumbnails");
|
||||
@@ -2484,12 +2612,10 @@ async fn main() -> Result<()> {
|
||||
println!(" Generated {} thumbnails", result.count);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Error: {}", e);
|
||||
eprintln!(" Failed to generate thumbnails: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nThumbnails generated successfully!");
|
||||
Ok(())
|
||||
}
|
||||
Commands::Status { uuid } => {
|
||||
@@ -2501,7 +2627,7 @@ async fn main() -> Result<()> {
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Video not found: {}", u))?]
|
||||
} else {
|
||||
db.list_videos().await?
|
||||
db.list_videos(10000, 0).await?.0
|
||||
};
|
||||
|
||||
println!("\n╔══════════════════════════════════════════════════════════════════════════════════╗");
|
||||
@@ -2513,6 +2639,22 @@ async fn main() -> Result<()> {
|
||||
"║ {:32} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} ║",
|
||||
"Video", "FS", "FS", "PSQL", "PObj", "MObj", "PVec", "QVec"
|
||||
);
|
||||
println!(
|
||||
"╠{:33}╪{:9}╪{:9}╪{:9}╪{:9}╪{:9}╪{:9}╪{:9}╣",
|
||||
str::repeat("─", 32),
|
||||
str::repeat("─", 8),
|
||||
str::repeat("─", 8),
|
||||
str::repeat("─", 8),
|
||||
str::repeat("─", 8),
|
||||
str::repeat("─", 8),
|
||||
str::repeat("─", 8),
|
||||
str::repeat("─", 8)
|
||||
);
|
||||
println!("╠══════════════════════════════════════════════════════════════════════════════════╣");
|
||||
println!(
|
||||
"║ {:32} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} ║",
|
||||
"Video", "FS", "FS", "PSQL", "PObj", "MObj", "PVec", "QVec"
|
||||
);
|
||||
println!(
|
||||
"║ {:32} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} ║",
|
||||
"", "Video", "JSON", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod watcher;
|
||||
|
||||
pub use watcher::{watch_directories, WatcherConfig};
|
||||
pub use watcher::{run_watcher, WatcherConfig};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use anyhow::Result;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
use crate::core::ingestion::IngestionService;
|
||||
|
||||
pub struct WatcherConfig {
|
||||
pub directories: Vec<String>,
|
||||
@@ -11,31 +14,94 @@ pub struct WatcherConfig {
|
||||
|
||||
impl Default for WatcherConfig {
|
||||
fn default() -> Self {
|
||||
// Default to SFTP demo directory if not specified
|
||||
let default_dir = std::env::var("MOMENTRY_SFTP_ROOT")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data/demo/".to_string());
|
||||
|
||||
Self {
|
||||
directories: vec![],
|
||||
poll_interval_ms: 5000,
|
||||
directories: vec![default_dir],
|
||||
poll_interval_ms: 60000, // 60 seconds polling interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn watch_directories(config: WatcherConfig, tx: mpsc::Sender<String>) -> Result<()> {
|
||||
// TODO: Implement directory watcher
|
||||
//
|
||||
// Options:
|
||||
// 1. Use notify crate for file system events
|
||||
// 2. Use polling as fallback
|
||||
//
|
||||
// When new video file is detected:
|
||||
// - Send job to Redis queue
|
||||
// - Trigger registration process
|
||||
/// Starts the file watcher in the background.
|
||||
/// Scans directories for video files and registers them if not already present.
|
||||
pub async fn run_watcher() -> Result<()> {
|
||||
let config = WatcherConfig::default();
|
||||
let dirs = config.directories.clone();
|
||||
|
||||
println!("Watching directories: {:?}", config.directories);
|
||||
|
||||
for dir in &config.directories {
|
||||
if Path::new(dir).exists() {
|
||||
println!("Directory exists: {}", dir);
|
||||
}
|
||||
if dirs.is_empty() {
|
||||
warn!("No directories configured for watching.");
|
||||
return Err(anyhow::anyhow!("No watch directories"));
|
||||
}
|
||||
|
||||
info!("Initializing Database for Watcher...");
|
||||
// Use Database::init() which handles config and pool creation
|
||||
let db = PostgresDb::init().await?;
|
||||
let service = Arc::new(IngestionService::new(db));
|
||||
|
||||
info!("Starting Ingestion Poller for: {:?}", dirs);
|
||||
|
||||
// Spawn background task
|
||||
tokio::spawn(async move {
|
||||
let mut interval = time::interval(time::Duration::from_millis(config.poll_interval_ms));
|
||||
|
||||
// Run once immediately on startup to catch existing files
|
||||
scan_and_ingest(&dirs, &service).await;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
scan_and_ingest(&dirs, &service).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn scan_and_ingest(directories: &[String], service: &Arc<IngestionService>) {
|
||||
// Allowed extensions list
|
||||
let allowed_extensions = vec!["mp4", "mov", "mkv"];
|
||||
|
||||
info!("Scanning directories for new videos...");
|
||||
|
||||
for dir in directories {
|
||||
let path = Path::new(dir);
|
||||
if !path.exists() {
|
||||
warn!("Directory does not exist, skipping: {}", dir);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let file_path = entry.path();
|
||||
if file_path.is_file() {
|
||||
// Check extension
|
||||
let is_video = if let Some(ext) = file_path.extension().and_then(|e| e.to_str())
|
||||
{
|
||||
allowed_extensions.contains(&ext.to_lowercase().as_str())
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_video {
|
||||
if let Some(p_str) = file_path.to_str() {
|
||||
// Try to ingest. The service checks if it already exists.
|
||||
match service.ingest(p_str).await {
|
||||
Ok(Some(uuid)) => {
|
||||
info!("Auto-registered: {} -> {}", file_path.display(), uuid);
|
||||
}
|
||||
Ok(None) => {
|
||||
// Already registered
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to ingest {}: {}", file_path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::core::chunk::{rule1_ingest, rule3_ingest};
|
||||
use crate::core::db::{
|
||||
MonitorJobStatus, PostgresDb, ProcessorJobStatus, ProcessorType, RedisClient, VideoStatus,
|
||||
};
|
||||
@@ -210,12 +211,58 @@ impl JobWorker {
|
||||
.map(|r| r.processor_type.as_str().to_string())
|
||||
.collect();
|
||||
|
||||
// Check prerequisites for Rule 1 Chunking BEFORE moving arrays
|
||||
let has_asr = completed_processors.iter().any(|p| p == "asr");
|
||||
let has_asrx = completed_processors.iter().any(|p| p == "asrx");
|
||||
let has_cut = completed_processors.iter().any(|p| p == "cut");
|
||||
|
||||
// Update processor arrays in job record
|
||||
self.db
|
||||
.update_job_processors_arrays(job_id, completed_processors, failed_processors)
|
||||
.await?;
|
||||
|
||||
if all_completed && !any_failed {
|
||||
// 🚀 P1 Trigger: Rule 1 Chunking
|
||||
if has_asr && has_asrx {
|
||||
info!("📝 Prerequisites met for Rule 1 Chunking. Starting ingestion...");
|
||||
let db_clone = self.db.clone();
|
||||
let uuid_clone = uuid.to_string();
|
||||
tokio::spawn(async move {
|
||||
match db_clone.get_video_by_uuid(&uuid_clone).await {
|
||||
Ok(Some(video)) => {
|
||||
let fps = video.fps;
|
||||
match rule1_ingest::ingest_rule1(db_clone.pool(), &uuid_clone, fps)
|
||||
.await
|
||||
{
|
||||
Ok(count) => info!(
|
||||
"✅ Rule 1 Ingestion completed: {} chunks inserted.",
|
||||
count
|
||||
),
|
||||
Err(e) => error!("❌ Rule 1 Ingestion failed: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(None) => error!("Video not found for chunking: {}", uuid_clone),
|
||||
Err(e) => error!("Failed to get video info for chunking: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🚀 P1 Trigger: Rule 3 Scene Chunking
|
||||
if has_cut && has_asr {
|
||||
info!("📝 Prerequisites met for Rule 3 Scene Chunking. Starting ingestion...");
|
||||
let db_clone = self.db.clone();
|
||||
let uuid_clone = uuid.to_string();
|
||||
tokio::spawn(async move {
|
||||
match rule3_ingest::ingest_rule3(db_clone.pool(), &uuid_clone).await {
|
||||
Ok(count) => info!(
|
||||
"✅ Rule 3 Scene Ingestion completed: {} scenes processed.",
|
||||
count
|
||||
),
|
||||
Err(e) => error!("❌ Rule 3 Scene Ingestion failed: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.db
|
||||
.update_job_status(job_id, MonitorJobStatus::Completed)
|
||||
.await?;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::core::processor::cut::CutResult;
|
||||
use crate::core::processor::face::FaceResult;
|
||||
use crate::core::processor::ocr::OcrResult;
|
||||
use crate::core::processor::pose::PoseResult;
|
||||
use crate::core::processor::visual_chunk::VisualChunkResult;
|
||||
use crate::core::processor::yolo::YoloResult;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -302,6 +303,24 @@ impl ProcessorPool {
|
||||
}
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
ProcessorType::VisualChunk => {
|
||||
let result = processor::process_visual_chunk_advanced(
|
||||
video_path,
|
||||
output_path.to_str().unwrap(),
|
||||
uuid,
|
||||
)
|
||||
.await?;
|
||||
// Store VisualChunk chunks in database
|
||||
tracing::info!(
|
||||
"VisualChunk completed, storing {} chunks for {}",
|
||||
result.chunk_count,
|
||||
job.uuid
|
||||
);
|
||||
if let Err(e) = Self::store_visual_chunk_chunks(db, &job.uuid, &result).await {
|
||||
tracing::error!("Failed to store VisualChunk chunks for {}: {}", job.uuid, e);
|
||||
}
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +624,13 @@ impl ProcessorPool {
|
||||
// Override chunk_id to include processor prefix for uniqueness
|
||||
chunk.chunk_id = format!("trace_yolo_{:04}", i);
|
||||
|
||||
// Populate text_content for BM25 search
|
||||
let object_names: Vec<String> =
|
||||
frame.objects.iter().map(|o| o.class_name.clone()).collect();
|
||||
if !object_names.is_empty() {
|
||||
chunk = chunk.with_text_content(object_names.join(" "));
|
||||
}
|
||||
|
||||
match db.store_chunk(&chunk).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
@@ -660,6 +686,12 @@ impl ProcessorPool {
|
||||
// Override chunk_id to include processor prefix for uniqueness
|
||||
chunk.chunk_id = format!("trace_ocr_{:04}", i);
|
||||
|
||||
// Populate text_content for BM25 search
|
||||
let texts: Vec<String> = frame.texts.iter().map(|t| t.text.clone()).collect();
|
||||
if !texts.is_empty() {
|
||||
chunk = chunk.with_text_content(texts.join(" "));
|
||||
}
|
||||
|
||||
match db.store_chunk(&chunk).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
@@ -715,6 +747,16 @@ impl ProcessorPool {
|
||||
// Override chunk_id to include processor prefix for uniqueness
|
||||
chunk.chunk_id = format!("trace_face_{:04}", i);
|
||||
|
||||
// Populate text_content for BM25 search (face IDs)
|
||||
let face_ids: Vec<String> = frame
|
||||
.faces
|
||||
.iter()
|
||||
.filter_map(|f| f.face_id.clone())
|
||||
.collect();
|
||||
if !face_ids.is_empty() {
|
||||
chunk = chunk.with_text_content(face_ids.join(" "));
|
||||
}
|
||||
|
||||
match db.store_chunk(&chunk).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
@@ -770,6 +812,16 @@ impl ProcessorPool {
|
||||
// Override chunk_id to include processor prefix for uniqueness
|
||||
chunk.chunk_id = format!("trace_pose_{:04}", i);
|
||||
|
||||
// Populate text_content for BM25 search (person count indicator)
|
||||
let person_count = frame.persons.len();
|
||||
if person_count > 0 {
|
||||
let text = format!("person person person")
|
||||
.repeat(person_count.min(10))
|
||||
.trim()
|
||||
.to_string();
|
||||
chunk = chunk.with_text_content(text);
|
||||
}
|
||||
|
||||
match db.store_chunk(&chunk).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
@@ -825,6 +877,16 @@ impl ProcessorPool {
|
||||
// Override chunk_id to include processor prefix for uniqueness
|
||||
chunk.chunk_id = format!("trace_asrx_{:04}", i);
|
||||
|
||||
// Populate text_content for BM25 search (already has text)
|
||||
chunk = chunk.with_text_content(segment.text.clone());
|
||||
|
||||
// Also store speaker_id in content
|
||||
chunk.content = serde_json::json!({
|
||||
"text": segment.text,
|
||||
"speaker_id": segment.speaker_id,
|
||||
"timestamp": segment.start,
|
||||
});
|
||||
|
||||
match db.store_chunk(&chunk).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("Stored ASRX chunk {} for video {}", i, uuid);
|
||||
@@ -837,6 +899,24 @@ impl ProcessorPool {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn store_visual_chunk_chunks(
|
||||
db: &PostgresDb,
|
||||
uuid: &str,
|
||||
visual_chunk_result: &VisualChunkResult,
|
||||
) -> Result<()> {
|
||||
for (i, chunk) in visual_chunk_result.chunks.iter().enumerate() {
|
||||
match db.store_chunk(chunk).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("Stored VisualChunk chunk {} for video {}", i, uuid);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to store VisualChunk chunk {}: {}", i, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_running_count(&self) -> usize {
|
||||
*self.running_count.read().await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user