feat: add thumbnail extraction and timeline display
Wire ffmpeg frame extraction into post-download processing and render adaptive thumbnail strips on the timeline via the asset protocol. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use crate::services::{keyframe_index, waveform_generator};
|
||||
use crate::models::ThumbnailSpritesheet;
|
||||
use crate::services::{keyframe_index, thumbnail_extractor, waveform_generator};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_keyframes(file_path: String) -> Result<Vec<f64>, String> {
|
||||
@@ -18,3 +19,19 @@ pub async fn extract_waveform(
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_thumbnails(
|
||||
file_path: String,
|
||||
duration: f64,
|
||||
) -> Result<Vec<ThumbnailSpritesheet>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let temp_dir = 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}"))?
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn run() {
|
||||
video::start_download,
|
||||
media_analysis::extract_keyframes,
|
||||
media_analysis::extract_waveform,
|
||||
media_analysis::extract_thumbnails,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1 +1,97 @@
|
||||
// Implemented in Task 11
|
||||
use crate::models::ThumbnailSpritesheet;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn compute_interval(duration: f64) -> f64 {
|
||||
if duration < 600.0 {
|
||||
1.0
|
||||
} else if duration < 3600.0 {
|
||||
2.0
|
||||
} else {
|
||||
5.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_thumbnails(
|
||||
file_path: &str,
|
||||
duration: f64,
|
||||
output_dir: &str,
|
||||
) -> Result<Vec<ThumbnailSpritesheet>, String> {
|
||||
std::fs::create_dir_all(output_dir)
|
||||
.map_err(|e| format!("Failed to create thumbnail dir: {e}"))?;
|
||||
|
||||
let interval = compute_interval(duration);
|
||||
let thumb_width = 160u32;
|
||||
let thumb_height = 90u32;
|
||||
let columns = 10u32;
|
||||
let rows_per_sheet = 10u32;
|
||||
let frames_per_sheet = (columns * rows_per_sheet) as usize;
|
||||
|
||||
let frames_pattern = format!("{output_dir}/frame_%06d.jpg");
|
||||
let status = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
file_path,
|
||||
"-vf",
|
||||
&format!("fps=1/{interval},scale={thumb_width}:{thumb_height}"),
|
||||
"-q:v",
|
||||
"5",
|
||||
&frames_pattern,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to extract thumbnails: {e}"))?;
|
||||
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
return Err(format!("Thumbnail extraction failed: {stderr}"));
|
||||
}
|
||||
|
||||
let actual_frames = std::fs::read_dir(output_dir)
|
||||
.map_err(|e| format!("Failed to read thumbnail dir: {e}"))?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with("frame_"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
|
||||
let mut sheets = Vec::new();
|
||||
let mut frame_index = 0;
|
||||
|
||||
while frame_index < actual_frames {
|
||||
let count = std::cmp::min(frames_per_sheet, actual_frames - frame_index);
|
||||
sheets.push(ThumbnailSpritesheet {
|
||||
file_path: output_dir.to_string(),
|
||||
start_index: frame_index,
|
||||
count,
|
||||
thumb_width,
|
||||
thumb_height,
|
||||
columns,
|
||||
interval_seconds: interval,
|
||||
});
|
||||
frame_index += count;
|
||||
}
|
||||
|
||||
Ok(sheets)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compute_interval_short_video() {
|
||||
assert!((compute_interval(120.0) - 1.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_interval_medium_video() {
|
||||
assert!((compute_interval(1800.0) - 2.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_interval_long_video() {
|
||||
assert!((compute_interval(7200.0) - 5.0).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user