1499 lines
46 KiB
Markdown
1499 lines
46 KiB
Markdown
# 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<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`:
|
|
|
|
```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<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:
|
|
|
|
```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::<String>()
|
|
}
|
|
```
|
|
|
|
- [ ] **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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<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:
|
|
|
|
```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<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):
|
|
|
|
```rust
|
|
#[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):
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```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<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`**
|
|
|
|
```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<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**
|
|
|
|
```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 `<script>` block:
|
|
|
|
```typescript
|
|
import { getCacheStats, clearAllCache } from '$lib/bindings/cache';
|
|
import type { CacheStats } from '$lib/bindings/cache';
|
|
```
|
|
|
|
Add state variables after the existing `cookieFilePath` state:
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```svelte
|
|
<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:
|
|
|
|
```css
|
|
.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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
import { session, upgradePreview, reprocessCurrentVideo } from '$lib/stores/videoSession.svelte';
|
|
```
|
|
|
|
- [ ] **Step 2: Add reprocess button state and handler**
|
|
|
|
Add after the existing derived state variables:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```svelte
|
|
{: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:
|
|
|
|
```svelte
|
|
{#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:
|
|
|
|
```css
|
|
.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**
|
|
|
|
```bash
|
|
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:
|
|
```json
|
|
"version": "0.1.2",
|
|
```
|
|
|
|
Update `src-tauri/Cargo.toml` version field:
|
|
```toml
|
|
version = "0.1.2"
|
|
```
|
|
|
|
Update `src-tauri/tauri.conf.json` version field:
|
|
```json
|
|
"version": "0.1.2"
|
|
```
|
|
|
|
- [ ] **Step 5: Commit version bump**
|
|
|
|
```bash
|
|
git add VERSION package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
|
git commit -m "chore: bump version to 0.1.2"
|
|
```
|