feat(captions): add list_system_fonts command with fc-list + fallback

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 10:31:30 -04:00
parent cc0ca95846
commit a6277f0aa5
3 changed files with 209 additions and 17 deletions

View File

@@ -1,23 +1,50 @@
use crate::models::ThumbnailSpritesheet;
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
use crate::services::dependency_manager::{ffmpeg_bin, ffprobe_bin};
use crate::services::{keyframe_index, thumbnail_extractor, waveform_generator};
use tauri::ipc::Channel;
#[tauri::command]
pub async fn extract_keyframes(file_path: String) -> Result<Vec<f64>, String> {
tokio::task::spawn_blocking(move || keyframe_index::extract_keyframes(&file_path))
eprintln!("[video-clipper:keyframes] starting for '{}'", file_path);
let result = tokio::task::spawn_blocking(move || keyframe_index::extract_keyframes(&file_path))
.await
.map_err(|e| format!("Task failed: {e}"))?
.map_err(|e| format!("Task failed: {e}"))?;
match &result {
Ok(kf) => eprintln!("[video-clipper:keyframes] done — {} keyframes", kf.len()),
Err(e) => eprintln!("[video-clipper:keyframes] FAILED: {}", e),
}
result
}
/// Extract waveform at multiple zoom tiers in a single ffmpeg call.
/// Streams progress (0.0–1.0) via the channel.
#[tauri::command]
pub async fn extract_waveform(
pub async fn extract_waveform_tiers(
file_path: String,
sample_count: usize,
) -> Result<Vec<f64>, String> {
tokio::task::spawn_blocking(move || {
waveform_generator::extract_waveform(&file_path, sample_count)
duration: f64,
on_progress: Channel<f64>,
) -> Result<WaveformTiers, String> {
eprintln!(
"[video-clipper:waveform-tiers] starting for '{}' (duration={:.0}s)",
file_path, duration
);
let result = tokio::task::spawn_blocking(move || {
waveform_generator::extract_waveform_tiers(&file_path, duration, move |pct| {
let _ = on_progress.send(pct);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
.map_err(|e| format!("Task failed: {e}"))?;
match &result {
Ok(tiers) => eprintln!(
"[video-clipper:waveform-tiers] done — {}/{}/{} peaks",
tiers.tier0.len(),
tiers.tier1.len(),
tiers.tier2.len()
),
Err(e) => eprintln!("[video-clipper:waveform-tiers] FAILED: {}", e),
}
result
}
#[tauri::command]
@@ -25,7 +52,11 @@ pub async fn extract_thumbnails(
file_path: String,
duration: f64,
) -> Result<Vec<ThumbnailSpritesheet>, String> {
tokio::task::spawn_blocking(move || {
eprintln!(
"[video-clipper:thumbnails] starting for '{}' (duration={})",
file_path, duration
);
let result = tokio::task::spawn_blocking(move || {
let temp_dir = std::env::temp_dir()
.join("video-clipper-thumbs")
.join(uuid::Uuid::new_v4().to_string());
@@ -33,7 +64,12 @@ pub async fn extract_thumbnails(
thumbnail_extractor::extract_thumbnails(&file_path, duration, &temp_dir_str)
})
.await
.map_err(|e| format!("Task failed: {e}"))?
.map_err(|e| format!("Task failed: {e}"))?;
match &result {
Ok(sheets) => eprintln!("[video-clipper:thumbnails] done — {} sheets", sheets.len()),
Err(e) => eprintln!("[video-clipper:thumbnails] FAILED: {}", e),
}
result
}
#[tauri::command]
@@ -42,3 +78,108 @@ pub async fn cleanup_thumbnails(paths: Vec<String>) -> Result<(), String> {
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn check_embedded_subtitles(file_path: String) -> Result<Option<String>, String> {
tokio::task::spawn_blocking(move || {
use std::process::Command;
eprintln!("[video-clipper:embedded-subs] checking '{}'", file_path);
let probe = Command::new(ffprobe_bin())
.args([
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-select_streams", "s",
&file_path,
])
.output()
.map_err(|e| format!("Failed to run ffprobe: {e}"))?;
if !probe.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&probe.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("Failed to parse ffprobe output: {e}"))?;
let streams = parsed.get("streams").and_then(|s| s.as_array());
let has_subs = streams.is_some_and(|s| !s.is_empty());
if !has_subs {
eprintln!("[video-clipper:embedded-subs] no subtitle streams found");
return Ok(None);
}
eprintln!("[video-clipper:embedded-subs] found subtitle stream, extracting as VTT");
let output_dir = std::env::temp_dir()
.join("video-clipper")
.join("subtitles");
std::fs::create_dir_all(&output_dir)
.map_err(|e| format!("Failed to create subtitle dir: {e}"))?;
let vtt_path = output_dir
.join("embedded.vtt")
.to_string_lossy()
.to_string();
let extract = Command::new(ffmpeg_bin())
.args(["-y", "-i", &file_path, "-map", "0:s:0", "-f", "webvtt", &vtt_path])
.output()
.map_err(|e| format!("Failed to extract subtitles: {e}"))?;
if extract.status.success() {
eprintln!("[video-clipper:embedded-subs] extracted to '{}'", vtt_path);
Ok(Some(vtt_path))
} else {
let stderr = String::from_utf8_lossy(&extract.stderr);
eprintln!("[video-clipper:embedded-subs] extraction failed: {}", stderr);
Ok(None)
}
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn list_system_fonts() -> Result<Vec<String>, String> {
tokio::task::spawn_blocking(|| {
// Try fc-list first (available if fontconfig is installed, which ffmpeg depends on)
let output = std::process::Command::new("fc-list")
.args([":lang=en", "family"])
.output();
match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
let mut families: Vec<String> = text
.lines()
.flat_map(|line| line.split(','))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
families.sort_unstable();
families.dedup();
Ok(families)
}
_ => {
// Fallback: common cross-platform fonts
Ok(vec![
"Arial".to_string(),
"Comic Sans MS".to_string(),
"Courier New".to_string(),
"Georgia".to_string(),
"Helvetica".to_string(),
"Impact".to_string(),
"Times New Roman".to_string(),
"Verdana".to_string(),
])
}
}
})
.await
.map_err(|e| format!("Font detection failed: {e}"))?
}