draw thumbnails on video clips

This commit is contained in:
Skyler Lehmkuhl 2026-03-01 09:35:31 -05:00
parent 6bd400d353
commit b3e1da3152
1 changed files with 85 additions and 1 deletions

View File

@ -185,6 +185,10 @@ pub struct TimelinePane {
/// Waveform upload progress: pool_index -> frames uploaded so far. /// Waveform upload progress: pool_index -> frames uploaded so far.
/// Tracks chunked GPU uploads across frames to avoid hitches. /// Tracks chunked GPU uploads across frames to avoid hitches.
waveform_upload_progress: std::collections::HashMap<usize, usize>, waveform_upload_progress: std::collections::HashMap<usize, usize>,
/// Cached egui textures for video thumbnail strip rendering.
/// Key: (clip_id, thumbnail_timestamp_millis) → TextureHandle
video_thumbnail_textures: std::collections::HashMap<(uuid::Uuid, i64), egui::TextureHandle>,
} }
/// Check if a clip type can be dropped on a layer type /// Check if a clip type can be dropped on a layer type
@ -372,6 +376,7 @@ impl TimelinePane {
context_menu_clip: None, context_menu_clip: None,
time_display_format: TimeDisplayFormat::Seconds, time_display_format: TimeDisplayFormat::Seconds,
waveform_upload_progress: std::collections::HashMap::new(), waveform_upload_progress: std::collections::HashMap::new(),
video_thumbnail_textures: std::collections::HashMap::new(),
} }
} }
@ -1515,12 +1520,16 @@ impl TimelinePane {
target_format: wgpu::TextureFormat, target_format: wgpu::TextureFormat,
waveform_stereo: bool, waveform_stereo: bool,
context_layers: &[&lightningbeam_core::layer::AnyLayer], context_layers: &[&lightningbeam_core::layer::AnyLayer],
video_manager: &std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
) -> Vec<(egui::Rect, uuid::Uuid, f64, f64)> { ) -> Vec<(egui::Rect, uuid::Uuid, f64, f64)> {
let painter = ui.painter(); let painter = ui.painter();
// Collect video clip rects for hover detection (to avoid borrow conflicts) // Collect video clip rects for hover detection (to avoid borrow conflicts)
let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f64)> = Vec::new(); let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f64)> = Vec::new();
// Track visible video clip IDs for texture cache cleanup
let mut visible_video_clip_ids: std::collections::HashSet<uuid::Uuid> = std::collections::HashSet::new();
// Theme colors for active/inactive layers // Theme colors for active/inactive layers
let active_style = theme.style(".timeline-row-active", ui.ctx()); let active_style = theme.style(".timeline-row-active", ui.ctx());
let inactive_style = theme.style(".timeline-row-inactive", ui.ctx()); let inactive_style = theme.style(".timeline-row-inactive", ui.ctx());
@ -2319,6 +2328,78 @@ impl TimelinePane {
} }
} }
// VIDEO THUMBNAIL STRIP: Draw sequence of thumbnails inside clip rect
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
visible_video_clip_ids.insert(clip_instance.clip_id);
let thumb_display_height = clip_rect.height() - 4.0;
if thumb_display_height > 8.0 {
let video_mgr = video_manager.lock().unwrap();
if let Some((tw, th, _)) = video_mgr.get_thumbnail_at(&clip_instance.clip_id, 0.0) {
let aspect = tw as f32 / th as f32;
let thumb_display_width = thumb_display_height * aspect;
let thumb_step_px = thumb_display_width;
let clip_width = clip_rect.width();
let num_thumbs = ((clip_width / thumb_step_px).ceil() as usize).max(1);
for i in 0..num_thumbs {
let x_offset = i as f32 * thumb_step_px;
if x_offset >= clip_width { break; }
// Map pixel position to content time
let time_offset = (x_offset as f64 + thumb_display_width as f64 * 0.5)
/ self.pixels_per_second as f64;
let content_time = clip_instance.trim_start + time_offset;
if let Some((tw, th, rgba_data)) = video_mgr.get_thumbnail_at(
&clip_instance.clip_id, content_time
) {
let ts_key = (content_time * 1000.0) as i64;
let cache_key = (clip_instance.clip_id, ts_key);
let texture = self.video_thumbnail_textures
.entry(cache_key)
.or_insert_with(|| {
let image = egui::ColorImage::from_rgba_unmultiplied(
[tw as usize, th as usize],
&rgba_data,
);
ui.ctx().load_texture(
format!("vthumb_{}_{}", clip_instance.clip_id, ts_key),
image,
egui::TextureOptions::LINEAR,
)
});
let full_rect = egui::Rect::from_min_size(
egui::pos2(clip_rect.min.x + x_offset, clip_rect.min.y + 2.0),
egui::vec2(thumb_display_width, thumb_display_height),
);
let thumb_rect = full_rect.intersect(clip_rect);
if thumb_rect.width() > 2.0 && thumb_rect.height() > 2.0 {
let uv_min = egui::pos2(
(thumb_rect.min.x - full_rect.min.x) / full_rect.width(),
(thumb_rect.min.y - full_rect.min.y) / full_rect.height(),
);
let uv_max = egui::pos2(
(thumb_rect.max.x - full_rect.min.x) / full_rect.width(),
(thumb_rect.max.y - full_rect.min.y) / full_rect.height(),
);
painter.image(
texture.id(),
thumb_rect,
egui::Rect::from_min_max(uv_min, uv_max),
egui::Color32::WHITE,
);
}
}
}
}
}
}
// VIDEO PREVIEW: Collect clip rect for hover detection // VIDEO PREVIEW: Collect clip rect for hover detection
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, instance_start)); video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, instance_start));
@ -2417,6 +2498,9 @@ impl TimelinePane {
); );
} }
// Clean up stale video thumbnail textures for clips no longer visible
self.video_thumbnail_textures.retain(|&(clip_id, _), _| visible_video_clip_ids.contains(&clip_id));
// Return video clip hover data for processing after input handling // Return video clip hover data for processing after input handling
video_clip_hovers video_clip_hovers
} }
@ -3423,7 +3507,7 @@ impl PaneRenderer for TimelinePane {
// Render layer rows with clipping // Render layer rows with clipping
ui.set_clip_rect(content_rect.intersect(original_clip_rect)); ui.set_clip_rect(content_rect.intersect(original_clip_rect));
let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers); let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager);
// Render playhead on top (clip to timeline area) // Render playhead on top (clip to timeline area)
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));