diff --git a/README.md b/README.md
index bca63f8a..c1efeaec 100644
--- a/README.md
+++ b/README.md
@@ -292,7 +292,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- - **HDR, video and the Classic Print / Book / Map modules.**
+ - **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
@@ -308,7 +308,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | ๐ก ยท CR3, compressed ORF and compressed SRW decode โฌ |
| Detail: sharpening, luminance + colour noise reduction | โ
ยท AI Denoise, Super Resolution โฌ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | โ
ยท content-aware fill, spot pin editing ๐ง |
-| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | โ
ยท HDR export โฌ |
+| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | โ
|
+| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG or 32-bit float TIFF | โ
ยท HDR display, AVIF HDR โฌ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | โ
|
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | โ
|
| MCP server (headless or live app, persistent libraries), CLI, control channel | โ
|
@@ -316,7 +317,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | โ
ยท camera lens profiles (our own) โฌ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama โ DNG | โ
|
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | โ
ยท WebGPU in the browser ๐ง |
-| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | โฌ (see [roadmap](ROADMAP.md#where-were-going)) |
+| AI: segmentation masks, AI denoise, super resolution, faces; video | โฌ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | โ
ยท WebGPU, Safari/Firefox testing ๐ง |
โ
works today ยท ๐ง in progress ยท โฌ not started
diff --git a/ROADMAP.md b/ROADMAP.md
index 32f6a89b..fe5197f9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -28,7 +28,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **AI & computational** | ~15โ20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34โ36 / 233โ252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7โ4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows โฌ |
-| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
+| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG and float TIFF export | On-screen HDR display, AVIF PQ/HLG export, HDR settings from crs XMP; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs
index 386fc8f5..beada8ad 100644
--- a/crates/gpu/src/lib.rs
+++ b/crates/gpu/src/lib.rs
@@ -337,7 +337,12 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R
let effective = s.effective();
let s: &DevelopSettings = &effective;
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
- if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
+ // Visualize HDR needs the float HDR render: CPU too
+ if !enabled()
+ || req.depth != lightcraft_pipeline::OutputDepth::U8
+ || req.proof.is_some()
+ || req.overlay == lightcraft_pipeline::Overlay::HdrRange
+ {
return None;
}
let s = &*lightcraft_pipeline::settings_for(s, req);
diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs
index 0613c65e..122b6a95 100644
--- a/crates/pipeline/src/lib.rs
+++ b/crates/pipeline/src/lib.rs
@@ -474,7 +474,8 @@ enum Src<'a> {
fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered {
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
- let rendition = settings_for(&effective, req);
+ let edit: &DevelopSettings = &effective;
+ let rendition = settings_for(edit, req);
let s: &DevelopSettings = &rendition;
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
let lap = |what: &str, t: &mut Option| {
@@ -547,7 +548,15 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
- let image = if plan.keep.is_some() { cut(&image) } else { image };
+ let mut image = if plan.keep.is_some() { cut(&image) } else { image };
+ if req.overlay == Overlay::HdrRange && edit.hdr.enabled {
+ // the HDR rendition of the same request says how far above SDR white each pixel goes
+ let hreq = RenderRequest { depth: OutputDepth::F32Hdr, overlay: Overlay::None, proof: None, ..*req };
+ if let Some(hdr) = render(src_img, info, edit, &hreq).deep {
+ visualize::hdr_range(&mut image, &hdr, req.space.luma());
+ }
+ lap("visualize hdr", &mut t);
+ }
Rendered { image, histogram, deep: None }
}
diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs
index e25797ea..bacc0eaa 100644
--- a/crates/pipeline/src/tests_hdr.rs
+++ b/crates/pipeline/src/tests_hdr.rs
@@ -87,3 +87,21 @@ fn sdr_renders_of_an_hdr_edit_use_the_sdr_rendition() {
let r = s.sdr_rendition();
assert!(!r.hdr.enabled && (r.light.exposure - 0.5).abs() < 1e-9 && r.light.highlights == -40.0);
}
+
+#[test]
+fn visualize_hdr_paints_only_what_rises_above_white() {
+ use crate::Overlay;
+ use crate::visualize::HDR_BANDS;
+ let src = sunset();
+ let req = RenderRequest { overlay: Overlay::HdrRange, ..RenderRequest::fit(240, 240) };
+ let banded = |p: &[u8; 4]| HDR_BANDS.iter().any(|b| (0..3).all(|k| (p[k] as i32 - b[k] as i32).abs() < 70) && p[0] > p[2] + 40);
+ let on = render(&src, &raw(), &hdr_on(3.0), &req);
+ let n = on.image.data.iter().filter(|p| banded(p)).count();
+ assert!(n > 0 && n < on.image.data.len() / 3, "{n} banded pixels");
+ // the rest is grey
+ assert!(on.image.data.iter().filter(|p| !banded(p)).all(|p| p[0] == p[1] && p[1] == p[2]));
+ // HDR off: the overlay draws nothing
+ let off = render(&src, &raw(), &DevelopSettings::default(), &req);
+ let plain = render(&src, &raw(), &DevelopSettings::default(), &RenderRequest::fit(240, 240));
+ assert_eq!(off.image.data, plain.image.data);
+}
diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs
index 99e45449..fc3d8390 100644
--- a/crates/pipeline/src/visualize.rs
+++ b/crates/pipeline/src/visualize.rs
@@ -33,8 +33,14 @@ pub enum Overlay {
/// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in
/// `color` at `opacity` (0..100; the colour views only).
Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 },
+ /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR
+ /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]).
+ HdrRange,
}
+/// [`Overlay::HdrRange`] colours (sRGB) for 0โ1, 1โ2, 2โ3 and โฅ 3 stops above SDR white.
+pub const HDR_BANDS: [[u8; 3]; 4] = [[255, 214, 92], [255, 150, 40], [235, 64, 52], [214, 60, 210]];
+
/// How [`Overlay::Mask`] draws the mask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MaskView {
@@ -114,6 +120,7 @@ impl Overlay {
Overlay::PointColorRange(i) => (1, i as f64),
Overlay::Spots(t) => (2, t as f64),
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
+ Overlay::HdrRange => (4, 0.0),
}
}
@@ -123,6 +130,7 @@ impl Overlay {
1 => Overlay::PointColorRange(v as u8),
2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8),
3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64),
+ 4 => Overlay::HdrRange,
_ => Overlay::None,
}
}
@@ -134,6 +142,7 @@ impl Overlay {
Overlay::PointColorRange(i) => 0x1000 + i as u64,
Overlay::Spots(t) => 0x2000 + t as u64,
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
+ Overlay::HdrRange => 0x4000,
}
}
@@ -162,7 +171,8 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]).
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) {
match o {
- Overlay::None => {}
+ // drawn by the renderer from the HDR rendition ([`hdr_range`])
+ Overlay::None | Overlay::HdrRange => {}
Overlay::PointColorRange(i) => {
if let Some(p) = plan.settings.point_colors.get(i as usize) {
point_color_range(img, &PointK::new(p));
@@ -245,6 +255,34 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) {
});
}
+/// Draw [`Overlay::HdrRange`] on `img` (the SDR render) from `hdr` (the HDR render of the same
+/// request, float linear with luminance weights `luma`): grey below SDR white, band colours above.
+pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) {
+ let crate::DeepSamples::F32(v) = &hdr.samples else { return };
+ if (hdr.width, hdr.height) != (img.width, img.height) {
+ return;
+ }
+ let w = img.width;
+ for_rows(&mut img.data, w, |y, row| {
+ for (x, p) in row.iter_mut().enumerate() {
+ let i = (y * w + x) * 3;
+ let Some(c) = v.get(i..i + 3) else { continue };
+ let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2];
+ let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55;
+ if lum > 1.0 {
+ let stops = lum.log2();
+ let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)];
+ for k in 0..3 {
+ p[k] = (grey * 0.25 + band[k] as f32 * 0.75).round().clamp(0.0, 255.0) as u8;
+ }
+ } else {
+ let g = grey.round().clamp(0.0, 255.0) as u8;
+ (p[0], p[1], p[2]) = (g, g, g);
+ }
+ }
+ });
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs
index 7d964856..1eedebb6 100644
--- a/crates/ui-egui/src/menubar.rs
+++ b/crates/ui-egui/src/menubar.rs
@@ -144,6 +144,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"view.zoom100",
"---",
"view.clipping",
+ "view.visualizeHdr",
"view.softProof",
"view.maskOverlay",
"view.maskOverlayMode",
@@ -305,6 +306,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option {
"view.filmstrip" => Some(u.filmstrip),
"view.histogram" => Some(u.histogram),
"view.clipping" => Some(u.show_clipping),
+ "view.visualizeHdr" => Some(u.hdr_visualize),
"view.softProof" => Some(u.soft_proof),
"view.maskOverlay" => Some(u.mask_overlay),
"view.maskPins" => Some(u.mask_pins),
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 5b79548b..2fe2f632 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -80,6 +80,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
("view.clipping", "Show Clipping", Some("J"), "View"),
+ ("view.visualizeHdr", "Visualize HDR", None, "View"),
// in grids S expands/collapses stacks (the engine command it shadows)
("view.softProof", "Soft Proofing", Some("S"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
@@ -575,6 +576,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option {
+ // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`).
+ app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize);
+ Ok(json!({"on": app.ui.hdr_visualize}))
+ }
"view.softProof" => {
// {on?, space?, destWarning?, displayWarning?}; no params toggles
let has = |k: &str| p.get(k).is_some();
diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs
index 2e708da6..d554d027 100644
--- a/crates/ui-egui/src/panels/detail.rs
+++ b/crates/ui-egui/src/panels/detail.rs
@@ -1080,6 +1080,9 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra
if edit && app.ui.point_color_visualize && app.ui.flyout_open("pointColor") && app.ui.point_color < d.point_colors.len() {
return Overlay::PointColorRange(app.ui.point_color as u8);
}
+ if edit && app.ui.hdr_visualize && d.hdr.enabled {
+ return Overlay::HdrRange;
+ }
Overlay::None
}
diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs
index fa2e6ad9..9a462fc0 100644
--- a/crates/ui-egui/src/panels/dialogs.rs
+++ b/crates/ui-egui/src/panels/dialogs.rs
@@ -532,6 +532,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd);
opts.bit_depth = Some(bd);
}
+ if matches!(opts.format, F::Jpeg | F::Tiff) {
+ let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays); TIFF needs 32-bit float";
+ ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip);
+ if opts.hdr && !opts.hdr_output() {
+ ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim));
+ }
+ }
if !rendered {
} else if opts.format == F::Avif {
ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim));
diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs
index ed7c7704..56f405ea 100644
--- a/crates/ui-egui/src/panels/edit.rs
+++ b/crates/ui-egui/src/panels/edit.rs
@@ -168,10 +168,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
divider(ui);
section(app, ui, &d, "light", "Light", |app, ui, d| {
+ hdr_row(app, ui, d);
for c in ["light.exposure", "light.contrast", "light.highlights", "light.shadows", "light.whites", "light.blacks"] {
control(app, ui, d, c, true);
}
ui.add_space(6.0);
+ if d.hdr.enabled {
+ let open = app.ui.flyout_open("sdrRendition");
+ if flyout_row(ui, "sdrRendition", "SDR Rendition", Icon::Sliders, open).clicked() {
+ app.ui.toggle_flyout("sdrRendition");
+ }
+ if open {
+ for c in ["hdr.sdrBrightness", "hdr.sdrContrast", "hdr.sdrHighlights", "hdr.sdrShadows", "hdr.sdrWhites", "hdr.sdrClarity"] {
+ control(app, ui, d, c, true);
+ }
+ ui.add_space(6.0);
+ }
+ }
let open = app.ui.flyout_open("curve");
if flyout_row(ui, "curve", crate::i18n::tr("Curve"), Icon::Curve, open).clicked() {
app.ui.toggle_flyout("curve");
@@ -1072,6 +1085,35 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
});
}
+// ------------------------------------------------------------------------------ HDR
+
+/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit.
+fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
+ egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
+ ui.horizontal(|ui| {
+ let mut on = d.hdr.enabled;
+ if ui.checkbox(&mut on, "HDR").on_hover_text("Edit in HDR: highlights above SDR white, for HDR exports and displays").changed() {
+ let _ = app.run("develop.hdr", json!({"enabled": on}));
+ }
+ if d.hdr.enabled {
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let mut v = app.ui.hdr_visualize;
+ if ui
+ .checkbox(&mut v, "Visualize HDR")
+ .on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it")
+ .changed()
+ {
+ app.ui.hdr_visualize = v;
+ }
+ });
+ }
+ });
+ });
+ if d.hdr.enabled {
+ control(app, ui, d, "hdr.maxEv", true);
+ }
+}
+
// ------------------------------------------------------------------------------ colour grading
fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
index 83b3df29..aaf654a6 100644
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -425,6 +425,8 @@ pub struct UiState {
pub point_color: usize,
/// Point Color "Visualize range": the selected sample's range in colour, the rest grey.
pub point_color_visualize: bool,
+ /// Light panel "Visualize HDR": grey below SDR white, colour bands above (HDR edits).
+ pub hdr_visualize: bool,
/// Red Eye panel: selected correction, and whether new ones are pet eyes.
pub eye: usize,
pub eye_pet: bool,
@@ -748,6 +750,7 @@ impl Default for UiState {
remove_opacity: 100.0,
point_color: 0,
point_color_visualize: false,
+ hdr_visualize: false,
eye: 0,
eye_pet: false,
visualize_spots: false,
diff --git a/docs/parity.md b/docs/parity.md
index 4c27755b..658984e6 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
-| F. Edit panel โ global adjustments (EDIT) | 42 | 2 | 4 | 1 | 28/28 (100%) | 13/14 (93%) |
+| F. Edit panel โ global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) |
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
@@ -37,9 +37,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) |
| O. Merge (MERGE) | 4 | 0 | 0 | 0 | โ | โ |
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | โ | โ |
-| Q. HDR (HDR) | 0 | 0 | 5 | 0 | โ | โ |
+| Q. HDR (HDR) | 2 | 3 | 0 | 0 | โ | โ |
| R. Video (VID) | 0 | 0 | 5 | 1 | โ | 0/2 (0%) |
-| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) |
+| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) |
| T. Share (SHARE) | 0 | 0 | 0 | 4 | โ | โ |
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | โ | 0/1 (0%) |
| V. Preferences (PREF) | 6 | 1 | 2 | 2 | 1/1 (100%) | 4/4 (100%) |
@@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 32 | 11 | 39 | 9 | โ | 22/24 (92%) |
-| **Total** | 394 | 38 | 84 | 36 | 193/200 (97%) | 142/152 (93%) |
+| **Total** | 397 | 42 | 77 | 36 | 193/200 (97%) | 142/152 (93%) |
-Weighted completion (โ
= 1, ๐ก = ยฝ, ๐ซ left out): **80.0%** of 516 in-scope rows โ P0 98.2% of 200 ยท P1 95.7% of 152 ยท P2 42.9% of 163.
+Weighted completion (โ
= 1, ๐ก = ยฝ, ๐ซ left out): **81.0%** of 516 in-scope rows โ P0 98.2% of 200 ยท P1 95.7% of 152 ยท P2 46.0% of 163.
## Top gaps
@@ -238,7 +238,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|---|---|---|---|---|---|
| LR-EDIT-AUTO | Auto settings | P0 | โ
| `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | |
| LR-EDIT-BW | Black & white | P0 | โ
| `cmd:develop.treatment` | |
-| LR-EDIT-HDR-MODE | HDR editing | P2 | โฌ | | |
+| LR-EDIT-HDR-MODE | HDR editing | P2 | โ
| `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR switch in the Light panel; see Q. HDR |
| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | โ
| `ctl:light.exposure` | |
| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | โ
| `ctl:light.contrast` | |
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | โ
| `ctl:light.highlights` | |
@@ -423,11 +423,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
-| LR-HDR-EDIT | HDR editing | P2 | โฌ | | |
-| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | โฌ | | |
-| LR-HDR-VISUALIZE | Visualize HDR range | P2 | โฌ | | |
-| LR-HDR-LIMIT | HDR headroom limit | P2 | โฌ | | |
-| LR-HDR-EXPORT | HDR export | P2 | โฌ | | |
+| LR-HDR-EDIT | HDR editing | P2 | ๐ก | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); HDR settings not yet read from crs XMP |
+| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | ๐ก | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands |
+| LR-HDR-VISUALIZE | Visualize HDR range | P2 | โ
| `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it |
+| LR-HDR-LIMIT | HDR headroom limit | P2 | โ
| `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops |
+| LR-HDR-EXPORT | HDR export | P2 | ๐ก | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 32-bit float TIFF; AVIF PQ/HLG not yet; no HEIC (no permissive HEVC encoder) |
## R. Video (VID)
@@ -451,7 +451,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EXP-BITDEPTH | Bit depth | P1 | โ
| `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
| LR-EXP-COMPRESSION | TIFF compression | P1 | โ
| `cmd:app.export` (`tiffCompression`: none / lzw / zip) | |
| LR-EXP-COLORSPACE | Output colour space | P0 | โ
| `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) |
-| LR-EXP-HDR | HDR output | P2 | โฌ | | |
+| LR-EXP-HDR | HDR output | P2 | ๐ก | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, float TIFF; AVIF later |
| LR-EXP-SHARPEN | Output sharpening | P1 | โ
| `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
| LR-EXP-METADATA | Metadata policy | P1 | โ
| `cmd:app.export` (`metadata`, `removeLocation`) | |
| LR-EXP-WATERMARK | Watermark | P1 | โ
| `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) |