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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 11:10:20 -04:00
parent 2f25db3cc5
commit c96b0cedc9
5 changed files with 97 additions and 3 deletions

View File

@@ -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<Vec<f64>, String> {
tokio::task::spawn_blocking(move || keyframe_index::extract_keyframes(&file_path))
.await
.map_err(|e| format!("Task failed: {e}"))?
}

View File

@@ -2,7 +2,7 @@ mod commands;
mod models; mod models;
mod services; mod services;
use commands::{dependencies, video}; use commands::{dependencies, media_analysis, video};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -16,6 +16,7 @@ pub fn run() {
dependencies::install_dependency, dependencies::install_dependency,
video::resolve_url, video::resolve_url,
video::start_download, video::start_download,
media_analysis::extract_keyframes,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1 +1,70 @@
// Implemented in Task 9 use std::process::Command;
pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
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::<f64>().ok()
} else {
None
}
})
.collect()
}
pub fn extract_keyframes(file_path: &str) -> Result<Vec<f64>, 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());
}
}

View File

@@ -1,3 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
export interface ThumbnailSpritesheet { export interface ThumbnailSpritesheet {
filePath: string; filePath: string;
startIndex: number; startIndex: number;
@@ -7,3 +9,7 @@ export interface ThumbnailSpritesheet {
columns: number; columns: number;
intervalSeconds: number; intervalSeconds: number;
} }
export async function extractKeyframes(filePath: string): Promise<number[]> {
return invoke<number[]>('extract_keyframes', { filePath });
}

View File

@@ -1,6 +1,7 @@
import type { VideoMetadata } from '$lib/bindings/video'; import type { VideoMetadata } from '$lib/bindings/video';
import { startDownload } from '$lib/bindings/video'; import { startDownload } from '$lib/bindings/video';
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis'; import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
import { extractKeyframes } from '$lib/bindings/mediaAnalysis';
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error'; export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed'; export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
@@ -77,6 +78,15 @@ export function reset() {
session.error = null; 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() { export async function beginDownload() {
if (!session.url) return; if (!session.url) return;
@@ -98,6 +108,7 @@ export async function beginDownload() {
session.downloadProgress = 1.0; session.downloadProgress = 1.0;
session.localFilePath = path; session.localFilePath = path;
session.downloadStatus = 'complete'; session.downloadStatus = 'complete';
void triggerPostDownloadProcessing(path);
} else { } else {
session.downloadStatus = 'failed'; session.downloadStatus = 'failed';
} }