added api for ai; got backend working
This commit is contained in:
98
src-tauri/src/ai_provider.rs
Normal file
98
src-tauri/src/ai_provider.rs
Normal file
@ -0,0 +1,98 @@
|
||||
use std::process::Command;
|
||||
use serde_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AICompleteResult {
|
||||
pub response: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct OllamaModelsResult {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Complete text using AI provider
|
||||
pub fn complete(
|
||||
prompt: &str,
|
||||
provider: &str,
|
||||
model: Option<&str>,
|
||||
api_key: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
system_prompt: Option<&str>,
|
||||
temperature: f64,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("ai_provider.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let mut args = vec![script_path, "complete", prompt, provider];
|
||||
|
||||
if let Some(m) = model {
|
||||
args.push(m);
|
||||
} else {
|
||||
args.push("null");
|
||||
}
|
||||
|
||||
if let Some(key) = api_key {
|
||||
args.push(key);
|
||||
} else {
|
||||
args.push("null");
|
||||
}
|
||||
|
||||
if let Some(url) = base_url {
|
||||
args.push(url);
|
||||
} else {
|
||||
args.push("null");
|
||||
}
|
||||
|
||||
if let Some(sys) = system_prompt {
|
||||
args.push(sys);
|
||||
} else {
|
||||
args.push("null");
|
||||
}
|
||||
|
||||
let temp_str = temperature.to_string();
|
||||
args.push(&temp_str);
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: AICompleteResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
/// List available Ollama models
|
||||
pub fn list_ollama_models(base_url: &str) -> Result<Vec<String>, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("ai_provider.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "list_ollama_models", base_url])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: OllamaModelsResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.models)
|
||||
}
|
||||
61
src-tauri/src/audio_cleaner.rs
Normal file
61
src-tauri/src/audio_cleaner.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use std::process::Command;
|
||||
use serde_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CleanAudioResult {
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DeepFilterStatus {
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Clean audio using DeepFilterNet or FFmpeg fallback
|
||||
pub fn clean_audio(input_path: &str, output_path: &str) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("audio_cleaner.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "clean_audio", input_path, output_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: CleanAudioResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.output_path)
|
||||
}
|
||||
|
||||
/// Check if DeepFilterNet is available
|
||||
pub fn is_deepfilter_available() -> Result<bool, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("audio_cleaner.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "is_deepfilter_available"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: DeepFilterStatus = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.available)
|
||||
}
|
||||
66
src-tauri/src/background_removal.rs
Normal file
66
src-tauri/src/background_removal.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use std::process::Command;
|
||||
use serde_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct BackgroundRemovalStatus {
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct BackgroundRemovalResult {
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
/// Check if background removal is available
|
||||
pub fn is_available() -> Result<bool, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("background_removal.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "is_available"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: BackgroundRemovalStatus = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.available)
|
||||
}
|
||||
|
||||
/// Remove background on export (placeholder for Phase 5)
|
||||
pub fn remove_background_on_export(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
replacement: &str,
|
||||
replacement_value: &str,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("background_removal.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "remove_background_on_export", input_path, output_path, replacement, replacement_value])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: BackgroundRemovalResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.output_path)
|
||||
}
|
||||
177
src-tauri/src/caption_generator.rs
Normal file
177
src-tauri/src/caption_generator.rs
Normal file
@ -0,0 +1,177 @@
|
||||
use std::process::Command;
|
||||
use serde_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Word {
|
||||
pub word: String,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub confidence: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CaptionStyle {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_size: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CaptionContent {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SaveCaptionsResult {
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
/// Generate SRT caption content
|
||||
pub fn generate_srt(
|
||||
words: &[Word],
|
||||
deleted_indices: Option<&std::collections::HashSet<usize>>,
|
||||
words_per_line: usize,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("caption_generator.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let words_json = serde_json::to_string(words)
|
||||
.map_err(|e| format!("Failed to serialize words: {}", e))?;
|
||||
|
||||
let deleted_json = match deleted_indices {
|
||||
Some(indices) => serde_json::to_string(indices)
|
||||
.map_err(|e| format!("Failed to serialize deleted indices: {}", e))?,
|
||||
None => "null".to_string(),
|
||||
};
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "generate_srt", &words_json, &deleted_json, &words_per_line.to_string()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: CaptionContent = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.content)
|
||||
}
|
||||
|
||||
/// Generate VTT caption content
|
||||
pub fn generate_vtt(
|
||||
words: &[Word],
|
||||
deleted_indices: Option<&std::collections::HashSet<usize>>,
|
||||
words_per_line: usize,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("caption_generator.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let words_json = serde_json::to_string(words)
|
||||
.map_err(|e| format!("Failed to serialize words: {}", e))?;
|
||||
|
||||
let deleted_json = match deleted_indices {
|
||||
Some(indices) => serde_json::to_string(indices)
|
||||
.map_err(|e| format!("Failed to serialize deleted indices: {}", e))?,
|
||||
None => "null".to_string(),
|
||||
};
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "generate_vtt", &words_json, &deleted_json, &words_per_line.to_string()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: CaptionContent = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.content)
|
||||
}
|
||||
|
||||
/// Generate ASS subtitle content
|
||||
pub fn generate_ass(
|
||||
words: &[Word],
|
||||
deleted_indices: Option<&std::collections::HashSet<usize>>,
|
||||
words_per_line: usize,
|
||||
style: Option<&CaptionStyle>,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("caption_generator.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let words_json = serde_json::to_string(words)
|
||||
.map_err(|e| format!("Failed to serialize words: {}", e))?;
|
||||
|
||||
let deleted_json = match deleted_indices {
|
||||
Some(indices) => serde_json::to_string(indices)
|
||||
.map_err(|e| format!("Failed to serialize deleted indices: {}", e))?,
|
||||
None => "null".to_string(),
|
||||
};
|
||||
|
||||
let style_json = match style {
|
||||
Some(s) => serde_json::to_string(s)
|
||||
.map_err(|e| format!("Failed to serialize style: {}", e))?,
|
||||
None => "null".to_string(),
|
||||
};
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "generate_ass", &words_json, &deleted_json, &words_per_line.to_string(), &style_json])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: CaptionContent = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.content)
|
||||
}
|
||||
|
||||
/// Save caption content to file
|
||||
pub fn save_captions(content: &str, output_path: &str) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("caption_generator.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "save_captions", content, output_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: SaveCaptionsResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.output_path)
|
||||
}
|
||||
82
src-tauri/src/diarization.rs
Normal file
82
src-tauri/src/diarization.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use std::process::Command;
|
||||
use serde_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Word {
|
||||
pub word: String,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub confidence: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Segment {
|
||||
pub id: usize,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub text: String,
|
||||
pub words: Vec<Word>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct TranscriptionResult {
|
||||
pub words: Vec<Word>,
|
||||
pub segments: Vec<Segment>,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// Apply speaker diarization to transcription result
|
||||
pub fn diarize_and_label(
|
||||
transcription_result: &TranscriptionResult,
|
||||
audio_path: &str,
|
||||
hf_token: Option<&str>,
|
||||
num_speakers: Option<u32>,
|
||||
use_gpu: bool,
|
||||
) -> Result<TranscriptionResult, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("diarization.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let transcription_json = serde_json::to_string(transcription_result)
|
||||
.map_err(|e| format!("Failed to serialize transcription: {}", e))?;
|
||||
|
||||
let mut args = vec![script_path, "diarize_and_label", &transcription_json, audio_path];
|
||||
|
||||
if let Some(token) = hf_token {
|
||||
args.push(token);
|
||||
} else {
|
||||
args.push("null");
|
||||
}
|
||||
|
||||
let speakers_str;
|
||||
if let Some(speakers) = num_speakers {
|
||||
speakers_str = speakers.to_string();
|
||||
args.push(&speakers_str);
|
||||
} else {
|
||||
args.push("null");
|
||||
}
|
||||
|
||||
args.push(if use_gpu { "true" } else { "false" });
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: TranscriptionResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@ -1,6 +1,13 @@
|
||||
// --- Commands ---
|
||||
|
||||
mod paths;
|
||||
mod transcription;
|
||||
mod video_editor;
|
||||
mod audio_cleaner;
|
||||
mod diarization;
|
||||
mod ai_provider;
|
||||
mod caption_generator;
|
||||
mod background_removal;
|
||||
|
||||
/// Returns the backend URL. Stubbed for now; will be replaced once the
|
||||
/// Python/Rust backend is fully wired up.
|
||||
@ -56,6 +63,162 @@ async fn transcribe_audio(file_path: String, model_name: String, language: Optio
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Export video using stream copy (fast, lossless)
|
||||
#[tauri::command]
|
||||
async fn export_stream_copy(input_path: String, output_path: String, keep_segments: serde_json::Value) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
video_editor::export_stream_copy(&input_path, &output_path, &keep_segments)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Export video with re-encoding
|
||||
#[tauri::command]
|
||||
async fn export_reencode(input_path: String, output_path: String, keep_segments: serde_json::Value, resolution: String, format_hint: String) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
video_editor::export_reencode(&input_path, &output_path, &keep_segments, &resolution, &format_hint)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Export video with re-encoding and subtitles
|
||||
#[tauri::command]
|
||||
async fn export_reencode_with_subs(input_path: String, output_path: String, keep_segments: serde_json::Value, subtitle_path: String, resolution: String, format_hint: String) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
video_editor::export_reencode_with_subs(&input_path, &output_path, &keep_segments, &subtitle_path, &resolution, &format_hint)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Get video information
|
||||
#[tauri::command]
|
||||
async fn get_video_info(input_path: String) -> Result<video_editor::VideoInfo, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
video_editor::get_video_info(&input_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Clean audio using DeepFilterNet or FFmpeg fallback
|
||||
#[tauri::command]
|
||||
async fn clean_audio(input_path: String, output_path: String) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
audio_cleaner::clean_audio(&input_path, &output_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Check if DeepFilterNet is available
|
||||
#[tauri::command]
|
||||
async fn is_deepfilter_available() -> Result<bool, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
audio_cleaner::is_deepfilter_available()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Apply speaker diarization to transcription result
|
||||
#[tauri::command]
|
||||
async fn diarize_and_label(transcription_result: diarization::TranscriptionResult, audio_path: String, hf_token: Option<String>, num_speakers: Option<u32>, use_gpu: Option<bool>) -> Result<diarization::TranscriptionResult, String> {
|
||||
let use_gpu = use_gpu.unwrap_or(true);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
diarization::diarize_and_label(&transcription_result, &audio_path, hf_token.as_deref(), num_speakers, use_gpu)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Complete text using AI provider
|
||||
#[tauri::command]
|
||||
async fn ai_complete(prompt: String, provider: String, model: Option<String>, api_key: Option<String>, base_url: Option<String>, system_prompt: Option<String>, temperature: Option<f64>) -> Result<String, String> {
|
||||
let temperature = temperature.unwrap_or(0.3);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ai_provider::complete(&prompt, &provider, model.as_deref(), api_key.as_deref(), base_url.as_deref(), system_prompt.as_deref(), temperature)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// List available Ollama models
|
||||
#[tauri::command]
|
||||
async fn list_ollama_models(base_url: Option<String>) -> Result<Vec<String>, String> {
|
||||
let base_url = base_url.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ai_provider::list_ollama_models(&base_url)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Generate SRT caption content
|
||||
#[tauri::command]
|
||||
async fn generate_srt(words: Vec<caption_generator::Word>, deleted_indices: Option<std::collections::HashSet<usize>>, words_per_line: Option<usize>) -> Result<String, String> {
|
||||
let words_per_line = words_per_line.unwrap_or(8);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
caption_generator::generate_srt(&words, deleted_indices.as_ref(), words_per_line)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Generate VTT caption content
|
||||
#[tauri::command]
|
||||
async fn generate_vtt(words: Vec<caption_generator::Word>, deleted_indices: Option<std::collections::HashSet<usize>>, words_per_line: Option<usize>) -> Result<String, String> {
|
||||
let words_per_line = words_per_line.unwrap_or(8);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
caption_generator::generate_vtt(&words, deleted_indices.as_ref(), words_per_line)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Generate ASS subtitle content
|
||||
#[tauri::command]
|
||||
async fn generate_ass(words: Vec<caption_generator::Word>, deleted_indices: Option<std::collections::HashSet<usize>>, words_per_line: Option<usize>, style: Option<caption_generator::CaptionStyle>) -> Result<String, String> {
|
||||
let words_per_line = words_per_line.unwrap_or(8);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
caption_generator::generate_ass(&words, deleted_indices.as_ref(), words_per_line, style.as_ref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Save caption content to file
|
||||
#[tauri::command]
|
||||
async fn save_captions(content: String, output_path: String) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
caption_generator::save_captions(&content, &output_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Check if background removal is available
|
||||
#[tauri::command]
|
||||
async fn is_background_removal_available() -> Result<bool, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
background_removal::is_available()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
/// Remove background on export (placeholder for Phase 5)
|
||||
#[tauri::command]
|
||||
async fn remove_background_on_export(input_path: String, output_path: String, replacement: String, replacement_value: String) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
background_removal::remove_background_on_export(&input_path, &output_path, &replacement, &replacement_value)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task error: {:?}", e))?
|
||||
}
|
||||
|
||||
// --- App entry point ---
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@ -79,6 +242,21 @@ pub fn run() {
|
||||
decrypt_string,
|
||||
ensure_model,
|
||||
transcribe_audio,
|
||||
export_stream_copy,
|
||||
export_reencode,
|
||||
export_reencode_with_subs,
|
||||
get_video_info,
|
||||
clean_audio,
|
||||
is_deepfilter_available,
|
||||
diarize_and_label,
|
||||
ai_complete,
|
||||
list_ollama_models,
|
||||
generate_srt,
|
||||
generate_vtt,
|
||||
generate_ass,
|
||||
save_captions,
|
||||
is_background_removal_available,
|
||||
remove_background_on_export,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
30
src-tauri/src/paths.rs
Normal file
30
src-tauri/src/paths.rs
Normal file
@ -0,0 +1,30 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve the project root from the executable path.
|
||||
/// In dev mode, the binary lives at: <root>/src-tauri/target/debug/<bin>
|
||||
/// So the project root is 4 levels above the binary.
|
||||
pub fn project_root() -> PathBuf {
|
||||
let exe = std::env::current_exe().expect("Failed to get executable path");
|
||||
// exe -> debug/ -> target/ -> src-tauri/ -> root
|
||||
exe.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Absolute path to the venv Python 3.10 interpreter.
|
||||
pub fn python_exe() -> PathBuf {
|
||||
project_root().join(".venv/bin/python3.10")
|
||||
}
|
||||
|
||||
/// Absolute path to a script in the backend directory.
|
||||
pub fn backend_script(name: &str) -> PathBuf {
|
||||
project_root().join("backend").join(name)
|
||||
}
|
||||
|
||||
/// Absolute path to a script at the project root.
|
||||
pub fn root_script(name: &str) -> PathBuf {
|
||||
project_root().join(name)
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use whisper_rs::{WhisperContext, WhisperContextParameters, FullParams, SamplingStrategy};
|
||||
use serde_json;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
pub struct TranscriptionResult {
|
||||
@ -26,176 +25,46 @@ pub struct Segment {
|
||||
pub words: Vec<Word>,
|
||||
}
|
||||
|
||||
/// Extract audio from a video/audio file to a 16kHz mono WAV using ffmpeg
|
||||
fn extract_to_wav(input_path: &str, output_path: &str) -> Result<(), String> {
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(["-y", "-i", input_path, "-vn", "-ar", "16000", "-ac", "1", "-f", "wav", output_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to run ffmpeg: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("ffmpeg exited with code: {:?}", status.code()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transcribe audio file using whisper-rs (real Whisper.cpp inference)
|
||||
/// Transcribe audio file using Python faster-whisper
|
||||
pub fn transcribe_audio(
|
||||
file_path: &str,
|
||||
model_name: &str,
|
||||
language: Option<&str>,
|
||||
) -> Result<TranscriptionResult, String> {
|
||||
// Ensure model is downloaded
|
||||
let model_path = ensure_model_downloaded(model_name)?;
|
||||
// Path to Python venv and script
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::root_script("transcribe.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
// Extract audio to temp 16kHz mono WAV
|
||||
let tmp_wav = tempfile::Builder::new()
|
||||
.suffix(".wav")
|
||||
.tempfile()
|
||||
.map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||
let wav_path = tmp_wav.path().to_string_lossy().to_string();
|
||||
|
||||
extract_to_wav(file_path, &wav_path)?;
|
||||
|
||||
// Read WAV as f32 samples
|
||||
let mut reader = hound::WavReader::open(&wav_path)
|
||||
.map_err(|e| format!("Failed to read WAV: {}", e))?;
|
||||
let spec = reader.spec();
|
||||
let samples: Vec<f32> = match spec.sample_format {
|
||||
hound::SampleFormat::Int => reader
|
||||
.samples::<i16>()
|
||||
.map(|s| s.map(|v| v as f32 / 32768.0).map_err(|e| format!("{}", e)))
|
||||
.collect::<Result<Vec<f32>, _>>()?,
|
||||
hound::SampleFormat::Float => reader
|
||||
.samples::<f32>()
|
||||
.map(|s| s.map_err(|e| format!("{}", e)))
|
||||
.collect::<Result<Vec<f32>, _>>()?,
|
||||
};
|
||||
|
||||
// Load Whisper model and transcribe
|
||||
let ctx_params = WhisperContextParameters::default();
|
||||
let ctx = WhisperContext::new_with_params(&model_path, ctx_params)
|
||||
.map_err(|e| format!("Failed to load model: {:?}", e))?;
|
||||
let mut state = ctx.create_state()
|
||||
.map_err(|e| format!("Failed to create state: {:?}", e))?;
|
||||
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
params.set_print_timestamps(false);
|
||||
params.set_token_timestamps(true);
|
||||
params.set_single_segment(false);
|
||||
// Build command args
|
||||
let mut args = vec![script_path, file_path, model_name];
|
||||
if let Some(lang) = language {
|
||||
params.set_language(Some(lang));
|
||||
args.push(lang);
|
||||
}
|
||||
|
||||
state.full(params, &samples)
|
||||
.map_err(|e| format!("Transcription failed: {:?}", e))?;
|
||||
// Run Python script
|
||||
let output = Command::new(python_exe)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
// Extract word-level results using the 0.16.0 iterator API
|
||||
let mut all_words: Vec<Word> = Vec::new();
|
||||
let mut segments: Vec<Segment> = Vec::new();
|
||||
let detected_language = language.unwrap_or("en").to_string();
|
||||
|
||||
for (seg_idx, segment) in state.as_iter().enumerate() {
|
||||
let seg_text = segment.to_str_lossy()
|
||||
.map_err(|e| format!("Segment text error: {:?}", e))?;
|
||||
let seg_t0 = segment.start_timestamp() as f64 / 100.0;
|
||||
let seg_t1 = segment.end_timestamp() as f64 / 100.0;
|
||||
|
||||
let mut seg_words: Vec<Word> = Vec::new();
|
||||
|
||||
for tok_i in 0..segment.n_tokens() {
|
||||
if let Some(token) = segment.get_token(tok_i) {
|
||||
let token_text = match token.to_str_lossy() {
|
||||
Ok(t) => t.into_owned(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
let token_data = token.token_data();
|
||||
|
||||
// Skip special tokens
|
||||
let trimmed = token_text.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('[') || trimmed.starts_with('<') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let word = Word {
|
||||
word: trimmed.to_string(),
|
||||
start: token_data.t0 as f64 / 100.0,
|
||||
end: token_data.t1 as f64 / 100.0,
|
||||
confidence: token_data.p as f64,
|
||||
};
|
||||
all_words.push(word.clone());
|
||||
seg_words.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
segments.push(Segment {
|
||||
id: seg_idx,
|
||||
start: seg_t0,
|
||||
end: seg_t1,
|
||||
text: seg_text.trim().to_string(),
|
||||
words: seg_words,
|
||||
});
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(TranscriptionResult {
|
||||
words: all_words,
|
||||
segments,
|
||||
language: detected_language,
|
||||
})
|
||||
// Parse JSON output
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: TranscriptionResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Download and cache Whisper model
|
||||
pub fn ensure_model_downloaded(model_name: &str) -> Result<String, String> {
|
||||
// Get app data directory for storing models
|
||||
let app_data_dir = dirs::data_dir()
|
||||
.ok_or("Could not find app data directory")?
|
||||
.join("TalkEdit")
|
||||
.join("models");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|e| format!("Failed to create models directory: {}", e))?;
|
||||
|
||||
let model_path = app_data_dir.join(format!("ggml-{}.bin", model_name));
|
||||
|
||||
// Check if model already exists
|
||||
if model_path.exists() {
|
||||
return Ok(model_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Only download smaller models automatically
|
||||
let allowed_models = ["tiny", "base", "small"];
|
||||
if !allowed_models.contains(&model_name) {
|
||||
return Err(format!("Model '{}' is not available for automatic download. Only tiny, base, and small models are supported.", model_name));
|
||||
}
|
||||
|
||||
println!("Downloading Whisper model: {}...", model_name);
|
||||
|
||||
// Download the model from ggerganov's whisper.cpp repo
|
||||
let url = format!("https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{}.bin", model_name);
|
||||
let response = ureq::get(&url)
|
||||
.call()
|
||||
.map_err(|e| format!("Failed to download model: {}", e))?;
|
||||
|
||||
let len = response
|
||||
.header("content-length")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
println!("Model size: {} bytes", len);
|
||||
|
||||
let mut reader = response.into_reader();
|
||||
let mut file = fs::File::create(&model_path)
|
||||
.map_err(|e| format!("Failed to create model file: {}", e))?;
|
||||
|
||||
std::io::copy(&mut reader, &mut file)
|
||||
.map_err(|e| format!("Failed to write model file: {}", e))?;
|
||||
|
||||
println!("Model downloaded successfully: {}", model_path.display());
|
||||
|
||||
Ok(model_path.to_string_lossy().to_string())
|
||||
/// Ensure model is available (faster-whisper handles this automatically)
|
||||
pub fn ensure_model_downloaded(_model_name: &str) -> Result<String, String> {
|
||||
// faster-whisper downloads models on first use, so just return success
|
||||
Ok("Model ready".to_string())
|
||||
}
|
||||
|
||||
|
||||
138
src-tauri/src/video_editor.rs
Normal file
138
src-tauri/src/video_editor.rs
Normal file
@ -0,0 +1,138 @@
|
||||
use std::process::Command;
|
||||
use serde_json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VideoInfo {
|
||||
pub duration: f64,
|
||||
pub size: u64,
|
||||
pub format: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub codec: String,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ExportResult {
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
/// Export video using stream copy (fast, lossless)
|
||||
pub fn export_stream_copy(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
keep_segments: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("video_editor.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let keep_segments_str = keep_segments.to_string();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "export_stream_copy", input_path, output_path, &keep_segments_str])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: ExportResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.output_path)
|
||||
}
|
||||
|
||||
/// Export video with re-encoding
|
||||
pub fn export_reencode(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
keep_segments: &serde_json::Value,
|
||||
resolution: &str,
|
||||
format_hint: &str,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("video_editor.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let keep_segments_str = keep_segments.to_string();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "export_reencode", input_path, output_path, &keep_segments_str, resolution, format_hint])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: ExportResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.output_path)
|
||||
}
|
||||
|
||||
/// Export video with re-encoding and subtitles
|
||||
pub fn export_reencode_with_subs(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
keep_segments: &serde_json::Value,
|
||||
subtitle_path: &str,
|
||||
resolution: &str,
|
||||
format_hint: &str,
|
||||
) -> Result<String, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("video_editor.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let keep_segments_str = keep_segments.to_string();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "export_reencode_with_subs", input_path, output_path, &keep_segments_str, subtitle_path, resolution, format_hint])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: ExportResult = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result.output_path)
|
||||
}
|
||||
|
||||
/// Get video information
|
||||
pub fn get_video_info(input_path: &str) -> Result<VideoInfo, String> {
|
||||
let python_exe = crate::paths::python_exe();
|
||||
let python_exe = python_exe.to_str().unwrap_or_default();
|
||||
let script_path = crate::paths::backend_script("video_editor.py");
|
||||
let script_path = script_path.to_str().unwrap_or_default();
|
||||
|
||||
let output = Command::new(python_exe)
|
||||
.args(&[script_path, "get_video_info", input_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run Python script: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Python script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: VideoInfo = serde_json::from_str(&stdout.trim())
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
Reference in New Issue
Block a user