feat(captions): add list_system_fonts command with fc-list + fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}"))?
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@ mod models;
|
||||
mod services;
|
||||
|
||||
use commands::{dependencies, export, media_analysis, video};
|
||||
use tauri::Manager;
|
||||
|
||||
struct MediaServerPort(u16);
|
||||
|
||||
#[tauri::command]
|
||||
fn get_media_server_port(state: tauri::State<'_, MediaServerPort>) -> u16 {
|
||||
state.0
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -11,16 +19,31 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.setup(|app| {
|
||||
// Bind synchronously (no async needed), then spawn on Tauri's runtime
|
||||
let (port, listener) = services::media_server::bind_media_server()
|
||||
.expect("Failed to bind media server");
|
||||
eprintln!("[video-clipper] media server bound to port {port}");
|
||||
app.manage(MediaServerPort(port));
|
||||
services::media_server::spawn_media_server(listener);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
dependencies::check_dependencies,
|
||||
dependencies::install_dependency,
|
||||
dependencies::check_subtitles_filter_available,
|
||||
video::resolve_url,
|
||||
video::start_download,
|
||||
video::check_cached_download,
|
||||
video::download_subtitles,
|
||||
media_analysis::extract_keyframes,
|
||||
media_analysis::extract_waveform,
|
||||
media_analysis::extract_waveform_tiers,
|
||||
media_analysis::extract_thumbnails,
|
||||
media_analysis::cleanup_thumbnails,
|
||||
media_analysis::check_embedded_subtitles,
|
||||
media_analysis::list_system_fonts,
|
||||
export::export_clips,
|
||||
get_media_server_port,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke, Channel } from '@tauri-apps/api/core';
|
||||
|
||||
export interface ThumbnailSpritesheet {
|
||||
filePath: string;
|
||||
@@ -10,15 +10,33 @@ export interface ThumbnailSpritesheet {
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
export interface WaveformTiers {
|
||||
/** ~2,000 peaks — overview for full-duration view */
|
||||
tier0: number[];
|
||||
/** ~10,000 peaks — moderate zoom */
|
||||
tier1: number[];
|
||||
/** All extracted peaks — deep zoom */
|
||||
tier2: number[];
|
||||
}
|
||||
|
||||
export async function extractKeyframes(filePath: string): Promise<number[]> {
|
||||
return invoke<number[]>('extract_keyframes', { filePath });
|
||||
}
|
||||
|
||||
export async function extractWaveform(
|
||||
export async function extractWaveformTiers(
|
||||
filePath: string,
|
||||
sampleCount: number
|
||||
): Promise<number[]> {
|
||||
return invoke<number[]>('extract_waveform', { filePath, sampleCount });
|
||||
duration: number,
|
||||
onProgress?: (pct: number) => void
|
||||
): Promise<WaveformTiers> {
|
||||
const channel = new Channel<number>();
|
||||
if (onProgress) {
|
||||
channel.onmessage = onProgress;
|
||||
}
|
||||
return invoke<WaveformTiers>('extract_waveform_tiers', {
|
||||
filePath,
|
||||
duration,
|
||||
onProgress: channel,
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractThumbnails(
|
||||
@@ -34,3 +52,13 @@ export async function extractThumbnails(
|
||||
export async function cleanupThumbnails(paths: string[]): Promise<void> {
|
||||
return invoke<void>('cleanup_thumbnails', { paths });
|
||||
}
|
||||
|
||||
export async function checkEmbeddedSubtitles(
|
||||
filePath: string
|
||||
): Promise<string | null> {
|
||||
return invoke<string | null>('check_embedded_subtitles', { filePath });
|
||||
}
|
||||
|
||||
export async function listSystemFonts(): Promise<string[]> {
|
||||
return invoke<string[]>('list_system_fonts');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user