From b76eefb40437505c75337311b40d21969dfe88f0 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 26 Jun 2026 03:01:11 -0400 Subject: [PATCH] hdr: per-document Clip vs Highlight-rolloff output mode (Stage A pt 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-document HDR→SDR mapping applied at the final linear→sRGB encode, so super-white (HDR) video highlights can be recovered instead of hard-clipped: - core: HdrOutputMode {Clip (default), HighlightRolloff} on Document (serde default), with a SetDocumentPropertiesAction variant for undoable edits. - shaders: a fs_main_rolloff entry point (preview linear_to_srgb.wgsl + the export inline shader) applying a C1 highlight knee — identity below 0.8, smooth rolloff [0.8,∞)→[0.8,1). SDR below the knee is untouched; Clip stays the historical path. - preview (stage.rs) and both export encodes (video_exporter.rs) pick the pipeline variant from document.hdr_output_mode — one value per frame, so no per-pixel uniform; mirrors the existing fs_main_straight pattern. - UI: an "HDR output" dropdown in the Document section of the info panel. Default (Clip) is bit-identical to previous behaviour. Completes Stage A: HDR-correct input (pt 1) + SDR-safe output mapping. HDR export (10-bit P010/PQ) and HDR display remain Stages B/C. --- .../src/actions/set_document_properties.rs | 19 ++++- .../lightningbeam-core/src/document.rs | 25 ++++++ .../src/export/video_exporter.rs | 76 ++++++++++++++++++- .../src/panes/infopanel.rs | 21 ++++- .../src/panes/shaders/linear_to_srgb.wgsl | 31 ++++++++ .../lightningbeam-editor/src/panes/stage.rs | 44 ++++++++++- 6 files changed, 210 insertions(+), 6 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs index 48c5e38..fc038b3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs @@ -4,7 +4,7 @@ //! with undo/redo support. use crate::action::Action; -use crate::document::Document; +use crate::document::{Document, HdrOutputMode}; use crate::shape::ShapeColor; /// Individual property change for a document @@ -15,6 +15,7 @@ pub enum DocumentPropertyChange { Duration(f64), Framerate(f64), BackgroundColor(ShapeColor), + HdrOutputMode(HdrOutputMode), } /// Stored old value for undo (either f64 or color) @@ -22,6 +23,7 @@ pub enum DocumentPropertyChange { enum OldValue { F64(f64), Color(ShapeColor), + Hdr(HdrOutputMode), } /// Action that sets a property on the document @@ -72,6 +74,14 @@ impl SetDocumentPropertiesAction { old_value: None, } } + + /// Create a new action to set the HDR→SDR output mode + pub fn set_hdr_output_mode(mode: HdrOutputMode) -> Self { + Self { + property: DocumentPropertyChange::HdrOutputMode(mode), + old_value: None, + } + } } impl Action for SetDocumentPropertiesAction { @@ -83,6 +93,7 @@ impl Action for SetDocumentPropertiesAction { DocumentPropertyChange::Duration(_) => OldValue::F64(document.duration), DocumentPropertyChange::Framerate(_) => OldValue::F64(document.framerate), DocumentPropertyChange::BackgroundColor(_) => OldValue::Color(document.background_color), + DocumentPropertyChange::HdrOutputMode(_) => OldValue::Hdr(document.hdr_output_mode), }); } @@ -92,6 +103,7 @@ impl Action for SetDocumentPropertiesAction { DocumentPropertyChange::Duration(v) => document.duration = *v, DocumentPropertyChange::Framerate(v) => document.framerate = *v, DocumentPropertyChange::BackgroundColor(c) => document.background_color = *c, + DocumentPropertyChange::HdrOutputMode(m) => document.hdr_output_mode = *m, } Ok(()) } @@ -106,11 +118,15 @@ impl Action for SetDocumentPropertiesAction { DocumentPropertyChange::Duration(_) => document.duration = v, DocumentPropertyChange::Framerate(_) => document.framerate = v, DocumentPropertyChange::BackgroundColor(_) => {} + DocumentPropertyChange::HdrOutputMode(_) => {} } } Some(OldValue::Color(c)) => { document.background_color = *c; } + Some(OldValue::Hdr(m)) => { + document.hdr_output_mode = *m; + } None => {} } Ok(()) @@ -123,6 +139,7 @@ impl Action for SetDocumentPropertiesAction { DocumentPropertyChange::Duration(_) => "duration", DocumentPropertyChange::Framerate(_) => "framerate", DocumentPropertyChange::BackgroundColor(_) => "background color", + DocumentPropertyChange::HdrOutputMode(_) => "HDR output mode", }; format!("Set {}", property_name) } diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 9fd4168..62eba1c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -145,6 +145,26 @@ pub enum TimelineMode { Frames, } +/// How super-white (HDR) values are mapped to the SDR display/export output at the final +/// linear→sRGB encode. SDR content sits in [0,1] and is unaffected by either mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum HdrOutputMode { + /// Hard-clip values above 1.0 (the historical behaviour). SDR-exact; HDR highlights blow out. + #[default] + Clip, + /// Roll highlights above a knee smoothly toward 1.0 to recover detail. Slightly dims near-white. + HighlightRolloff, +} + +impl HdrOutputMode { + pub fn name(&self) -> &'static str { + match self { + HdrOutputMode::Clip => "Clip", + HdrOutputMode::HighlightRolloff => "Highlight rolloff", + } + } +} + /// Asset category for folder tree access #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetCategory { @@ -244,6 +264,10 @@ pub struct Document { #[serde(default)] pub timeline_mode: TimelineMode, + /// How HDR (super-white) values are mapped to the SDR output at the final encode. + #[serde(default)] + pub hdr_output_mode: HdrOutputMode, + /// Current UI layout state (serialized for save/load) #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_layout: Option, @@ -278,6 +302,7 @@ impl Default for Document { ml }, duration: 10.0, + hdr_output_mode: HdrOutputMode::default(), root: GraphicsObject::default(), vector_clips: HashMap::new(), video_clips: HashMap::new(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index a19a227..bb10917 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -75,6 +75,8 @@ pub struct ExportGpuResources { pub staging_buffer: wgpu::Buffer, /// Linear to sRGB blit pipeline for final output pub linear_to_srgb_pipeline: wgpu::RenderPipeline, + /// Variant with highlight rolloff (document HDR output mode = Highlight rolloff). + pub linear_to_srgb_pipeline_rolloff: wgpu::RenderPipeline, /// Bind group layout for linear to sRGB blit pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout, /// Sampler for linear to sRGB conversion @@ -236,6 +238,41 @@ impl ExportGpuResources { cache: None, }); + // Highlight-rolloff variant: identical but the `fs_main_rolloff` entry point. + let linear_to_srgb_pipeline_rolloff = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("linear_to_srgb_pipeline_rolloff"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main_rolloff"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + let linear_to_srgb_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("linear_to_srgb_sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -263,6 +300,7 @@ impl ExportGpuResources { yuv_texture_view, staging_buffer, linear_to_srgb_pipeline, + linear_to_srgb_pipeline_rolloff, linear_to_srgb_bind_group_layout, linear_to_srgb_sampler, canvas_blit, @@ -340,6 +378,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { return vec4(srgb, a); } + +// Highlight rolloff: identity below the knee, smooth C1 rolloff [knee,∞)→[knee,1) above (recovers +// super-white HDR detail). SDR below the knee is untouched. Mirrors panes/shaders/linear_to_srgb.wgsl. +fn highlight_rolloff_ch(x: f32) -> f32 { + let knee = 0.8; + if x <= knee { + return x; + } + let headroom = 1.0 - knee; + return knee + headroom * (1.0 - exp(-(x - knee) / headroom)); +} + +// Variant of fs_main with highlight rolloff (document HDR output mode = Highlight rolloff). +@fragment +fn fs_main_rolloff(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let a = src.a; + let straight = select(src.rgb / a, vec3(0.0), a <= 0.0); + let rolled = vec3( + highlight_rolloff_ch(straight.r), + highlight_rolloff_ch(straight.g), + highlight_rolloff_ch(straight.b), + ); + let srgb = linear_to_srgb(rolled); + return vec4(srgb, a); +} "#; /// Convert RGBA8 pixels to YUV420p format using BT.709 color space @@ -1127,7 +1191,11 @@ pub fn render_frame_to_rgba_hdr( timestamp_writes: None, }); - render_pass.set_pipeline(&gpu_resources.linear_to_srgb_pipeline); + let final_pipeline = match document.hdr_output_mode { + lightningbeam_core::document::HdrOutputMode::HighlightRolloff => &gpu_resources.linear_to_srgb_pipeline_rolloff, + lightningbeam_core::document::HdrOutputMode::Clip => &gpu_resources.linear_to_srgb_pipeline, + }; + render_pass.set_pipeline(final_pipeline); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.draw(0..4, 0..1); } @@ -1394,7 +1462,11 @@ pub fn render_frame_to_gpu_rgba( timestamp_writes: None, }); - render_pass.set_pipeline(&gpu_resources.linear_to_srgb_pipeline); + let final_pipeline = match document.hdr_output_mode { + lightningbeam_core::document::HdrOutputMode::HighlightRolloff => &gpu_resources.linear_to_srgb_pipeline_rolloff, + lightningbeam_core::document::HdrOutputMode::Clip => &gpu_resources.linear_to_srgb_pipeline, + }; + render_pass.set_pipeline(final_pipeline); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.draw(0..4, 0..1); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 5813e8e..f730d72 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -980,10 +980,10 @@ impl InfopanelPane { // Extract all needed values up front, then drop the borrow before closures // that need mutable access to shared or self. - let (mut width, mut height, mut duration, mut framerate, layer_count, background_color) = { + let (mut width, mut height, mut duration, mut framerate, layer_count, background_color, mut hdr_mode) = { let document = shared.action_executor.document(); (document.width, document.height, document.duration, document.framerate, - document.root.children.len(), document.background_color) + document.root.children.len(), document.background_color, document.hdr_output_mode) }; // Canvas width @@ -1087,6 +1087,23 @@ impl InfopanelPane { } }); + // HDR output mode (how super-white video highlights map to SDR output) + ui.horizontal(|ui| { + use lightningbeam_core::document::HdrOutputMode; + ui.label("HDR output:"); + egui::ComboBox::from_id_salt(("hdr_output_mode", path)) + .selected_text(hdr_mode.name()) + .show_ui(ui, |ui| { + let mut changed = false; + changed |= ui.selectable_value(&mut hdr_mode, HdrOutputMode::Clip, HdrOutputMode::Clip.name()).changed(); + changed |= ui.selectable_value(&mut hdr_mode, HdrOutputMode::HighlightRolloff, HdrOutputMode::HighlightRolloff.name()).changed(); + if changed { + let action = SetDocumentPropertiesAction::set_hdr_output_mode(hdr_mode); + shared.pending_actions.push(Box::new(action)); + } + }); + }); + // Layer count (read-only) ui.horizontal(|ui| { ui.label("Layers:"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl index 8537377..bc52aec 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl @@ -50,3 +50,34 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { a ); } + +// Highlight rolloff: identity below the knee, then a smooth C1 rolloff that maps [knee, ∞) → [knee, 1) +// so super-white (HDR) detail is recovered instead of hard-clipped. SDR below the knee is untouched. +fn highlight_rolloff(x: f32) -> f32 { + let knee = 0.8; + if x <= knee { + return x; + } + let headroom = 1.0 - knee; + return knee + headroom * (1.0 - exp(-(x - knee) / headroom)); +} + +// Variant of fs_main with highlight rolloff (document HDR output mode = Highlight rolloff). +@fragment +fn fs_main_rolloff(in: VertexOutput) -> @location(0) vec4 { + let linear = textureSample(input_tex, input_sampler, in.uv); + let a = linear.a; + let straight = select(linear.rgb / a, vec3(0.0), a <= 0.0); + let rolled = vec3( + highlight_rolloff(straight.r), + highlight_rolloff(straight.g), + highlight_rolloff(straight.b), + ); + + return vec4( + linear_to_srgb_channel(rolled.r), + linear_to_srgb_channel(rolled.g), + linear_to_srgb_channel(rolled.b), + a + ); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 7582049..fc8daa0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -135,6 +135,8 @@ struct SharedVelloResources { blit_bind_group_layout: wgpu::BindGroupLayout, /// HDR to sRGB blit pipeline (linear→sRGB conversion for display) hdr_blit_pipeline: wgpu::RenderPipeline, + /// Variant of `hdr_blit_pipeline` with highlight rolloff (document HDR output mode). + hdr_blit_pipeline_rolloff: wgpu::RenderPipeline, sampler: wgpu::Sampler, /// Shared image cache for avoiding re-decoding images every frame image_cache: Mutex, @@ -346,6 +348,41 @@ impl SharedVelloResources { cache: None, }); + // Variant with highlight rolloff (document HDR output mode); identical but `fs_main_rolloff`. + let hdr_blit_pipeline_rolloff = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("hdr_blit_pipeline_rolloff"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &hdr_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &hdr_shader, + entry_point: Some("fs_main_rolloff"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + // Create sampler let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("vello_blit_sampler"), @@ -383,6 +420,7 @@ impl SharedVelloResources { blit_pipeline, blit_bind_group_layout, hdr_blit_pipeline, + hdr_blit_pipeline_rolloff, sampler, image_cache: Mutex::new(lightningbeam_core::renderer::ImageCache::new()), video_manager, @@ -2947,7 +2985,11 @@ impl egui_wgpu::CallbackTrait for VelloCallback { occlusion_query_set: None, }); - render_pass.set_pipeline(&shared.hdr_blit_pipeline); + let hdr_pipeline = match self.ctx.document.hdr_output_mode { + lightningbeam_core::document::HdrOutputMode::HighlightRolloff => &shared.hdr_blit_pipeline_rolloff, + lightningbeam_core::document::HdrOutputMode::Clip => &shared.hdr_blit_pipeline, + }; + render_pass.set_pipeline(hdr_pipeline); render_pass.set_bind_group(0, hdr_bind_group, &[]); render_pass.draw(0..3, 0..1); // Full-screen triangle (3 vertices) }