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:
parent
ff490ab9ae
commit
b76eefb404
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue