diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 3e2f1be..b1b5e40 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -2029,12 +2029,23 @@ impl BlitTransform { // y' = b*x + d*y + f // Column-major 3×3: col0=(a,b,0), col1=(c,d,0), col2=(e,f,1) let [a, b, c, d, e, f] = combined.as_coeffs(); + // The .w slots are unused by the 3×3 matrix; the shader reads them as an RGB + // tint (1,1,1 = no tint — the default for all normal blits). Self { - col0: [a as f32, b as f32, 0.0, 0.0], - col1: [c as f32, d as f32, 0.0, 0.0], - col2: [e as f32, f as f32, 1.0, 0.0], + col0: [a as f32, b as f32, 0.0, 1.0], + col1: [c as f32, d as f32, 0.0, 1.0], + col2: [e as f32, f as f32, 1.0, 1.0], } } + + /// Multiply the sampled color by an RGB tint (for onion-skin ghosts). Packed into + /// the matrix uniform's `.w` slots, so no extra binding is needed. + pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self { + self.col0[3] = r; + self.col1[3] = g; + self.col2[3] = b; + self + } } impl CanvasBlitPipeline { diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 29791c4..62d7cb4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -103,6 +103,7 @@ pub enum AppAction { TogglePlayPause, CancelAction, ToggleDebugOverlay, + ToggleOnionSkin, #[cfg(debug_assertions)] ToggleTestMode, @@ -150,7 +151,7 @@ impl AppAction { Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools", Self::TogglePlayPause | Self::CancelAction | - Self::ToggleDebugOverlay => "Global", + Self::ToggleDebugOverlay | Self::ToggleOnionSkin => "Global", #[cfg(debug_assertions)] Self::ToggleTestMode => "Global", @@ -246,6 +247,7 @@ impl AppAction { Self::TogglePlayPause => "Toggle Play/Pause", Self::CancelAction => "Cancel / Escape", Self::ToggleDebugOverlay => "Toggle Debug Overlay", + Self::ToggleOnionSkin => "Toggle Onion Skinning", #[cfg(debug_assertions)] Self::ToggleTestMode => "Toggle Test Mode", Self::PianoRollDelete => "Piano Roll: Delete", @@ -281,7 +283,7 @@ impl AppAction { Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon, Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect, Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit, - Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, + Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, Self::ToggleOnionSkin, #[cfg(debug_assertions)] Self::ToggleTestMode, Self::PianoRollDelete, Self::StageDelete, @@ -330,6 +332,7 @@ impl From for AppAction { MenuAction::AddTestClip => Self::AddTestClip, MenuAction::DeleteLayer => Self::DeleteLayer, MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility, + MenuAction::ToggleOnionSkin => Self::ToggleOnionSkin, MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable MenuAction::NewKeyframe => Self::NewKeyframe, MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe, @@ -392,6 +395,7 @@ impl TryFrom for MenuAction { AppAction::AddTestClip => MenuAction::AddTestClip, AppAction::DeleteLayer => MenuAction::DeleteLayer, AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility, + AppAction::ToggleOnionSkin => MenuAction::ToggleOnionSkin, AppAction::NewKeyframe => MenuAction::NewKeyframe, AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe, AppAction::DeleteFrame => MenuAction::DeleteFrame, @@ -507,6 +511,7 @@ pub fn all_defaults() -> HashMap> { defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na))); defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na))); defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na))); + defaults.insert(AppAction::ToggleOnionSkin, Some(Shortcut::new(ShortcutKey::O, nc, ns, na))); #[cfg(debug_assertions)] defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na))); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 823d110..e56a75b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1026,6 +1026,8 @@ struct EditorApp { recording_mirror_rx: Option>, /// Current file path (None if not yet saved) current_file_path: Option, + /// Onion-skinning view settings (toggle + frame counts + opacity). + onion_skin: crate::panes::OnionSkinSettings, /// On-demand loader for raster keyframe pixels from the project `.beam` container. raster_store: lightningbeam_core::raster_store::RasterStore, /// Miss-sink: raster keyframe ids the canvas wanted but whose pixels weren't @@ -1313,6 +1315,7 @@ impl EditorApp { waveform_result_tx, recording_mirror_rx, current_file_path: None, // No file loaded initially + onion_skin: crate::panes::OnionSkinSettings::default(), raster_store: lightningbeam_core::raster_store::RasterStore::new(None), raster_fault_requests: Default::default(), raster_load_result_tx, @@ -3816,6 +3819,9 @@ impl EditorApp { MenuAction::RecenterView => { self.pending_view_action = Some(MenuAction::RecenterView); } + MenuAction::ToggleOnionSkin => { + self.onion_skin.enabled = !self.onion_skin.enabled; + } MenuAction::NextLayout => { println!("Menu: Next Layout"); let next_index = (self.current_layout_index + 1) % self.layouts.len(); @@ -6459,6 +6465,12 @@ impl eframe::App for EditorApp { // Create render context let mut ctx = RenderContext { shared: panes::SharedPaneState { + onion: { + // Onion skinning is disabled during playback. + let mut o = self.onion_skin; + o.enabled = o.enabled && !self.is_playing; + o + }, tool_icon_cache: &mut self.tool_icon_cache, icon_cache: &mut self.icon_cache, selected_tool: &mut self.selected_tool, diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 2d55671..ddf0daf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -332,6 +332,7 @@ pub enum MenuAction { ZoomOut, ActualSize, RecenterView, + ToggleOnionSkin, NextLayout, PreviousLayout, #[allow(dead_code)] // Handler exists in main.rs, menu item not yet wired @@ -432,6 +433,7 @@ impl MenuItemDef { const ZOOM_OUT: Self = Self { label: "Zoom Out", action: MenuAction::ZoomOut, shortcut: Some(Shortcut::new(ShortcutKey::Minus, CTRL, NO_SHIFT, NO_ALT)) }; const ACTUAL_SIZE: Self = Self { label: "Actual Size", action: MenuAction::ActualSize, shortcut: Some(Shortcut::new(ShortcutKey::Num0, CTRL, NO_SHIFT, NO_ALT)) }; const RECENTER_VIEW: Self = Self { label: "Recenter View", action: MenuAction::RecenterView, shortcut: None }; + const TOGGLE_ONION_SKIN: Self = Self { label: "Onion Skinning", action: MenuAction::ToggleOnionSkin, shortcut: Some(Shortcut::new(ShortcutKey::O, NO_CTRL, NO_SHIFT, NO_ALT)) }; const NEXT_LAYOUT: Self = Self { label: "Next Layout", action: MenuAction::NextLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketRight, CTRL, NO_SHIFT, NO_ALT)) }; const PREVIOUS_LAYOUT: Self = Self { label: "Previous Layout", action: MenuAction::PreviousLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketLeft, CTRL, NO_SHIFT, NO_ALT)) }; @@ -455,6 +457,7 @@ impl MenuItemDef { &Self::DELETE, &Self::SELECT_ALL, &Self::SELECT_NONE, &Self::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME, &Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE, + &Self::TOGGLE_ONION_SKIN, &Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT, &Self::SETTINGS, &Self::CLOSE_WINDOW, ] @@ -566,6 +569,7 @@ impl MenuItemDef { MenuDef::Item(&Self::ZOOM_OUT), MenuDef::Item(&Self::ACTUAL_SIZE), MenuDef::Item(&Self::RECENTER_VIEW), + MenuDef::Item(&Self::TOGGLE_ONION_SKIN), MenuDef::Separator, MenuDef::Submenu { label: "Layout", diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 21f0251..9c1b3e4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -143,7 +143,40 @@ pub fn find_sampled_audio_track(document: &lightningbeam_core::document::Documen } /// Shared state that all panes can access +/// Onion-skinning view settings (editor-only; not saved with the document). Ghosts the +/// active layer's neighbouring keyframes, warm-tinted for past and cool for future. +#[derive(Clone, Copy, Debug)] +pub struct OnionSkinSettings { + pub enabled: bool, + pub frames_before: usize, + pub frames_after: usize, + /// Opacity of the nearest ghost; further ghosts fall off linearly. + pub opacity: f32, +} + +impl Default for OnionSkinSettings { + fn default() -> Self { + Self { enabled: false, frames_before: 2, frames_after: 2, opacity: 0.35 } + } +} + +impl OnionSkinSettings { + /// RGB tint multipliers for past (warm) and future (cool) ghosts. + pub const PAST_TINT: [f32; 3] = [1.0, 0.45, 0.45]; + pub const FUTURE_TINT: [f32; 3] = [0.45, 0.6, 1.0]; + + /// Opacity for the `n`-th ghost away from the current frame (n = 1 is nearest), + /// linearly falling off so the furthest ghost is faintest. + pub fn ghost_opacity(&self, n: usize, total: usize) -> f32 { + if total == 0 { return self.opacity; } + let falloff = 1.0 - (n.saturating_sub(1) as f32) / (total as f32); + self.opacity * falloff.clamp(0.15, 1.0) + } +} + pub struct SharedPaneState<'a> { + /// Effective onion-skin settings (already gated to off during playback by main.rs). + pub onion: OnionSkinSettings, pub tool_icon_cache: &'a mut crate::ToolIconCache, #[allow(dead_code)] // Used by pane chrome rendering in main.rs pub icon_cache: &'a mut crate::IconCache, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl index e193544..37c56ee 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl @@ -67,5 +67,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r; let masked_a = c.a * mask; let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6); - return vec4(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a); + // RGB tint packed into the matrix uniform's unused .w slots (1,1,1 = no tint). + // Used by onion-skin ghosts (warm = past, cool = future). + let tint = vec3(transform.col0.w, transform.col1.w, transform.col2.w); + return vec4(c.r * inv_a * tint.r, c.g * inv_a * tint.g, c.b * inv_a * tint.b, masked_a); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index b5d93bb..7e7e869 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -435,6 +435,8 @@ struct VelloRenderContext { document: std::sync::Arc, /// Current tool interaction state tool_state: lightningbeam_core::tool::ToolState, + /// Onion-skinning settings (already gated off during playback). + onion: crate::panes::OnionSkinSettings, /// Active layer for tool operations active_layer_id: Option, /// Delta for drag preview (world space) @@ -1296,6 +1298,96 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.release(hdr_layer_handle); } RenderedLayerType::Raster { transform: layer_transform, .. } => { + // --- Onion-skin ghosts: the active layer's neighbouring keyframes, + // warm-tinted for past / cool for future, faint and behind the + // current frame. `ctx.onion.enabled` is already gated off during + // playback. Ghosts use the full texture if resident, else the proxy. + if self.ctx.onion.enabled + && Some(rendered_layer.layer_id) == self.ctx.active_layer_id + { + let onion = self.ctx.onion; + let ghosts: Vec<(uuid::Uuid, u32, u32, [f32; 3], f32)> = { + let doc = &self.ctx.document; + match doc.get_layer(&rendered_layer.layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) => { + let after = rl.keyframes.partition_point(|kf| kf.time <= self.ctx.playback_time); + let mut v = Vec::new(); + if after > 0 { + let cur = after - 1; + for d in 1..=onion.frames_before { + if cur >= d { + let kf = &rl.keyframes[cur - d]; + v.push((kf.id, kf.width, kf.height, + crate::panes::OnionSkinSettings::PAST_TINT, + onion.ghost_opacity(d, onion.frames_before))); + } + } + for d in 1..=onion.frames_after { + if cur + d < rl.keyframes.len() { + let kf = &rl.keyframes[cur + d]; + v.push((kf.id, kf.width, kf.height, + crate::panes::OnionSkinSettings::FUTURE_TINT, + onion.ghost_opacity(d, onion.frames_after))); + } + } + } + v + } + _ => Vec::new(), + } + }; + for (kf_id, lw, lh, tint, opacity) in ghosts { + let gh = buffer_pool.acquire(device, hdr_spec); + if let (Some(gv), Some(hdr_view)) = ( + buffer_pool.get_view(gh), + &instance_resources.hdr_texture_view, + ) { + let mut drew = false; + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + let has_full = gpu_brush.raster_layer_cache.contains_key(&kf_id); + if !has_full { + // Upload the proxy on demand from the keyframe's pixels. + if let Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) = + self.ctx.document.get_layer(&rendered_layer.layer_id) + { + if let Some(p) = rl.keyframes.iter().find(|k| k.id == kf_id) + .and_then(|k| k.proxy.as_ref()) + { + gpu_brush.ensure_proxy_texture(device, queue, kf_id, &p.pixels, p.width, p.height); + } + } + } + let src = if has_full { + gpu_brush.raster_layer_cache.get(&kf_id).map(|c| (c, c.width, c.height, false)) + } else { + gpu_brush.get_proxy_texture(&kf_id).map(|c| (c, lw, lh, true)) + }; + if let Some((canvas, sw, sh, is_proxy)) = src { + let bt = crate::gpu_brush::BlitTransform::new(*layer_transform, sw, sh, width, height) + .with_tint(tint[0], tint[1], tint[2]); + if is_proxy { + shared.canvas_blit.blit_smooth(device, queue, canvas.src_view(), gv, &bt, None); + } else { + shared.canvas_blit.blit(device, queue, canvas.src_view(), gv, &bt, None); + } + drew = true; + } + } + if drew { + let cl = lightningbeam_core::gpu::CompositorLayer::new( + gh, rendered_layer.opacity * opacity, rendered_layer.blend_mode, + ); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("onion_ghost_encoder"), + }); + shared.compositor.composite(device, queue, &mut enc, &[cl], &buffer_pool, hdr_view, None); + queue.submit(Some(enc.finish())); + } + } + buffer_pool.release(gh); + } + } + // Raster layer — GPU canvas blit directly to HDR (bypasses Vello). // Tool override canvas (gpu_canvas_kf) takes priority over cached // texture; if neither full-res source is present, the low-res proxy. @@ -11875,6 +11967,7 @@ impl PaneRenderer for StagePane { instance_id: self.instance_id, document: shared.action_executor.document_arc(), tool_state: shared.tool_state.clone(), + onion: shared.onion, active_layer_id: *shared.active_layer_id, drag_delta, selection: shared.selection.clone(),