mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Add Ctrl+O / Cmd+O to show every mask overlay at once
Previously the masking overlay showed only the selected mask's region (toggled by O). This adds a new Overlay::AllMasks variant that paints the evaluated alpha of every visible mask that has components in one tint, wired to a new "Show All Mask Overlays" command (Ctrl+O on Windows/Linux, Cmd+O on macOS) reachable from the View menu, the masking panel header (stack icon), and the keyboard shortcut. Both the CPU and GPU render paths evaluate all visible masks and hand their alpha planes to visualize::apply, which layers each mask's region so multiple masks are visible at once. The all-masks overlay takes precedence over the single-mask overlay so the shortcut is not shadowed by the always-on per-mask view.
This commit is contained in:
@@ -688,6 +688,16 @@ pub fn render(
|
||||
_ => Err(m),
|
||||
}
|
||||
});
|
||||
// every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once)
|
||||
let all_overlay_alphas: Option<Vec<Plane>> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) {
|
||||
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
|
||||
masks.as_ref().map(|all| {
|
||||
list.iter().enumerate().map(|(mi, _)| {
|
||||
let b = cx.slice(all, mi * n, n);
|
||||
cx.read_plane(&b, w, h)
|
||||
}).collect()
|
||||
})
|
||||
} 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);
|
||||
@@ -723,7 +733,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);
|
||||
|
||||
@@ -551,7 +551,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
|
||||
@@ -603,6 +604,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 {
|
||||
|
||||
@@ -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,33 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"view.visualizeHdr",
|
||||
"view.softProof",
|
||||
"view.maskOverlay",
|
||||
"view.maskOverlayAll",
|
||||
"view.maskOverlayMode",
|
||||
"view.maskOverlayColor",
|
||||
"view.maskPins",
|
||||
@@ -312,6 +313,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),
|
||||
|
||||
@@ -91,6 +91,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+O / Cmd+O: every mask's region at once, not just the selected one
|
||||
("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+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"),
|
||||
@@ -634,6 +636,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();
|
||||
|
||||
@@ -1065,6 +1065,18 @@ 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+O / Cmd+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))
|
||||
|
||||
@@ -197,6 +197,10 @@ 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;
|
||||
if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, "Show all overlays (Ctrl+O)").clicked() {
|
||||
app.ui.mask_overlay_all = !all;
|
||||
}
|
||||
});
|
||||
});
|
||||
if d.masks.is_empty() {
|
||||
|
||||
@@ -332,6 +332,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+O / Cmd+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)>,
|
||||
@@ -813,6 +816,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_overlay_orient: 0,
|
||||
show_filenames: true,
|
||||
|
||||
@@ -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+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, "Ctrl+O turns on the all-masks overlay");
|
||||
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, "Ctrl+O toggles the all-masks overlay 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);
|
||||
|
||||
Reference in New Issue
Block a user