From c96b0cedc91b0c21963660570db94e3b63f96ee5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 21 Sep 2026 11:10:20 -0400 Subject: [PATCH] feat: add keyframe index extraction and frame/keyframe navigation Wire ffprobe-based keyframe indexing through Tauri and auto-load positions after download completes. Co-authored-by: Cursor --- src-tauri/src/commands/media_analysis.rs | 9 ++- src-tauri/src/lib.rs | 3 +- src-tauri/src/services/keyframe_index.rs | 71 +++++++++++++++++++++++- src/lib/bindings/mediaAnalysis.ts | 6 ++ src/lib/stores/videoSession.svelte.ts | 11 ++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/media_analysis.rs b/src-tauri/src/commands/media_analysis.rs index 6dd7e62..c813569 100644 --- a/src-tauri/src/commands/media_analysis.rs +++ b/src-tauri/src/commands/media_analysis.rs @@ -1 +1,8 @@ -// Keyframe, waveform, thumbnail extraction commands — implemented in Tasks 9-11 +use crate::services::keyframe_index; + +#[tauri::command] +pub async fn extract_keyframes(file_path: String) -> Result, String> { + tokio::task::spawn_blocking(move || keyframe_index::extract_keyframes(&file_path)) + .await + .map_err(|e| format!("Task failed: {e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ec78dc4..33d0e22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ mod commands; mod models; mod services; -use commands::{dependencies, video}; +use commands::{dependencies, media_analysis, video}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -16,6 +16,7 @@ pub fn run() { dependencies::install_dependency, video::resolve_url, video::start_download, + media_analysis::extract_keyframes, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/services/keyframe_index.rs b/src-tauri/src/services/keyframe_index.rs index 9510587..ea9808f 100644 --- a/src-tauri/src/services/keyframe_index.rs +++ b/src-tauri/src/services/keyframe_index.rs @@ -1 +1,70 @@ -// Implemented in Task 9 +use std::process::Command; + +pub fn parse_ffprobe_output(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split(',').collect(); + // Format: media_type,pts_time,flags + // We want lines where flags contain 'K' (keyframe) + if parts.len() >= 3 && parts[2].contains('K') { + parts[1].parse::().ok() + } else { + None + } + }) + .collect() +} + +pub fn extract_keyframes(file_path: &str) -> Result, String> { + let output = Command::new("ffprobe") + .args([ + "-select_streams", + "v", + "-show_entries", + "frame=pts_time,flags", + "-of", + "csv=p=0", + "-skip_frame", + "nokey", + file_path, + ]) + .output() + .map_err(|e| format!("Failed to run ffprobe: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("ffprobe failed: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut positions = parse_ffprobe_output(&stdout); + positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + Ok(positions) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ffprobe_keyframes() { + let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n"; + let result = parse_ffprobe_output(output); + assert_eq!(result.len(), 3); + assert!((result[0] - 0.0).abs() < 0.001); + assert!((result[1] - 1.0).abs() < 0.001); + assert!((result[2] - 2.0).abs() < 0.001); + } + + #[test] + fn test_parse_ffprobe_empty() { + assert!(parse_ffprobe_output("").is_empty()); + } + + #[test] + fn test_parse_ffprobe_no_keyframes() { + let output = "video,0.500000,___\nvideo,1.500000,___\n"; + assert!(parse_ffprobe_output(output).is_empty()); + } +} diff --git a/src/lib/bindings/mediaAnalysis.ts b/src/lib/bindings/mediaAnalysis.ts index 8c21ef5..c2ee2e6 100644 --- a/src/lib/bindings/mediaAnalysis.ts +++ b/src/lib/bindings/mediaAnalysis.ts @@ -1,3 +1,5 @@ +import { invoke } from '@tauri-apps/api/core'; + export interface ThumbnailSpritesheet { filePath: string; startIndex: number; @@ -7,3 +9,7 @@ export interface ThumbnailSpritesheet { columns: number; intervalSeconds: number; } + +export async function extractKeyframes(filePath: string): Promise { + return invoke('extract_keyframes', { filePath }); +} diff --git a/src/lib/stores/videoSession.svelte.ts b/src/lib/stores/videoSession.svelte.ts index 64f07c7..472c6f0 100644 --- a/src/lib/stores/videoSession.svelte.ts +++ b/src/lib/stores/videoSession.svelte.ts @@ -1,6 +1,7 @@ import type { VideoMetadata } from '$lib/bindings/video'; import { startDownload } from '$lib/bindings/video'; import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis'; +import { extractKeyframes } from '$lib/bindings/mediaAnalysis'; export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error'; export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed'; @@ -77,6 +78,15 @@ export function reset() { session.error = null; } +async function triggerPostDownloadProcessing(filePath: string) { + try { + session.keyframePositions = await extractKeyframes(filePath); + } catch (e) { + console.error('Keyframe extraction failed:', e); + } + // Waveform and thumbnails will be added in Tasks 10 and 11 +} + export async function beginDownload() { if (!session.url) return; @@ -98,6 +108,7 @@ export async function beginDownload() { session.downloadProgress = 1.0; session.localFilePath = path; session.downloadStatus = 'complete'; + void triggerPostDownloadProcessing(path); } else { session.downloadStatus = 'failed'; }