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

@@ -0,0 +1,43 @@
# Fix pyenv pot-provider detection & version bump to v0.1.1
## Task Description
The built `.app` bundle was not detecting the `bgutil-ytdlp-pot-provider` yt-dlp plugin, even after the initial `fix_path_env()` implementation that added Homebrew paths to `PATH`. After fixing, bump version to 0.1.1, commit, push, and tag.
## Root Cause
`fix_path_env()` added `/opt/homebrew/bin` to PATH, which found Homebrew's `yt-dlp` binary. However, the pot-provider plugin was pip-installed under **pyenv**'s Python (`~/.pyenv/versions/3.12.6/lib/python3.12/site-packages/yt_dlp_plugins`). Homebrew's yt-dlp uses a different Python runtime with no plugin directories (`[debug] Plugin directories: none`). The pyenv shim for `yt-dlp` correctly resolves to pyenv's Python where the plugin is installed.
## Changes Made
### `src-tauri/src/lib.rs` — Enhanced `fix_path_env()`
- Added `~/.pyenv/shims` to PATH **before** Homebrew paths so pyenv-managed yt-dlp (with pip plugins) takes priority
- Sets `PYENV_ROOT` env var when pyenv exists so shims resolve correctly
- Added `~/.nvm/versions/node/<latest>/bin` discovery for nvm-managed Node.js
- Added `~/.deno/bin` to handled paths
- Restructured the function for clarity: home-relative paths first, then system-wide paths
### Version Bump (via `scripts/bump-version.sh 0.1.1`)
- `VERSION` → 0.1.1
- `package.json` → 0.1.1
- `src-tauri/Cargo.toml` → 0.1.1
- `src-tauri/tauri.conf.json` → 0.1.1
- `src-tauri/Cargo.lock` regenerated
### Git Operations
- Committed: `d46973a` on `master`
- Pushed to `origin/master`
- Tagged: `v0.1.1` (annotated, pushed)
## Verification
- `npm run check` — 0 errors, 0 warnings
- `npm test` — 5 files, 31 tests passed
- `cargo build` — clean compile
- `npm run release` — built `.app` and `.dmg` successfully
- Launched `.app` bundle — all 4 dependencies detected (ffmpeg, yt-dlp, JS runtime, bgutil-ytdlp-pot-provider)
## Lessons Learned
- **Python version managers (pyenv) create shim binaries** that dispatch to a specific Python version. When a tool like yt-dlp is both Homebrew-installed and pyenv/pip-installed, PATH ordering determines which one runs — and they can have different plugin directories.
- **Simulating the `.app` environment** with `env -i HOME="$HOME" PATH="<minimal>" yt-dlp -v` was the key diagnostic step that instantly reproduced the issue (`Plugin directories: none` vs `Plugin directories: ~/.pyenv/...`).
- The `bump-version.sh` script works correctly for multi-file version bumps across `VERSION`, `package.json`, `Cargo.toml`, `tauri.conf.json`, and `Cargo.lock`.
## Follow-up Items
- The `npm pkg set` command in `bump-version.sh` produces a cosmetic JSON error with `--json` flag before falling back; could be cleaned up.

View File

@@ -0,0 +1,86 @@
# v0.1.2 Polish — Placeholders, Auto-Upgrade, Analysis Cache
**Date**: 2026-09-22 14:50 ET
**Duration**: ~20 minutes (brainstorm) + ~20 minutes (execution)
**Version**: 0.1.1 → 0.1.2
**Commits**: 2f81011..925a3fc (11 commits)
## Task Description
Three polish features for v0.1.2:
1. Fix misleading "Generating Waveform…" / "Generating Thumbnails…" placeholder text shown in the timeline before any video is loaded
2. Automatically use cached HQ video files instead of prompting user to "Upgrade" when the export file is already downloaded
3. Cache video analysis results (waveforms, keyframes, thumbnails, captions) to avoid reprocessing the same video on reload
## Changes Made
### Feature 1: Idle-State Placeholder Text
- **`src/lib/timeline/renderer.ts`**: Added `hasVideo` boolean parameter to `drawTimeline()` and `showLabel` to `drawPlaceholderLane()`. When no video is loaded, timeline lanes show empty backgrounds without misleading "Generating…" text.
- **`src/lib/components/Timeline.svelte`**: Passes `session.status === 'ready'` as the `hasVideo` argument.
### Feature 2: Auto-Use Cached HQ Video
- **`src/lib/stores/videoSession.svelte.ts`**: Restructured `beginDownload()` to check export (HQ) cache before preview cache. When HQ file exists on disk, it's used directly as the active video — no preview download, no upgrade toast. The upgrade toast is preserved for mid-session HQ completions (to avoid jarring video source swaps during playback).
### Feature 3: Analysis Cache System
**New Rust service** (`src-tauri/src/services/cache_manager.rs`):
- Cache stored at `~/Library/Application Support/gui-video-clipper/cache/`
- Videos keyed by SHA-256 URL hash (first 16 hex chars)
- JSON manifest per video with waveform tiers, keyframe positions, thumbnail spritesheets, caption paths
- Functions: `load_manifest`, `save_manifest`, `clear_cache_entry`, `clear_all_cache`, `get_cache_stats`
- 5 unit tests for URL hashing and path computation
**New IPC commands** (`src-tauri/src/commands/cache.rs`):
- `load_cached_analysis` / `save_analysis_to_cache` / `clear_video_cache` / `clear_all_cache` / `get_cache_stats` / `get_thumbnail_cache_dir` / `get_caption_cache_dir`
- Path resolution: manifest stores relative paths, IPC layer resolves to absolute for frontend
**Updated `extract_thumbnails`**: Added optional `output_dir` parameter so thumbnails can be directed to cache directory instead of random temp dir.
**TypeScript bindings** (`src/lib/bindings/cache.ts`): Full wrapper layer for all cache IPC commands.
**Frontend integration** (`src/lib/stores/videoSession.svelte.ts`):
- `triggerPostDownloadProcessing()` checks cache first — on hit, populates session state instantly and skips ffmpeg. On miss, extracts normally then saves to cache.
- Thumbnails directed to cache dir via `getThumbnailCacheDir`
- New `reprocessCurrentVideo()` export: clears cache entry and re-runs extraction
**Preferences panel**: Cache management section showing total size and entry count with "Clear All Cache" button.
**StatusBar**: Per-video "↻" reprocess button visible when processing is complete.
**Dependency added**: `sha2 = "0.10"` for URL hashing.
## Files Changed (15 files)
### New Files (3)
- `src-tauri/src/services/cache_manager.rs`
- `src-tauri/src/commands/cache.rs`
- `src/lib/bindings/cache.ts`
### Modified Files (12)
- `src/lib/timeline/renderer.ts`
- `src/lib/components/Timeline.svelte`
- `src/lib/stores/videoSession.svelte.ts`
- `src-tauri/src/services/mod.rs`
- `src-tauri/src/commands/mod.rs`
- `src-tauri/src/lib.rs`
- `src-tauri/Cargo.toml` + `Cargo.lock`
- `src-tauri/src/commands/media_analysis.rs`
- `src/lib/bindings/mediaAnalysis.ts`
- `src/lib/components/PreferencesPanel.svelte`
- `src/lib/components/StatusBar.svelte`
- `VERSION`, `package.json`, `tauri.conf.json`
## Test Results
- **Rust**: 56 tests pass, 0 warnings
- **Frontend**: svelte-check 0 errors/warnings, vitest 31/31 pass
## Follow-Up Items
- **HQ+toast edge case**: When video loaded with cached HQ file, `triggerPostDownloadProcessing` can still set `showUpgradeToast = true` even though playback is already HQ. Cosmetic — toast appears but does nothing harmful. Could add a guard `activeVideoPath !== exportFilePath` in a future pass.
- **`now_iso8601` naming**: Function in `cache_manager.rs` returns Unix epoch seconds, not ISO 8601. Cosmetic naming issue.
- **Caption cache dir**: `getCaptionCacheDir` is wired but not used yet — captions still go to temp dir. The manifest stores whatever path captions end up at. Could wire caption output to cache dir in a future pass for full persistence.
- **Project files**: Cache manifest structure is designed as forward-looking scaffold for future project file save/load. No UI implemented yet.
## Lessons Learned
- Checking export cache before preview cache in `beginDownload()` was a clean refactor that simplified the flow — early return avoids duplicate logic.
- Inlining waveform/keyframe data in JSON manifests works well for the data sizes involved (~2 MB worst case). No need for binary serialization.
- The `#![allow(dead_code)]` at module scope in `cache_manager.rs` was pragmatic but broader than ideal — individual annotations would be more precise. Resolved naturally once Task 5 wired up the commands.

View File

@@ -0,0 +1,57 @@
# Fix v0.1.2 Regressions
**Date:** 2026-09-22 15:21
**Task:** Fix three regressions introduced by the v0.1.2 caching work
## Changes Made
### Bug 1: Slow keyframe/thumbnail/caption generation (seconds → minutes)
**Root cause:** When the HQ export file was cached from a prior session, `beginDownload()` passed the HQ file to `triggerPostDownloadProcessing()`. All ffmpeg operations ran on the full-resolution file instead of the ~360p preview, causing 10-50x slowdown.
**Fix in `src/lib/stores/videoSession.svelte.ts`:** In the HQ early-return path, also check for cached preview and prefer it for ffmpeg analysis while still using HQ for playback:
```typescript
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
triggerPostDownloadProcessing(previewForProcessing || exportCached);
```
### Bug 2: Blank thumbnails
**Root cause:** Tauri's asset protocol scope in `tauri.conf.json` only allowed temp directories. The new thumbnail cache directory under `~/Library/Application Support/` was blocked, so `convertFileSrc()` requests were denied.
**Fix (3 files):**
1. `src-tauri/src/services/cache_manager.rs`: Added `OnceLock<PathBuf>` + `init()` function so `cache_base_dir()` uses the Tauri-resolved app data directory (matching `$APPDATA`).
2. `src-tauri/src/lib.rs`: Call `cache_manager::init(app.path().app_data_dir())` during setup.
3. `src-tauri/tauri.conf.json`: Added `"$APPDATA/**"` to the asset protocol scope.
**Side effect:** Existing caches under `~/Library/Application Support/gui-video-clipper/cache/` are orphaned. New location is `~/Library/Application Support/xyz.cottongin.gui-video-clipper/cache/`. One-time cost for a brand-new feature.
### Bug 3: False upgrade toast when already on HQ
**Root cause:** `triggerPostDownloadProcessing()` unconditionally set `showUpgradeToast = true` when `exportStatus === 'complete'`, even when `activeVideoPath` was already the export file.
**Fix in `src/lib/stores/videoSession.svelte.ts`:** Added guard `&& session.activeVideoPath !== session.exportFilePath` to both toast-setting locations (cache-hit and cache-miss blocks).
### Defensive fix: ffmpeg `-y` flag
**File:** `src-tauri/src/services/thumbnail_extractor.rs`
Added `-y` flag to the ffmpeg thumbnail extraction command to prevent hangs if re-extracting to a directory with existing frame files.
## Files Changed
- `src/lib/stores/videoSession.svelte.ts` — Fixes 1 and 3
- `src-tauri/src/services/cache_manager.rs` — Fix 2 (OnceLock for app data dir)
- `src-tauri/src/lib.rs` — Fix 2 (init cache manager in setup)
- `src-tauri/tauri.conf.json` — Fix 2 (add $APPDATA to scope)
- `src-tauri/src/services/thumbnail_extractor.rs` — Defensive `-y` flag
## Lessons Learned
1. When adding a caching layer that moves files from temp dirs to persistent dirs, always verify that security scopes (Tauri asset protocol, CSP, etc.) cover the new location.
2. When adding an "HQ early return" optimization, consider that downstream processing might still benefit from the smaller preview file — separate the "playback source" from the "analysis source."
3. Toast/notification conditions should always guard against the "already in target state" case, not just the "target state reached" case.
4. ffmpeg commands writing to potentially non-empty directories need `-y` to avoid interactive prompts that hang `.output()` calls.
## Follow-up Items
- Consider cleaning up orphaned caches under the old `gui-video-clipper` directory.

View File

@@ -0,0 +1,55 @@
# Fix Post-v0.1.2 Issues
**Date:** 2026-09-22 15:48
**Task:** Fix broken keyframe detection, missing cached captions, and About dialog version duplication
## Changes Made
### Issue 1: Keyframe buttons always greyed out
**Root cause:** ffprobe 8.x no longer populates the `flags` field. The parser in `keyframe_index.rs` expected 3-column CSV output (`media_type,pts_time,flags`) and checked `parts[2].contains('K')`. With ffprobe 8.0.1, the output was just timestamps (flags absent), so the parser's `parts.len() >= 3` check failed on every line, always returning `[]`.
Confirmed locally: `ffprobe -show_entries frame=pts_time,flags -of csv=p=0 -skip_frame nokey` produces:
```
0.000000,
1.000000
```
**Fix in `src-tauri/src/services/keyframe_index.rs`:**
- Changed ffprobe command to only request `frame=pts_time` (dropped `flags` since `-skip_frame nokey` already filters to keyframes)
- Rewrote `parse_ffprobe_output` to handle: (a) new format: plain timestamp per line with optional trailing comma, (b) legacy format: `section,pts_time[,flags]`
- Updated all tests — 5 tests covering new format, legacy with flags, legacy with section prefix, empty, and whitespace
### Issue 2: Captions not cached or loaded from cache
**Root cause:** In `save_analysis_to_cache` (cache.rs), caption paths were relativized via `strip_prefix(&cache_dir)`. But captions live outside the cache dir (e.g. `/tmp/video-clipper/subtitles/embedded.vtt`), so `strip_prefix` always failed and `relative_caption` was `None`. Captions were never saved to the manifest.
**Fix (2 files):**
1. `src-tauri/src/commands/cache.rs`: When `strip_prefix` fails, copy the caption file into `cache_dir/captions/` and store the relative path.
2. `src/lib/stores/videoSession.svelte.ts`: In the cache-hit path of `triggerPostDownloadProcessing`, if `captionFilePath` is null (old caches without captions), fall back to `loadCaptionsWithCache(filePath)` for re-extraction.
### Issue 3: About dialog shows "v0.1.2 (0.1.2)"
**Root cause:** The native macOS About dialog shows "Version {CFBundleShortVersionString} ({CFBundleVersion})". Tauri sets both from the same `version` field, producing "Version 0.1.2 (0.1.2)".
**Fix in `src-tauri/src/lib.rs`:** Created a custom macOS app menu with `AboutMetadataBuilder` that only sets `short_version` (no build number). Also provides standard Edit menu (undo/redo/cut/copy/paste/select-all). Version is read at compile time via `env!("CARGO_PKG_VERSION")`.
## Files Changed
- `src-tauri/src/services/keyframe_index.rs` — Fix 1: ffprobe command + parser + tests
- `src-tauri/src/commands/cache.rs` — Fix 2: copy captions into cache dir
- `src/lib/stores/videoSession.svelte.ts` — Fix 2: caption fallback on cache hit
- `src-tauri/src/lib.rs` — Fix 3: custom macOS menu
## Verification
- `cargo check`: 0 warnings, 0 errors
- `cargo test`: 58 passed, 0 failed
- `svelte-check`: 0 errors, 0 warnings
- `ReadLints`: no issues
## Lessons Learned
1. ffprobe output format can change across major versions. The `flags` field was silently dropped in ffprobe 8.x. Always test against the actual binary on the target system.
2. When caching file references, files outside the cache directory need to be copied in — `strip_prefix` silently returns `None` on failure via `.ok()`, making the bug invisible.
3. Native macOS About dialogs auto-format version info as "Version X (Build Y)". When both values are identical, it looks like a bug. Use `AboutMetadataBuilder` to control this.

View File

@@ -0,0 +1,42 @@
# Fix About Dialog & Reprocess Always Uses Low-Res
**Date**: 2026-09-22 16:07
**Task**: Fix native About dialog duplication/icon + force reprocessing to always use low-res preview file
## Issues Addressed
### 1. Native About Dialog Still Showing (Duplicate Version)
**Problem**: The macOS menu "About" item still opened the native About panel (which shows duplicate version strings "v0.1.2 (0.1.2)") instead of the custom Svelte `AboutDialog`.
**Fix**: Replaced the `AboutMetadataBuilder`-based `.about()` menu item with a custom `MenuItemBuilder` that emits a Tauri event (`open-about`). The frontend listens for this event via `listen('open-about', ...)` in `App.svelte` and toggles `showAboutDialog = true`.
**Files changed**:
- `src-tauri/src/lib.rs` — Replaced `AboutMetadataBuilder` usage with `MenuItemBuilder` + `PredefinedMenuItem` for hide/show/quit. Added `.on_menu_event()` handler to emit `open-about` event.
- `src/App.svelte` — Added `import { listen } from '@tauri-apps/api/event'` and a `$effect` that listens for `open-about` and opens the custom dialog.
### 2. Broken Icon in About Dialog
**Problem**: The `<img src="/favicon.svg">` tag failed to load in the Tauri webview, showing a broken image placeholder.
**Fix**: Inlined the SVG directly into the `AboutDialog.svelte` component, eliminating the external file reference. Added `rx="90"` to the background rect for rounded-corner macOS icon styling.
**Files changed**:
- `src/lib/components/AboutDialog.svelte` — Replaced `<img>` tag with inline `<svg>`.
### 3. Reprocessing Uses Full-Resolution File (Performance)
**Problem**: `reprocessCurrentVideo()` used `session.activeVideoPath` which points to the HQ export file after upgrade. All ffmpeg analysis (waveform, keyframes, thumbnails) on the full-res file takes minutes instead of seconds.
**Fix**: `reprocessCurrentVideo()` now explicitly prefers `session.previewFilePath` for processing. If that equals the export file (or is null), it falls back to checking the disk cache for a low-res preview via `checkCachedDownload(title, 'preview')`. Only uses `activeVideoPath` as a last resort.
**Files changed**:
- `src/lib/stores/videoSession.svelte.ts` — Rewrote `reprocessCurrentVideo()` to resolve the low-res preview path before triggering processing.
## Verification
- `cargo check` — 0 errors, 0 warnings
- `cargo test` — 58 tests passed
- `svelte-check` — 0 errors, 0 warnings
- `ReadLints` — No linter errors
## Lessons Learned
- Tauri's `.about(Some(metadata))` on `SubmenuBuilder` always creates the *native* macOS About panel — there is no way to redirect it to a custom webview dialog. To intercept "About", you must use a regular `MenuItemBuilder` with `.on_menu_event()`.
- Inline SVGs in Svelte components are more reliable than `<img src="...">` references in Tauri webviews, where path resolution can differ between dev and production builds.
- When a session tracks both a preview and export file path, any function that runs ffmpeg analysis must explicitly resolve which path to use — defaulting to `activeVideoPath` is a footgun since that gets upgraded to HQ.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "gui-video-clipper",
"version": "0.1.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gui-video-clipper",
"version": "0.1.0",
"version": "0.1.2",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",

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",

View File

@@ -27,7 +27,8 @@
"/tmp/**",
"/private/tmp/**",
"/private/var/**",
"/var/**"
"/var/**",
"$APPDATA/**"
]
}
}

View File

@@ -14,6 +14,7 @@
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
import { session } from '$lib/stores/videoSession.svelte';
import { listen } from '@tauri-apps/api/event';
import type { TransportKeyAction } from '$lib/transport/playback';
let showSetupWizard = $state(true);
@@ -35,6 +36,14 @@
loadPreferences();
});
// Listen for native macOS menu "About" action
$effect(() => {
const unlisten = listen('open-about', () => {
showAboutDialog = true;
});
return () => { unlisten.then((fn) => fn()); };
});
$effect(() => {
if (session.status === 'resolving') {
shuttleRate = 1;

View File

@@ -31,7 +31,43 @@
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<img class="app-icon" src="/favicon.svg" alt="GUI Video Clipper icon" />
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="90" fill="#18181b"/>
<g transform="translate(-3, 3)">
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<g transform="translate(3, -3)">
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
<g transform="rotate(20, 256, 256)">
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
</g>
</svg>
<h2>GUI Video Clipper</h2>
{#if version}
<span class="version">v{version}</span>

View File

@@ -209,9 +209,13 @@ async function triggerPostDownloadProcessing(filePath: string) {
}
if (cached.captionFilePath) {
session.captionFilePath = cached.captionFilePath;
} else {
// Captions weren't cached — re-extract
await loadCaptionsWithCache(filePath);
}
session.processingStep = 'done';
if (session.exportStatus === 'complete' && session.exportFilePath) {
if (session.exportStatus === 'complete' && session.exportFilePath
&& session.activeVideoPath !== session.exportFilePath) {
session.showUpgradeToast = true;
}
return;
@@ -272,7 +276,8 @@ async function triggerPostDownloadProcessing(filePath: string) {
console.error('Failed to save analysis to cache:', err);
}
if (session.exportStatus === 'complete' && session.exportFilePath) {
if (session.exportStatus === 'complete' && session.exportFilePath
&& session.activeVideoPath !== session.exportFilePath) {
session.showUpgradeToast = true;
}
}
@@ -319,8 +324,9 @@ export async function beginDownload() {
session.previewProgress = 1.0;
session.previewStatus = 'complete';
session.activeVideoPath = exportCached;
// No upgrade toast — we're already at best quality
triggerPostDownloadProcessing(exportCached);
// Prefer preview file for analysis (much faster ffmpeg operations)
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
triggerPostDownloadProcessing(previewForProcessing || exportCached);
return;
}
@@ -403,7 +409,15 @@ export async function beginDownload() {
/** Clear cache for the current video and re-extract all analysis data. */
export async function reprocessCurrentVideo() {
if (!session.url || !session.activeVideoPath) return;
const filePath = session.activeVideoPath;
// Always use the low-res preview file for processing (never the HQ export)
let processingPath: string | null = session.previewFilePath;
if (!processingPath || processingPath === session.exportFilePath) {
// Try to find a cached preview file on disk
processingPath = await checkCachedDownload(session.title, 'preview').catch(() => null);
}
// Last resort: fall back to whatever is active (should rarely happen)
const filePath = processingPath || session.activeVideoPath;
// Clear cache
try {