feat: add waveform generation and timeline display
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use crate::services::keyframe_index;
|
||||
use crate::services::{keyframe_index, waveform_generator};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_keyframes(file_path: String) -> Result<Vec<f64>, String> {
|
||||
@@ -6,3 +6,15 @@ pub async fn extract_keyframes(file_path: String) -> Result<Vec<f64>, String> {
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_waveform(
|
||||
file_path: String,
|
||||
sample_count: usize,
|
||||
) -> Result<Vec<f64>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
waveform_generator::extract_waveform(&file_path, sample_count)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn run() {
|
||||
video::resolve_url,
|
||||
video::start_download,
|
||||
media_analysis::extract_keyframes,
|
||||
media_analysis::extract_waveform,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1 +1,90 @@
|
||||
// Implemented in Task 10
|
||||
use std::process::Command;
|
||||
|
||||
pub fn extract_waveform(file_path: &str, sample_count: usize) -> Result<Vec<f64>, String> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
file_path,
|
||||
"-ac",
|
||||
"1",
|
||||
"-filter:a",
|
||||
&format!("aresample={sample_count}"),
|
||||
"-f",
|
||||
"f32le",
|
||||
"-vn",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("ffmpeg waveform extraction failed: {stderr}"));
|
||||
}
|
||||
|
||||
let samples = parse_f32_samples(&output.stdout);
|
||||
let peaks = compute_peaks(&samples, sample_count);
|
||||
Ok(peaks)
|
||||
}
|
||||
|
||||
fn parse_f32_samples(data: &[u8]) -> Vec<f32> {
|
||||
data.chunks_exact(4)
|
||||
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compute_peaks(samples: &[f32], target_count: usize) -> Vec<f64> {
|
||||
if samples.is_empty() || target_count == 0 {
|
||||
return vec![0.0; target_count];
|
||||
}
|
||||
|
||||
let chunk_size = (samples.len() as f64 / target_count as f64).ceil() as usize;
|
||||
let chunk_size = chunk_size.max(1);
|
||||
|
||||
let mut peaks: Vec<f64> = samples
|
||||
.chunks(chunk_size)
|
||||
.map(|chunk| chunk.iter().map(|s| s.abs() as f64).fold(0.0_f64, f64::max))
|
||||
.collect();
|
||||
|
||||
let max_peak = peaks.iter().cloned().fold(0.0_f64, f64::max);
|
||||
if max_peak > 0.0 {
|
||||
for peak in &mut peaks {
|
||||
*peak /= max_peak;
|
||||
}
|
||||
}
|
||||
|
||||
peaks.resize(target_count, 0.0);
|
||||
peaks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compute_peaks_basic() {
|
||||
let samples = vec![0.5, -0.8, 0.3, -0.1, 0.9, -0.2];
|
||||
let peaks = compute_peaks(&samples, 3);
|
||||
assert_eq!(peaks.len(), 3);
|
||||
assert!(peaks[0] > 0.0);
|
||||
assert!(peaks[1] > 0.0);
|
||||
assert!(peaks[2] > 0.0);
|
||||
assert!((peaks[2] - 1.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_peaks_empty() {
|
||||
let peaks = compute_peaks(&[], 5);
|
||||
assert_eq!(peaks.len(), 5);
|
||||
assert!(peaks.iter().all(|p| *p == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_f32_samples() {
|
||||
let val: f32 = 0.5;
|
||||
let bytes = val.to_le_bytes();
|
||||
let samples = parse_f32_samples(&bytes);
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert!((samples[0] - 0.5).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user