14 Commits

Author SHA1 Message Date
9499d776fd 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>
2026-09-22 16:16:00 -04:00
925a3fc054 chore: bump version to 0.1.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:49:47 -04:00
36ff3094b6 feat: add per-video reprocess button to status bar
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:48:26 -04:00
2c3d035d1f feat: add cache management section to Preferences panel
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:47:29 -04:00
b180d4324c feat: integrate analysis cache into video session processing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:45:37 -04:00
b7a5caf4ae feat: add TypeScript bindings for cache IPC commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:44:24 -04:00
6818938cb0 feat: add optional output_dir param to extract_thumbnails
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:43:24 -04:00
b7fb7929f0 feat: add cache IPC commands for analysis caching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:41:37 -04:00
1773ae81e6 feat: add cache_manager service for analysis caching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:39:43 -04:00
dc3e7945c6 chore: add sha2 dependency for URL hashing in analysis cache
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:38:16 -04:00
8bd2b34103 feat: auto-use cached HQ video file without upgrade prompt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:36:33 -04:00
90f598050c fix: show empty timeline lanes when no video is loaded
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:34:58 -04:00
2f810111cc docs: add v0.1.2 polish implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:31:36 -04:00
2055524d30 docs: add v0.1.2 polish design spec (placeholders, auto-upgrade, analysis cache)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:26:44 -04:00
30 changed files with 2850 additions and 82 deletions

View File

@@ -1 +1 @@
0.1.1
0.1.2

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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
# v0.1.2 Polish: Placeholders, Auto-Upgrade, and Analysis Cache
**Date**: 2026-09-22
**Version**: 0.1.2
**Status**: Design
## Overview
Three polish features for v0.1.2:
1. Fix misleading placeholder text shown before any video is loaded
2. Automatically use cached HQ video files instead of prompting to upgrade
3. Cache video analysis results (waveforms, keyframes, thumbnails, captions) to avoid reprocessing
## 1. Idle-State Placeholder Text
### Problem
When the app first loads with no video, the timeline shows "Generating Waveform..." and "Generating Thumbnails..." in the placeholder lanes. No work is happening — this is misleading.
### Solution
Make `drawTimeline` context-aware by accepting a `hasVideo` boolean (derived from `session.status === 'ready'`).
- **No video loaded** (`hasVideo === false`): Draw empty lane backgrounds with no text.
- **Video loaded, data pending** (`hasVideo === true`, data missing): Show "Generating Waveform..." / "Generating Thumbnails..." as today.
### Files Changed
- `src/lib/timeline/renderer.ts`: Add `hasVideo` parameter to `drawTimeline()`. Update `drawPlaceholderLane()` to accept a boolean controlling whether to show text. When `hasVideo` is false, render the background rectangle but skip the text label.
- `src/lib/components/Timeline.svelte`: Pass `session.status === 'ready'` as the `hasVideo` argument.
## 2. Auto-Use Cached HQ Video
### Problem
When a video's HQ (export) file is already cached on disk from a prior session, the app still downloads a low-res preview, then shows an "Upgrade" toast to switch to HQ. The user should get the HQ version immediately.
### Solution
At the start of `beginDownload()`, check the export cache first. If the HQ file exists:
- Set `activeVideoPath` to the HQ file immediately
- Set `exportFilePath`, `exportStatus = 'complete'`, `exportProgress = 1.0`
- Mark preview as complete too (skip the preview download entirely)
- Run `triggerPostDownloadProcessing()` against the HQ file
- No upgrade toast shown
When no cached HQ exists, the existing two-track download flow (preview first, background HQ) continues unchanged. The upgrade toast still appears when HQ completes mid-session — silently swapping the video source during active playback would be jarring.
### Behavior Matrix
| HQ cached? | Preview cached? | Behavior |
|---|---|---|
| Yes | (irrelevant) | Use HQ directly. No preview download. No toast. |
| No | Yes | Use cached preview. Start background HQ download. Toast on HQ completion. |
| No | No | Download preview, start background HQ. Toast on HQ completion. |
### Files Changed
- `src/lib/stores/videoSession.svelte.ts`: Restructure `beginDownload()` to check export cache before preview cache. Add early-return path that sets HQ as active video.
## 3. Analysis Cache System
### Problem
Every time a video is loaded, waveforms, keyframes, and thumbnails are re-extracted from scratch via ffmpeg. For a 5-minute video this takes 10-30 seconds. Re-opening the same video should be instant.
### Architecture
#### Cache Directory
```
~/Library/Application Support/gui-video-clipper/cache/
<url-hash>/
manifest.json
thumbnails/
frame_000001.jpg
frame_000002.jpg
...
captions/
en.vtt
```
The URL hash is the first 16 hex characters of the SHA-256 of the source URL. This provides sufficient collision resistance while keeping paths short.
#### Manifest Schema
```json
{
"version": 1,
"sourceUrl": "https://youtube.com/watch?v=abc123",
"title": "Example Video",
"duration": 312.5,
"fps": 30.0,
"createdAt": "2026-09-22T18:00:00Z",
"lastAccessedAt": "2026-09-22T18:05:00Z",
"waveformTiers": {
"tier0": [0.1, 0.5, ...],
"tier1": [0.1, 0.3, 0.5, ...],
"tier2": [0.05, 0.1, 0.15, ...]
},
"keyframePositions": [0.0, 2.5, 5.0, 7.5],
"thumbnailSpritesheets": [
{
"filePath": "thumbnails",
"startIndex": 0,
"count": 100,
"thumbWidth": 160,
"thumbHeight": 90,
"columns": 10,
"intervalSeconds": 1.0
}
],
"captionFilePath": "captions/en.vtt"
}
```
**Data sizing**: Waveform tier2 is at most 200K floats (~1.6 MB as JSON). Tier0 and tier1 add ~100 KB combined. Keyframes are a few hundred entries. This all fits comfortably inline in JSON. Thumbnails are JPEG files on disk, referenced by relative path.
All paths in the manifest are relative to the cache entry directory.
#### New Rust Service: `cache_manager.rs`
```rust
// Public API
pub fn cache_dir_for_url(url: &str) -> PathBuf
pub fn load_manifest(url: &str) -> Option<CacheManifest>
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String>
pub fn clear_cache_entry(url: &str) -> Result<(), String>
pub fn clear_all_cache() -> Result<(), String>
pub fn get_cache_stats() -> Result<CacheStats, String>
pub fn thumbnail_cache_dir(url: &str) -> PathBuf
pub fn caption_cache_dir(url: &str) -> PathBuf
```
`CacheManifest` struct mirrors the JSON schema above. `CacheStats` contains `entry_count: usize` and `total_bytes: u64`.
#### New IPC Commands
Added to `src-tauri/src/commands/` (new file `cache.rs` or added to existing):
| Command | Signature | Purpose |
|---|---|---|
| `load_cached_analysis` | `(url: String) -> Option<CachedAnalysis>` | Load all cached data for a URL |
| `save_analysis_to_cache` | `(url, waveform_tiers, keyframe_positions, thumbnail_spritesheets, caption_file_path) -> ()` | Persist analysis results |
| `clear_video_cache` | `(url: String) -> ()` | Delete one video's cache |
| `clear_all_cache` | `() -> ()` | Delete the entire cache dir |
| `get_cache_stats` | `() -> CacheStats` | Get entry count and total size |
| `get_thumbnail_cache_dir` | `(url: String) -> String` | Get the cache directory path for thumbnail output |
| `get_caption_cache_dir` | `(url: String) -> String` | Get the cache directory path for caption output |
`CachedAnalysis` contains:
- `waveformTiers: WaveformTiers`
- `keyframePositions: Vec<f64>`
- `thumbnailSpritesheets: Vec<ThumbnailSpritesheet>` (with absolute paths resolved from the cache dir)
- `captionFilePath: Option<String>` (absolute path)
`save_analysis_to_cache` accepts the individual analysis fields rather than a wrapper struct, keeping the IPC boundary simple. The Rust side constructs the `CacheManifest` from these fields plus metadata (source URL, title, timestamps) looked up from the manifest or computed fresh.
#### Frontend Integration
**`videoSession.svelte.ts` — `triggerPostDownloadProcessing()`**:
1. Call `load_cached_analysis(session.url)` before running ffmpeg.
2. On cache hit: populate session state directly, jump to `processingStep = 'done'`. Skip all ffmpeg extraction.
3. On cache miss: run extraction as today. After all steps complete (waveform, keyframes, thumbnails, captions), call `save_analysis_to_cache(...)` to persist.
**New TypeScript bindings**: `src/lib/bindings/cache.ts` with wrapper functions for the new IPC commands.
#### Thumbnail Path Changes
Currently, `extract_thumbnails` (Rust command) generates a UUID-based temp dir for thumbnails. With caching:
- Add an optional `output_dir: Option<String>` parameter to the `extract_thumbnails` Tauri command. When provided, thumbnails are written there. When `None`, the existing UUID-based temp dir behavior is preserved as a fallback.
- The frontend calls `get_thumbnail_cache_dir(session.url)` to get the cache-managed output path, then passes it to `extract_thumbnails`.
- Similarly, `get_caption_cache_dir(session.url)` provides the path for caption file output.
### Cache Invalidation UX
**Preferences Panel** (`PreferencesPanel.svelte`):
- New "Cache" section at the bottom
- Display total cache size (e.g., "Cache: 450 MB across 12 videos")
- "Clear All Cache" button with confirmation
**Per-Video Reprocess** (in `StatusBar.svelte` or near the transport controls):
- A small "↻" (reprocess) button visible when a video is loaded and processing is complete (`processingStep === 'done'`)
- Calls `clear_video_cache(session.url)` then re-runs `triggerPostDownloadProcessing()` on the current video file
- This re-extracts analysis data only — it does not re-download the video
### Future: Project Files
The cache manifest structure is designed to be referenced by future project files. A project file would store:
- The source URL
- Clip regions (start/end times, labels, colors)
- Caption settings
- A reference to the cache entry (by URL hash)
This design means a project file doesn't duplicate analysis data — it points to the cache. If the cache is cleared, the project can re-derive everything from the source URL.
No project file save/load UI is implemented in this version. The manifest structure is the forward-looking scaffold.
### Dead Code Annotations
Any types or fields scaffolded for future project file support (e.g., fields in `CacheManifest` not yet read by the frontend) will be annotated with `#[allow(dead_code)]` and a `// Future: used by project file save/load` comment. This satisfies the requirement to handle dead code warnings proactively (item 3b).
## Constraints
- **Zero new warnings/errors**: All existing warnings and errors were recently resolved. Any new warnings introduced during implementation must be addressed immediately.
- **macOS-only paths**: `~/Library/Application Support/` is macOS-specific, which matches the project's macOS-only target.
- **Backward compatibility**: Existing temp-dir downloads (preview/export) continue to work as before. The cache is additive — it doesn't replace the download mechanism.

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

@@ -1,6 +1,6 @@
{
"name": "gui-video-clipper",
"version": "0.1.1",
"version": "0.1.2",
"description": "macOS GUI video clipper (Tauri + Svelte)",
"type": "module",
"scripts": {

3
src-tauri/Cargo.lock generated
View File

@@ -1281,12 +1281,13 @@ dependencies = [
[[package]]
name = "gui-video-clipper"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"axum",
"dirs",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",

View File

@@ -1,6 +1,6 @@
[package]
name = "gui-video-clipper"
version = "0.1.1"
version = "0.1.2"
description = "A macOS GUI app for clipping online videos"
authors = ["cottongin"]
edition = "2021"
@@ -32,6 +32,7 @@ tempfile = "3.27.0"
dirs = "6.0.0"
axum = "0.8"
tower-http = { version = "0.6", features = ["fs", "cors"] }
sha2 = "0.10"
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration

View File

@@ -0,0 +1,166 @@
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
use crate::services::cache_manager::{self, CacheManifest, CacheStats};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedAnalysis {
pub waveform_tiers: Option<WaveformTiers>,
pub keyframe_positions: Vec<f64>,
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
pub caption_file_path: Option<String>,
}
#[tauri::command]
pub async fn load_cached_analysis(url: String) -> Option<CachedAnalysis> {
let url_clone = url.clone();
let manifest = tokio::task::spawn_blocking(move || cache_manager::load_manifest(&url_clone))
.await
.ok()
.flatten()?;
let cache_dir = cache_manager::cache_dir_for_url(&url);
// Resolve relative paths in thumbnail spritesheets to absolute paths
let spritesheets: Vec<ThumbnailSpritesheet> = manifest
.thumbnail_spritesheets
.into_iter()
.map(|mut sheet| {
let abs_path = cache_dir.join(&sheet.file_path);
sheet.file_path = abs_path.to_string_lossy().to_string();
sheet
})
.collect();
// Verify thumbnail files actually exist on disk
let thumbs_valid = if spritesheets.is_empty() {
false
} else {
let first_dir = std::path::Path::new(&spritesheets[0].file_path);
first_dir.is_dir()
};
// Resolve caption path to absolute
let caption_path = manifest
.caption_file_path
.map(|rel| cache_dir.join(&rel).to_string_lossy().to_string())
.filter(|p| std::path::Path::new(p).is_file());
Some(CachedAnalysis {
waveform_tiers: manifest.waveform_tiers,
keyframe_positions: manifest.keyframe_positions,
thumbnail_spritesheets: if thumbs_valid {
spritesheets
} else {
vec![]
},
caption_file_path: caption_path,
})
}
#[tauri::command]
pub async fn save_analysis_to_cache(
url: String,
title: String,
duration: f64,
fps: f64,
waveform_tiers: Option<WaveformTiers>,
keyframe_positions: Vec<f64>,
thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
caption_file_path: Option<String>,
) -> Result<(), String> {
let cache_dir = cache_manager::cache_dir_for_url(&url);
// Convert absolute spritesheet paths to relative
let relative_sheets: Vec<ThumbnailSpritesheet> = thumbnail_spritesheets
.into_iter()
.map(|mut sheet| {
if let Ok(rel) = std::path::Path::new(&sheet.file_path).strip_prefix(&cache_dir) {
sheet.file_path = rel.to_string_lossy().to_string();
}
sheet
})
.collect();
// Convert absolute caption path to relative (copy into cache if needed)
let relative_caption = caption_file_path.and_then(|abs| {
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!(
"{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
);
let manifest = CacheManifest {
version: 1,
source_url: url.clone(),
title,
duration,
fps,
created_at: now.clone(),
last_accessed_at: now,
waveform_tiers,
keyframe_positions,
thumbnail_spritesheets: relative_sheets,
caption_file_path: relative_caption,
};
tokio::task::spawn_blocking(move || cache_manager::save_manifest(&url, &manifest))
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn clear_video_cache(url: String) -> Result<(), String> {
tokio::task::spawn_blocking(move || cache_manager::clear_cache_entry(&url))
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn clear_all_cache() -> Result<(), String> {
tokio::task::spawn_blocking(cache_manager::clear_all_cache)
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn get_cache_stats() -> Result<CacheStats, String> {
tokio::task::spawn_blocking(cache_manager::get_cache_stats)
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub fn get_thumbnail_cache_dir(url: String) -> String {
let dir = cache_manager::thumbnail_cache_dir(&url);
std::fs::create_dir_all(&dir).ok();
dir.to_string_lossy().to_string()
}
#[tauri::command]
pub fn get_caption_cache_dir(url: String) -> String {
let dir = cache_manager::caption_cache_dir(&url);
std::fs::create_dir_all(&dir).ok();
dir.to_string_lossy().to_string()
}

View File

@@ -51,15 +51,19 @@ pub async fn extract_waveform_tiers(
pub async fn extract_thumbnails(
file_path: String,
duration: f64,
output_dir: Option<String>,
) -> Result<Vec<ThumbnailSpritesheet>, String> {
eprintln!(
"[video-clipper:thumbnails] starting for '{}' (duration={})",
file_path, duration
);
let result = tokio::task::spawn_blocking(move || {
let temp_dir = std::env::temp_dir()
.join("video-clipper-thumbs")
.join(uuid::Uuid::new_v4().to_string());
let temp_dir = match output_dir {
Some(dir) => std::path::PathBuf::from(dir),
None => std::env::temp_dir()
.join("video-clipper-thumbs")
.join(uuid::Uuid::new_v4().to_string()),
};
let temp_dir_str = temp_dir.to_string_lossy().to_string();
thumbnail_extractor::extract_thumbnails(&file_path, duration, &temp_dir_str)
})

View File

@@ -1,3 +1,4 @@
pub mod cache;
pub mod dependencies;
pub mod export;
pub mod media_analysis;

View File

@@ -5,8 +5,9 @@ mod services;
use std::collections::HashSet;
use std::path::PathBuf;
use commands::{dependencies, export, media_analysis, video};
use tauri::Manager;
use commands::{cache, dependencies, export, media_analysis, video};
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,
@@ -123,6 +168,13 @@ pub fn run() {
media_analysis::check_embedded_subtitles,
media_analysis::list_system_fonts,
export::export_clips,
cache::load_cached_analysis,
cache::save_analysis_to_cache,
cache::clear_video_cache,
cache::clear_all_cache,
cache::get_cache_stats,
cache::get_thumbnail_cache_dir,
cache::get_caption_cache_dir,
get_media_server_port,
])
.run(tauri::generate_context!())

View File

@@ -0,0 +1,221 @@
// Future: used by project file save/load and cache Tauri commands.
#![allow(dead_code)]
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")]
pub struct CacheManifest {
pub version: u32,
pub source_url: String,
pub title: String,
pub duration: f64,
pub fps: f64,
pub created_at: String,
pub last_accessed_at: String,
pub waveform_tiers: Option<WaveformTiers>,
pub keyframe_positions: Vec<f64>,
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
pub caption_file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheStats {
pub entry_count: usize,
pub total_bytes: u64,
}
/// First 16 hex characters of the SHA-256 hash of the URL.
pub fn url_hash(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let result = hasher.finalize();
result[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
}
pub fn cache_base_dir() -> PathBuf {
APP_DATA_DIR
.get()
.cloned()
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("gui-video-clipper")
})
.join("cache")
}
pub fn cache_dir_for_url(url: &str) -> PathBuf {
cache_base_dir().join(url_hash(url))
}
pub fn thumbnail_cache_dir(url: &str) -> PathBuf {
cache_dir_for_url(url).join("thumbnails")
}
pub fn caption_cache_dir(url: &str) -> PathBuf {
cache_dir_for_url(url).join("captions")
}
fn manifest_path(url: &str) -> PathBuf {
cache_dir_for_url(url).join("manifest.json")
}
fn now_iso8601() -> String {
// Use a simple UTC timestamp without pulling in chrono
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Format as ISO 8601 — good enough for cache timestamps
format!("{secs}")
}
pub fn load_manifest(url: &str) -> Option<CacheManifest> {
let path = manifest_path(url);
if !path.is_file() {
return None;
}
let data = std::fs::read_to_string(&path).ok()?;
let mut manifest: CacheManifest = serde_json::from_str(&data).ok()?;
// Update last-accessed timestamp
manifest.last_accessed_at = now_iso8601();
if let Ok(json) = serde_json::to_string_pretty(&manifest) {
let _ = std::fs::write(&path, json);
}
Some(manifest)
}
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String> {
let dir = cache_dir_for_url(url);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create cache dir: {e}"))?;
let path = dir.join("manifest.json");
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| format!("Failed to serialize manifest: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write manifest: {e}"))?;
eprintln!("[video-clipper:cache] saved manifest to '{}'", path.display());
Ok(())
}
pub fn clear_cache_entry(url: &str) -> Result<(), String> {
let dir = cache_dir_for_url(url);
if dir.is_dir() {
std::fs::remove_dir_all(&dir)
.map_err(|e| format!("Failed to remove cache entry: {e}"))?;
eprintln!("[video-clipper:cache] cleared entry at '{}'", dir.display());
}
Ok(())
}
pub fn clear_all_cache() -> Result<(), String> {
let base = cache_base_dir();
if base.is_dir() {
std::fs::remove_dir_all(&base)
.map_err(|e| format!("Failed to remove cache dir: {e}"))?;
eprintln!("[video-clipper:cache] cleared all cache at '{}'", base.display());
}
Ok(())
}
pub fn get_cache_stats() -> Result<CacheStats, String> {
let base = cache_base_dir();
if !base.is_dir() {
return Ok(CacheStats {
entry_count: 0,
total_bytes: 0,
});
}
let mut entry_count = 0usize;
let mut total_bytes = 0u64;
let entries = std::fs::read_dir(&base)
.map_err(|e| format!("Failed to read cache dir: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
entry_count += 1;
total_bytes += dir_size(&path);
}
}
Ok(CacheStats {
entry_count,
total_bytes,
})
}
fn dir_size(path: &std::path::Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() {
total += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
} else if p.is_dir() {
total += dir_size(&p);
}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_hash_length() {
let hash = url_hash("https://youtube.com/watch?v=abc123");
assert_eq!(hash.len(), 16);
}
#[test]
fn test_url_hash_deterministic() {
let url = "https://youtube.com/watch?v=abc123";
assert_eq!(url_hash(url), url_hash(url));
}
#[test]
fn test_url_hash_different_urls() {
let a = url_hash("https://youtube.com/watch?v=abc");
let b = url_hash("https://youtube.com/watch?v=xyz");
assert_ne!(a, b);
}
#[test]
fn test_cache_dir_for_url_contains_hash() {
let url = "https://youtube.com/watch?v=test";
let dir = cache_dir_for_url(url);
let hash = url_hash(url);
assert!(dir.to_string_lossy().contains(&hash));
}
#[test]
fn test_thumbnail_cache_dir_is_subdir() {
let url = "https://example.com/video";
let base = cache_dir_for_url(url);
let thumbs = thumbnail_cache_dir(url);
assert!(thumbs.starts_with(&base));
assert!(thumbs.to_string_lossy().ends_with("thumbnails"));
}
}

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

@@ -1,3 +1,4 @@
pub mod cache_manager;
pub mod clip_exporter;
pub mod dependency_manager;
pub mod download_manager;

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

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GUI Video Clipper",
"version": "0.1.1",
"version": "0.1.2",
"identifier": "xyz.cottongin.gui-video-clipper",
"build": {
"beforeDevCommand": "npm run dev",
@@ -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;

60
src/lib/bindings/cache.ts Normal file
View File

@@ -0,0 +1,60 @@
import { invoke } from '@tauri-apps/api/core';
import type { WaveformTiers, ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
export interface CachedAnalysis {
waveformTiers: WaveformTiers | null;
keyframePositions: number[];
thumbnailSpritesheets: ThumbnailSpritesheet[];
captionFilePath: string | null;
}
export interface CacheStats {
entryCount: number;
totalBytes: number;
}
export async function loadCachedAnalysis(url: string): Promise<CachedAnalysis | null> {
return invoke<CachedAnalysis | null>('load_cached_analysis', { url });
}
export async function saveAnalysisToCache(
url: string,
title: string,
duration: number,
fps: number,
waveformTiers: WaveformTiers | null,
keyframePositions: number[],
thumbnailSpritesheets: ThumbnailSpritesheet[],
captionFilePath: string | null
): Promise<void> {
return invoke<void>('save_analysis_to_cache', {
url,
title,
duration,
fps,
waveformTiers,
keyframePositions,
thumbnailSpritesheets,
captionFilePath,
});
}
export async function clearVideoCache(url: string): Promise<void> {
return invoke<void>('clear_video_cache', { url });
}
export async function clearAllCache(): Promise<void> {
return invoke<void>('clear_all_cache');
}
export async function getCacheStats(): Promise<CacheStats> {
return invoke<CacheStats>('get_cache_stats');
}
export async function getThumbnailCacheDir(url: string): Promise<string> {
return invoke<string>('get_thumbnail_cache_dir', { url });
}
export async function getCaptionCacheDir(url: string): Promise<string> {
return invoke<string>('get_caption_cache_dir', { url });
}

View File

@@ -41,11 +41,13 @@ export async function extractWaveformTiers(
export async function extractThumbnails(
filePath: string,
duration: number
duration: number,
outputDir?: string
): Promise<ThumbnailSpritesheet[]> {
return invoke<ThumbnailSpritesheet[]>('extract_thumbnails', {
filePath,
duration,
outputDir: outputDir ?? null,
});
}

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

@@ -6,6 +6,8 @@
setCookieSource,
type CookieSource,
} from '$lib/stores/preferences.svelte';
import { getCacheStats, clearAllCache } from '$lib/bindings/cache';
import type { CacheStats } from '$lib/bindings/cache';
let { onClose }: { onClose: () => void } = $props();
@@ -13,6 +15,34 @@
let cookieFilePath = $state(
preferences.cookieSource.type === 'file' ? preferences.cookieSource.path : ''
);
let cacheStats = $state<CacheStats | null>(null);
let clearingCache = $state(false);
$effect(() => {
getCacheStats().then((stats) => {
cacheStats = stats;
});
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const val = bytes / Math.pow(1024, i);
return `${val.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
async function handleClearAllCache() {
clearingCache = true;
try {
await clearAllCache();
cacheStats = { entryCount: 0, totalBytes: 0 };
} catch (err) {
console.error('Failed to clear cache:', err);
} finally {
clearingCache = false;
}
}
async function pickOutputDirectory() {
const selected = await open({
@@ -128,6 +158,23 @@
</div>
</section>
<section>
<h3>Cache</h3>
{#if cacheStats}
<p class="cache-info">
{formatBytes(cacheStats.totalBytes)} used across {cacheStats.entryCount} video{cacheStats.entryCount === 1 ? '' : 's'}
</p>
{:else}
<p class="cache-info">Loading…</p>
{/if}
<button
onclick={handleClearAllCache}
disabled={clearingCache || (cacheStats?.entryCount === 0)}
>
{clearingCache ? 'Clearing…' : 'Clear All Cache'}
</button>
</section>
<div class="actions">
<button class="primary" onclick={onClose}>Done</button>
</div>
@@ -207,6 +254,12 @@
margin-left: 20px;
}
.cache-info {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.actions {
display: flex;
justify-content: flex-end;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getVersion } from '@tauri-apps/api/app';
import { session, upgradePreview } from '$lib/stores/videoSession.svelte';
import { session, upgradePreview, reprocessCurrentVideo } from '$lib/stores/videoSession.svelte';
let { onOpenAbout }: { onOpenAbout?: () => void } = $props();
@@ -25,6 +25,23 @@
let isPreviewComplete = $derived(session.previewStatus === 'complete');
let isExportDownloading = $derived(session.exportStatus === 'downloading');
let isExportComplete = $derived(session.exportStatus === 'complete');
let canReprocess = $derived(
session.status === 'ready' &&
session.processingStep === 'done' &&
session.activeVideoPath !== null
);
let reprocessing = $state(false);
async function handleReprocess() {
reprocessing = true;
try {
await reprocessCurrentVideo();
} finally {
reprocessing = false;
}
}
</script>
<div class="status-bar">
@@ -53,6 +70,16 @@
{:else}
<span class="status-text">Ready</span>
{/if}
{#if canReprocess && !isPreviewDownloading}
<button
class="reprocess-btn"
onclick={handleReprocess}
disabled={reprocessing}
title="Reprocess video (re-extract waveform, thumbnails, keyframes)"
>
{reprocessing ? '⏳' : '↻'}
</button>
{/if}
{#if appVersion}
<button class="version-btn" onclick={() => onOpenAbout?.()} title="About GUI Video Clipper">
v{appVersion}
@@ -108,6 +135,27 @@
color: var(--bg-primary, #1e1e2e);
}
.reprocess-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 14px;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
line-height: 1;
}
.reprocess-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.reprocess-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.version-btn {
margin-left: auto;
background: none;

View File

@@ -83,7 +83,8 @@
clipStore.selectedClipId,
clipStore.pendingInPoint,
waveformData,
session.thumbnailSpritesheets
session.thumbnailSpritesheets,
session.status === 'ready'
);
ctx.restore();
}

View File

@@ -10,6 +10,12 @@ import {
} from '$lib/bindings/mediaAnalysis';
import { clearThumbnailCache } from '$lib/timeline/thumbnailRenderer';
import { clearAll as clearAllClips } from '$lib/stores/clips.svelte';
import {
loadCachedAnalysis,
saveAnalysisToCache,
getThumbnailCacheDir,
clearVideoCache,
} from '$lib/bindings/cache';
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
@@ -159,7 +165,7 @@ export function dismissUpgradeToast() {
session.showUpgradeToast = false;
}
async function loadCaptions(filePath: string) {
async function loadCaptionsWithCache(filePath: string) {
session.processingStep = 'captions';
// Priority 1: Check for embedded subtitle streams in the file
@@ -187,10 +193,38 @@ async function loadCaptions(filePath: string) {
async function triggerPostDownloadProcessing(filePath: string) {
const duration = session.duration;
// Run processing SEQUENTIALLY to avoid overloading the CPU with
// concurrent ffmpeg subprocesses. Each one decodes the full file.
// Order: waveform first (most useful for editing), then keyframes, then thumbnails.
// Check cache first
try {
const cached = await loadCachedAnalysis(session.url);
if (cached) {
console.log('[triggerPostDownloadProcessing] cache hit for', session.url);
if (cached.waveformTiers) {
session.waveformTiers = cached.waveformTiers;
}
if (cached.keyframePositions.length > 0) {
session.keyframePositions = cached.keyframePositions;
}
if (cached.thumbnailSpritesheets.length > 0) {
session.thumbnailSpritesheets = cached.thumbnailSpritesheets;
}
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
&& session.activeVideoPath !== session.exportFilePath) {
session.showUpgradeToast = true;
}
return;
}
} catch (err) {
console.error('Cache load failed, proceeding with extraction:', err);
}
// Cache miss — run extraction sequentially
session.processingStep = 'waveform';
session.processingProgress = 0;
try {
@@ -214,19 +248,36 @@ async function triggerPostDownloadProcessing(filePath: string) {
session.processingStep = 'thumbnails';
session.processingProgress = 0;
try {
const sheets = await extractThumbnails(filePath, duration);
const thumbDir = await getThumbnailCacheDir(session.url);
const sheets = await extractThumbnails(filePath, duration, thumbDir);
session.thumbnailSpritesheets = sheets;
} catch (err) {
console.error('Thumbnail extraction failed:', err);
}
// Captions can run after analysis since they use yt-dlp, not ffmpeg
await loadCaptions(filePath);
// Captions — use cache dir for output
await loadCaptionsWithCache(filePath);
session.processingStep = 'done';
// If export download already completed while we were processing, show upgrade toast
if (session.exportStatus === 'complete' && session.exportFilePath) {
// Save to cache
try {
await saveAnalysisToCache(
session.url,
session.title,
session.duration,
session.fps,
session.waveformTiers,
session.keyframePositions,
session.thumbnailSpritesheets,
session.captionFilePath
);
} catch (err) {
console.error('Failed to save analysis to cache:', err);
}
if (session.exportStatus === 'complete' && session.exportFilePath
&& session.activeVideoPath !== session.exportFilePath) {
session.showUpgradeToast = true;
}
}
@@ -262,6 +313,23 @@ export async function beginDownload() {
session.processingStep = 'downloading';
// --- Check export (HQ) cache first ---
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
if (exportCached) {
// HQ file already on disk — use it directly, skip preview entirely
session.exportFilePath = exportCached;
session.exportProgress = 1.0;
session.exportStatus = 'complete';
session.previewFilePath = exportCached;
session.previewProgress = 1.0;
session.previewStatus = 'complete';
session.activeVideoPath = exportCached;
// Prefer preview file for analysis (much faster ffmpeg operations)
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
triggerPostDownloadProcessing(previewForProcessing || exportCached);
return;
}
// --- Preview download (low-res, fast) ---
const previewCached = await checkCachedDownload(session.title, 'preview').catch(() => null);
if (previewCached) {
@@ -305,45 +373,66 @@ export async function beginDownload() {
}
// --- Export download (best quality, background) ---
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
if (exportCached) {
session.exportFilePath = exportCached;
session.exportProgress = 1.0;
session.exportStatus = 'complete';
if ((session.processingStep as ProcessingStep) === 'done') {
session.showUpgradeToast = true;
}
} else {
session.exportStatus = 'downloading';
session.exportProgress = 0;
session.exportFilePath = null;
// Only start if not already cached (we handled cached case above with early return)
session.exportStatus = 'downloading';
session.exportProgress = 0;
session.exportFilePath = null;
// Fire-and-forget — runs in background
runDownload(
session.url,
EXPORT_FORMAT,
'export',
(pct) => { session.exportProgress = pct; },
(path) => { session.exportFilePath = path; },
(success, path) => {
if (success) {
session.exportProgress = 1.0;
session.exportFilePath = path;
session.exportStatus = 'complete';
if ((session.processingStep as ProcessingStep) === 'done') {
session.showUpgradeToast = true;
}
} else {
session.exportStatus = 'failed';
runDownload(
session.url,
EXPORT_FORMAT,
'export',
(pct) => { session.exportProgress = pct; },
(path) => { session.exportFilePath = path; },
(success, path) => {
if (success) {
session.exportProgress = 1.0;
session.exportFilePath = path;
session.exportStatus = 'complete';
if ((session.processingStep as ProcessingStep) === 'done') {
session.showUpgradeToast = true;
}
},
(message) => {
} else {
session.exportStatus = 'failed';
console.error('Export download error:', message);
}
).catch((e) => {
},
(message) => {
session.exportStatus = 'failed';
console.error('Export download failed:', e);
});
}
console.error('Export download error:', message);
}
).catch((e) => {
session.exportStatus = 'failed';
console.error('Export download failed:', e);
});
}
/** Clear cache for the current video and re-extract all analysis data. */
export async function reprocessCurrentVideo() {
if (!session.url || !session.activeVideoPath) return;
// 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 {
await clearVideoCache(session.url);
} catch (err) {
console.error('Failed to clear video cache:', err);
}
// Reset analysis state
clearThumbnailCache();
session.keyframePositions = [];
session.waveformTiers = null;
session.thumbnailSpritesheets = [];
session.captionFilePath = null;
// Re-run processing (will miss cache and extract fresh)
await triggerPostDownloadProcessing(filePath);
}

View File

@@ -37,7 +37,8 @@ export function drawTimeline(
selectedClipId: string | null = null,
pendingInPoint: number | null = null,
waveform: WaveformData = { tiers: null },
thumbnailSpritesheets: ThumbnailSpritesheet[] = []
thumbnailSpritesheets: ThumbnailSpritesheet[] = [],
hasVideo: boolean = false
): void {
const { width, height } = state;
ctx.clearRect(0, 0, width, height);
@@ -50,13 +51,13 @@ export function drawTimeline(
if (thumbnailSpritesheets.length > 0) {
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT);
} else {
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails', hasVideo);
}
if (waveform.tiers) {
drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
} else {
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform', hasVideo);
}
drawClips(ctx, state, clips, selectedClipId, pendingInPoint);
@@ -114,16 +115,19 @@ function drawPlaceholderLane(
state: TimelineState,
y: number,
height: number,
label: string
label: string,
showLabel: boolean
): void {
ctx.fillStyle = '#2a2a3e';
ctx.fillRect(0, y, state.width, height);
ctx.strokeStyle = '#353550';
ctx.strokeRect(0, y, state.width, height);
ctx.fillStyle = '#6c7086';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
if (showLabel) {
ctx.fillStyle = '#6c7086';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
}
}
export function drawPlayhead(