hdr: per-document Clip vs Highlight-rolloff output mode (Stage A pt 2)

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.
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 03:01:11 -04:00
parent ff490ab9ae
commit b76eefb404
6 changed files with 210 additions and 6 deletions

View File

@ -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)
}

View File

@ -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<LayoutNode>,
@ -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(),

View File

@ -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<f32> {
return vec4<f32>(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<f32> {
let src = textureSample(source_tex, source_sampler, in.uv);
let a = src.a;
let straight = select(src.rgb / a, vec3<f32>(0.0), a <= 0.0);
let rolled = vec3<f32>(
highlight_rolloff_ch(straight.r),
highlight_rolloff_ch(straight.g),
highlight_rolloff_ch(straight.b),
);
let srgb = linear_to_srgb(rolled);
return vec4<f32>(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);
}

View File

@ -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:");

View File

@ -50,3 +50,34 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32> {
let linear = textureSample(input_tex, input_sampler, in.uv);
let a = linear.a;
let straight = select(linear.rgb / a, vec3<f32>(0.0), a <= 0.0);
let rolled = vec3<f32>(
highlight_rolloff(straight.r),
highlight_rolloff(straight.g),
highlight_rolloff(straight.b),
);
return vec4<f32>(
linear_to_srgb_channel(rolled.r),
linear_to_srgb_channel(rolled.g),
linear_to_srgb_channel(rolled.b),
a
);
}

View File

@ -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<lightningbeam_core::renderer::ImageCache>,
@ -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)
}