Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ DEEPGRAM_API_KEY=your_deepgram_key_here
ANTHROPIC_API_KEY=your_anthropic_key_here
LLM_PROVIDER=deepseek
DEEPSEEK_API_KEY=
OPENAI_API_KEY=
GEMINI_API_KEY=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
node_modules
dist
bun.lock
src-tauri/gen/schemas/*
package-lock.json
target
src-tauri/target
.env
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This repository implements the desktop app foundation using **Tauri 2 + React +

## Key Features

- **Dynamic Multi-LLM Support**: Supports both **DeepSeek** (default) and **Claude** (anthropic) for viral moment detection and hooks analysis.
- **Dynamic Multi-LLM Support**: Supports **DeepSeek** (default), **Claude** (anthropic), **OpenAI**, and **Google Gemini** for cloud-based viral moment detection and hooks analysis, plus **Ollama** for fully-local inference. Pick your provider and model in API Settings.
- **Automated Pipeline**: Imports media, extracts audio, transcribes using Deepgram, and automatically analyzes and ranks moments in a single automated chain.
- **Local SQLite Storage**: Saves transcripts, candidates, custom names, and rendering data locally.
- **Native Project Manager**: Create, open, rename, and delete projects from the dashboard.
Expand Down
26 changes: 25 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ async fn environment_status(state: tauri::State<'_, AppState>) -> Result<Environ
has_deepgram_key: std::env::var("DEEPGRAM_API_KEY").is_ok(),
has_anthropic_key: std::env::var("ANTHROPIC_API_KEY").is_ok(),
has_deepseek_key: std::env::var("DEEPSEEK_API_KEY").is_ok(),
has_openai_key: std::env::var("OPENAI_API_KEY").is_ok(),
has_gemini_key: std::env::var("GEMINI_API_KEY").is_ok(),
llm_provider,
has_local_whisper_model,
has_ollama,
Expand Down Expand Up @@ -464,6 +466,28 @@ async fn generate_candidates(
.await
.map_err(to_command_error)?
}
"openai" => {
let key = api_key
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.ok_or_else(|| "Set OPENAI_API_KEY or supply OpenAI API Key to generate candidates.".to_string())?;
let model = model_name
.or_else(|| std::env::var("OPENAI_MODEL").ok())
.unwrap_or_else(|| "gpt-4o-mini".to_string());
llm::detect_candidates_with_openai(&normalized, &key, &model)
.await
.map_err(to_command_error)?
}
"gemini" => {
let key = api_key
.or_else(|| std::env::var("GEMINI_API_KEY").ok())
.ok_or_else(|| "Set GEMINI_API_KEY or supply Gemini API Key to generate candidates.".to_string())?;
let model = model_name
.or_else(|| std::env::var("GEMINI_MODEL").ok())
.unwrap_or_else(|| "gemini-2.5-flash".to_string());
llm::detect_candidates_with_gemini(&normalized, &key, &model)
.await
.map_err(to_command_error)?
}
_ => {
let key = api_key
.or_else(|| std::env::var("DEEPSEEK_API_KEY").ok())
Expand Down Expand Up @@ -874,7 +898,7 @@ fn build_drawtext_filters(
let mut font_option = String::new();
for path in &font_paths {
if std::path::Path::new(path).exists() {
font_option = format!("fontfile='{}':", path.replace(':', "\:"));
font_option = format!("fontfile='{}':", path.replace(':', r"\:"));
break;
}
}
Expand Down
155 changes: 155 additions & 0 deletions src-tauri/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,161 @@ Avoid rambling setup, context-dependent references, and pure filler. Return up t
parse_candidate_json(&text, min_duration)
}

#[derive(Debug, Deserialize)]
struct OpenAiMessage {
content: String,
}

#[derive(Debug, Deserialize)]
struct OpenAiChoice {
message: OpenAiMessage,
}

#[derive(Debug, Deserialize)]
struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
}

pub async fn detect_candidates_with_openai(
transcript: &NormalizedTranscript,
api_key: &str,
model: &str,
) -> Result<Vec<CandidateDraft>> {
let segments = compact_segments(&transcript.segments);
let prompt = format!(
"You are identifying the most viral moments and strongest short-form clip candidates from a long-form transcript. \
For each candidate, the clip must be self-contained, starting with an extremely engaging hook within the first 3 seconds (to capture immediate attention on social feeds), \
30-90 seconds long, and cut at clean sentence/thought boundaries. Favor highly shareable content: concrete stories, \
strong opinions, emotional turns, surprising or counter-intuitive claims, clear payoffs, and high-energy/dramatic peaks. \
Avoid rambling setup, context-dependent references, and pure filler. Return up to 10 candidates as JSON matching this schema: \
{{\"candidates\":[{{\"start\":0.0,\"end\":0.0,\"score\":0.0,\"hook\":\"...\",\"rationale\":\"...\"}}]}}\n\nTranscript:\n{segments}"
);

let response = reqwest::Client::new()
.post("https://api.openai.com/v1/chat/completions")
.header("Authorization", format!("Bearer {api_key}"))
.json(&json!({
"model": model,
"messages": [
{
"role": "user",
"content": prompt,
}
],
"temperature": 0.2,
"response_format": {
"type": "json_object"
}
}))
.send()
.await
.context("calling OpenAI")?;

if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("OpenAI request failed ({status}): {body}"));
}

let res_body: OpenAiResponse = response.json().await.context("parsing OpenAI response")?;
let text = res_body
.choices
.first()
.map(|c| c.message.content.clone())
.ok_or_else(|| anyhow!("OpenAI response did not include choices content"))?;

let min_duration = if transcript.duration < 60.0 {
(transcript.duration * 0.5).max(5.0)
} else {
30.0
};
parse_candidate_json(&text, min_duration)
}

#[derive(Debug, Deserialize)]
struct GeminiPart {
text: Option<String>,
}

#[derive(Debug, Deserialize)]
struct GeminiContent {
parts: Vec<GeminiPart>,
}

#[derive(Debug, Deserialize)]
struct GeminiCandidate {
content: GeminiContent,
}

#[derive(Debug, Deserialize)]
struct GeminiResponse {
candidates: Vec<GeminiCandidate>,
}

pub async fn detect_candidates_with_gemini(
transcript: &NormalizedTranscript,
api_key: &str,
model: &str,
) -> Result<Vec<CandidateDraft>> {
let segments = compact_segments(&transcript.segments);
let prompt = format!(
"You are identifying the most viral moments and strongest short-form clip candidates from a long-form transcript. \
For each candidate, the clip must be self-contained, starting with an extremely engaging hook within the first 3 seconds (to capture immediate attention on social feeds), \
30-90 seconds long, and cut at clean sentence/thought boundaries. Favor highly shareable content: concrete stories, \
strong opinions, emotional turns, surprising or counter-intuitive claims, clear payoffs, and high-energy/dramatic peaks. \
Avoid rambling setup, context-dependent references, and pure filler. Return up to 10 candidates as JSON matching this schema: \
{{\"candidates\":[{{\"start\":0.0,\"end\":0.0,\"score\":0.0,\"hook\":\"...\",\"rationale\":\"...\"}}]}}\n\nTranscript:\n{segments}"
);

let url = format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent");

let response = reqwest::Client::new()
.post(&url)
.header("x-goog-api-key", api_key)
.json(&json!({
"contents": [
{
"parts": [
{ "text": prompt }
]
}
],
"generationConfig": {
"temperature": 0.2,
"responseMimeType": "application/json"
}
}))
.send()
.await
.context("calling Gemini")?;

if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Gemini request failed ({status}): {body}"));
}

let res_body: GeminiResponse = response.json().await.context("parsing Gemini response")?;
let text = res_body
.candidates
.into_iter()
.find_map(|candidate| {
candidate
.content
.parts
.into_iter()
.find_map(|part| part.text)
})
.ok_or_else(|| anyhow!("Gemini response did not include text content"))?;

let min_duration = if transcript.duration < 60.0 {
(transcript.duration * 0.5).max(5.0)
} else {
30.0
};
parse_candidate_json(&text, min_duration)
}

#[derive(Debug, Serialize)]
struct ClaudeMessage<'a> {
role: &'a str,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub struct EnvironmentStatus {
pub has_deepgram_key: bool,
pub has_anthropic_key: bool,
pub has_deepseek_key: bool,
pub has_openai_key: bool,
pub has_gemini_key: bool,
pub llm_provider: String,
pub has_local_whisper_model: bool,
pub has_ollama: bool,
Expand Down
Loading