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:
@@ -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.
|
||||
86
chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md
Normal file
86
chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"/tmp/**",
|
||||
"/private/tmp/**",
|
||||
"/private/var/**",
|
||||
"/var/**"
|
||||
"/var/**",
|
||||
"$APPDATA/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user