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:
accusys
2026-03-25 14:52:51 +08:00
parent 47e86b696f
commit 383201cacd
193 changed files with 40268 additions and 422 deletions

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View 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
View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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};

View File

@@ -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);
}
}

View File

@@ -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
View 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());
}
}

View File

@@ -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());
}
}