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 crate::services::{keyframe_index, thumbnail_extractor, waveform_generator};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn extract_keyframes(file_path: String) -> Result<Vec<f64>, String> {
|
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
|
.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]
|
#[tauri::command]
|
||||||
pub async fn extract_waveform(
|
pub async fn extract_waveform_tiers(
|
||||||
file_path: String,
|
file_path: String,
|
||||||
sample_count: usize,
|
duration: f64,
|
||||||
) -> Result<Vec<f64>, String> {
|
on_progress: Channel<f64>,
|
||||||
tokio::task::spawn_blocking(move || {
|
) -> Result<WaveformTiers, String> {
|
||||||
waveform_generator::extract_waveform(&file_path, sample_count)
|
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
|
.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]
|
#[tauri::command]
|
||||||
@@ -25,7 +52,11 @@ pub async fn extract_thumbnails(
|
|||||||
file_path: String,
|
file_path: String,
|
||||||
duration: f64,
|
duration: f64,
|
||||||
) -> Result<Vec<ThumbnailSpritesheet>, String> {
|
) -> 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()
|
let temp_dir = std::env::temp_dir()
|
||||||
.join("video-clipper-thumbs")
|
.join("video-clipper-thumbs")
|
||||||
.join(uuid::Uuid::new_v4().to_string());
|
.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)
|
thumbnail_extractor::extract_thumbnails(&file_path, duration, &temp_dir_str)
|
||||||
})
|
})
|
||||||
.await
|
.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]
|
#[tauri::command]
|
||||||
@@ -42,3 +78,108 @@ pub async fn cleanup_thumbnails(paths: Vec<String>) -> Result<(), String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task failed: {e}"))?
|
.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;
|
mod services;
|
||||||
|
|
||||||
use commands::{dependencies, export, media_analysis, video};
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -11,16 +19,31 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
dependencies::check_dependencies,
|
dependencies::check_dependencies,
|
||||||
dependencies::install_dependency,
|
dependencies::install_dependency,
|
||||||
|
dependencies::check_subtitles_filter_available,
|
||||||
video::resolve_url,
|
video::resolve_url,
|
||||||
video::start_download,
|
video::start_download,
|
||||||
|
video::check_cached_download,
|
||||||
|
video::download_subtitles,
|
||||||
media_analysis::extract_keyframes,
|
media_analysis::extract_keyframes,
|
||||||
media_analysis::extract_waveform,
|
media_analysis::extract_waveform_tiers,
|
||||||
media_analysis::extract_thumbnails,
|
media_analysis::extract_thumbnails,
|
||||||
media_analysis::cleanup_thumbnails,
|
media_analysis::cleanup_thumbnails,
|
||||||
|
media_analysis::check_embedded_subtitles,
|
||||||
|
media_analysis::list_system_fonts,
|
||||||
export::export_clips,
|
export::export_clips,
|
||||||
|
get_media_server_port,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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 {
|
export interface ThumbnailSpritesheet {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -10,15 +10,33 @@ export interface ThumbnailSpritesheet {
|
|||||||
intervalSeconds: number;
|
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[]> {
|
export async function extractKeyframes(filePath: string): Promise<number[]> {
|
||||||
return invoke<number[]>('extract_keyframes', { filePath });
|
return invoke<number[]>('extract_keyframes', { filePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractWaveform(
|
export async function extractWaveformTiers(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
sampleCount: number
|
duration: number,
|
||||||
): Promise<number[]> {
|
onProgress?: (pct: number) => void
|
||||||
return invoke<number[]>('extract_waveform', { filePath, sampleCount });
|
): 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(
|
export async function extractThumbnails(
|
||||||
@@ -34,3 +52,13 @@ export async function extractThumbnails(
|
|||||||
export async function cleanupThumbnails(paths: string[]): Promise<void> {
|
export async function cleanupThumbnails(paths: string[]): Promise<void> {
|
||||||
return invoke<void>('cleanup_thumbnails', { paths });
|
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