diff --git a/chat-summaries/2026-09-22_14-10-fix-pyenv-pot-provider-v011-summary.md b/chat-summaries/2026-09-22_14-10-fix-pyenv-pot-provider-v011-summary.md new file mode 100644 index 0000000..6377a37 --- /dev/null +++ b/chat-summaries/2026-09-22_14-10-fix-pyenv-pot-provider-v011-summary.md @@ -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//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="" 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. diff --git a/chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md b/chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md new file mode 100644 index 0000000..0878205 --- /dev/null +++ b/chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md @@ -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. diff --git a/chat-summaries/2026-09-22_15-21-fix-v012-regressions-summary.md b/chat-summaries/2026-09-22_15-21-fix-v012-regressions-summary.md new file mode 100644 index 0000000..064e6af --- /dev/null +++ b/chat-summaries/2026-09-22_15-21-fix-v012-regressions-summary.md @@ -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` + `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. diff --git a/chat-summaries/2026-09-22_15-48-fix-post-v012-issues-summary.md b/chat-summaries/2026-09-22_15-48-fix-post-v012-issues-summary.md new file mode 100644 index 0000000..aacec09 --- /dev/null +++ b/chat-summaries/2026-09-22_15-48-fix-post-v012-issues-summary.md @@ -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. diff --git a/chat-summaries/2026-09-22_16-07-about-dialog-reprocess-lowres-summary.md b/chat-summaries/2026-09-22_16-07-about-dialog-reprocess-lowres-summary.md new file mode 100644 index 0000000..d2dfb0b --- /dev/null +++ b/chat-summaries/2026-09-22_16-07-about-dialog-reprocess-lowres-summary.md @@ -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 `` 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 `` tag with inline ``. + +### 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 `` 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. diff --git a/package-lock.json b/package-lock.json index 1af4494..5e6cc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src-tauri/src/commands/cache.rs b/src-tauri/src/commands/cache.rs index 82838a9..e958de8 100644 --- a/src-tauri/src/commands/cache.rs +++ b/src-tauri/src/commands/cache.rs @@ -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!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd40204..dac0806 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/services/cache_manager.rs b/src-tauri/src/services/cache_manager.rs index c30284a..c3df942 100644 --- a/src-tauri/src/services/cache_manager.rs +++ b/src-tauri/src/services/cache_manager.rs @@ -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 = 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") } diff --git a/src-tauri/src/services/keyframe_index.rs b/src-tauri/src/services/keyframe_index.rs index 5560ea4..f58a800 100644 --- a/src-tauri/src/services/keyframe_index.rs +++ b/src-tauri/src/services/keyframe_index.rs @@ -5,11 +5,18 @@ pub fn parse_ffprobe_output(output: &str) -> Vec { 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::().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::() { + 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::().ok() } else { None } @@ -23,7 +30,7 @@ pub fn extract_keyframes(file_path: &str) -> Result, 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()); } } diff --git a/src-tauri/src/services/thumbnail_extractor.rs b/src-tauri/src/services/thumbnail_extractor.rs index 44362cc..7d3453d 100644 --- a/src-tauri/src/services/thumbnail_extractor.rs +++ b/src-tauri/src/services/thumbnail_extractor.rs @@ -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", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2e3a35d..319acf2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,8 @@ "/tmp/**", "/private/tmp/**", "/private/var/**", - "/var/**" + "/var/**", + "$APPDATA/**" ] } } diff --git a/src/App.svelte b/src/App.svelte index c5a91cf..c814953 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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; diff --git a/src/lib/components/AboutDialog.svelte b/src/lib/components/AboutDialog.svelte index d694ce4..572de81 100644 --- a/src/lib/components/AboutDialog.svelte +++ b/src/lib/components/AboutDialog.svelte @@ -31,7 +31,43 @@ onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} > - GUI Video Clipper icon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

GUI Video Clipper

{#if version} v{version} diff --git a/src/lib/stores/videoSession.svelte.ts b/src/lib/stores/videoSession.svelte.ts index 8f48668..373b68b 100644 --- a/src/lib/stores/videoSession.svelte.ts +++ b/src/lib/stores/videoSession.svelte.ts @@ -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 {