improve video import performance a bit

This commit is contained in:
Skyler Lehmkuhl 2025-12-17 14:12:16 -05:00
parent caba4305d8
commit 2dea1eab9e
5 changed files with 181 additions and 24 deletions

View File

@ -15,6 +15,7 @@ crossterm = "0.27"
rand = "0.8" rand = "0.8"
base64 = "0.22" base64 = "0.22"
pathdiff = "0.2" pathdiff = "0.2"
rayon = "1.10"
# Audio export # Audio export
hound = "3.5" hound = "3.5"

View File

@ -1523,7 +1523,7 @@ impl Engine {
pool_index, detail_level, chunk_indices); pool_index, detail_level, chunk_indices);
// Get audio file data from pool // Get audio file data from pool
if let Some(audio_file) = self.audio_pool.get_file(pool_index) { if let Some(audio_file) = self.audio_pool.get_file(pool_index) {
println!("✅ [ENGINE] Found audio file in pool, spawning background thread"); println!("✅ [ENGINE] Found audio file in pool, queuing work in thread pool");
// Clone necessary data for background thread // Clone necessary data for background thread
let data = audio_file.data.clone(); let data = audio_file.data.clone();
let channels = audio_file.channels; let channels = audio_file.channels;
@ -1531,8 +1531,8 @@ impl Engine {
let path = audio_file.path.clone(); let path = audio_file.path.clone();
let chunk_tx = self.chunk_generation_tx.clone(); let chunk_tx = self.chunk_generation_tx.clone();
// Generate chunks in background thread to avoid blocking audio thread // Generate chunks using rayon's thread pool to avoid spawning thousands of threads
std::thread::spawn(move || { rayon::spawn(move || {
// Create temporary AudioFile for chunk generation // Create temporary AudioFile for chunk generation
let temp_audio_file = crate::audio::pool::AudioFile::with_format( let temp_audio_file = crate::audio::pool::AudioFile::with_format(
path, path,
@ -1563,6 +1563,9 @@ impl Engine {
chunks: event_chunks, chunks: event_chunks,
}); });
} }
// Yield to other threads to reduce CPU contention with video playback
std::thread::sleep(std::time::Duration::from_millis(1));
}); });
} else { } else {
eprintln!("❌ [ENGINE] Pool index {} not found for waveform generation", pool_index); eprintln!("❌ [ENGINE] Pool index {} not found for waveform generation", pool_index);

View File

@ -21,7 +21,7 @@ pub struct VideoMetadata {
} }
/// Video decoder with LRU frame caching /// Video decoder with LRU frame caching
struct VideoDecoder { pub struct VideoDecoder {
path: String, path: String,
width: u32, // Original video width width: u32, // Original video width
height: u32, // Original video height height: u32, // Original video height
@ -43,7 +43,9 @@ impl VideoDecoder {
/// ///
/// `max_width` and `max_height` specify the maximum output dimensions. /// `max_width` and `max_height` specify the maximum output dimensions.
/// Video will be scaled down if larger, preserving aspect ratio. /// Video will be scaled down if larger, preserving aspect ratio.
fn new(path: String, cache_size: usize, max_width: Option<u32>, max_height: Option<u32>) -> Result<Self, String> { /// `build_keyframes` controls whether to build the keyframe index immediately (slow)
/// or defer it for async building later.
fn new(path: String, cache_size: usize, max_width: Option<u32>, max_height: Option<u32>, build_keyframes: bool) -> Result<Self, String> {
ffmpeg::init().map_err(|e| e.to_string())?; ffmpeg::init().map_err(|e| e.to_string())?;
let input = ffmpeg::format::input(&path) let input = ffmpeg::format::input(&path)
@ -92,11 +94,16 @@ impl VideoDecoder {
let fps = f64::from(video_stream.avg_frame_rate()); let fps = f64::from(video_stream.avg_frame_rate());
// Build keyframe index for fast seeking // Optionally build keyframe index for fast seeking
// This scans the video once to find all keyframe positions let keyframe_positions = if build_keyframes {
eprintln!("[Video Decoder] Building keyframe index for {}", path); eprintln!("[Video Decoder] Building keyframe index for {}", path);
let keyframe_positions = Self::build_keyframe_index(&path, stream_index)?; let positions = Self::build_keyframe_index(&path, stream_index)?;
eprintln!("[Video Decoder] Found {} keyframes", keyframe_positions.len()); eprintln!("[Video Decoder] Found {} keyframes", positions.len());
positions
} else {
eprintln!("[Video Decoder] Deferring keyframe index building for {}", path);
Vec::new()
};
Ok(Self { Ok(Self {
path, path,
@ -118,6 +125,31 @@ impl VideoDecoder {
}) })
} }
/// Build keyframe index for this decoder
/// This can be called asynchronously after decoder creation
fn build_and_set_keyframe_index(&mut self) -> Result<(), String> {
eprintln!("[Video Decoder] Building keyframe index for {}", self.path);
let positions = Self::build_keyframe_index(&self.path, self.stream_index)?;
eprintln!("[Video Decoder] Found {} keyframes", positions.len());
self.keyframe_positions = positions;
Ok(())
}
/// Get the output width (scaled dimensions)
pub fn get_output_width(&self) -> u32 {
self.output_width
}
/// Get the output height (scaled dimensions)
pub fn get_output_height(&self) -> u32 {
self.output_height
}
/// Decode a frame at the specified timestamp (public wrapper)
pub fn decode_frame(&mut self, timestamp: f64) -> Result<Vec<u8>, String> {
self.get_frame(timestamp)
}
/// Build an index of all keyframe positions in the video /// Build an index of all keyframe positions in the video
/// This enables fast seeking by knowing exactly where keyframes are /// This enables fast seeking by knowing exactly where keyframes are
fn build_keyframe_index(path: &str, stream_index: usize) -> Result<Vec<i64>, String> { fn build_keyframe_index(path: &str, stream_index: usize) -> Result<Vec<i64>, String> {
@ -407,6 +439,9 @@ impl VideoManager {
/// ///
/// `target_width` and `target_height` specify the maximum dimensions /// `target_width` and `target_height` specify the maximum dimensions
/// for decoded frames. Video will be scaled down if larger. /// for decoded frames. Video will be scaled down if larger.
///
/// The keyframe index is NOT built during this call - use `build_keyframe_index_async`
/// in a background thread to build it asynchronously.
pub fn load_video( pub fn load_video(
&mut self, &mut self,
clip_id: Uuid, clip_id: Uuid,
@ -417,12 +452,13 @@ impl VideoManager {
// First probe the video for metadata // First probe the video for metadata
let metadata = probe_video(&path)?; let metadata = probe_video(&path)?;
// Create decoder with target dimensions // Create decoder with target dimensions, without building keyframe index
let decoder = VideoDecoder::new( let decoder = VideoDecoder::new(
path, path,
self.cache_size, self.cache_size,
Some(target_width), Some(target_width),
Some(target_height), Some(target_height),
false, // Don't build keyframe index synchronously
)?; )?;
// Store decoder in pool // Store decoder in pool
@ -431,6 +467,20 @@ impl VideoManager {
Ok(metadata) Ok(metadata)
} }
/// Build keyframe index for a loaded video asynchronously
///
/// This should be called from a background thread after load_video()
/// to avoid blocking the UI during import.
pub fn build_keyframe_index(&self, clip_id: &Uuid) -> Result<(), String> {
let decoder_arc = self.decoders.get(clip_id)
.ok_or_else(|| format!("Video clip {} not found", clip_id))?;
let mut decoder = decoder_arc.lock()
.map_err(|e| format!("Failed to lock decoder: {}", e))?;
decoder.build_and_set_keyframe_index()
}
/// Get a decoded frame for a specific clip at a specific timestamp /// Get a decoded frame for a specific clip at a specific timestamp
/// ///
/// Returns None if the clip is not loaded or decoding fails. /// Returns None if the clip is not loaded or decoding fails.
@ -467,10 +517,14 @@ impl VideoManager {
Some(frame) Some(frame)
} }
/// Generate thumbnails for a video clip /// Generate thumbnails for a video clip (single batch version - use generate_thumbnails_progressive instead)
/// ///
/// Thumbnails are generated every 5 seconds at 64px width. /// Thumbnails are generated every 5 seconds at 128px width.
/// This should be called in a background thread to avoid blocking. /// This should be called in a background thread to avoid blocking.
/// Thumbnails are inserted into the cache progressively as they're generated,
/// allowing the UI to display them immediately.
///
/// DEPRECATED: Use generate_thumbnails_progressive which releases the lock between thumbnails.
pub fn generate_thumbnails(&mut self, clip_id: &Uuid, duration: f64) -> Result<(), String> { pub fn generate_thumbnails(&mut self, clip_id: &Uuid, duration: f64) -> Result<(), String> {
let decoder_arc = self.decoders.get(clip_id) let decoder_arc = self.decoders.get(clip_id)
.ok_or("Clip not loaded")? .ok_or("Clip not loaded")?
@ -479,7 +533,9 @@ impl VideoManager {
let mut decoder = decoder_arc.lock() let mut decoder = decoder_arc.lock()
.map_err(|e| format!("Failed to lock decoder: {}", e))?; .map_err(|e| format!("Failed to lock decoder: {}", e))?;
let mut thumbnails = Vec::new(); // Initialize thumbnail cache entry with empty vec
self.thumbnail_cache.insert(*clip_id, Vec::new());
let interval = 5.0; // Generate thumbnail every 5 seconds let interval = 5.0; // Generate thumbnail every 5 seconds
let mut t = 0.0; let mut t = 0.0;
@ -505,18 +561,32 @@ impl VideoManager {
thumb_height, thumb_height,
); );
thumbnails.push((t, Arc::new(thumb_data))); // Insert thumbnail into cache immediately so UI can display it
if let Some(thumbnails) = self.thumbnail_cache.get_mut(clip_id) {
thumbnails.push((t, Arc::new(thumb_data)));
}
} }
t += interval; t += interval;
} }
// Store thumbnails in cache
self.thumbnail_cache.insert(*clip_id, thumbnails);
Ok(()) Ok(())
} }
/// Get the decoder Arc for a clip (for external thumbnail generation)
/// This allows external code to decode frames without holding the VideoManager lock
pub fn get_decoder(&self, clip_id: &Uuid) -> Option<Arc<Mutex<VideoDecoder>>> {
self.decoders.get(clip_id).cloned()
}
/// Insert a thumbnail into the cache (for external thumbnail generation)
pub fn insert_thumbnail(&mut self, clip_id: &Uuid, timestamp: f64, data: Arc<Vec<u8>>) {
self.thumbnail_cache
.entry(*clip_id)
.or_insert_with(Vec::new)
.push((timestamp, data));
}
/// Get the thumbnail closest to the specified timestamp /// Get the thumbnail closest to the specified timestamp
/// ///
/// Returns None if no thumbnails have been generated for this clip. /// Returns None if no thumbnails have been generated for this clip.
@ -582,6 +652,17 @@ impl Default for VideoManager {
} }
/// Simple nearest-neighbor downsampling for RGBA images /// Simple nearest-neighbor downsampling for RGBA images
pub fn downsample_rgba_public(
src: &[u8],
src_width: u32,
src_height: u32,
dst_width: u32,
dst_height: u32,
) -> Vec<u8> {
downsample_rgba(src, src_width, src_height, dst_width, dst_height)
}
/// Simple nearest-neighbor downsampling for RGBA images (internal)
fn downsample_rgba( fn downsample_rgba(
src: &[u8], src: &[u8],
src_width: u32, src_width: u32,

View File

@ -44,6 +44,7 @@ lightningcss = "1.0.0-alpha.68"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "serde"] }
petgraph = "0.6" petgraph = "0.6"
rayon = "1.10"
# Native file dialogs # Native file dialogs
rfd = "0.15" rfd = "0.15"

View File

@ -54,6 +54,19 @@ struct Args {
fn main() -> eframe::Result { fn main() -> eframe::Result {
println!("🚀 Starting Lightningbeam Editor..."); println!("🚀 Starting Lightningbeam Editor...");
// Configure rayon thread pool to use fewer threads, leaving cores free for video playback
let num_cpus = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
let waveform_threads = (num_cpus.saturating_sub(2)).max(2); // Leave 2 cores free, minimum 2 threads
rayon::ThreadPoolBuilder::new()
.num_threads(waveform_threads)
.thread_name(|i| format!("waveform-{}", i))
.build_global()
.expect("Failed to build rayon thread pool");
println!("✅ Configured waveform generation to use {} threads (leaving {} cores for video)",
waveform_threads, num_cpus - waveform_threads);
// Parse command line arguments // Parse command line arguments
let args = Args::parse(); let args = Args::parse();
@ -2095,7 +2108,7 @@ impl EditorApp {
let clip_id = clip.id; let clip_id = clip.id;
// Load video into VideoManager // Load video into VideoManager (without building keyframe index)
let doc_width = self.action_executor.document().width as u32; let doc_width = self.action_executor.document().width as u32;
let doc_height = self.action_executor.document().height as u32; let doc_height = self.action_executor.document().height as u32;
@ -2106,6 +2119,18 @@ impl EditorApp {
} }
drop(video_mgr); drop(video_mgr);
// Spawn background thread to build keyframe index asynchronously
let video_manager_clone = Arc::clone(&self.video_manager);
let keyframe_clip_id = clip_id;
std::thread::spawn(move || {
let video_mgr = video_manager_clone.lock().unwrap();
if let Err(e) = video_mgr.build_keyframe_index(&keyframe_clip_id) {
eprintln!("Failed to build keyframe index: {}", e);
} else {
println!(" Built keyframe index for video clip {}", keyframe_clip_id);
}
});
// Spawn background thread for audio extraction if video has audio // Spawn background thread for audio extraction if video has audio
if metadata.has_audio { if metadata.has_audio {
if let Some(ref audio_controller) = self.audio_controller { if let Some(ref audio_controller) = self.audio_controller {
@ -2180,15 +2205,61 @@ impl EditorApp {
} }
// Spawn background thread for thumbnail generation // Spawn background thread for thumbnail generation
// Get decoder once, then generate thumbnails without holding VideoManager lock
let video_manager_clone = Arc::clone(&self.video_manager); let video_manager_clone = Arc::clone(&self.video_manager);
let duration = metadata.duration; let duration = metadata.duration;
let thumb_clip_id = clip_id;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut video_mgr = video_manager_clone.lock().unwrap(); // Get decoder Arc with brief lock
if let Err(e) = video_mgr.generate_thumbnails(&clip_id, duration) { let decoder_arc = {
eprintln!("Failed to generate video thumbnails: {}", e); let video_mgr = video_manager_clone.lock().unwrap();
} else { match video_mgr.get_decoder(&thumb_clip_id) {
println!(" Generated thumbnails for video clip {}", clip_id); Some(arc) => arc,
None => {
eprintln!("Failed to get decoder for thumbnail generation");
return;
}
}
};
// VideoManager lock released - video can now be displayed!
let interval = 5.0;
let mut t = 0.0;
let mut thumbnail_count = 0;
while t < duration {
// Decode frame WITHOUT holding VideoManager lock
let thumb_opt = {
let mut decoder = decoder_arc.lock().unwrap();
match decoder.decode_frame(t) {
Ok(rgba_data) => {
let w = decoder.get_output_width();
let h = decoder.get_output_height();
Some((rgba_data, w, h))
}
Err(_) => None,
}
};
// Downsample without any locks
if let Some((rgba_data, w, h)) = thumb_opt {
use lightningbeam_core::video::downsample_rgba_public;
let thumb_w = 128u32;
let thumb_h = (h as f32 / w as f32 * thumb_w as f32) as u32;
let thumb_data = downsample_rgba_public(&rgba_data, w, h, thumb_w, thumb_h);
// Brief lock just to insert
{
let mut video_mgr = video_manager_clone.lock().unwrap();
video_mgr.insert_thumbnail(&thumb_clip_id, t, Arc::new(thumb_data));
}
thumbnail_count += 1;
}
t += interval;
} }
println!(" Generated {} thumbnails for video clip {}", thumbnail_count, thumb_clip_id);
}); });
// Add clip to document // Add clip to document