From ed022995bd71272f77bc932b33ca07a5ba3114ec Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sat, 20 Jun 2026 22:44:56 -0400 Subject: [PATCH] Keyframe diamonds: pointing-hand cursor + prefetch during playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pointing-hand cursor when hovering a clickable keyframe diamond. - Prefetch (Phase 3e, playback only): each update during playback, page in the next few upcoming keyframes (PREFETCH_AHEAD=4) per raster layer that aren't resident, via the existing async worker. Their full pixels land before the playhead reaches them, so playback shows full frames instead of the low-res proxy on every frame (the proxy→full pop was the "flicker"). Reactive faults still cover scrubbing. --- .../lightningbeam-editor/src/main.rs | 23 ++++++++++++++++--- .../src/panes/timeline.rs | 7 ++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 99b586b..823d110 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -5283,13 +5283,30 @@ impl eframe::App for EditorApp { } } - // 2) Dispatch background page-ins for newly-requested keyframes. - let wanted: Vec = { + // 2) Dispatch background page-ins: reactive misses, plus (during playback) + // a prefetch of upcoming keyframes so their full pixels are resident + // before the playhead reaches them — otherwise every frame shows the + // low-res proxy and the proxy→full pops read as flicker. + let mut to_load: Vec = { let mut s = self.raster_fault_requests.lock().unwrap(); s.drain().collect() }; + if self.is_playing && self.raster_store.has_path() { + const PREFETCH_AHEAD: usize = 4; + let t = self.playback_time; + let doc = self.action_executor.document(); + for layer in doc.all_layers() { + if let lightningbeam_core::layer::AnyLayer::Raster(rl) = layer { + for kf in rl.keyframes.iter().filter(|kf| kf.time >= t).take(PREFETCH_AHEAD) { + if kf.raw_pixels.is_empty() && kf.needs_fault_in { + to_load.push(kf.id); + } + } + } + } + } if self.raster_store.has_path() { - for kf_id in wanted { + for kf_id in to_load { if self.raster_loads_inflight.contains(&kf_id) { continue; } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 27dc552..2d7e5ca 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -4867,6 +4867,13 @@ impl TimelinePane { } } + // Pointing-hand cursor when hovering a clickable keyframe diamond. + if let Some(hover) = response.hover_pos() { + if self.keyframe_diamond_hits.iter().any(|(r, _)| r.contains(hover)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + // Click a keyframe diamond → snap the playhead exactly to that keyframe. // (Hit targets come from the previous frame's render_layers; diamonds don't // move between frames, so a click lands on the right one.)