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:
2026-09-21 11:16:10 -04:00
parent 63a89ed433
commit ab053edd14
10 changed files with 245 additions and 8 deletions

7
src-tauri/Cargo.lock generated
View File

@@ -1300,6 +1300,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -3252,6 +3258,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",

View File

@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-shell = "2.3.6"

View File

@@ -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}"))?
}

View File

@@ -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");

View File

@@ -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);
}
}

View File

@@ -18,7 +18,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$TEMP/**", "$TMP/**", "/tmp/**"]
}
}
},
"bundle": {