This commit is contained in:
Brandon Thomas committed 2026-10-13 02:01:30 -04:00
commit d8f10a1ef7
19 files changed
+165 -4

No files matched your search

+22 -1
View File
@@ -712,6 +712,27 @@ pub fn render(
_ => Err(m),
}
});
// every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once).
// The masks buffer holds each visible mask's alpha back-to-back in the same `list` order the
// single-mask overlay uses, so read the whole buffer back in ONE GPU->CPU transfer and split it
// on the CPU, instead of one synchronous readback per mask (which stalls the pipeline N times).
let all_overlay_alphas: Option<Vec<Plane>> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) {
masks.as_ref().map(|all| {
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
if list.is_empty() {
return Vec::new();
}
let raw: Vec<f32> = cx.read(all, n * list.len());
let mut alphas = Vec::with_capacity(list.len());
for mi in 0..list.len() {
let s = mi * n;
alphas.push(Plane { width: w, height: h, data: raw[s..s + n].to_vec() });
}
alphas
})
} else {
None
};
let data: Vec<[u8; 4]> = cx.read(&out, n);
let image = Rgba8 { width: w, height: h, data };
lap("finish + readback", &mut t, &mut cx);
@@ -747,7 +768,7 @@ pub fn render(
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
}
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
image = image.crop(k.x, k.y, k.w, k.h);
+20 -1
View File
@@ -600,7 +600,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
lap("histogram", &mut t);
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
let masks = overlay_alphas_all(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref());
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
@@ -652,6 +653,24 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane>
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
}
/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components,
/// reusing the render's own evaluation where available (a fresh evaluation otherwise).
fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Vec<Plane>> {
if !matches!(o, Overlay::AllMasks { .. }) {
return None;
}
let ev = plan.settings.light.exposure as f32;
let mut out = Vec::new();
for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) {
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
out.push(e.alpha.clone());
} else {
out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()));
}
}
Some(out)
}
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
/// (default look, keeping the crop so framing matches, under the photo's own rendering process).
pub fn before_settings(s: &DevelopSettings) -> DevelopSettings {
+64 -2
View File
@@ -33,6 +33,9 @@ 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 },
/// The evaluated alpha of every visible mask that has components, drawn as one `view` in
/// `color` at `opacity` (0..100; the colour views only) — every mask's region at once.
AllMasks { 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,
@@ -120,6 +123,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::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64),
Overlay::HdrRange => (4, 0.0),
}
}
@@ -131,6 +135,14 @@ impl Overlay {
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,
5 if v.is_finite() && v >= 0.0 => {
let rgb = (v as u64 >> 20) & 0xff_ffff;
Overlay::AllMasks {
view: MaskView::ALL.get(((v as u64 >> 16) & 0xf) as usize).copied().unwrap_or_default(),
color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8],
opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8,
}
}
_ => Overlay::None,
}
}
@@ -142,6 +154,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::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity),
Overlay::HdrRange => 0x4000,
}
}
@@ -168,8 +181,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
}
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
/// 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>) {
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of
/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components.
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) {
match o {
// drawn by the renderer from the HDR rendition ([`hdr_range`])
Overlay::None | Overlay::HdrRange => {}
@@ -184,6 +198,15 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>)
mask_view(img, a, view, color, opacity);
}
}
Overlay::AllMasks { view, color, opacity } => {
if let Some(planes) = masks {
for a in planes {
if (a.width, a.height) == (img.width, img.height) {
mask_view(img, a, view, color, opacity);
}
}
}
}
}
}
@@ -380,4 +403,43 @@ mod tests {
assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color);
assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw));
}
#[test]
fn all_masks_overlay_shows_every_visible_mask_at_once() {
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let src = Rgb32f::from_fn(80, 40, |_, _| [0.1, 0.1, 0.1]);
let info = SourceInfo::default();
let left = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
let right = MaskShape::Radial { center: Point::new(0.75, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 1,
visible: true,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }],
..Default::default()
});
s.masks.push(Mask {
id: 2,
visible: true,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }],
..Default::default()
});
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 };
let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40);
assert_eq!(img.data[in_l], [255; 4], "left mask region painted");
assert_eq!(img.data[in_r], [255; 4], "right mask region painted");
assert_eq!(img.data[mid], [0, 0, 0, 255], "between the masks: black");
// a hidden mask is skipped
s.masks[1].visible = false;
let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted");
assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted");
// parts round trip and a stable, distinct cache key
let (k, v) = o.to_parts();
assert_eq!(Overlay::from_parts(k, v), o);
assert_ne!(o.key(), Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }.key());
}
}
+2
View File
@@ -1382,6 +1382,8 @@
"Show Luminance Map": "Luminanzkarte anzeigen",
"Show Mask": "Maske anzeigen",
"Show Mask Overlay": "Maskenüberlagerung anzeigen",
"Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen",
"Show all overlays": "Alle Überlagerungen anzeigen",
"Show Mask Pins": "Maskenpins anzeigen",
"Show Original": "Original anzeigen",
"Show Photo Counts": "Fotoanzahl anzeigen",
+2
View File
@@ -1351,6 +1351,8 @@
"Show Luminance Map": "Mostrar mapa de luminancia",
"Show Mask": "Mostrar máscara",
"Show Mask Overlay": "Mostrar superposición de máscara",
"Show All Mask Overlays": "Mostrar todas las superposiciones de máscara",
"Show all overlays": "Mostrar todas las superposiciones",
"Show Mask Pins": "Mostrar chinchetas de máscara",
"Show Original": "Mostrar original",
"Show Person": "Mostrar persona",
+2
View File
@@ -1318,6 +1318,8 @@
"Show Luminance Map": "Afficher la carte de luminance",
"Show Mask": "Afficher le masque",
"Show Mask Overlay": "Afficher l'incrustation du masque",
"Show All Mask Overlays": "Afficher toutes les superpositions de masque",
"Show all overlays": "Afficher toutes les superpositions",
"Show Mask Pins": "Afficher les épingles de masque",
"Show Original": "Afficher l'original",
"Show Photo Counts": "Afficher le nombre de photos",
+2
View File
@@ -1264,6 +1264,8 @@
"Show Luminance Map": "輝度マップを表示",
"Show Mask": "マスクを表示",
"Show Mask Overlay": "マスクの重ね表示",
"Show All Mask Overlays": "すべてのマスクオーバーレイを表示",
"Show all overlays": "すべてのオーバーレイを表示",
"Show Mask Pins": "マスクのピンを表示",
"Show Original": "元の写真を表示",
"Show Photo Counts": "写真の枚数を表示",
+2
View File
@@ -1341,6 +1341,8 @@
"Show Luminance Map": "Mostrar mapa de luminância",
"Show Mask": "Mostrar máscara",
"Show Mask Overlay": "Mostrar sobreposição da máscara",
"Show All Mask Overlays": "Mostrar todas as sobreposições de máscara",
"Show all overlays": "Mostrar todas as sobreposições",
"Show Mask Pins": "Mostrar pinos da máscara",
"Show Original": "Mostrar original",
"Show Photo Counts": "Mostrar contagem de fotos",
+2
View File
@@ -1109,6 +1109,8 @@
"Show Luminance Map": "Показать карту яркости",
"Show Mask": "Показать маску",
"Show Mask Overlay": "Показать наложение маски",
"Show All Mask Overlays": "Показать все наложения масок",
"Show all overlays": "Показать все наложения",
"Show Mask Pins": "Показать булавки масок",
"Show Original": "Показать оригинал",
"Show Photo Counts": "Показывать количество фото",
+2
View File
@@ -1332,6 +1332,8 @@
"Show Hidden Local Locations": "Показати приховані локальні розташування",
"Show Luminance Map": "Показати карту яскравості",
"Show Mask Overlay": "Показати накладання маски",
"Show All Mask Overlays": "Показати всі накладення масок",
"Show all overlays": "Показати всі накладення",
"Show Mask Pins": "Показати маркери масок",
"Show Original": "Показати оригінал",
"Show Photo Counts": "Показати кількість фото",
+2
View File
@@ -1326,6 +1326,8 @@
"Show Luminance Map": "显示明亮度图",
"Show Mask": "显示蒙版",
"Show Mask Overlay": "显示蒙版叠加",
"Show All Mask Overlays": "显示所有蒙版叠加",
"Show all overlays": "显示所有叠加",
"Show Mask Pins": "显示蒙版图钉",
"Show Original": "显示原图",
"Show Photo Counts": "显示照片数量",
+2
View File
@@ -1326,6 +1326,8 @@
"Show Luminance Map": "顯示明度圖",
"Show Mask": "顯示遮色片",
"Show Mask Overlay": "顯示遮色片疊加",
"Show All Mask Overlays": "顯示所有蒙版疊加",
"Show all overlays": "顯示所有疊加",
"Show Mask Pins": "顯示遮色片控制點",
"Show Original": "顯示原始照片",
"Show Photo Counts": "顯示照片數量",
+2
View File
@@ -157,6 +157,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"view.visualizeHdr",
"view.softProof",
"view.maskOverlay",
"view.maskOverlayAll",
"view.maskOverlayMode",
"view.maskOverlayColor",
"view.maskPins",
@@ -320,6 +321,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
"view.visualizeHdr" => Some(u.hdr_visualize),
"view.softProof" => Some(u.soft_proof),
"view.maskOverlay" => Some(u.mask_overlay),
"view.maskOverlayAll" => Some(u.mask_overlay_all),
"view.maskPins" => Some(u.mask_pins),
"view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original),
"view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide),
+6
View File
@@ -92,6 +92,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.softProof", "Soft Proofing", Some("S"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
// Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one
("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+Shift+O"), "View"),
// Shift+O in the Masking panel (elsewhere it cycles the crop overlay)
("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"),
("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"),
@@ -838,6 +840,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay);
Ok(json!({"maskOverlay": app.ui.mask_overlay}))
}
"view.maskOverlayAll" => {
app.ui.mask_overlay_all = !app.ui.mask_overlay_all;
Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all}))
}
"view.maskOverlayMode" => {
use lightcraft_engine::pipeline::MaskView;
let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
+9
View File
@@ -1242,6 +1242,15 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra
if app.ui.fullscreen {
return Overlay::None;
}
// Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay
// so the shortcut is not shadowed by the always-on single-mask overlay.
if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) {
return Overlay::AllMasks {
view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(),
color: app.ui.mask_overlay_color,
opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8,
};
}
if app.ui.right == RightPanel::Masking
&& app.ui.mask_overlay
&& let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id))
+10
View File
@@ -197,6 +197,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() {
app.ui.mask_overlay = !on;
}
let all = app.ui.mask_overlay_all;
let all_mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
let all_base = crate::i18n::tr("Show all overlays");
let all_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.maskOverlayAll") {
Some(sc) => format!("{all_base} ({})", crate::menubar::shortcut_text(sc, all_mac)),
None => all_base.to_string(),
};
if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, &all_tip).clicked() {
app.ui.mask_overlay_all = !all;
}
});
});
if d.masks.is_empty() {
+4
View File
@@ -544,6 +544,9 @@ pub struct UiState {
pub mask_overlay_color: [u8; 3],
pub mask_overlay_opacity: f32,
pub mask_pins: bool,
/// Masking: show every visible mask's region as one overlay at once (Ctrl+Shift+O / Cmd+Shift+O),
/// instead of just the selected mask's ([`mask_overlay`]).
pub mask_overlay_all: bool,
/// The overlay (on, mode) to go back to when Show Luminance Map is turned off.
#[serde(skip)]
pub luminance_map_restore: Option<(bool, String)>,
@@ -1059,6 +1062,7 @@ impl Default for UiState {
mask_overlay_color: [230, 30, 40],
mask_overlay_opacity: 50.0,
mask_pins: true,
mask_overlay_all: false,
crop_overlay: CropOverlay::Thirds,
crop_default_aspect: None,
crop_overlay_orient: 0,
+9
View File
@@ -168,6 +168,15 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() {
assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged");
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. }));
// Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected
// mask overlay so it is not shadowed by the always-on single-mask overlay.
exec(&mut h, "view.maskOverlayAll", json!({}));
assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on");
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. }));
exec(&mut h, "view.maskOverlayAll", json!({}));
assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off");
assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. }));
// Failed edits and global adjustments must not hide the mask overlay.
assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err());
assert!(h.app.ui.mask_overlay);
+1
View File
@@ -304,6 +304,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover |
| LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) |
| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |
| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |