Fix video thumbnail strip bugs + persist thumbnails (resumable)
Thumbnail rendering fixes: - Strip now tiles from each clip's true (unclamped) origin and draws only the tiles intersecting the visible rect, so it scrolls correctly and shows the right frames when a clip is scrolled partly off the left. Both render sites (collapsed group + expanded track) share one draw_video_thumbnail_strip helper. - On-clip strip no longer freezes on the first thumbnail: get_thumbnail_at now returns the actual thumbnail timestamp and the GPU texture cache keys on it, so tiles refresh as closer thumbnails finish generating. - Hover preview derives content time from the clip's true origin too (matches the strip when scrolled off-screen). - insert_thumbnail keeps the cache sorted + deduped (fixes a latent unsorted binary_search bug, and makes concurrent restore + resume race-safe). Thumbnail persistence (mirrors waveform persistence): - MediaKind::Thumbnail rows, keyed by thumbnail_media_id(clip_id) (clip id XOR a sentinel). Each clip's thumbnails PNG-encoded into one opaque LBTN blob (editor owns the format), snapshotted cheaply (Arc clones) and encoded off the UI thread. - Save writes the packs (kept in place on re-save); load reads them into LoadedProject.thumbnail_blobs; the editor decodes + inserts them on a background thread, so reload shows thumbnails instantly with no re-decode (even if the source video file is missing). - Partial sets are persisted with a complete flag and RESUMED on load: generate_keyframe_thumbnails takes a should_skip predicate so a save made mid-generation continues from where it left off instead of redoing the work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c784816615
commit
097345be76
|
|
@ -55,6 +55,9 @@ pub enum MediaKind {
|
||||||
/// A precomputed waveform LOD pyramid blob for an audio item (keyed by the
|
/// A precomputed waveform LOD pyramid blob for an audio item (keyed by the
|
||||||
/// same id as the audio it describes). See `daw_backend::audio::waveform_pyramid`.
|
/// same id as the audio it describes). See `daw_backend::audio::waveform_pyramid`.
|
||||||
Waveform = 4,
|
Waveform = 4,
|
||||||
|
/// A pack of precomputed video thumbnails for a video clip (keyed by a
|
||||||
|
/// sentinel-derived id from the clip id). Opaque blob; format owned by the editor.
|
||||||
|
Thumbnail = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaKind {
|
impl MediaKind {
|
||||||
|
|
@ -65,6 +68,7 @@ impl MediaKind {
|
||||||
2 => Some(Self::Raster),
|
2 => Some(Self::Raster),
|
||||||
3 => Some(Self::ImageAsset),
|
3 => Some(Self::ImageAsset),
|
||||||
4 => Some(Self::Waveform),
|
4 => Some(Self::Waveform),
|
||||||
|
5 => Some(Self::Thumbnail),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,11 @@ pub struct LoadedProject {
|
||||||
/// Loaded audio pool entries
|
/// Loaded audio pool entries
|
||||||
pub audio_pool_entries: Vec<AudioPoolEntry>,
|
pub audio_pool_entries: Vec<AudioPoolEntry>,
|
||||||
|
|
||||||
|
/// Persisted video-thumbnail packs by clip id (opaque LBTN blobs; decoded and
|
||||||
|
/// inserted into the VideoManager by the editor). Clips present here don't need
|
||||||
|
/// their thumbnails regenerated on load.
|
||||||
|
pub thumbnail_blobs: std::collections::HashMap<uuid::Uuid, Vec<u8>>,
|
||||||
|
|
||||||
/// List of files that couldn't be found
|
/// List of files that couldn't be found
|
||||||
pub missing_files: Vec<MissingFileInfo>,
|
pub missing_files: Vec<MissingFileInfo>,
|
||||||
}
|
}
|
||||||
|
|
@ -239,12 +244,23 @@ fn waveform_media_id(pool_index: usize) -> Uuid {
|
||||||
Uuid::from_u128(SENTINEL | (pool_index as u128))
|
Uuid::from_u128(SENTINEL | (pool_index as u128))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deterministic id for the thumbnail-pack media row of a video clip. Derived from
|
||||||
|
/// the clip id by XOR with a fixed constant — bijective and stable across saves, so
|
||||||
|
/// it reuses the row in place, and it can't (in practice) collide with the random
|
||||||
|
/// v4 ids used for other media of a different kind.
|
||||||
|
fn thumbnail_media_id(clip_id: Uuid) -> Uuid {
|
||||||
|
// "LBTN" repeated — moves clip ids into a distinct region of the id space.
|
||||||
|
const SENTINEL: u128 = 0x4C42_544E_4C42_544E_4C42_544E_4C42_544E;
|
||||||
|
Uuid::from_u128(clip_id.as_u128() ^ SENTINEL)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save_beam(
|
pub fn save_beam(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
document: &Document,
|
document: &Document,
|
||||||
audio_project: &mut AudioProject,
|
audio_project: &mut AudioProject,
|
||||||
audio_pool_entries: Vec<AudioPoolEntry>,
|
audio_pool_entries: Vec<AudioPoolEntry>,
|
||||||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
||||||
|
thumbnail_blobs: &std::collections::HashMap<uuid::Uuid, Vec<u8>>,
|
||||||
_settings: &SaveSettings,
|
_settings: &SaveSettings,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let fn_start = std::time::Instant::now();
|
let fn_start = std::time::Instant::now();
|
||||||
|
|
@ -400,6 +416,19 @@ pub fn save_beam(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- video thumbnail packs -> media rows (opaque LBTN blob), keyed by a
|
||||||
|
// sentinel-derived id from the video clip id ---
|
||||||
|
for clip_id in document.video_clips.keys() {
|
||||||
|
let tn_id = thumbnail_media_id(*clip_id);
|
||||||
|
if let Some(blob) = thumbnail_blobs.get(clip_id) {
|
||||||
|
txn.put_media_packed(tn_id, MediaKind::Thumbnail, "lbtn", blob, MediaMeta::default())?;
|
||||||
|
live_media.insert(tn_id);
|
||||||
|
} else if txn.media_exists(tn_id)? {
|
||||||
|
// Not regenerated this session — keep the stored pack.
|
||||||
|
live_media.insert(tn_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- orphan cleanup: drop media for removed clips/keyframes ---
|
// --- orphan cleanup: drop media for removed clips/keyframes ---
|
||||||
let removed = txn.retain_media(&live_media)?;
|
let removed = txn.retain_media(&live_media)?;
|
||||||
|
|
||||||
|
|
@ -576,10 +605,26 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Persisted video thumbnail packs (opaque LBTN blobs), keyed by clip id. The
|
||||||
|
// editor decodes + inserts them and skips regeneration for these clips.
|
||||||
|
let mut thumbnail_blobs = std::collections::HashMap::new();
|
||||||
|
for clip_id in document.video_clips.keys() {
|
||||||
|
let tn_id = thumbnail_media_id(*clip_id);
|
||||||
|
if let Ok(Some(info)) = archive.media_info(tn_id) {
|
||||||
|
if info.kind == MediaKind::Thumbnail {
|
||||||
|
match archive.read_media_full(tn_id) {
|
||||||
|
Ok(bytes) => { thumbnail_blobs.insert(*clip_id, bytes); }
|
||||||
|
Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to read thumbnails for {}: {}", clip_id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"📊 [LOAD_BEAM] ✅ Loaded {} audio entries, {} raster frames in {:.2}ms",
|
"📊 [LOAD_BEAM] ✅ Loaded {} audio entries, {} raster frames, {} thumbnail packs in {:.2}ms",
|
||||||
restored_entries.len(),
|
restored_entries.len(),
|
||||||
raster_load_count,
|
raster_load_count,
|
||||||
|
thumbnail_blobs.len(),
|
||||||
fn_start.elapsed().as_secs_f64() * 1000.0
|
fn_start.elapsed().as_secs_f64() * 1000.0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -588,6 +633,7 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
|
||||||
audio_project,
|
audio_project,
|
||||||
layer_to_track_map,
|
layer_to_track_map,
|
||||||
audio_pool_entries: restored_entries,
|
audio_pool_entries: restored_entries,
|
||||||
|
thumbnail_blobs,
|
||||||
missing_files,
|
missing_files,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -815,6 +861,7 @@ fn load_beam_zip_legacy(path: &Path) -> Result<LoadedProject, String> {
|
||||||
audio_project,
|
audio_project,
|
||||||
layer_to_track_map,
|
layer_to_track_map,
|
||||||
audio_pool_entries: restored_entries,
|
audio_pool_entries: restored_entries,
|
||||||
|
thumbnail_blobs: std::collections::HashMap::new(), // legacy ZIP has no thumbnail packs
|
||||||
missing_files,
|
missing_files,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@ pub fn generate_keyframe_thumbnails(
|
||||||
path: &str,
|
path: &str,
|
||||||
interval_secs: f64,
|
interval_secs: f64,
|
||||||
thumb_width: u32,
|
thumb_width: u32,
|
||||||
|
mut should_skip: impl FnMut(f64) -> bool,
|
||||||
mut on_thumb: impl FnMut(f64, Arc<Vec<u8>>),
|
mut on_thumb: impl FnMut(f64, Arc<Vec<u8>>),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Own decoder at thumbnail resolution; builds its own keyframe index. The
|
// Own decoder at thumbnail resolution; builds its own keyframe index. The
|
||||||
|
|
@ -383,8 +384,14 @@ pub fn generate_keyframe_thumbnails(
|
||||||
if ks - last_emitted < interval_secs {
|
if ks - last_emitted < interval_secs {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(rgba) = decoder.get_frame(ks) {
|
// This keyframe is a target slot; advance regardless of skip so the chosen
|
||||||
|
// slots are deterministic (lets a resumed pass target the same timestamps).
|
||||||
last_emitted = ks;
|
last_emitted = ks;
|
||||||
|
// Skip slots already covered (resume after a partial save / dedup).
|
||||||
|
if should_skip(ks) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(rgba) = decoder.get_frame(ks) {
|
||||||
on_thumb(ks, Arc::new(rgba));
|
on_thumb(ks, Arc::new(rgba));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -471,6 +478,11 @@ pub struct VideoManager {
|
||||||
/// Low-resolution (64px width) thumbnails for scrubbing
|
/// Low-resolution (64px width) thumbnails for scrubbing
|
||||||
thumbnail_cache: HashMap<Uuid, Vec<(f64, Arc<Vec<u8>>)>>,
|
thumbnail_cache: HashMap<Uuid, Vec<(f64, Arc<Vec<u8>>)>>,
|
||||||
|
|
||||||
|
/// Clips whose thumbnail generation finished. Only complete sets are worth
|
||||||
|
/// persisting — a partial set (saved mid-generation) is dropped so the load
|
||||||
|
/// regenerates it fully rather than leaving it permanently incomplete.
|
||||||
|
thumbnails_complete: std::collections::HashSet<Uuid>,
|
||||||
|
|
||||||
/// Maximum number of frames to cache per decoder
|
/// Maximum number of frames to cache per decoder
|
||||||
cache_size: usize,
|
cache_size: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -493,6 +505,7 @@ impl VideoManager {
|
||||||
frame_cache: LruCache::unbounded(),
|
frame_cache: LruCache::unbounded(),
|
||||||
frame_cache_bytes: 0,
|
frame_cache_bytes: 0,
|
||||||
thumbnail_cache: HashMap::new(),
|
thumbnail_cache: HashMap::new(),
|
||||||
|
thumbnails_complete: std::collections::HashSet::new(),
|
||||||
cache_size,
|
cache_size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -592,18 +605,57 @@ impl VideoManager {
|
||||||
self.decoders.get(clip_id).cloned()
|
self.decoders.get(clip_id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a thumbnail into the cache (for external thumbnail generation)
|
/// Snapshot all cached thumbnails for persistence (clip id -> sorted
|
||||||
pub fn insert_thumbnail(&mut self, clip_id: &Uuid, timestamp: f64, data: Arc<Vec<u8>>) {
|
/// (timestamp, rgba) pairs). Cheap: clones the `Arc`s, not the pixel data.
|
||||||
self.thumbnail_cache
|
/// Partial sets are persisted too — pair with [`complete_thumbnail_clips`] so
|
||||||
.entry(*clip_id)
|
/// the load knows which clips still need generation resumed.
|
||||||
.or_insert_with(Vec::new)
|
pub fn snapshot_all_thumbnails(&self) -> HashMap<Uuid, Vec<(f64, Arc<Vec<u8>>)>> {
|
||||||
.push((timestamp, data));
|
self.thumbnail_cache.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the thumbnail closest to the specified timestamp
|
/// The set of clips whose thumbnail generation has finished (a full keyframe
|
||||||
|
/// pass). A persisted set flagged incomplete is resumed on load.
|
||||||
|
pub fn complete_thumbnail_clips(&self) -> std::collections::HashSet<Uuid> {
|
||||||
|
self.thumbnails_complete.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a clip's thumbnail generation as complete (called when the background
|
||||||
|
/// generator finishes the full keyframe pass).
|
||||||
|
pub fn mark_thumbnails_complete(&mut self, clip_id: &Uuid) {
|
||||||
|
self.thumbnails_complete.insert(*clip_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the clip already has a thumbnail within `tol` seconds of `ts`.
|
||||||
|
/// Lets the generator skip keyframes already covered (resume / dedup).
|
||||||
|
pub fn has_thumbnail_near(&self, clip_id: &Uuid, ts: f64, tol: f64) -> bool {
|
||||||
|
self.thumbnail_cache
|
||||||
|
.get(clip_id)
|
||||||
|
.map_or(false, |v| v.iter().any(|(t, _)| (t - ts).abs() < tol))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a thumbnail into the cache, keeping it **sorted by timestamp** and
|
||||||
|
/// **deduped** (an existing entry at the same timestamp is replaced). Sorted
|
||||||
|
/// order is required by `get_thumbnail_at`'s binary search, and dedup makes
|
||||||
|
/// concurrent restore + resumed generation idempotent (no double inserts).
|
||||||
|
pub fn insert_thumbnail(&mut self, clip_id: &Uuid, timestamp: f64, data: Arc<Vec<u8>>) {
|
||||||
|
let vec = self.thumbnail_cache.entry(*clip_id).or_default();
|
||||||
|
match vec.binary_search_by(|(t, _)| {
|
||||||
|
t.partial_cmp(×tamp).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}) {
|
||||||
|
Ok(i) => vec[i] = (timestamp, data),
|
||||||
|
Err(i) => vec.insert(i, (timestamp, data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the thumbnail closest to the specified timestamp.
|
||||||
///
|
///
|
||||||
|
/// Returns `(actual_timestamp, width, height, data)` — `actual_timestamp` is
|
||||||
|
/// the time of the thumbnail actually chosen (which may differ from the
|
||||||
|
/// requested `timestamp`, and changes as closer thumbnails finish generating).
|
||||||
|
/// Callers key their GPU texture cache on it so the on-clip strip refreshes as
|
||||||
|
/// better thumbnails load instead of freezing on the first one.
|
||||||
/// Returns None if no thumbnails have been generated for this clip.
|
/// Returns None if no thumbnails have been generated for this clip.
|
||||||
pub fn get_thumbnail_at(&self, clip_id: &Uuid, timestamp: f64) -> Option<(u32, u32, Arc<Vec<u8>>)> {
|
pub fn get_thumbnail_at(&self, clip_id: &Uuid, timestamp: f64) -> Option<(f64, u32, u32, Arc<Vec<u8>>)> {
|
||||||
let thumbnails = self.thumbnail_cache.get(clip_id)?;
|
let thumbnails = self.thumbnail_cache.get(clip_id)?;
|
||||||
|
|
||||||
if thumbnails.is_empty() {
|
if thumbnails.is_empty() {
|
||||||
|
|
@ -631,14 +683,14 @@ impl VideoManager {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let (_, rgba_data) = &thumbnails[idx];
|
let (actual_ts, rgba_data) = &thumbnails[idx];
|
||||||
|
|
||||||
// Return (width, height, data)
|
// Return (actual_timestamp, width, height, data)
|
||||||
// Thumbnails are always 128px width
|
// Thumbnails are always 128px width
|
||||||
let thumb_width = 128;
|
let thumb_width = 128;
|
||||||
let thumb_height = (rgba_data.len() / (thumb_width * 4)) as u32;
|
let thumb_height = (rgba_data.len() / (thumb_width * 4)) as u32;
|
||||||
|
|
||||||
Some((thumb_width as u32, thumb_height, Arc::clone(rgba_data)))
|
Some((*actual_ts, thumb_width as u32, thumb_height, Arc::clone(rgba_data)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a video clip and its cached data
|
/// Remove a video clip and its cached data
|
||||||
|
|
@ -661,6 +713,7 @@ impl VideoManager {
|
||||||
|
|
||||||
// Remove thumbnails
|
// Remove thumbnails
|
||||||
self.thumbnail_cache.remove(clip_id);
|
self.thumbnail_cache.remove(clip_id);
|
||||||
|
self.thumbnails_complete.remove(clip_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all frame caches (useful for memory management)
|
/// Clear all frame caches (useful for memory management)
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,79 @@ impl FocusIconCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a clip's thumbnails to an `LBTN` blob (version 2): a `complete` flag
|
||||||
|
/// then each frame PNG-encoded and packed with its timestamp. Thumbnails are 128px
|
||||||
|
/// wide; height is implied by the PNG. `complete` records whether the generating
|
||||||
|
/// pass finished — a partial pack is persisted and resumed on load. Frames that
|
||||||
|
/// fail to encode are skipped (the count reflects what's written).
|
||||||
|
fn encode_thumbnail_blob(thumbs: &[(f64, std::sync::Arc<Vec<u8>>)], complete: bool) -> Vec<u8> {
|
||||||
|
let mut entries: Vec<(f64, Vec<u8>)> = Vec::with_capacity(thumbs.len());
|
||||||
|
for (ts, rgba) in thumbs {
|
||||||
|
let width = 128u32;
|
||||||
|
let height = (rgba.len() / (width as usize * 4)) as u32;
|
||||||
|
if height == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let img = lightningbeam_core::brush_engine::image_from_raw((**rgba).clone(), width, height);
|
||||||
|
if let Ok(png) = lightningbeam_core::brush_engine::encode_png(&img) {
|
||||||
|
entries.push((*ts, png));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(b"LBTN");
|
||||||
|
out.extend_from_slice(&2u32.to_le_bytes()); // version
|
||||||
|
out.push(if complete { 1 } else { 0 });
|
||||||
|
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
|
||||||
|
for (ts, png) in &entries {
|
||||||
|
out.extend_from_slice(&ts.to_le_bytes());
|
||||||
|
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(png);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read just the `complete` flag from an `LBTN` blob (cheap header read, no PNG
|
||||||
|
/// decode). Unknown/old versions are treated as incomplete (→ regenerate).
|
||||||
|
fn thumbnail_blob_is_complete(blob: &[u8]) -> bool {
|
||||||
|
blob.len() >= 9
|
||||||
|
&& &blob[0..4] == b"LBTN"
|
||||||
|
&& u32::from_le_bytes(blob[4..8].try_into().unwrap()) == 2
|
||||||
|
&& blob[8] == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an `LBTN` blob into `(timestamp, rgba)` thumbnails (128px wide). Returns
|
||||||
|
/// empty for unknown/old versions.
|
||||||
|
fn decode_thumbnail_blob(blob: &[u8]) -> Vec<(f64, std::sync::Arc<Vec<u8>>)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if blob.len() < 13 || &blob[0..4] != b"LBTN" {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if u32::from_le_bytes(blob[4..8].try_into().unwrap()) != 2 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// blob[8] = complete flag (read separately via thumbnail_blob_is_complete)
|
||||||
|
let count = u32::from_le_bytes(blob[9..13].try_into().unwrap()) as usize;
|
||||||
|
let mut pos = 13;
|
||||||
|
for _ in 0..count {
|
||||||
|
if pos + 12 > blob.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let ts = f64::from_le_bytes(blob[pos..pos + 8].try_into().unwrap());
|
||||||
|
pos += 8;
|
||||||
|
let png_len = u32::from_le_bytes(blob[pos..pos + 4].try_into().unwrap()) as usize;
|
||||||
|
pos += 4;
|
||||||
|
if pos + png_len > blob.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Ok(img) = lightningbeam_core::brush_engine::decode_png(&blob[pos..pos + png_len]) {
|
||||||
|
out.push((ts, std::sync::Arc::new(img.into_raw())));
|
||||||
|
}
|
||||||
|
pos += png_len;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Command sent to file operations worker thread
|
/// Command sent to file operations worker thread
|
||||||
enum FileCommand {
|
enum FileCommand {
|
||||||
Save {
|
Save {
|
||||||
|
|
@ -497,6 +570,12 @@ enum FileCommand {
|
||||||
/// Serialized waveform-pyramid blobs per audio pool index, persisted into
|
/// Serialized waveform-pyramid blobs per audio pool index, persisted into
|
||||||
/// the container so a later load needn't re-decode the source media.
|
/// the container so a later load needn't re-decode the source media.
|
||||||
waveform_blobs: std::collections::HashMap<usize, Vec<u8>>,
|
waveform_blobs: std::collections::HashMap<usize, Vec<u8>>,
|
||||||
|
/// Raw video thumbnails by clip id (cheap Arc-clone snapshot). The worker
|
||||||
|
/// PNG-encodes them off the UI thread, then persists them in the container.
|
||||||
|
thumbnail_snapshot: std::collections::HashMap<uuid::Uuid, Vec<(f64, std::sync::Arc<Vec<u8>>)>>,
|
||||||
|
/// Clips whose thumbnail generation has finished — partial sets are still
|
||||||
|
/// persisted (and resumed on load), but flagged incomplete.
|
||||||
|
complete_thumbnail_clips: std::collections::HashSet<uuid::Uuid>,
|
||||||
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
||||||
},
|
},
|
||||||
Load {
|
Load {
|
||||||
|
|
@ -571,8 +650,8 @@ impl FileOperationsWorker {
|
||||||
fn run(self) {
|
fn run(self) {
|
||||||
while let Ok(command) = self.command_rx.recv() {
|
while let Ok(command) = self.command_rx.recv() {
|
||||||
match command {
|
match command {
|
||||||
FileCommand::Save { path, document, layer_to_track_map, large_media_mode, waveform_blobs, progress_tx } => {
|
FileCommand::Save { path, document, layer_to_track_map, large_media_mode, waveform_blobs, thumbnail_snapshot, complete_thumbnail_clips, progress_tx } => {
|
||||||
self.handle_save(path, document, &layer_to_track_map, large_media_mode, waveform_blobs, progress_tx);
|
self.handle_save(path, document, &layer_to_track_map, large_media_mode, waveform_blobs, thumbnail_snapshot, complete_thumbnail_clips, progress_tx);
|
||||||
}
|
}
|
||||||
FileCommand::Load { path, progress_tx } => {
|
FileCommand::Load { path, progress_tx } => {
|
||||||
self.handle_load(path, progress_tx);
|
self.handle_load(path, progress_tx);
|
||||||
|
|
@ -589,10 +668,23 @@ impl FileOperationsWorker {
|
||||||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
||||||
large_media_mode: lightningbeam_core::file_io::LargeMediaMode,
|
large_media_mode: lightningbeam_core::file_io::LargeMediaMode,
|
||||||
waveform_blobs: std::collections::HashMap<usize, Vec<u8>>,
|
waveform_blobs: std::collections::HashMap<usize, Vec<u8>>,
|
||||||
|
thumbnail_snapshot: std::collections::HashMap<uuid::Uuid, Vec<(f64, std::sync::Arc<Vec<u8>>)>>,
|
||||||
|
complete_thumbnail_clips: std::collections::HashSet<uuid::Uuid>,
|
||||||
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
||||||
) {
|
) {
|
||||||
use lightningbeam_core::file_io::{save_beam, SaveSettings};
|
use lightningbeam_core::file_io::{save_beam, SaveSettings};
|
||||||
|
|
||||||
|
// PNG-encode the thumbnail snapshot into per-clip LBTN blobs (off the UI
|
||||||
|
// thread), flagging each pack complete-or-partial so the load can resume.
|
||||||
|
let thumbnail_blobs: std::collections::HashMap<uuid::Uuid, Vec<u8>> = thumbnail_snapshot
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, thumbs)| !thumbs.is_empty())
|
||||||
|
.map(|(clip_id, thumbs)| {
|
||||||
|
let complete = complete_thumbnail_clips.contains(clip_id);
|
||||||
|
(*clip_id, encode_thumbnail_blob(thumbs, complete))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let save_start = std::time::Instant::now();
|
let save_start = std::time::Instant::now();
|
||||||
eprintln!("📊 [SAVE] Starting save operation...");
|
eprintln!("📊 [SAVE] Starting save operation...");
|
||||||
|
|
||||||
|
|
@ -641,7 +733,7 @@ impl FileOperationsWorker {
|
||||||
large_media_mode,
|
large_media_mode,
|
||||||
..SaveSettings::default()
|
..SaveSettings::default()
|
||||||
};
|
};
|
||||||
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &settings) {
|
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &thumbnail_blobs, &settings) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
@ -3716,6 +3808,14 @@ impl EditorApp {
|
||||||
.map(|(&idx, blob)| (idx, blob.as_ref().clone()))
|
.map(|(&idx, blob)| (idx, blob.as_ref().clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Snapshot all video thumbnails (cheap Arc-clone) + which clips finished
|
||||||
|
// generating; the worker PNG-encodes them with a complete/partial flag so a
|
||||||
|
// save mid-generation persists progress and resumes on load.
|
||||||
|
let (thumbnail_snapshot, complete_thumbnail_clips) = {
|
||||||
|
let vm = self.video_manager.lock().unwrap();
|
||||||
|
(vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips())
|
||||||
|
};
|
||||||
|
|
||||||
// Send save command to worker thread
|
// Send save command to worker thread
|
||||||
let command = FileCommand::Save {
|
let command = FileCommand::Save {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
|
@ -3723,6 +3823,8 @@ impl EditorApp {
|
||||||
layer_to_track_map: self.layer_to_track_map.clone(),
|
layer_to_track_map: self.layer_to_track_map.clone(),
|
||||||
large_media_mode: self.config.large_media_default,
|
large_media_mode: self.config.large_media_default,
|
||||||
waveform_blobs,
|
waveform_blobs,
|
||||||
|
thumbnail_snapshot,
|
||||||
|
complete_thumbnail_clips,
|
||||||
progress_tx,
|
progress_tx,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3808,7 +3910,7 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply loaded project data (called after successful load in background)
|
/// Apply loaded project data (called after successful load in background)
|
||||||
fn apply_loaded_project(&mut self, loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) {
|
fn apply_loaded_project(&mut self, mut loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) {
|
||||||
use lightningbeam_core::action::ActionExecutor;
|
use lightningbeam_core::action::ActionExecutor;
|
||||||
|
|
||||||
let apply_start = std::time::Instant::now();
|
let apply_start = std::time::Instant::now();
|
||||||
|
|
@ -4059,11 +4161,39 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
|
// Restore persisted video thumbnails (decode + insert off-thread). Only
|
||||||
|
// *complete* packs are skipped from regeneration; *partial* packs are
|
||||||
|
// restored AND have generation resumed below (it skips the keyframes already
|
||||||
|
// restored), so a save mid-generation continues from where it left off.
|
||||||
|
let skip_thumbnail_clips: std::collections::HashSet<uuid::Uuid> = loaded_project
|
||||||
|
.thumbnail_blobs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, blob)| thumbnail_blob_is_complete(blob))
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
if !loaded_project.thumbnail_blobs.is_empty() {
|
||||||
|
let blobs = std::mem::take(&mut loaded_project.thumbnail_blobs);
|
||||||
|
let video_manager = Arc::clone(&self.video_manager);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for (clip_id, blob) in blobs {
|
||||||
|
let complete = thumbnail_blob_is_complete(&blob);
|
||||||
|
let thumbs = decode_thumbnail_blob(&blob);
|
||||||
|
let mut vm = video_manager.lock().unwrap();
|
||||||
|
for (ts, rgba) in thumbs {
|
||||||
|
vm.insert_thumbnail(&clip_id, ts, rgba);
|
||||||
|
}
|
||||||
|
if complete {
|
||||||
|
vm.mark_thumbnails_complete(&clip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Re-register video clips with the VideoManager (decoder + keyframe index +
|
// Re-register video clips with the VideoManager (decoder + keyframe index +
|
||||||
// thumbnails). Restored video_clips carry only metadata + file_path; without
|
// thumbnails). Restored video_clips carry only metadata + file_path; without
|
||||||
// this they render black with no thumbnails.
|
// this they render black with no thumbnails.
|
||||||
let step9_start = std::time::Instant::now();
|
let step9_start = std::time::Instant::now();
|
||||||
self.register_loaded_videos();
|
self.register_loaded_videos(&skip_thumbnail_clips);
|
||||||
eprintln!("📊 [APPLY] Step 9: Registered loaded videos in {:.2}ms", step9_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [APPLY] Step 9: Registered loaded videos in {:.2}ms", step9_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
// Reset playback state
|
// Reset playback state
|
||||||
|
|
@ -4088,7 +4218,7 @@ impl EditorApp {
|
||||||
/// so it can decode and display. Mirrors the import path's setup (decoder +
|
/// so it can decode and display. Mirrors the import path's setup (decoder +
|
||||||
/// background keyframe index + thumbnails) but does NOT re-extract audio —
|
/// background keyframe index + thumbnails) but does NOT re-extract audio —
|
||||||
/// the extracted audio is already restored via the audio pool.
|
/// the extracted audio is already restored via the audio pool.
|
||||||
fn register_loaded_videos(&mut self) {
|
fn register_loaded_videos(&mut self, skip_thumbnail_clips: &std::collections::HashSet<uuid::Uuid>) {
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -4117,9 +4247,13 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.spawn_keyframe_index(clip_id);
|
self.spawn_keyframe_index(clip_id);
|
||||||
|
// Skip regeneration for clips whose thumbnails were restored from the
|
||||||
|
// container — they're being decoded + inserted in the background.
|
||||||
|
if !skip_thumbnail_clips.contains(&clip_id) {
|
||||||
self.spawn_thumbnail_generation(clip_id);
|
self.spawn_thumbnail_generation(clip_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a background thread that builds the keyframe (seek) index for a
|
/// Spawn a background thread that builds the keyframe (seek) index for a
|
||||||
/// video clip. The clip must already be registered via `load_video`.
|
/// video clip. The clip must already be registered via `load_video`.
|
||||||
|
|
@ -4174,17 +4308,26 @@ impl EditorApp {
|
||||||
};
|
};
|
||||||
|
|
||||||
let vm_insert = Arc::clone(&video_manager);
|
let vm_insert = Arc::clone(&video_manager);
|
||||||
|
let vm_skip = Arc::clone(&video_manager);
|
||||||
let result = lightningbeam_core::video::generate_keyframe_thumbnails(
|
let result = lightningbeam_core::video::generate_keyframe_thumbnails(
|
||||||
&path,
|
&path,
|
||||||
5.0,
|
5.0,
|
||||||
128,
|
128,
|
||||||
|
// Resume: skip keyframes already covered (e.g. restored from a
|
||||||
|
// partial persisted pack), so generation only fills the gaps.
|
||||||
|
|ts| vm_skip.lock().unwrap().has_thumbnail_near(&clip_id, ts, 2.5),
|
||||||
|ts, rgba| {
|
|ts, rgba| {
|
||||||
let mut vm = vm_insert.lock().unwrap();
|
let mut vm = vm_insert.lock().unwrap();
|
||||||
vm.insert_thumbnail(&clip_id, ts, rgba);
|
vm.insert_thumbnail(&clip_id, ts, rgba);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Err(e) = result {
|
match result {
|
||||||
eprintln!("Thumbnail generation failed for {}: {}", clip_id, e);
|
Ok(()) => {
|
||||||
|
// Full keyframe pass finished — mark complete so this set is
|
||||||
|
// worth persisting (a partial set is left unmarked → not saved).
|
||||||
|
vm_insert.lock().unwrap().mark_thumbnails_complete(&clip_id);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Thumbnail generation failed for {}: {}", clip_id, e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,94 @@ fn clip_instance_y_bounds(row: usize, total_rows: usize) -> (f32, f32) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Draw the strip of video thumbnails across a clip.
|
||||||
|
///
|
||||||
|
/// Thumbnails are tiled from the clip's **true** (unclamped) screen origin
|
||||||
|
/// `clip_origin_x` over its full width `clip_true_width_px`, and only the tiles
|
||||||
|
/// intersecting `visible_rect` are drawn. This makes the strip scroll naturally
|
||||||
|
/// with the timeline and show the correct content when the clip is partly off the
|
||||||
|
/// left edge (a tile's content time is derived from its offset from the true
|
||||||
|
/// origin, not from the clamped visible edge).
|
||||||
|
///
|
||||||
|
/// Each tile's GPU texture is cached by the **actual** thumbnail timestamp (from
|
||||||
|
/// [`VideoManager::get_thumbnail_at`]), so as closer thumbnails finish generating
|
||||||
|
/// the strip refreshes instead of freezing on whatever loaded first.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn draw_video_thumbnail_strip(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
ui: &egui::Ui,
|
||||||
|
video_mgr: &lightningbeam_core::video::VideoManager,
|
||||||
|
textures: &mut std::collections::HashMap<(uuid::Uuid, i64), egui::TextureHandle>,
|
||||||
|
clip_id: uuid::Uuid,
|
||||||
|
trim_start: f64,
|
||||||
|
clip_origin_x: f32,
|
||||||
|
clip_true_width_px: f32,
|
||||||
|
visible_rect: egui::Rect,
|
||||||
|
thumb_top_y: f32,
|
||||||
|
thumb_height: f32,
|
||||||
|
pixels_per_second: f64,
|
||||||
|
) {
|
||||||
|
if clip_true_width_px <= 0.0 || thumb_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Aspect ratio from any available thumbnail; sets the per-tile width.
|
||||||
|
let aspect = match video_mgr.get_thumbnail_at(&clip_id, 0.0) {
|
||||||
|
Some((_, tw, th, _)) if th > 0 => tw as f32 / th as f32,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let step = (thumb_height * aspect).max(1.0);
|
||||||
|
let num_thumbs = ((clip_true_width_px / step).ceil() as usize).max(1);
|
||||||
|
|
||||||
|
// Iterate only the tiles that intersect the visible rect.
|
||||||
|
let first = (((visible_rect.min.x - clip_origin_x) / step).floor() as i64).max(0) as usize;
|
||||||
|
let last = (((visible_rect.max.x - clip_origin_x) / step).ceil() as i64).max(0) as usize;
|
||||||
|
let last = last.min(num_thumbs);
|
||||||
|
|
||||||
|
for i in first..last {
|
||||||
|
let thumb_x = clip_origin_x + i as f32 * step;
|
||||||
|
// Content time at the tile centre, measured from the TRUE clip origin so
|
||||||
|
// it's stable as the clip scrolls.
|
||||||
|
let content_time =
|
||||||
|
trim_start + (i as f64 * step as f64 + step as f64 * 0.5) / pixels_per_second;
|
||||||
|
|
||||||
|
let Some((actual_ts, tw, th, rgba_data)) = video_mgr.get_thumbnail_at(&clip_id, content_time)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ts_key = (actual_ts * 1000.0) as i64;
|
||||||
|
let texture = textures.entry((clip_id, ts_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_id, ts_key),
|
||||||
|
image,
|
||||||
|
egui::TextureOptions::LINEAR,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let full_rect = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(thumb_x, thumb_top_y),
|
||||||
|
egui::vec2(step, thumb_height),
|
||||||
|
);
|
||||||
|
let thumb_rect = full_rect.intersect(visible_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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the effective clip duration for a clip instance on a given layer.
|
/// Get the effective clip duration for a clip instance on a given layer.
|
||||||
/// For groups on vector layers, the duration spans all consecutive keyframes
|
/// For groups on vector layers, the duration spans all consecutive keyframes
|
||||||
/// where the group is present. For regular clips, returns the clip's internal duration.
|
/// where the group is present. For regular clips, returns the clip's internal duration.
|
||||||
|
|
@ -2639,12 +2727,12 @@ impl TimelinePane {
|
||||||
video_manager: &std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
|
video_manager: &std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
|
||||||
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||||||
playback_time: f64,
|
playback_time: f64,
|
||||||
) -> (Vec<(egui::Rect, uuid::Uuid, f64, f64)>, Vec<AutomationLaneRender>) {
|
) -> (Vec<(egui::Rect, uuid::Uuid, f64, f32)>, Vec<AutomationLaneRender>) {
|
||||||
let painter = ui.painter().clone();
|
let painter = ui.painter().clone();
|
||||||
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
|
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
|
||||||
|
|
||||||
// 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, f32)> = Vec::new();
|
||||||
|
|
||||||
// Track visible video clip IDs for texture cache cleanup
|
// Track visible video clip IDs for texture cache cleanup
|
||||||
let mut visible_video_clip_ids: std::collections::HashSet<uuid::Uuid> = std::collections::HashSet::new();
|
let mut visible_video_clip_ids: std::collections::HashSet<uuid::Uuid> = std::collections::HashSet::new();
|
||||||
|
|
@ -2959,69 +3047,29 @@ impl TimelinePane {
|
||||||
egui::pos2(ci_rect.min.x, span_y_min),
|
egui::pos2(ci_rect.min.x, span_y_min),
|
||||||
egui::pos2(ci_rect.max.x, span_y_max),
|
egui::pos2(ci_rect.max.x, span_y_max),
|
||||||
);
|
);
|
||||||
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, ci_start));
|
// 4th elem = clip's TRUE (unclamped) origin x, for correct
|
||||||
|
// hover content time when scrolled partly off the left.
|
||||||
|
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx));
|
||||||
|
|
||||||
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
|
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
|
||||||
if thumb_display_height > 8.0 {
|
if thumb_display_height > 8.0 {
|
||||||
let video_mgr = video_manager.lock().unwrap();
|
let video_mgr = video_manager.lock().unwrap();
|
||||||
if let Some((tw, th, _)) = video_mgr.get_thumbnail_at(&ci.clip_id, 0.0) {
|
// Tile from the clip's true origin (sx unclamped; ci_rect is
|
||||||
let aspect = tw as f32 / th as f32;
|
// the clamped visible rect) — scrolls correctly off-screen.
|
||||||
let thumb_display_width = thumb_display_height * aspect;
|
draw_video_thumbnail_strip(
|
||||||
let ci_width = ci_rect.width();
|
&painter,
|
||||||
let num_thumbs = ((ci_width / thumb_display_width).ceil() as usize).max(1);
|
ui,
|
||||||
|
&video_mgr,
|
||||||
for ti in 0..num_thumbs {
|
&mut self.video_thumbnail_textures,
|
||||||
let x_offset = ti as f32 * thumb_display_width;
|
ci.clip_id,
|
||||||
if x_offset >= ci_width { break; }
|
ci.trim_start,
|
||||||
|
rect.min.x + sx,
|
||||||
let time_offset = (x_offset as f64 + thumb_display_width as f64 * 0.5)
|
ex - sx,
|
||||||
/ self.pixels_per_second as f64;
|
ci_rect,
|
||||||
let content_time = ci.trim_start + time_offset;
|
ci_rect.min.y + 2.0,
|
||||||
|
thumb_display_height,
|
||||||
if let Some((tw, th, rgba_data)) = video_mgr.get_thumbnail_at(&ci.clip_id, content_time) {
|
self.pixels_per_second as f64,
|
||||||
let ts_key = (content_time * 1000.0) as i64;
|
|
||||||
let cache_key = (ci.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_{}_{}", ci.clip_id, ts_key),
|
|
||||||
image,
|
|
||||||
egui::TextureOptions::LINEAR,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let full_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(ci_rect.min.x + x_offset, ci_rect.min.y + 2.0),
|
|
||||||
egui::vec2(thumb_display_width, thumb_display_height),
|
|
||||||
);
|
|
||||||
let thumb_rect = full_rect.intersect(ci_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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3818,75 +3866,31 @@ impl TimelinePane {
|
||||||
let thumb_display_height = clip_rect.height() - 4.0;
|
let thumb_display_height = clip_rect.height() - 4.0;
|
||||||
if thumb_display_height > 8.0 {
|
if thumb_display_height > 8.0 {
|
||||||
let video_mgr = video_manager.lock().unwrap();
|
let video_mgr = video_manager.lock().unwrap();
|
||||||
if let Some((tw, th, _)) = video_mgr.get_thumbnail_at(&clip_instance.clip_id, 0.0) {
|
// Tile from the clip's true origin (start_x is unclamped;
|
||||||
let aspect = tw as f32 / th as f32;
|
// clip_rect is the clamped visible rect) so the strip scrolls
|
||||||
let thumb_display_width = thumb_display_height * aspect;
|
// correctly and shows the right content when partly off-screen.
|
||||||
let thumb_step_px = thumb_display_width;
|
draw_video_thumbnail_strip(
|
||||||
|
&painter,
|
||||||
let clip_width = clip_rect.width();
|
ui,
|
||||||
let num_thumbs = ((clip_width / thumb_step_px).ceil() as usize).max(1);
|
&video_mgr,
|
||||||
|
&mut self.video_thumbnail_textures,
|
||||||
for i in 0..num_thumbs {
|
clip_instance.clip_id,
|
||||||
let x_offset = i as f32 * thumb_step_px;
|
clip_instance.trim_start,
|
||||||
if x_offset >= clip_width { break; }
|
rect.min.x + start_x,
|
||||||
|
end_x - start_x,
|
||||||
// Map pixel position to content time
|
clip_rect,
|
||||||
let time_offset = (x_offset as f64 + thumb_display_width as f64 * 0.5)
|
clip_rect.min.y + 2.0,
|
||||||
/ self.pixels_per_second as f64;
|
thumb_display_height,
|
||||||
let content_time = clip_instance.trim_start + time_offset;
|
self.pixels_per_second as f64,
|
||||||
|
|
||||||
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. Store the
|
||||||
|
// clip's TRUE (unclamped) origin x so the hover content time is
|
||||||
|
// correct even when the clip is scrolled partly off the left.
|
||||||
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, rect.min.x + start_x));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw border per segment (per loop iteration for looping clips)
|
// Draw border per segment (per loop iteration for looping clips)
|
||||||
|
|
@ -5918,26 +5922,24 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling
|
// VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling
|
||||||
// This ensures hover events aren't consumed by the main input handler
|
// This ensures hover events aren't consumed by the main input handler
|
||||||
for (clip_rect, clip_id, trim_start, instance_start) in video_clip_hovers {
|
for (clip_rect, clip_id, trim_start, clip_origin_x) in video_clip_hovers {
|
||||||
let hover_response = ui.allocate_rect(clip_rect, egui::Sense::hover());
|
let hover_response = ui.allocate_rect(clip_rect, egui::Sense::hover());
|
||||||
|
|
||||||
if hover_response.hovered() {
|
if hover_response.hovered() {
|
||||||
if let Some(hover_pos) = hover_response.hover_pos() {
|
if let Some(hover_pos) = hover_response.hover_pos() {
|
||||||
// Calculate timestamp at hover position
|
// Content time from the clip's TRUE origin. `clip_rect` is clamped
|
||||||
let hover_offset_pixels = hover_pos.x - clip_rect.min.x;
|
// to the viewport, so using its left edge mislabels the frame when
|
||||||
let hover_offset_time = (hover_offset_pixels as f64) / (self.pixels_per_second as f64);
|
// the clip is scrolled partly off the left (same bug the strip had).
|
||||||
let hover_timestamp = instance_start + hover_offset_time;
|
let hover_offset_pixels = hover_pos.x - clip_origin_x;
|
||||||
|
let clip_content_time = trim_start + (hover_offset_pixels as f64) / (self.pixels_per_second as f64);
|
||||||
// Remap to clip content time accounting for trim
|
|
||||||
let clip_content_time = trim_start + (hover_timestamp - instance_start);
|
|
||||||
|
|
||||||
// Try to get thumbnail from video manager
|
// Try to get thumbnail from video manager
|
||||||
let thumbnail_data: Option<(u32, u32, std::sync::Arc<Vec<u8>>)> = {
|
let thumbnail_data: Option<(f64, u32, u32, std::sync::Arc<Vec<u8>>)> = {
|
||||||
let video_mgr = shared.video_manager.lock().unwrap();
|
let video_mgr = shared.video_manager.lock().unwrap();
|
||||||
video_mgr.get_thumbnail_at(&clip_id, clip_content_time)
|
video_mgr.get_thumbnail_at(&clip_id, clip_content_time)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((thumb_width, thumb_height, ref thumb_data)) = thumbnail_data {
|
if let Some((_, thumb_width, thumb_height, ref thumb_data)) = thumbnail_data {
|
||||||
// Create texture from thumbnail
|
// Create texture from thumbnail
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||||
[thumb_width as usize, thumb_height as usize],
|
[thumb_width as usize, thumb_height as usize],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue