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:
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user