feat: Initial v0.9 release with API Key authentication
## v0.9.20260325_144654 ### Features - API Key Authentication System - Job Worker System - V2 Backup Versioning ### Bug Fixes - get_processor_results_by_job column mapping Co-authored-by: OpenCode
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const ASR_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AsrResult {
|
||||
@@ -17,53 +20,33 @@ pub struct AsrSegment {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn process_asr(video_path: &str, output_path: &str) -> Result<AsrResult> {
|
||||
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("scripts")
|
||||
.join("asr_processor.py");
|
||||
pub async fn process_asr(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<AsrResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("asr_processor.py");
|
||||
|
||||
let venv_python = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("venv")
|
||||
.join("bin")
|
||||
.join("python");
|
||||
tracing::info!("[ASR] Starting ASR processing: {}", video_path);
|
||||
|
||||
println!("[ASR] Starting ASR processing...");
|
||||
println!("[ASR] Video: {}", video_path);
|
||||
|
||||
let output = Command::new(venv_python)
|
||||
.arg(script_path)
|
||||
.arg(video_path)
|
||||
.arg(output_path)
|
||||
.output()
|
||||
.context("Failed to run ASR processor")?;
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
for line in stderr.lines() {
|
||||
if line.starts_with("ASR_START") {
|
||||
println!("[ASR] Loading model...");
|
||||
} else if line.starts_with("ASR_LANGUAGE:") {
|
||||
let lang = line.trim_start_matches("ASR_LANGUAGE:");
|
||||
println!("[ASR] Detected language: {}", lang);
|
||||
} else if line.starts_with("ASR_PROGRESS:") {
|
||||
let count = line.trim_start_matches("ASR_PROGRESS:");
|
||||
println!("[ASR] Processed {} segments...", count);
|
||||
} else if line.starts_with("ASR_COMPLETE:") {
|
||||
let count = line.trim_start_matches("ASR_COMPLETE:");
|
||||
println!("[ASR] Completed! Total: {} segments", count);
|
||||
}
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("ASR failed: {}", stderr);
|
||||
}
|
||||
executor
|
||||
.run(
|
||||
"asr_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"ASR",
|
||||
Some(ASR_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read ASR output")?;
|
||||
|
||||
let result: AsrResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse ASR output")?;
|
||||
|
||||
println!(
|
||||
tracing::info!(
|
||||
"[ASR] Result: {} segments, language: {:?}",
|
||||
result.segments.len(),
|
||||
result.language
|
||||
@@ -71,3 +54,72 @@ pub async fn process_asr(video_path: &str, output_path: &str) -> Result<AsrResul
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_asr_result_serialization() {
|
||||
let result = AsrResult {
|
||||
language: Some("en".to_string()),
|
||||
language_probability: Some(0.95),
|
||||
segments: vec![
|
||||
AsrSegment {
|
||||
start: 0.0,
|
||||
end: 2.5,
|
||||
text: "Hello world".to_string(),
|
||||
},
|
||||
AsrSegment {
|
||||
start: 2.5,
|
||||
end: 5.0,
|
||||
text: "Test speech".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("Hello world"));
|
||||
assert!(json.contains("en"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asr_result_deserialization() {
|
||||
let json = r#"{
|
||||
"language": "zh",
|
||||
"language_probability": 0.98,
|
||||
"segments": [
|
||||
{"start": 0.0, "end": 1.5, "text": "測試"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: AsrResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.language, Some("zh".to_string()));
|
||||
assert_eq!(result.language_probability, Some(0.98));
|
||||
assert_eq!(result.segments.len(), 1);
|
||||
assert_eq!(result.segments[0].text, "測試");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asr_segment_default() {
|
||||
let segment = AsrSegment {
|
||||
start: 0.0,
|
||||
end: 1.0,
|
||||
text: String::new(),
|
||||
};
|
||||
assert_eq!(segment.start, 0.0);
|
||||
assert_eq!(segment.end, 1.0);
|
||||
assert!(segment.text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asr_result_empty_segments() {
|
||||
let result = AsrResult {
|
||||
language: None,
|
||||
language_probability: None,
|
||||
segments: vec![],
|
||||
};
|
||||
assert!(result.language.is_none());
|
||||
assert!(result.segments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const ASRX_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AsrxResult {
|
||||
pub language: Option<String>,
|
||||
pub segments: Vec<AsrxSegment>,
|
||||
}
|
||||
|
||||
@@ -11,18 +19,130 @@ pub struct AsrxSegment {
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub text: String,
|
||||
pub speaker_id: String,
|
||||
pub speaker_embedding: Option<Vec<f32>>,
|
||||
pub speaker_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn process_asrx(video_path: &str, output_path: &str) -> Result<AsrxResult> {
|
||||
// TODO: Implement speaker diarization
|
||||
// Options:
|
||||
// 1. Use pyannote.audio
|
||||
// 2. Use whisperx
|
||||
// 3. Use Python subprocess
|
||||
pub async fn process_asrx(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<AsrxResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("asrx_processor.py");
|
||||
|
||||
println!("Processing speaker diarization for: {}", video_path);
|
||||
tracing::info!("[ASRX] Starting speaker diarization: {}", video_path);
|
||||
|
||||
Ok(AsrxResult { segments: vec![] })
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[ASRX] Script not found, returning empty result");
|
||||
return Ok(AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(executor.python_path());
|
||||
cmd.arg(&script_path).arg(video_path).arg(output_path);
|
||||
|
||||
if let Some(u) = uuid {
|
||||
cmd.arg("--uuid").arg(u);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
let child = cmd.spawn().context("Failed to run ASRX processor")?;
|
||||
|
||||
let output = match timeout(ASRX_TIMEOUT, child.wait_with_output()).await {
|
||||
Ok(Ok(output)) => output,
|
||||
Ok(Err(e)) => return Err(e).context("Failed to run ASRX processor"),
|
||||
Err(_) => anyhow::bail!("ASRX processing timed out after {:?}", ASRX_TIMEOUT),
|
||||
};
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
for line in stderr.lines() {
|
||||
if line.starts_with("ASRX_START") {
|
||||
tracing::info!("[ASRX] Loading model...");
|
||||
} else if line.starts_with("ASRX_PROGRESS:") {
|
||||
let count = line.trim_start_matches("ASRX_PROGRESS:");
|
||||
tracing::info!("[ASRX] Processed {} segments...", count);
|
||||
} else if line.starts_with("ASRX_COMPLETE:") {
|
||||
let count = line.trim_start_matches("ASRX_COMPLETE:");
|
||||
tracing::info!("[ASRX] Completed! Total: {} segments", count);
|
||||
}
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("ASRX failed: {}", stderr);
|
||||
}
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read ASRX output")?;
|
||||
|
||||
let result: AsrxResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse ASRX output")?;
|
||||
|
||||
tracing::info!("[ASRX] Result: {} segments", result.segments.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_asrx_result_serialization() {
|
||||
let result = AsrxResult {
|
||||
language: Some("en".to_string()),
|
||||
segments: vec![AsrxSegment {
|
||||
start: 0.0,
|
||||
end: 2.5,
|
||||
text: "Hello".to_string(),
|
||||
speaker_id: Some("SPEAKER_00".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("Hello"));
|
||||
assert!(json.contains("SPEAKER_00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asrx_result_deserialization() {
|
||||
let json = r#"{
|
||||
"language": "zh",
|
||||
"segments": [
|
||||
{"start": 0.0, "end": 1.5, "text": "測試", "speaker_id": "SPEAKER_01"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: AsrxResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.language, Some("zh".to_string()));
|
||||
assert_eq!(result.segments.len(), 1);
|
||||
assert_eq!(
|
||||
result.segments[0].speaker_id,
|
||||
Some("SPEAKER_01".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asrx_result_empty_segments() {
|
||||
let result = AsrxResult {
|
||||
language: None,
|
||||
segments: vec![],
|
||||
};
|
||||
assert!(result.segments.is_empty());
|
||||
assert!(result.language.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asrx_segment_times() {
|
||||
let segment = AsrxSegment {
|
||||
start: 0.0,
|
||||
end: 5.0,
|
||||
text: "Test".to_string(),
|
||||
speaker_id: None,
|
||||
};
|
||||
assert!(segment.end > segment.start);
|
||||
}
|
||||
}
|
||||
|
||||
77
src/core/processor/caption.rs
Normal file
77
src/core/processor/caption.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const CAPTION_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CaptionResult {
|
||||
pub video_path: String,
|
||||
pub total_frames: usize,
|
||||
pub captions: Vec<FrameCaption>,
|
||||
pub summary: CaptionSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FrameCaption {
|
||||
pub index: u32,
|
||||
pub timestamp: f64,
|
||||
pub caption: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CaptionSummary {
|
||||
pub avg_caption_length: f64,
|
||||
pub gpt4v_count: usize,
|
||||
pub llava_count: usize,
|
||||
pub metadata_count: usize,
|
||||
}
|
||||
|
||||
pub async fn process_caption(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<CaptionResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("caption_processor.py");
|
||||
|
||||
tracing::info!("[CAPTION] Starting caption generation: {}", video_path);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[CAPTION] Script not found, returning empty result");
|
||||
return Ok(CaptionResult {
|
||||
video_path: video_path.to_string(),
|
||||
total_frames: 0,
|
||||
captions: vec![],
|
||||
summary: CaptionSummary {
|
||||
avg_caption_length: 0.0,
|
||||
gpt4v_count: 0,
|
||||
llava_count: 0,
|
||||
metadata_count: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"caption_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"CAPTION",
|
||||
Some(CAPTION_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read CAPTION output")?;
|
||||
|
||||
let result: CaptionResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse CAPTION output")?;
|
||||
|
||||
tracing::info!("[CAPTION] Result: {} frames captioned", result.total_frames);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
127
src/core/processor/cut.rs
Normal file
127
src/core/processor/cut.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const CUT_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CutResult {
|
||||
pub frame_count: u64,
|
||||
pub fps: f64,
|
||||
pub scenes: Vec<CutScene>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CutScene {
|
||||
pub scene_number: u32,
|
||||
pub start_frame: u64,
|
||||
pub end_frame: u64,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
}
|
||||
|
||||
pub async fn process_cut(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<CutResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("cut_processor.py");
|
||||
|
||||
tracing::info!("[CUT] Starting scene detection: {}", video_path);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[CUT] Script not found, returning empty result");
|
||||
return Ok(CutResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
scenes: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"cut_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"CUT",
|
||||
Some(CUT_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read CUT output")?;
|
||||
|
||||
let result: CutResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse CUT output")?;
|
||||
|
||||
tracing::info!("[CUT] Result: {} scenes detected", result.scenes.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cut_result_serialization() {
|
||||
let result = CutResult {
|
||||
frame_count: 100,
|
||||
fps: 30.0,
|
||||
scenes: vec![CutScene {
|
||||
scene_number: 1,
|
||||
start_frame: 0,
|
||||
end_frame: 30,
|
||||
start_time: 0.0,
|
||||
end_time: 1.0,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("scene_number"));
|
||||
assert!(json.contains("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cut_result_deserialization() {
|
||||
let json = r#"{
|
||||
"frame_count": 100,
|
||||
"fps": 30.0,
|
||||
"scenes": [
|
||||
{"scene_number": 1, "start_frame": 0, "end_frame": 30, "start_time": 0.0, "end_time": 1.0},
|
||||
{"scene_number": 2, "start_frame": 31, "end_frame": 60, "start_time": 1.033, "end_time": 2.0}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: CutResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.frame_count, 100);
|
||||
assert_eq!(result.scenes.len(), 2);
|
||||
assert_eq!(result.scenes[1].scene_number, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cut_result_empty_scenes() {
|
||||
let result = CutResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
scenes: vec![],
|
||||
};
|
||||
assert!(result.scenes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cut_scene_times() {
|
||||
let scene = CutScene {
|
||||
scene_number: 1,
|
||||
start_frame: 0,
|
||||
end_frame: 30,
|
||||
start_time: 0.0,
|
||||
end_time: 1.0,
|
||||
};
|
||||
assert!(scene.end_time > scene.start_time);
|
||||
assert_eq!(scene.scene_number, 1);
|
||||
}
|
||||
}
|
||||
395
src/core/processor/executor.rs
Normal file
395
src/core/processor/executor.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
pub max_attempts: u32,
|
||||
pub initial_delay_ms: u64,
|
||||
pub max_delay_ms: u64,
|
||||
pub backoff_multiplier: f64,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: 3,
|
||||
initial_delay_ms: 1000,
|
||||
max_delay_ms: 30000,
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
pub fn new(max_attempts: u32) -> Self {
|
||||
Self {
|
||||
max_attempts,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_delay(mut self, delay_ms: u64) -> Self {
|
||||
self.initial_delay_ms = delay_ms;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_delay(mut self, max_delay_ms: u64) -> Self {
|
||||
self.max_delay_ms = max_delay_ms;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_python_env() -> Result<()> {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let venv_python = manifest.join("venv").join("bin").join("python");
|
||||
|
||||
if !venv_python.exists() {
|
||||
anyhow::bail!(
|
||||
"Python venv not found at {:?}\n\
|
||||
Run: /opt/homebrew/bin/python3.11 -m venv venv",
|
||||
venv_python
|
||||
);
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let output = rt
|
||||
.block_on(async { Command::new(&venv_python).arg("--version").output().await })
|
||||
.context("Failed to run Python")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Python validation failed");
|
||||
}
|
||||
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
tracing::info!("Python version: {}", version.trim());
|
||||
|
||||
if !version.contains("3.11") {
|
||||
tracing::warn!("Expected Python 3.11, got: {}", version.trim());
|
||||
}
|
||||
|
||||
let script_path = manifest.join("scripts");
|
||||
if !script_path.exists() {
|
||||
anyhow::bail!("Scripts directory not found at {:?}", script_path);
|
||||
}
|
||||
|
||||
tracing::info!("Python environment validated successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct PythonExecutor {
|
||||
venv_python: PathBuf,
|
||||
scripts_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PythonExecutor {
|
||||
pub fn new() -> Result<Self> {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let venv_python = manifest.join("venv").join("bin").join("python");
|
||||
let scripts_dir = manifest.join("scripts");
|
||||
|
||||
if !venv_python.exists() {
|
||||
anyhow::bail!(
|
||||
"Python venv not found at {:?}. Run: /opt/homebrew/bin/python3.11 -m venv venv",
|
||||
venv_python
|
||||
);
|
||||
}
|
||||
|
||||
if !scripts_dir.exists() {
|
||||
anyhow::bail!("Scripts directory not found at {:?}", scripts_dir);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
venv_python,
|
||||
scripts_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_env(&self) -> Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let output = rt
|
||||
.block_on(async {
|
||||
Command::new(&self.venv_python)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
})
|
||||
.context("Failed to run Python")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Python validation failed");
|
||||
}
|
||||
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
if !version.contains("3.11") {
|
||||
tracing::warn!("Python version mismatch: {}", version);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&self,
|
||||
script_name: &str,
|
||||
args: &[&str],
|
||||
uuid: Option<&str>,
|
||||
log_prefix: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<()> {
|
||||
let script_path = self.scripts_dir.join(script_name);
|
||||
|
||||
if !script_path.exists() {
|
||||
anyhow::bail!("Script not found: {:?}", script_path);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&self.venv_python);
|
||||
cmd.arg(&script_path);
|
||||
|
||||
for arg in args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
if let Some(u) = uuid {
|
||||
cmd.arg("--uuid").arg(u);
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
tracing::info!("[{}] Starting: {:?}", log_prefix, script_name);
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to run {}", script_name))?;
|
||||
|
||||
let stdout = child.stdout.take().context("Failed to capture stdout")?;
|
||||
let stderr = child.stderr.take().context("Failed to capture stderr")?;
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
|
||||
let run_future = async {
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = stdout_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(line)) => {
|
||||
if line.starts_with(&format!("{}_", log_prefix)) {
|
||||
tracing::info!("[{}] {}", log_prefix, line);
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => tracing::warn!("[{}] stdout error: {}", log_prefix, e),
|
||||
}
|
||||
}
|
||||
line = stderr_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(line)) => {
|
||||
if line.starts_with(&format!("{}_", log_prefix)) {
|
||||
tracing::info!("[{}] {}", log_prefix, line);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!("[{}] stderr error: {}", log_prefix, e),
|
||||
}
|
||||
}
|
||||
status = child.wait() => {
|
||||
match status {
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
tracing::error!("[{}] Process failed: {}", log_prefix, status);
|
||||
return Err(anyhow::anyhow!("{} exited with: {}", script_name, status));
|
||||
}
|
||||
tracing::info!("[{}] Completed successfully", log_prefix);
|
||||
}
|
||||
Err(e) => tracing::error!("[{}] wait error: {}", log_prefix, e),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Some(duration) = timeout_duration {
|
||||
match timeout(duration, run_future).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
child.kill().await.context("Failed to kill process")?;
|
||||
anyhow::bail!("{} timed out after {:?}", script_name, duration);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
run_future.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_with_output(
|
||||
&self,
|
||||
script_name: &str,
|
||||
args: &[&str],
|
||||
uuid: Option<&str>,
|
||||
log_prefix: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<()> {
|
||||
self.run(script_name, args, uuid, log_prefix, timeout_duration)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_with_retry(
|
||||
&self,
|
||||
script_name: &str,
|
||||
args: &[&str],
|
||||
uuid: Option<&str>,
|
||||
log_prefix: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
retry_config: Option<RetryConfig>,
|
||||
) -> Result<()> {
|
||||
let config = retry_config.unwrap_or_default();
|
||||
let mut attempt = 0;
|
||||
let mut delay_ms = config.initial_delay_ms;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
match self
|
||||
.run(script_name, args, uuid, log_prefix, timeout_duration)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(
|
||||
"[{}] Succeeded on attempt {}/{}",
|
||||
log_prefix,
|
||||
attempt,
|
||||
config.max_attempts
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt >= config.max_attempts {
|
||||
tracing::error!(
|
||||
"[{}] Failed after {} attempts: {}",
|
||||
log_prefix,
|
||||
attempt,
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
"[{}] Attempt {}/{} failed: {}. Retrying in {}ms...",
|
||||
log_prefix,
|
||||
attempt,
|
||||
config.max_attempts,
|
||||
e,
|
||||
delay_ms
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
|
||||
delay_ms = (delay_ms as f64 * config.backoff_multiplier) as u64;
|
||||
delay_ms = delay_ms.min(config.max_delay_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn script_path(&self, script_name: &str) -> PathBuf {
|
||||
self.scripts_dir.join(script_name)
|
||||
}
|
||||
|
||||
pub fn python_path(&self) -> &PathBuf {
|
||||
&self.venv_python
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PythonExecutor {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to create PythonExecutor")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_python_executor_new_with_venv() {
|
||||
let executor = PythonExecutor::new();
|
||||
assert!(
|
||||
executor.is_ok(),
|
||||
"PythonExecutor should create successfully with venv"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_executor_paths() {
|
||||
let executor = PythonExecutor::new().unwrap();
|
||||
let python_path = executor.python_path();
|
||||
assert!(
|
||||
python_path.exists(),
|
||||
"Python path should exist: {:?}",
|
||||
python_path
|
||||
);
|
||||
assert!(
|
||||
python_path.to_string_lossy().contains("venv"),
|
||||
"Should be in venv"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_script_path() {
|
||||
let executor = PythonExecutor::new().unwrap();
|
||||
let script_path = executor.script_path("asr_processor.py");
|
||||
assert!(script_path.to_string_lossy().contains("scripts"));
|
||||
assert!(script_path.to_string_lossy().contains("asr_processor.py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_script_path_nonexistent() {
|
||||
let executor = PythonExecutor::new().unwrap();
|
||||
let path = executor.script_path("nonexistent_script.py");
|
||||
assert!(!path.exists(), "Nonexistent script path should not exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_path_is_executable() {
|
||||
let executor = PythonExecutor::new().unwrap();
|
||||
let path = executor.python_path();
|
||||
let metadata = std::fs::metadata(path);
|
||||
assert!(metadata.is_ok(), "Python path should be accessible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_config_default() {
|
||||
let config = RetryConfig::default();
|
||||
assert_eq!(config.max_attempts, 3);
|
||||
assert_eq!(config.initial_delay_ms, 1000);
|
||||
assert_eq!(config.max_delay_ms, 30000);
|
||||
assert_eq!(config.backoff_multiplier, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_config_builder() {
|
||||
let config = RetryConfig::new(5).with_delay(2000).with_max_delay(60000);
|
||||
assert_eq!(config.max_attempts, 5);
|
||||
assert_eq!(config.initial_delay_ms, 2000);
|
||||
assert_eq!(config.max_delay_ms, 60000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_config_clone() {
|
||||
let config = RetryConfig::default();
|
||||
let cloned = config.clone();
|
||||
assert_eq!(cloned.max_attempts, config.max_attempts);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,145 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const FACE_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FaceResult {
|
||||
pub frame_count: u64,
|
||||
pub fps: f64,
|
||||
pub frames: Vec<FaceFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FaceFrame {
|
||||
pub frame: u64,
|
||||
pub timestamp: f64,
|
||||
pub faces: Vec<Face>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Face {
|
||||
pub face_id: String,
|
||||
pub face_id: Option<String>,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub confidence: f32,
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
}
|
||||
|
||||
pub async fn process_face(video_path: &str, output_path: &str) -> Result<FaceResult> {
|
||||
// TODO: Implement face detection
|
||||
// Options:
|
||||
// 1. Use MTCNN or RetinaFace with ONNX
|
||||
// 2. Use Python subprocess
|
||||
pub async fn process_face(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<FaceResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("face_processor.py");
|
||||
|
||||
println!("Processing face detection for: {}", video_path);
|
||||
tracing::info!("[FACE] Starting face detection: {}", video_path);
|
||||
|
||||
Ok(FaceResult { frames: vec![] })
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[FACE] Script not found, returning empty result");
|
||||
return Ok(FaceResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"face_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"FACE",
|
||||
Some(FACE_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read FACE output")?;
|
||||
|
||||
let result: FaceResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse FACE output")?;
|
||||
|
||||
tracing::info!("[FACE] Result: {} frames", result.frames.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_face_result_serialization() {
|
||||
let result = FaceResult {
|
||||
frame_count: 100,
|
||||
fps: 30.0,
|
||||
frames: vec![FaceFrame {
|
||||
frame: 0,
|
||||
timestamp: 0.0,
|
||||
faces: vec![Face {
|
||||
face_id: Some("face_1".to_string()),
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 60,
|
||||
confidence: 0.95,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("face_1"));
|
||||
assert!(json.contains("\"width\":50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_face_result_deserialization() {
|
||||
let json = r#"{
|
||||
"frame_count": 50,
|
||||
"fps": 25.0,
|
||||
"frames": [
|
||||
{
|
||||
"frame": 30,
|
||||
"timestamp": 1.2,
|
||||
"faces": [
|
||||
{"face_id": "f1", "x": 10, "y": 20, "width": 30, "height": 40, "confidence": 0.85}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: FaceResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.frame_count, 50);
|
||||
assert_eq!(result.frames.len(), 1);
|
||||
assert_eq!(result.frames[0].faces[0].x, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_face_result_empty_frames() {
|
||||
let result = FaceResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
};
|
||||
assert!(result.frames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_face_confidence() {
|
||||
let face = Face {
|
||||
face_id: None,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
confidence: 0.5,
|
||||
};
|
||||
assert!(face.confidence >= 0.0 && face.confidence <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
pub mod asr;
|
||||
pub mod asrx;
|
||||
pub mod caption;
|
||||
pub mod cut;
|
||||
pub mod executor;
|
||||
pub mod face;
|
||||
pub mod ocr;
|
||||
pub mod pose;
|
||||
pub mod story;
|
||||
pub mod yolo;
|
||||
|
||||
pub use asr::{process_asr, AsrResult, AsrSegment};
|
||||
pub use asrx::process_asrx;
|
||||
pub use face::process_face;
|
||||
pub use ocr::process_ocr;
|
||||
pub use pose::process_pose;
|
||||
pub use yolo::process_yolo;
|
||||
pub use asrx::{process_asrx, AsrxResult, AsrxSegment};
|
||||
pub use caption::{process_caption, CaptionResult, CaptionSummary, FrameCaption};
|
||||
pub use cut::{process_cut, CutResult, CutScene};
|
||||
pub use executor::{validate_python_env, PythonExecutor, RetryConfig};
|
||||
pub use face::{process_face, Face, FaceFrame, FaceResult};
|
||||
pub use ocr::{process_ocr, OcrFrame, OcrResult, OcrText};
|
||||
pub use pose::{process_pose, Bbox, Keypoint, PersonPose, PoseFrame, PoseResult};
|
||||
pub use story::{process_story, StoryChildChunk, StoryParentChunk, StoryResult, StoryStats};
|
||||
pub use yolo::{process_yolo, YoloFrame, YoloObject, YoloResult};
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const OCR_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OcrResult {
|
||||
pub frame_count: u64,
|
||||
pub fps: f64,
|
||||
pub frames: Vec<OcrFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OcrFrame {
|
||||
pub frame: u64,
|
||||
pub timestamp: f64,
|
||||
pub texts: Vec<OcrText>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OcrText {
|
||||
pub text: String,
|
||||
pub x: i32,
|
||||
@@ -23,14 +30,116 @@ pub struct OcrText {
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
pub async fn process_ocr(video_path: &str, output_path: &str) -> Result<OcrResult> {
|
||||
// TODO: Implement OCR processing
|
||||
// Options:
|
||||
// 1. Use tesseract
|
||||
// 2. Use Python pytesseract via subprocess
|
||||
// 3. Use Rust OCR library
|
||||
pub async fn process_ocr(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<OcrResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("ocr_processor.py");
|
||||
|
||||
println!("Processing OCR for: {}", video_path);
|
||||
tracing::info!("[OCR] Starting text recognition: {}", video_path);
|
||||
|
||||
Ok(OcrResult { frames: vec![] })
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[OCR] Script not found, returning empty result");
|
||||
return Ok(OcrResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"ocr_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"OCR",
|
||||
Some(OCR_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read OCR output")?;
|
||||
|
||||
let result: OcrResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse OCR output")?;
|
||||
|
||||
tracing::info!("[OCR] Result: {} frames", result.frames.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ocr_result_serialization() {
|
||||
let result = OcrResult {
|
||||
frame_count: 100,
|
||||
fps: 30.0,
|
||||
frames: vec![OcrFrame {
|
||||
frame: 0,
|
||||
timestamp: 0.0,
|
||||
texts: vec![OcrText {
|
||||
text: "Hello".to_string(),
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 30,
|
||||
confidence: 0.95,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("Hello"));
|
||||
assert!(json.contains("\"x\":10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ocr_result_deserialization() {
|
||||
let json = r#"{
|
||||
"frame_count": 50,
|
||||
"fps": 25.0,
|
||||
"frames": [
|
||||
{
|
||||
"frame": 30,
|
||||
"timestamp": 1.2,
|
||||
"texts": [
|
||||
{"text": "Test", "x": 0, "y": 0, "width": 50, "height": 20, "confidence": 0.88}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: OcrResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.frame_count, 50);
|
||||
assert_eq!(result.frames.len(), 1);
|
||||
assert_eq!(result.frames[0].texts[0].text, "Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ocr_result_empty_frames() {
|
||||
let result = OcrResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
};
|
||||
assert!(result.frames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ocr_text_confidence() {
|
||||
let text = OcrText {
|
||||
text: "OCR".to_string(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
confidence: 0.75,
|
||||
};
|
||||
assert!(text.confidence >= 0.0 && text.confidence <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const POSE_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PoseResult {
|
||||
pub frame_count: u64,
|
||||
pub fps: f64,
|
||||
pub frames: Vec<PoseFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PoseFrame {
|
||||
pub frame: u64,
|
||||
pub timestamp: f64,
|
||||
pub persons: Vec<PersonPose>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PersonPose {
|
||||
pub keypoints: Vec<Keypoint>,
|
||||
pub bbox: Bbox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Keypoint {
|
||||
pub name: String,
|
||||
pub x: f32,
|
||||
@@ -27,7 +34,7 @@ pub struct Keypoint {
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Bbox {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
@@ -35,13 +42,135 @@ pub struct Bbox {
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
pub async fn process_pose(video_path: &str, output_path: &str) -> Result<PoseResult> {
|
||||
// TODO: Implement pose estimation
|
||||
// Options:
|
||||
// 1. Use MoveNet or PoseNet with ONNX
|
||||
// 2. Use Python subprocess with ultralytics
|
||||
pub async fn process_pose(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<PoseResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("pose_processor.py");
|
||||
|
||||
println!("Processing pose estimation for: {}", video_path);
|
||||
tracing::info!("[POSE] Starting pose estimation: {}", video_path);
|
||||
|
||||
Ok(PoseResult { frames: vec![] })
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[POSE] Script not found, returning empty result");
|
||||
return Ok(PoseResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"pose_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"POSE",
|
||||
Some(POSE_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read POSE output")?;
|
||||
|
||||
let result: PoseResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse POSE output")?;
|
||||
|
||||
tracing::info!("[POSE] Result: {} frames", result.frames.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pose_result_serialization() {
|
||||
let result = PoseResult {
|
||||
frame_count: 100,
|
||||
fps: 30.0,
|
||||
frames: vec![PoseFrame {
|
||||
frame: 0,
|
||||
timestamp: 0.0,
|
||||
persons: vec![PersonPose {
|
||||
keypoints: vec![Keypoint {
|
||||
name: "nose".to_string(),
|
||||
x: 100.0,
|
||||
y: 50.0,
|
||||
confidence: 0.9,
|
||||
}],
|
||||
bbox: Bbox {
|
||||
x: 80,
|
||||
y: 30,
|
||||
width: 40,
|
||||
height: 80,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("nose"));
|
||||
assert!(json.contains("\"confidence\":0.9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pose_result_deserialization() {
|
||||
let json = r#"{
|
||||
"frame_count": 50,
|
||||
"fps": 25.0,
|
||||
"frames": [
|
||||
{
|
||||
"frame": 30,
|
||||
"timestamp": 1.2,
|
||||
"persons": [
|
||||
{
|
||||
"keypoints": [{"name": "left_eye", "x": 100.5, "y": 50.2, "confidence": 0.85}],
|
||||
"bbox": {"x": 90, "y": 40, "width": 20, "height": 30}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: PoseResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.frame_count, 50);
|
||||
assert_eq!(result.frames.len(), 1);
|
||||
assert_eq!(result.frames[0].persons[0].keypoints[0].name, "left_eye");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pose_result_empty_frames() {
|
||||
let result = PoseResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
};
|
||||
assert!(result.frames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keypoint_confidence() {
|
||||
let kp = Keypoint {
|
||||
name: "test".to_string(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
confidence: 0.75,
|
||||
};
|
||||
assert!(kp.confidence >= 0.0 && kp.confidence <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bbox_dimensions() {
|
||||
let bbox = Bbox {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 50,
|
||||
height: 100,
|
||||
};
|
||||
assert!(bbox.width > 0);
|
||||
assert!(bbox.height > 0);
|
||||
}
|
||||
}
|
||||
|
||||
250
src/core/processor/story.rs
Normal file
250
src/core/processor/story.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const STORY_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StoryResult {
|
||||
pub child_chunks: Vec<StoryChildChunk>,
|
||||
pub parent_chunks: Vec<StoryParentChunk>,
|
||||
pub stats: StoryStats,
|
||||
pub metadata: serde_json::Value,
|
||||
pub parent_chunk_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StoryStats {
|
||||
pub total_child_chunks: usize,
|
||||
pub total_parent_chunks: usize,
|
||||
pub asr_children: usize,
|
||||
pub cut_children: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StoryChildChunk {
|
||||
pub chunk_id: String,
|
||||
pub chunk_type: String,
|
||||
pub source: String,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub text_content: Option<String>,
|
||||
pub content: serde_json::Value,
|
||||
pub child_chunk_ids: Vec<String>,
|
||||
pub parent_chunk_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StoryParentChunk {
|
||||
pub chunk_id: String,
|
||||
pub chunk_type: String,
|
||||
pub source: String,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub text_content: String,
|
||||
pub content: serde_json::Value,
|
||||
pub child_chunk_ids: Vec<String>,
|
||||
pub parent_chunk_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn process_story(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<StoryResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("story_processor.py");
|
||||
|
||||
tracing::info!("[STORY] Starting story generation: {}", video_path);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[STORY] Script not found, returning empty result");
|
||||
return Ok(StoryResult {
|
||||
child_chunks: vec![],
|
||||
parent_chunks: vec![],
|
||||
stats: StoryStats {
|
||||
total_child_chunks: 0,
|
||||
total_parent_chunks: 0,
|
||||
asr_children: 0,
|
||||
cut_children: 0,
|
||||
},
|
||||
metadata: serde_json::json!({}),
|
||||
parent_chunk_size: 5,
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"story_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"STORY",
|
||||
Some(STORY_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read STORY output")?;
|
||||
|
||||
let result: StoryResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse STORY output")?;
|
||||
|
||||
tracing::info!(
|
||||
"[STORY] Result: {} parent chunks, {} child chunks",
|
||||
result.stats.total_parent_chunks,
|
||||
result.stats.total_child_chunks
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_story_result_serialization() {
|
||||
let result = StoryResult {
|
||||
child_chunks: vec![StoryChildChunk {
|
||||
chunk_id: "asr_0001".to_string(),
|
||||
chunk_type: "sentence".to_string(),
|
||||
source: "asr".to_string(),
|
||||
start_time: 0.0,
|
||||
end_time: 5.0,
|
||||
text_content: Some("Hello world".to_string()),
|
||||
content: serde_json::json!({}),
|
||||
child_chunk_ids: vec![],
|
||||
parent_chunk_id: Some("story_asr_0000".to_string()),
|
||||
}],
|
||||
parent_chunks: vec![StoryParentChunk {
|
||||
chunk_id: "story_asr_0000".to_string(),
|
||||
chunk_type: "story".to_string(),
|
||||
source: "story_asr".to_string(),
|
||||
start_time: 0.0,
|
||||
end_time: 25.0,
|
||||
text_content: "[0s-25s] Hello world...".to_string(),
|
||||
content: serde_json::json!({
|
||||
"description": "[0s-25s] Hello world...",
|
||||
"child_count": 5
|
||||
}),
|
||||
child_chunk_ids: vec!["asr_0001".to_string()],
|
||||
parent_chunk_id: None,
|
||||
}],
|
||||
stats: StoryStats {
|
||||
total_child_chunks: 10,
|
||||
total_parent_chunks: 2,
|
||||
asr_children: 10,
|
||||
cut_children: 0,
|
||||
},
|
||||
metadata: serde_json::json!({}),
|
||||
parent_chunk_size: 5,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("asr_0001"));
|
||||
assert!(json.contains("story_asr_0000"));
|
||||
assert!(json.contains("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_story_result_deserialization() {
|
||||
let json = r#"{
|
||||
"child_chunks": [{
|
||||
"chunk_id": "asr_0001",
|
||||
"chunk_type": "sentence",
|
||||
"source": "asr",
|
||||
"start_time": 0.0,
|
||||
"end_time": 5.0,
|
||||
"text_content": "Hello",
|
||||
"content": {},
|
||||
"child_chunk_ids": [],
|
||||
"parent_chunk_id": null
|
||||
}],
|
||||
"parent_chunks": [{
|
||||
"chunk_id": "story_asr_0000",
|
||||
"chunk_type": "story",
|
||||
"source": "story_asr",
|
||||
"start_time": 0.0,
|
||||
"end_time": 5.0,
|
||||
"text_content": "Hello segment",
|
||||
"content": {"description": "Hello segment"},
|
||||
"child_chunk_ids": ["asr_0001"],
|
||||
"parent_chunk_id": null
|
||||
}],
|
||||
"stats": {
|
||||
"total_child_chunks": 1,
|
||||
"total_parent_chunks": 1,
|
||||
"asr_children": 1,
|
||||
"cut_children": 0
|
||||
},
|
||||
"metadata": {},
|
||||
"parent_chunk_size": 5
|
||||
}"#;
|
||||
|
||||
let result: StoryResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.child_chunks.len(), 1);
|
||||
assert_eq!(result.parent_chunks.len(), 1);
|
||||
assert_eq!(result.stats.total_child_chunks, 1);
|
||||
assert_eq!(result.stats.total_parent_chunks, 1);
|
||||
assert_eq!(result.parent_chunks[0].child_chunk_ids[0], "asr_0001");
|
||||
assert_eq!(result.child_chunks[0].parent_chunk_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_child_relationship() {
|
||||
let result = StoryResult {
|
||||
child_chunks: vec![
|
||||
StoryChildChunk {
|
||||
chunk_id: "asr_0001".to_string(),
|
||||
chunk_type: "sentence".to_string(),
|
||||
source: "asr".to_string(),
|
||||
start_time: 0.0,
|
||||
end_time: 5.0,
|
||||
text_content: Some("First".to_string()),
|
||||
content: serde_json::json!({}),
|
||||
child_chunk_ids: vec![],
|
||||
parent_chunk_id: Some("story_asr_0000".to_string()),
|
||||
},
|
||||
StoryChildChunk {
|
||||
chunk_id: "asr_0002".to_string(),
|
||||
chunk_type: "sentence".to_string(),
|
||||
source: "asr".to_string(),
|
||||
start_time: 5.0,
|
||||
end_time: 10.0,
|
||||
text_content: Some("Second".to_string()),
|
||||
content: serde_json::json!({}),
|
||||
child_chunk_ids: vec![],
|
||||
parent_chunk_id: Some("story_asr_0000".to_string()),
|
||||
},
|
||||
],
|
||||
parent_chunks: vec![StoryParentChunk {
|
||||
chunk_id: "story_asr_0000".to_string(),
|
||||
chunk_type: "story".to_string(),
|
||||
source: "story_asr".to_string(),
|
||||
start_time: 0.0,
|
||||
end_time: 10.0,
|
||||
text_content: "Combined narrative".to_string(),
|
||||
content: serde_json::json!({}),
|
||||
child_chunk_ids: vec!["asr_0001".to_string(), "asr_0002".to_string()],
|
||||
parent_chunk_id: None,
|
||||
}],
|
||||
stats: StoryStats {
|
||||
total_child_chunks: 2,
|
||||
total_parent_chunks: 1,
|
||||
asr_children: 2,
|
||||
cut_children: 0,
|
||||
},
|
||||
metadata: serde_json::json!({}),
|
||||
parent_chunk_size: 5,
|
||||
};
|
||||
|
||||
assert_eq!(result.parent_chunks[0].child_chunk_ids.len(), 2);
|
||||
assert!(result
|
||||
.child_chunks
|
||||
.iter()
|
||||
.all(|c| c.parent_chunk_id.is_some()));
|
||||
assert!(result.parent_chunks[0].parent_chunk_id.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const YOLO_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct YoloResult {
|
||||
pub frame_count: u64,
|
||||
pub fps: f64,
|
||||
pub frames: Vec<YoloFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct YoloFrame {
|
||||
pub frame: u64,
|
||||
pub timestamp: f64,
|
||||
pub objects: Vec<YoloObject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct YoloObject {
|
||||
pub class_name: String,
|
||||
pub class_id: u32,
|
||||
@@ -24,13 +31,123 @@ pub struct YoloObject {
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
pub async fn process_yolo(video_path: &str, output_path: &str) -> Result<YoloResult> {
|
||||
// TODO: Implement YOLO processing
|
||||
// Options:
|
||||
// 1. Use ONNX Runtime (ort) with YOLO model
|
||||
// 2. Use Python subprocess with ultralytics
|
||||
pub async fn process_yolo(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
) -> Result<YoloResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("yolo_processor.py");
|
||||
|
||||
println!("Processing YOLO for: {}", video_path);
|
||||
tracing::info!("[YOLO] Starting object detection: {}", video_path);
|
||||
|
||||
Ok(YoloResult { frames: vec![] })
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[YOLO] Script not found, returning empty result");
|
||||
return Ok(YoloResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run(
|
||||
"yolo_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"YOLO",
|
||||
Some(YOLO_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read YOLO output")?;
|
||||
|
||||
let result: YoloResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse YOLO output")?;
|
||||
|
||||
tracing::info!(
|
||||
"[YOLO] Result: {} frames, {:.2} fps",
|
||||
result.frame_count,
|
||||
result.fps
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_yolo_result_serialization() {
|
||||
let result = YoloResult {
|
||||
frame_count: 100,
|
||||
fps: 30.0,
|
||||
frames: vec![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,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("person"));
|
||||
assert!(json.contains("100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yolo_result_deserialization() {
|
||||
let json = r#"{
|
||||
"frame_count": 50,
|
||||
"fps": 25.0,
|
||||
"frames": [
|
||||
{
|
||||
"frame": 10,
|
||||
"timestamp": 0.4,
|
||||
"objects": [
|
||||
{"class_name": "car", "class_id": 2, "x": 0, "y": 0, "width": 100, "height": 80, "confidence": 0.87}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: YoloResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.frame_count, 50);
|
||||
assert_eq!(result.fps, 25.0);
|
||||
assert_eq!(result.frames.len(), 1);
|
||||
assert_eq!(result.frames[0].objects[0].class_name, "car");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yolo_object_confidence_range() {
|
||||
let obj = YoloObject {
|
||||
class_name: "test".to_string(),
|
||||
class_id: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
confidence: 0.5,
|
||||
};
|
||||
assert!(obj.confidence >= 0.0 && obj.confidence <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yolo_result_empty_frames() {
|
||||
let result = YoloResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
};
|
||||
assert!(result.frames.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user