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:
@@ -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}"))?
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number[]> {
|
||||
return invoke<number[]>('extract_keyframes', { filePath });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user