Files
gui-video-clipper/docs/superpowers/plans/2026-09-22-v0.1.2-polish.md
2026-09-22 14:31:36 -04:00

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 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:

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:

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:

  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: 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:

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: 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<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 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:

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, WaveformTiers and ThumbnailSpritesheet from crate::models

  • Produces: Tauri IPC commands:

    • load_cached_analysis(url: String) -> Option<CachedAnalysis> where CachedAnalysis has 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() -> 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:

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_thumbnails Tauri command with optional output_dir: Option<String> 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):

#[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 exported reprocessCurrentVideo() 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 triggerPostDownloadProcessing with 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 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):

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:

/** 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() 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 <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"