From af06f2dbc1bb9d71d2ee0d637174b22f3227cccd Mon Sep 17 00:00:00 2001 From: akvaithi Date: Mon, 5 Oct 2026 14:45:02 -0500 Subject: [PATCH] LR-HDR-VISUALIZE: HDR controls in the Light panel, Visualize HDR, export option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Light panel: HDR switch, headroom limit and an SDR Rendition flyout (shown with HDR on); Visualize HDR beside the switch. - `Overlay::HdrRange` / `view.visualizeHdr`: the SDR render dimmed to grey, with pixels the HDR rendition puts above SDR white painted in four bands by stops above it. Rendered on the CPU from the float HDR render. - Export dialog: "HDR output" for JPEG (gain map) and 32-bit TIFF. - Parity (Q. HDR 2 โœ… 3 ๐ŸŸก, LR-EDIT-HDR-MODE โœ…, LR-EXP-HDR ๐ŸŸก), README, ROADMAP. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++-- ROADMAP.md | 2 +- crates/gpu/src/lib.rs | 7 ++++- crates/pipeline/src/lib.rs | 13 +++++++-- crates/pipeline/src/tests_hdr.rs | 18 ++++++++++++ crates/pipeline/src/visualize.rs | 40 +++++++++++++++++++++++++- crates/ui-egui/src/menubar.rs | 2 ++ crates/ui-egui/src/menus.rs | 6 ++++ crates/ui-egui/src/panels/detail.rs | 3 ++ crates/ui-egui/src/panels/dialogs.rs | 7 +++++ crates/ui-egui/src/panels/edit.rs | 42 ++++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 3 ++ docs/parity.md | 24 ++++++++-------- 13 files changed, 154 insertions(+), 20 deletions(-) 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) |