From 2f810111cc346f655494865af9dd63b11730ecde Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 22 Sep 2026 14:31:36 -0400 Subject: [PATCH] docs: add v0.1.2 polish implementation plan Co-authored-by: Cursor --- .../plans/2026-09-22-v0.1.2-polish.md | 1498 +++++++++++++++++ 1 file changed, 1498 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-22-v0.1.2-polish.md diff --git a/docs/superpowers/plans/2026-09-22-v0.1.2-polish.md b/docs/superpowers/plans/2026-09-22-v0.1.2-polish.md new file mode 100644 index 0000000..882f107 --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-v0.1.2-polish.md @@ -0,0 +1,1498 @@ +# v0.1.2 Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix misleading idle-state placeholders, auto-use cached HQ video files, and add an analysis cache system to avoid reprocessing videos. + +**Architecture:** Three independent features implemented bottom-up. Feature 1 (placeholders) is a surgical frontend change. Feature 2 (auto-upgrade) restructures the download flow in the session store. Feature 3 (analysis cache) adds a new Rust service + IPC commands + frontend integration, with the cache living in `~/Library/Application Support/gui-video-clipper/cache/`. + +**Tech Stack:** Tauri v2 (Rust backend), Svelte 5 (TypeScript frontend), sha2 crate for URL hashing, serde_json for manifest serialization. + +## Global Constraints + +- **Zero new warnings/errors** — all warnings were recently resolved; any new ones must be fixed immediately. +- **macOS-only** — `~/Library/Application Support/` paths are fine. +- **Dead code annotations** — scaffolded future fields get `#[allow(dead_code)]` with `// Future: used by project file save/load`. +- **Imports at top of file** — no inline imports in function bodies. +- **Exhaustive switches** — use `never` check in default case for discriminated unions. + +## File Map + +### New Files +| File | Responsibility | +|---|---| +| `src-tauri/src/services/cache_manager.rs` | Cache directory management, manifest read/write, URL hashing, cleanup, stats | +| `src-tauri/src/commands/cache.rs` | Tauri IPC commands wrapping cache_manager service | +| `src/lib/bindings/cache.ts` | TypeScript wrappers for cache IPC commands | + +### Modified Files +| File | Changes | +|---|---| +| `src/lib/timeline/renderer.ts` | Add `hasVideo` param to `drawTimeline` and `drawPlaceholderLane` | +| `src/lib/components/Timeline.svelte` | Pass `hasVideo` to `drawTimeline` | +| `src/lib/stores/videoSession.svelte.ts` | Restructure `beginDownload()` for auto-upgrade; integrate cache load/save in `triggerPostDownloadProcessing()` | +| `src-tauri/src/services/mod.rs` | Add `pub mod cache_manager;` | +| `src-tauri/src/commands/mod.rs` | Add `pub mod cache;` | +| `src-tauri/src/lib.rs` | Register new cache IPC commands in invoke_handler | +| `src-tauri/Cargo.toml` | Add `sha2` dependency | +| `src-tauri/src/commands/media_analysis.rs` | Add optional `output_dir` param to `extract_thumbnails` command | +| `src/lib/bindings/mediaAnalysis.ts` | Add optional `outputDir` param to `extractThumbnails` binding | +| `src/lib/components/PreferencesPanel.svelte` | Add cache management section | +| `src/lib/components/StatusBar.svelte` | Add per-video reprocess button | +| `tests/lib/timeline/renderer.test.ts` | Update `drawTimeline` call sites for new `hasVideo` param | + +--- + +### Task 1: Fix Idle-State Placeholder Text + +**Files:** +- Modify: `src/lib/timeline/renderer.ts:31-65` (drawTimeline + drawPlaceholderLane) +- Modify: `src/lib/components/Timeline.svelte:77-88` (drawMainCanvas call) +- Modify: `tests/lib/timeline/renderer.test.ts` (no drawTimeline tests exist, but imports may break) + +**Interfaces:** +- Consumes: existing `TimelineState`, `WaveformData`, `ThumbnailSpritesheet`, `Clip` types +- Produces: updated `drawTimeline(ctx, state, currentTime, duration, clips, selectedClipId, pendingInPoint, waveform, thumbnailSpritesheets, hasVideo)` — new `hasVideo: boolean` as final parameter + +- [ ] **Step 1: Update `drawPlaceholderLane` to accept `showLabel` boolean** + +In `src/lib/timeline/renderer.ts`, change `drawPlaceholderLane` (lines 112-127) to conditionally render the text: + +```typescript +function drawPlaceholderLane( + ctx: CanvasRenderingContext2D, + state: TimelineState, + y: number, + height: number, + 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); + 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); + } +} +``` + +- [ ] **Step 2: Add `hasVideo` parameter to `drawTimeline`** + +In `src/lib/timeline/renderer.ts`, update the `drawTimeline` function signature (line 31) and pass `hasVideo` to placeholder calls: + +```typescript +export function drawTimeline( + ctx: CanvasRenderingContext2D, + state: TimelineState, + currentTime: number, + duration: number, + clips: Clip[] = [], + selectedClipId: string | null = null, + pendingInPoint: number | null = null, + waveform: WaveformData = { tiers: null }, + thumbnailSpritesheets: ThumbnailSpritesheet[] = [], + hasVideo: boolean = false +): void { + const { width, height } = state; + ctx.clearRect(0, 0, width, height); + + ctx.fillStyle = '#181825'; + ctx.fillRect(0, 0, width, height); + + drawTimeTicks(ctx, state, duration); + + if (thumbnailSpritesheets.length > 0) { + drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT); + } else { + 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', hasVideo); + } + + drawClips(ctx, state, clips, selectedClipId, pendingInPoint); + + drawPlayhead(ctx, state, currentTime); +} +``` + +- [ ] **Step 3: Pass `hasVideo` from Timeline.svelte** + +In `src/lib/components/Timeline.svelte`, update `drawMainCanvas()` (around line 77) to pass the new argument: + +```typescript + function drawMainCanvas() { + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.save(); + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + drawTimeline( + ctx, + timelineState, + session.currentTime, + session.duration, + clipStore.clips, + clipStore.selectedClipId, + clipStore.pendingInPoint, + waveformData, + session.thumbnailSpritesheets, + session.status === 'ready' + ); + ctx.restore(); + } +``` + +- [ ] **Step 4: Run TypeScript checks and tests** + +Run: `npm run check && npm run test` +Expected: All pass. The `hasVideo` parameter defaults to `false`, so existing test call sites for `timeToX`/`xToTime` are unaffected (they don't call `drawTimeline`). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/timeline/renderer.ts src/lib/components/Timeline.svelte +git commit -m "fix: show empty timeline lanes when no video is loaded" +``` + +--- + +### Task 2: Auto-Use Cached HQ Video on Load + +**Files:** +- Modify: `src/lib/stores/videoSession.svelte.ts:246-350` (beginDownload function) + +**Interfaces:** +- Consumes: `checkCachedDownload(title, variant)` from `$lib/bindings/video` +- Produces: same `beginDownload()` export, but with new internal logic that checks export cache first + +- [ ] **Step 1: Restructure `beginDownload()` to check export cache first** + +In `src/lib/stores/videoSession.svelte.ts`, replace the `beginDownload()` function (lines 246-349) with the restructured version. The key change: check export cache before preview cache, and if HQ exists, use it directly without touching preview at all. + +```typescript +export async function beginDownload() { + if (!session.url || !session.title) return; + + const oldThumbnailDirs = [ + ...new Set(session.thumbnailSpritesheets.map((sheet) => sheet.filePath)), + ]; + clearThumbnailCache(); + session.keyframePositions = []; + session.waveformTiers = null; + session.thumbnailSpritesheets = []; + + if (oldThumbnailDirs.length > 0) { + void cleanupThumbnails(oldThumbnailDirs).catch((err) => { + console.error('Thumbnail temp dir cleanup failed:', err); + }); + } + + 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; + // No upgrade toast — we're already at best quality + triggerPostDownloadProcessing(exportCached); + return; + } + + // --- Preview download (low-res, fast) --- + const previewCached = await checkCachedDownload(session.title, 'preview').catch(() => null); + if (previewCached) { + session.previewFilePath = previewCached; + session.activeVideoPath = previewCached; + session.previewProgress = 1.0; + session.previewStatus = 'complete'; + triggerPostDownloadProcessing(previewCached); + } else { + session.previewStatus = 'downloading'; + session.previewProgress = 0; + session.previewFilePath = null; + + try { + await runDownload( + session.url, + PREVIEW_FORMAT, + 'preview', + (pct) => { session.previewProgress = pct; }, + (path) => { session.previewFilePath = path; }, + (success, path) => { + if (success) { + session.previewProgress = 1.0; + session.previewFilePath = path; + session.activeVideoPath = path; + session.previewStatus = 'complete'; + triggerPostDownloadProcessing(path); + } else { + session.previewStatus = 'failed'; + } + }, + (message) => { + session.previewStatus = 'failed'; + console.error('Preview download error:', message); + } + ); + } catch (e) { + session.previewStatus = 'failed'; + console.error('Preview download failed:', e); + } + } + + // --- Export download (best quality, background) --- + // Only start if not already cached (we handled cached case above with early return) + session.exportStatus = 'downloading'; + session.exportProgress = 0; + session.exportFilePath = null; + + 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'; + } + }, + (message) => { + session.exportStatus = 'failed'; + console.error('Export download error:', message); + } + ).catch((e) => { + session.exportStatus = 'failed'; + console.error('Export download failed:', e); + }); +} +``` + +- [ ] **Step 2: Run TypeScript checks** + +Run: `npm run check` +Expected: PASS, no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/stores/videoSession.svelte.ts +git commit -m "feat: auto-use cached HQ video file without upgrade prompt" +``` + +--- + +### Task 3: Add `sha2` Dependency to Cargo.toml + +**Files:** +- Modify: `src-tauri/Cargo.toml:20-31` (dependencies section) + +**Interfaces:** +- Consumes: nothing +- Produces: `sha2` crate available for use in `cache_manager.rs` + +- [ ] **Step 1: Add sha2 to Cargo.toml** + +In `src-tauri/Cargo.toml`, add to the `[dependencies]` section after the existing `serde_json` line: + +```toml +sha2 = "0.10" +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd src-tauri && cargo check` +Expected: PASS, sha2 crate resolved and available. + +- [ ] **Step 3: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "chore: add sha2 dependency for URL hashing in analysis cache" +``` + +--- + +### Task 4: Implement `cache_manager.rs` Rust Service + +**Files:** +- Create: `src-tauri/src/services/cache_manager.rs` +- Modify: `src-tauri/src/services/mod.rs` (add module declaration) + +**Interfaces:** +- Consumes: `sha2` crate, `serde`/`serde_json`, `dirs` crate (already in Cargo.toml), `WaveformTiers` and `ThumbnailSpritesheet` from `crate::models` +- Produces: + - `pub fn url_hash(url: &str) -> String` — first 16 hex chars of SHA-256 + - `pub fn cache_base_dir() -> PathBuf` — `~/Library/Application Support/gui-video-clipper/cache/` + - `pub fn cache_dir_for_url(url: &str) -> PathBuf` — base dir + url hash + - `pub fn thumbnail_cache_dir(url: &str) -> PathBuf` — cache dir + `thumbnails/` + - `pub fn caption_cache_dir(url: &str) -> PathBuf` — cache dir + `captions/` + - `pub fn load_manifest(url: &str) -> Option` + - `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` + - `pub struct CacheManifest` — serde-serializable manifest + - `pub struct CacheStats` — `{ entry_count: usize, total_bytes: u64 }` + +- [ ] **Step 1: Create `cache_manager.rs` with types and URL hashing** + +Create `src-tauri/src/services/cache_manager.rs`: + +```rust +use crate::models::{ThumbnailSpritesheet, WaveformTiers}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +#[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, + pub keyframe_positions: Vec, + pub thumbnail_spritesheets: Vec, + pub caption_file_path: Option, +} + +#[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(); + hex::encode(&result[..8]) +} + +pub fn cache_base_dir() -> PathBuf { + 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") +} +``` + +Wait — `hex::encode` requires the `hex` crate. Since we're only encoding 8 bytes, use a manual formatter instead to avoid adding another dependency: + +```rust +/// 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::() +} +``` + +- [ ] **Step 2: Add manifest load/save functions** + +Append to `cache_manager.rs`: + +```rust +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 { + 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(()) +} +``` + +- [ ] **Step 3: Add cache cleanup and stats functions** + +Append to `cache_manager.rs`: + +```rust +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 { + 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 +} +``` + +- [ ] **Step 4: Add Rust unit tests for `url_hash`** + +Append to the bottom of `cache_manager.rs`: + +```rust +#[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")); + } +} +``` + +- [ ] **Step 5: Register the module** + +In `src-tauri/src/services/mod.rs`, add: + +```rust +pub mod cache_manager; +``` + +- [ ] **Step 6: Run Rust tests and checks** + +Run: `cd src-tauri && cargo test && cargo check` +Expected: All tests pass, no warnings. + +- [ ] **Step 7: Commit** + +```bash +git add src-tauri/src/services/cache_manager.rs src-tauri/src/services/mod.rs +git commit -m "feat: add cache_manager service for analysis caching" +``` + +--- + +### Task 5: Add Cache IPC Commands (Rust) + +**Files:** +- Create: `src-tauri/src/commands/cache.rs` +- Modify: `src-tauri/src/commands/mod.rs` (add module declaration) +- Modify: `src-tauri/src/lib.rs:111-128` (register commands in invoke_handler) + +**Interfaces:** +- Consumes: `cache_manager::*` from Task 4, `WaveformTiers` and `ThumbnailSpritesheet` from `crate::models` +- Produces: Tauri IPC commands: + - `load_cached_analysis(url: String) -> Option` where `CachedAnalysis` has fields: `waveform_tiers: Option`, `keyframe_positions: Vec`, `thumbnail_spritesheets: Vec`, `caption_file_path: Option` + - `save_analysis_to_cache(url: String, title: String, duration: f64, fps: f64, waveform_tiers: Option, keyframe_positions: Vec, thumbnail_spritesheets: Vec, caption_file_path: Option)` + - `clear_video_cache(url: String)` + - `clear_all_cache()` + - `get_cache_stats() -> CacheStats` + - `get_thumbnail_cache_dir(url: String) -> String` + - `get_caption_cache_dir(url: String) -> String` + +- [ ] **Step 1: Create `commands/cache.rs` with all IPC commands** + +Create `src-tauri/src/commands/cache.rs`: + +```rust +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, + pub keyframe_positions: Vec, + pub thumbnail_spritesheets: Vec, + pub caption_file_path: Option, +} + +#[tauri::command] +pub async fn load_cached_analysis(url: String) -> Option { + 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 = 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, + keyframe_positions: Vec, + thumbnail_spritesheets: Vec, + caption_file_path: Option, +) -> Result<(), String> { + let cache_dir = cache_manager::cache_dir_for_url(&url); + + // Convert absolute spritesheet paths to relative + let relative_sheets: Vec = 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 + 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 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 { + 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() +} +``` + +- [ ] **Step 2: Register module in `commands/mod.rs`** + +In `src-tauri/src/commands/mod.rs`, add: + +```rust +pub mod cache; +``` + +- [ ] **Step 3: Register IPC commands in `lib.rs`** + +In `src-tauri/src/lib.rs`, add the cache commands to the `invoke_handler` (around line 111). The full handler becomes: + +```rust + .invoke_handler(tauri::generate_handler![ + dependencies::check_dependencies, + dependencies::install_dependency, + dependencies::check_subtitles_filter_available, + video::resolve_url, + video::start_download, + video::check_cached_download, + video::download_subtitles, + media_analysis::extract_keyframes, + media_analysis::extract_waveform_tiers, + media_analysis::extract_thumbnails, + media_analysis::cleanup_thumbnails, + 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, + ]) +``` + +- [ ] **Step 4: Run Rust checks** + +Run: `cd src-tauri && cargo check` +Expected: PASS, no warnings. If there are dead-code warnings on `CachedAnalysis` fields, add `#[allow(dead_code)]` — but they shouldn't occur because all fields are used by serde serialization. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/commands/cache.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs +git commit -m "feat: add cache IPC commands for analysis caching" +``` + +--- + +### Task 6: Add Optional `output_dir` to `extract_thumbnails` Command + +**Files:** +- Modify: `src-tauri/src/commands/media_analysis.rs:50-73` (extract_thumbnails command) +- Modify: `src/lib/bindings/mediaAnalysis.ts:42-50` (extractThumbnails binding) + +**Interfaces:** +- Consumes: `thumbnail_extractor::extract_thumbnails(file_path, duration, output_dir)` — existing service, unchanged +- Produces: updated `extract_thumbnails` Tauri command with optional `output_dir: Option` parameter; updated `extractThumbnails(filePath, duration, outputDir?)` TS binding + +- [ ] **Step 1: Update Rust command to accept optional `output_dir`** + +In `src-tauri/src/commands/media_analysis.rs`, replace the `extract_thumbnails` command (lines 50-73): + +```rust +#[tauri::command] +pub async fn extract_thumbnails( + file_path: String, + duration: f64, + output_dir: Option, +) -> Result, String> { + eprintln!( + "[video-clipper:thumbnails] starting for '{}' (duration={})", + file_path, duration + ); + let result = tokio::task::spawn_blocking(move || { + 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) + }) + .await + .map_err(|e| format!("Task failed: {e}"))?; + match &result { + Ok(sheets) => eprintln!("[video-clipper:thumbnails] done — {} sheets", sheets.len()), + Err(e) => eprintln!("[video-clipper:thumbnails] FAILED: {}", e), + } + result +} +``` + +- [ ] **Step 2: Update TypeScript binding** + +In `src/lib/bindings/mediaAnalysis.ts`, update the `extractThumbnails` function (lines 42-50): + +```typescript +export async function extractThumbnails( + filePath: string, + duration: number, + outputDir?: string +): Promise { + return invoke('extract_thumbnails', { + filePath, + duration, + outputDir: outputDir ?? null, + }); +} +``` + +- [ ] **Step 3: Run checks** + +Run: `cd src-tauri && cargo check && cd .. && npm run check` +Expected: PASS. The new parameter is `Option` on Rust side and optional on TS side, so existing callers are unaffected. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/commands/media_analysis.rs src/lib/bindings/mediaAnalysis.ts +git commit -m "feat: add optional output_dir param to extract_thumbnails" +``` + +--- + +### Task 7: Create TypeScript Cache Bindings + +**Files:** +- Create: `src/lib/bindings/cache.ts` + +**Interfaces:** +- Consumes: Tauri IPC commands from Task 5: `load_cached_analysis`, `save_analysis_to_cache`, `clear_video_cache`, `clear_all_cache`, `get_cache_stats`, `get_thumbnail_cache_dir`, `get_caption_cache_dir` +- Produces: TypeScript functions with matching signatures: + - `loadCachedAnalysis(url: string): Promise` + - `saveAnalysisToCache(url, title, duration, fps, waveformTiers, keyframePositions, thumbnailSpritesheets, captionFilePath): Promise` + - `clearVideoCache(url: string): Promise` + - `clearAllCache(): Promise` + - `getCacheStats(): Promise` + - `getThumbnailCacheDir(url: string): Promise` + - `getCaptionCacheDir(url: string): Promise` + - Type exports: `CachedAnalysis`, `CacheStats` + +- [ ] **Step 1: Create `src/lib/bindings/cache.ts`** + +```typescript +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 { + return invoke('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 { + return invoke('save_analysis_to_cache', { + url, + title, + duration, + fps, + waveformTiers, + keyframePositions, + thumbnailSpritesheets, + captionFilePath, + }); +} + +export async function clearVideoCache(url: string): Promise { + return invoke('clear_video_cache', { url }); +} + +export async function clearAllCache(): Promise { + return invoke('clear_all_cache'); +} + +export async function getCacheStats(): Promise { + return invoke('get_cache_stats'); +} + +export async function getThumbnailCacheDir(url: string): Promise { + return invoke('get_thumbnail_cache_dir', { url }); +} + +export async function getCaptionCacheDir(url: string): Promise { + return invoke('get_caption_cache_dir', { url }); +} +``` + +- [ ] **Step 2: Run TypeScript checks** + +Run: `npm run check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/bindings/cache.ts +git commit -m "feat: add TypeScript bindings for cache IPC commands" +``` + +--- + +### Task 8: Integrate Cache into `videoSession.svelte.ts` + +**Files:** +- Modify: `src/lib/stores/videoSession.svelte.ts` (imports, `triggerPostDownloadProcessing`, `loadCaptions`) + +**Interfaces:** +- Consumes: + - `loadCachedAnalysis(url)` from `$lib/bindings/cache` (Task 7) + - `saveAnalysisToCache(url, title, duration, fps, waveformTiers, keyframePositions, thumbnailSpritesheets, captionFilePath)` from `$lib/bindings/cache` (Task 7) + - `getThumbnailCacheDir(url)` from `$lib/bindings/cache` (Task 7) + - `getCaptionCacheDir(url)` from `$lib/bindings/cache` (Task 7) + - `clearVideoCache(url)` from `$lib/bindings/cache` (Task 7) + - `extractThumbnails(filePath, duration, outputDir?)` updated signature from Task 6 +- Produces: updated `triggerPostDownloadProcessing()` that checks cache first, saves after processing; new exported `reprocessCurrentVideo()` function + +- [ ] **Step 1: Add cache imports** + +At the top of `src/lib/stores/videoSession.svelte.ts`, add after the existing imports: + +```typescript +import { + loadCachedAnalysis, + saveAnalysisToCache, + getThumbnailCacheDir, + clearVideoCache, +} from '$lib/bindings/cache'; +``` + +- [ ] **Step 2: Replace `triggerPostDownloadProcessing` with cache-aware version** + +Replace the `triggerPostDownloadProcessing` function (lines 187-231): + +```typescript +async function triggerPostDownloadProcessing(filePath: string) { + const duration = session.duration; + + // 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; + } + session.processingStep = 'done'; + if (session.exportStatus === 'complete' && 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 { + const tiers = await extractWaveformTiers(filePath, duration, (pct) => { + session.processingProgress = pct; + }); + session.waveformTiers = tiers; + } catch (err) { + console.error('Waveform extraction failed:', err); + } + + session.processingStep = 'keyframes'; + session.processingProgress = 0; + try { + const kf = await extractKeyframes(filePath); + session.keyframePositions = kf; + } catch (err) { + console.error('Keyframe extraction failed:', err); + } + + session.processingStep = 'thumbnails'; + session.processingProgress = 0; + try { + 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 — use cache dir for output + await loadCaptionsWithCache(filePath); + + session.processingStep = 'done'; + + // 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.showUpgradeToast = true; + } +} +``` + +- [ ] **Step 3: Replace `loadCaptions` with cache-aware version** + +Rename `loadCaptions` to `loadCaptionsWithCache` and update it to write captions to the cache dir. Replace the existing `loadCaptions` function (lines 162-184): + +```typescript +async function loadCaptionsWithCache(filePath: string) { + session.processingStep = 'captions'; + + // Priority 1: Check for embedded subtitle streams in the file + try { + const embeddedPath = await checkEmbeddedSubtitles(filePath); + if (embeddedPath) { + session.captionFilePath = embeddedPath; + return; + } + } catch (err) { + console.error('Embedded subtitle check failed:', err); + } + + // Priority 2: Download external subtitles via yt-dlp (manual > auto-generated) + if (session.hasCaptions && session.url) { + try { + const path = await downloadSubtitles(session.url, session.captionsAreAuto); + session.captionFilePath = path; + } catch (err) { + console.error('Subtitle download failed:', err); + } + } +} +``` + +- [ ] **Step 4: Add `reprocessCurrentVideo` export** + +Add at the bottom of `src/lib/stores/videoSession.svelte.ts`, before the closing: + +```typescript +/** 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; + + // 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); +} +``` + +- [ ] **Step 5: Run TypeScript checks** + +Run: `npm run check` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/stores/videoSession.svelte.ts +git commit -m "feat: integrate analysis cache into video session processing" +``` + +--- + +### Task 9: Add Cache Section to Preferences Panel + +**Files:** +- Modify: `src/lib/components/PreferencesPanel.svelte` + +**Interfaces:** +- Consumes: `getCacheStats()` and `clearAllCache()` from `$lib/bindings/cache` (Task 7) +- Produces: visual "Cache" section in Preferences with size display and clear button + +- [ ] **Step 1: Add cache imports and state** + +In `src/lib/components/PreferencesPanel.svelte`, add import at the top of the `