From a6277f0aa501f391e1cc5275ded18696b71d82b5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 22 Sep 2026 10:31:30 -0400 Subject: [PATCH] feat(captions): add list_system_fonts command with fc-list + fallback Co-authored-by: Cursor --- src-tauri/src/commands/media_analysis.rs | 163 +++++++++++++++++++++-- src-tauri/src/lib.rs | 25 +++- src/lib/bindings/mediaAnalysis.ts | 38 +++++- 3 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/media_analysis.rs b/src-tauri/src/commands/media_analysis.rs index d2a8cb0..e4dc8a5 100644 --- a/src-tauri/src/commands/media_analysis.rs +++ b/src-tauri/src/commands/media_analysis.rs @@ -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, 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, String> { - tokio::task::spawn_blocking(move || { - waveform_generator::extract_waveform(&file_path, sample_count) + duration: f64, + on_progress: Channel, +) -> Result { + 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, 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) -> Result<(), String> { .await .map_err(|e| format!("Task failed: {e}"))? } + +#[tauri::command] +pub async fn check_embedded_subtitles(file_path: String) -> Result, 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, 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 = 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}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cdc2e28..2c9e6ec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/lib/bindings/mediaAnalysis.ts b/src/lib/bindings/mediaAnalysis.ts index 03f92fd..223a69a 100644 --- a/src/lib/bindings/mediaAnalysis.ts +++ b/src/lib/bindings/mediaAnalysis.ts @@ -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 { return invoke('extract_keyframes', { filePath }); } -export async function extractWaveform( +export async function extractWaveformTiers( filePath: string, - sampleCount: number -): Promise { - return invoke('extract_waveform', { filePath, sampleCount }); + duration: number, + onProgress?: (pct: number) => void +): Promise { + const channel = new Channel(); + if (onProgress) { + channel.onmessage = onProgress; + } + return invoke('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 { return invoke('cleanup_thumbnails', { paths }); } + +export async function checkEmbeddedSubtitles( + filePath: string +): Promise { + return invoke('check_embedded_subtitles', { filePath }); +} + +export async function listSystemFonts(): Promise { + return invoke('list_system_fonts'); +}