46 KiB
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
nevercheck 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,Cliptypes -
Produces: updated
drawTimeline(ctx, state, currentTime, duration, clips, selectedClipId, pendingInPoint, waveform, thumbnailSpritesheets, hasVideo)— newhasVideo: booleanas final parameter -
Step 1: Update
drawPlaceholderLaneto acceptshowLabelboolean
In src/lib/timeline/renderer.ts, change drawPlaceholderLane (lines 112-127) to conditionally render the text:
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
hasVideoparameter todrawTimeline
In src/lib/timeline/renderer.ts, update the drawTimeline function signature (line 31) and pass hasVideo to placeholder calls:
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
hasVideofrom Timeline.svelte
In src/lib/components/Timeline.svelte, update drawMainCanvas() (around line 77) to pass the new argument:
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
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.
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
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:
sha2crate available for use incache_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:
sha2 = "0.10"
- Step 2: Verify it compiles
Run: cd src-tauri && cargo check
Expected: PASS, sha2 crate resolved and available.
- Step 3: Commit
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:
sha2crate,serde/serde_json,dirscrate (already in Cargo.toml),WaveformTiersandThumbnailSpritesheetfromcrate::models -
Produces:
pub fn url_hash(url: &str) -> String— first 16 hex chars of SHA-256pub fn cache_base_dir() -> PathBuf—~/Library/Application Support/gui-video-clipper/cache/pub fn cache_dir_for_url(url: &str) -> PathBuf— base dir + url hashpub 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<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 struct CacheManifest— serde-serializable manifestpub struct CacheStats—{ entry_count: usize, total_bytes: u64 }
-
Step 1: Create
cache_manager.rswith types and URL hashing
Create src-tauri/src/services/cache_manager.rs:
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<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();
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:
/// 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>()
}
- Step 2: Add manifest load/save functions
Append to cache_manager.rs:
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(())
}
- Step 3: Add cache cleanup and stats functions
Append to cache_manager.rs:
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
}
- Step 4: Add Rust unit tests for
url_hash
Append to the bottom of cache_manager.rs:
#[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:
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
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,WaveformTiersandThumbnailSpritesheetfromcrate::models -
Produces: Tauri IPC commands:
load_cached_analysis(url: String) -> Option<CachedAnalysis>whereCachedAnalysishas fields:waveform_tiers: Option<WaveformTiers>,keyframe_positions: Vec<f64>,thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,caption_file_path: Option<String>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>)clear_video_cache(url: String)clear_all_cache()get_cache_stats() -> CacheStatsget_thumbnail_cache_dir(url: String) -> Stringget_caption_cache_dir(url: String) -> String
-
Step 1: Create
commands/cache.rswith all IPC commands
Create src-tauri/src/commands/cache.rs:
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
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<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()
}
- Step 2: Register module in
commands/mod.rs
In src-tauri/src/commands/mod.rs, add:
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:
.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
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_thumbnailsTauri command with optionaloutput_dir: Option<String>parameter; updatedextractThumbnails(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):
#[tauri::command]
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 = 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):
export async function extractThumbnails(
filePath: string,
duration: number,
outputDir?: string
): Promise<ThumbnailSpritesheet[]> {
return invoke<ThumbnailSpritesheet[]>('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<String> on Rust side and optional on TS side, so existing callers are unaffected.
- Step 4: Commit
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<CachedAnalysis | null>saveAnalysisToCache(url, title, duration, fps, waveformTiers, keyframePositions, thumbnailSpritesheets, captionFilePath): Promise<void>clearVideoCache(url: string): Promise<void>clearAllCache(): Promise<void>getCacheStats(): Promise<CacheStats>getThumbnailCacheDir(url: string): Promise<string>getCaptionCacheDir(url: string): Promise<string>- Type exports:
CachedAnalysis,CacheStats
-
Step 1: Create
src/lib/bindings/cache.ts
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 });
}
- Step 2: Run TypeScript checks
Run: npm run check
Expected: PASS.
- Step 3: Commit
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 exportedreprocessCurrentVideo()function -
Step 1: Add cache imports
At the top of src/lib/stores/videoSession.svelte.ts, add after the existing imports:
import {
loadCachedAnalysis,
saveAnalysisToCache,
getThumbnailCacheDir,
clearVideoCache,
} from '$lib/bindings/cache';
- Step 2: Replace
triggerPostDownloadProcessingwith cache-aware version
Replace the triggerPostDownloadProcessing function (lines 187-231):
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
loadCaptionswith 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):
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
reprocessCurrentVideoexport
Add at the bottom of src/lib/stores/videoSession.svelte.ts, before the closing:
/** 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
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()andclearAllCache()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 <script> block:
import { getCacheStats, clearAllCache } from '$lib/bindings/cache';
import type { CacheStats } from '$lib/bindings/cache';
Add state variables after the existing cookieFilePath state:
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;
}
}
- Step 2: Add cache section to the template
In the template, add a new <section> block after the "YouTube Cookie Source" section (after line 129, before the .actions div):
<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>
- Step 3: Add CSS for cache info
In the <style> block, add:
.cache-info {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
- Step 4: Run TypeScript checks
Run: npm run check
Expected: PASS.
- Step 5: Commit
git add src/lib/components/PreferencesPanel.svelte
git commit -m "feat: add cache management section to Preferences panel"
Task 10: Add Per-Video Reprocess Button to StatusBar
Files:
- Modify:
src/lib/components/StatusBar.svelte
Interfaces:
-
Consumes:
reprocessCurrentVideo()from$lib/stores/videoSession.svelte(Task 8),session.processingStep,session.status -
Produces: "↻" button in status bar visible when video is loaded and processing is done
-
Step 1: Add import
In src/lib/components/StatusBar.svelte, update the import from videoSession to include reprocessCurrentVideo:
import { session, upgradePreview, reprocessCurrentVideo } from '$lib/stores/videoSession.svelte';
- Step 2: Add reprocess button state and handler
Add after the existing derived state variables:
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;
}
}
- Step 3: Add reprocess button to template
In the template, add the reprocess button inside the isPreviewComplete && isExportComplete block (after the upgrade-link button, before the closing {:else if}). The full block becomes:
{:else if isPreviewComplete && isExportComplete}
<span class="status-text success">✓ {session.title} — ready to export</span>
{#if session.showUpgradeToast}
<button class="upgrade-link" onclick={upgradePreview}>Reload Preview (HQ)</button>
{/if}
{#if canReprocess}
<button
class="reprocess-btn"
onclick={handleReprocess}
disabled={reprocessing}
title="Reprocess video (re-extract waveform, thumbnails, keyframes)"
>
{reprocessing ? '⏳' : '↻'}
</button>
{/if}
Also add the reprocess button to the "Ready" state at the end, in case processing is done but export status is different. Actually, the canReprocess derived already checks processingStep === 'done', so we should show it in any status-bar state where it's relevant. Add it right before the version button, outside the conditional chain:
Actually, it's simpler to add it after all the status conditionals but before the version button. Replace the version button section to include:
{#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}
</button>
{/if}
- Step 4: Add CSS for reprocess button
In the <style> block, add:
.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;
}
- Step 5: Run TypeScript checks
Run: npm run check
Expected: PASS.
- Step 6: Commit
git add src/lib/components/StatusBar.svelte
git commit -m "feat: add per-video reprocess button to status bar"
Task 11: Final Integration Check and Version Bump
Files:
- Modify:
VERSION(bump to 0.1.2) - Modify:
src-tauri/tauri.conf.json(bump version) - Modify:
src-tauri/Cargo.toml(bump version) - Modify:
package.json(bump version)
Interfaces:
-
Consumes: all previous tasks
-
Produces: clean build with zero warnings, bumped version
-
Step 1: Run full Rust test suite
Run: cd src-tauri && cargo test
Expected: All tests pass, zero warnings.
- Step 2: Run full frontend checks and tests
Run: npm run check && npm run test
Expected: All pass, zero warnings.
- Step 3: Run a full build
Run: npm run tauri build 2>&1 | tail -20
Expected: Build completes. Watch for any warnings in the output.
- Step 4: Bump version to 0.1.2
Update VERSION:
0.1.2
Update package.json version field:
"version": "0.1.2",
Update src-tauri/Cargo.toml version field:
version = "0.1.2"
Update src-tauri/tauri.conf.json version field:
"version": "0.1.2"
- Step 5: Commit version bump
git add VERSION package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
git commit -m "chore: bump version to 0.1.2"