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