fix: about dialog, cache paths, keyframes, reprocess always uses low-res

- Replace native macOS About menu with custom Svelte dialog via Tauri event
- Inline SVG icon in AboutDialog (fixes broken image in webview)
- Align cache directory with Tauri $APPDATA, add to asset protocol scope
- Fix ffprobe 8.x keyframe parsing (skip_frame nokey, flexible parser)
- Copy caption files into cache dir when outside cache path
- Add -y flag to ffmpeg thumbnail extraction (prevent hang on re-extract)
- Reprocess always uses preview (low-res) file, never HQ export
- Prefer cached preview file for analysis even when HQ is cached
- Guard upgrade toast when already on HQ

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 16:16:00 -04:00
parent 925a3fc054
commit 9499d776fd
15 changed files with 471 additions and 30 deletions

View File

@@ -82,12 +82,25 @@ pub async fn save_analysis_to_cache(
})
.collect();
// Convert absolute caption path to relative
// Convert absolute caption path to relative (copy into cache if needed)
let relative_caption = caption_file_path.and_then(|abs| {
std::path::Path::new(&abs)
.strip_prefix(&cache_dir)
.ok()
.map(|rel| rel.to_string_lossy().to_string())
let abs_path = std::path::Path::new(&abs);
// Already inside cache dir
if let Ok(rel) = abs_path.strip_prefix(&cache_dir) {
return Some(rel.to_string_lossy().to_string());
}
// Outside cache dir — copy it in
if abs_path.is_file() {
let captions_dir = cache_dir.join("captions");
std::fs::create_dir_all(&captions_dir).ok();
if let Some(filename) = abs_path.file_name() {
let dest = captions_dir.join(filename);
if std::fs::copy(abs_path, &dest).is_ok() {
return Some(format!("captions/{}", filename.to_string_lossy()));
}
}
}
None
});
let now = format!(

View File

@@ -6,7 +6,8 @@ use std::collections::HashSet;
use std::path::PathBuf;
use commands::{cache, dependencies, export, media_analysis, video};
use tauri::Manager;
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder};
use tauri::{Emitter, Manager};
struct MediaServerPort(u16);
@@ -100,14 +101,58 @@ pub fn run() {
.setup(|app| {
fix_path_env();
// Initialize cache manager with Tauri-resolved app data directory
let app_data_dir = app.path().app_data_dir()
.expect("Failed to resolve app data dir");
std::fs::create_dir_all(&app_data_dir).ok();
services::cache_manager::init(app_data_dir);
// 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);
// Custom macOS menu — open our Svelte About dialog instead of native
let about_item = MenuItemBuilder::new("About GUI Video Clipper")
.id("about")
.build(app)?;
let app_menu = SubmenuBuilder::new(app, "GUI Video Clipper")
.item(&about_item)
.separator()
.item(&PredefinedMenuItem::hide(app, None)?)
.item(&PredefinedMenuItem::hide_others(app, None)?)
.item(&PredefinedMenuItem::show_all(app, None)?)
.separator()
.quit()
.build()?;
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?;
let menu = MenuBuilder::new(app)
.item(&app_menu)
.item(&edit_menu)
.build()?;
app.set_menu(menu)?;
Ok(())
})
.on_menu_event(|app, event| {
if event.id().as_ref() == "about" {
let _ = app.emit("open-about", ());
}
})
.invoke_handler(tauri::generate_handler![
dependencies::check_dependencies,
dependencies::install_dependency,

View File

@@ -5,6 +5,15 @@ use crate::models::{ThumbnailSpritesheet, WaveformTiers};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::OnceLock;
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Initialize the cache manager with the Tauri-resolved app data directory.
/// Must be called once during app setup before any cache operations.
pub fn init(app_data_dir: PathBuf) {
APP_DATA_DIR.set(app_data_dir).ok();
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -41,9 +50,14 @@ pub fn url_hash(url: &str) -> String {
}
pub fn cache_base_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("gui-video-clipper")
APP_DATA_DIR
.get()
.cloned()
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("gui-video-clipper")
})
.join("cache")
}

View File

@@ -5,11 +5,18 @@ pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split(',').collect();
// Format: media_type,pts_time,flags
// We want lines where flags contain 'K' (keyframe)
if parts.len() >= 3 && parts[2].contains('K') {
parts[1].parse::<f64>().ok()
let trimmed = line.trim().trim_end_matches(',');
if trimmed.is_empty() {
return None;
}
// New format (ffprobe 8.x): just a timestamp per line
if let Ok(val) = trimmed.parse::<f64>() {
return Some(val);
}
// Legacy format: section,pts_time,flags or section,pts_time
let parts: Vec<&str> = trimmed.split(',').collect();
if parts.len() >= 2 {
parts[1].trim().parse::<f64>().ok()
} else {
None
}
@@ -23,7 +30,7 @@ pub fn extract_keyframes(file_path: &str) -> Result<Vec<f64>, String> {
"-select_streams",
"v",
"-show_entries",
"frame=pts_time,flags",
"frame=pts_time",
"-of",
"csv=p=0",
"-skip_frame",
@@ -49,8 +56,8 @@ mod tests {
use super::*;
#[test]
fn test_parse_ffprobe_keyframes() {
let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n";
fn test_parse_new_format_plain_timestamps() {
let output = "0.000000,\n1.000000\n2.000000,\n";
let result = parse_ffprobe_output(output);
assert_eq!(result.len(), 3);
assert!((result[0] - 0.0).abs() < 0.001);
@@ -59,13 +66,31 @@ mod tests {
}
#[test]
fn test_parse_ffprobe_empty() {
fn test_parse_legacy_format_with_flags() {
let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n";
let result = parse_ffprobe_output(output);
assert_eq!(result.len(), 5);
assert!((result[0] - 0.0).abs() < 0.001);
assert!((result[2] - 1.0).abs() < 0.001);
assert!((result[4] - 2.0).abs() < 0.001);
}
#[test]
fn test_parse_legacy_format_with_section_prefix() {
let output = "frame,0.000000,\nframe,1.000000\n";
let result = parse_ffprobe_output(output);
assert_eq!(result.len(), 2);
assert!((result[0] - 0.0).abs() < 0.001);
assert!((result[1] - 1.0).abs() < 0.001);
}
#[test]
fn test_parse_empty() {
assert!(parse_ffprobe_output("").is_empty());
}
#[test]
fn test_parse_ffprobe_no_keyframes() {
let output = "video,0.500000,___\nvideo,1.500000,___\n";
assert!(parse_ffprobe_output(output).is_empty());
fn test_parse_whitespace_only() {
assert!(parse_ffprobe_output(" \n \n").is_empty());
}
}

View File

@@ -30,6 +30,7 @@ pub fn extract_thumbnails(
let frames_pattern = format!("{output_dir}/frame_%06d.jpg");
let status = Command::new(ffmpeg_bin())
.args([
"-y",
"-i",
file_path,
"-vf",