# 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 `