mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 14:39:41 -06:00
Merge PR #1074: LR-VIEW-ZOOM: diagnostic overlays render zoom windows; soft
# Conflicts: # docs/gaps.md # docs/parity-checklist.md # docs/ui-parity.md
This commit is contained in:
@@ -450,11 +450,12 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
|
||||
}
|
||||
|
||||
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
|
||||
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on
|
||||
/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it,
|
||||
/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart.
|
||||
pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange
|
||||
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do
|
||||
/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and
|
||||
/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes
|
||||
/// its slider-drag drafts by it (issue #330), so the two can't drift apart.
|
||||
pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange
|
||||
}
|
||||
|
||||
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
|
||||
@@ -473,7 +474,8 @@ pub fn try_render(
|
||||
// sections switched off with their eye render as if at their defaults (issue #316)
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) {
|
||||
let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some());
|
||||
if !enabled() || !handles(req.depth, device_proof, req.overlay) {
|
||||
return Err(Fallback::NotTried);
|
||||
}
|
||||
let s = &*lightcraft_pipeline::settings_for(s, req);
|
||||
|
||||
@@ -73,6 +73,13 @@ const FIELDS: &[(&str, usize)] = &[
|
||||
("OUT_Y", 3),
|
||||
("OUT_TRC", 1),
|
||||
("OUT_GAMMA", 1),
|
||||
// soft proofing (matrix proofs; `ProofParams`): working → proof RGB, its luma, proof → output, warnings
|
||||
("PROOF", 1),
|
||||
("PROOF_M", 9),
|
||||
("PROOF_Y", 3),
|
||||
("PROOF_OUT", 9),
|
||||
("PROOF_DEST_WARN", 1),
|
||||
("PROOF_DISP_WARN", 1),
|
||||
// first row of a band dispatch (the kernel runs over rows Y0.., see `render`)
|
||||
("Y0", 1),
|
||||
];
|
||||
@@ -233,6 +240,14 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
|
||||
let (trc, gamma) = fp.out_trc.code();
|
||||
p.u("OUT_TRC", trc);
|
||||
p.f("OUT_GAMMA", gamma);
|
||||
if let Some(pp) = fp.proof.as_ref().filter(|pp| pp.table.is_none()) {
|
||||
p.b("PROOF", true);
|
||||
p.fs("PROOF_M", pp.to_proof.as_flattened());
|
||||
p.fs("PROOF_Y", &pp.luma);
|
||||
p.fs("PROOF_OUT", pp.proof_to_out.as_flattened());
|
||||
p.b("PROOF_DEST_WARN", pp.dest_warning);
|
||||
p.b("PROOF_DISP_WARN", pp.display_warning);
|
||||
}
|
||||
if let Some((amt, cell, rough, seed)) = fp.grain {
|
||||
p.b("GRAIN", true);
|
||||
p.f("GRAIN_AMT", amt);
|
||||
|
||||
@@ -669,8 +669,10 @@ pub fn render(
|
||||
|
||||
// 5. per-pixel stage
|
||||
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
|
||||
// soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`)
|
||||
fp.proof = req.proof.map(|pr| pr.params(req.space));
|
||||
if let Some(d) = &req.display {
|
||||
fp.for_display(d, None);
|
||||
fp.for_display(d, req.proof);
|
||||
}
|
||||
let present = Present {
|
||||
clarity: prep.clarity.is_some(),
|
||||
|
||||
@@ -252,6 +252,46 @@ fn out_encode(v: f32) -> f32 {
|
||||
return 1.099 * pow(l, 0.45) - 0.099;
|
||||
}
|
||||
|
||||
// The 3×3 matrix stored row by row from field `o`.
|
||||
fn mat_at(o: u32) -> array<vec3<f32>, 3> {
|
||||
return array<vec3<f32>, 3>(
|
||||
vec3<f32>(pf(o), pf(o + 1u), pf(o + 2u)),
|
||||
vec3<f32>(pf(o + 3u), pf(o + 4u), pf(o + 5u)),
|
||||
vec3<f32>(pf(o + 6u), pf(o + 7u), pf(o + 8u)),
|
||||
);
|
||||
}
|
||||
|
||||
// `finish::gamut_map` (SDR): the chroma scale that brings `r` into 0..1 towards its luminance (1: in gamut) ...
|
||||
fn gamut_scale(r: vec3<f32>, luma: vec3<f32>) -> f32 {
|
||||
let yy = clamp(dot(luma, r), 0.0, 1.0);
|
||||
var tg = 1.0;
|
||||
for (var k = 0u; k < 3u; k++) {
|
||||
let cc = r[k];
|
||||
if (cc < 0.0) {
|
||||
tg = min(tg, yy / max(yy - cc, 1e-9));
|
||||
} else if (cc > 1.0) {
|
||||
tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9));
|
||||
}
|
||||
}
|
||||
return tg;
|
||||
}
|
||||
|
||||
// ... and `r` scaled by it.
|
||||
fn gamut_apply(r: vec3<f32>, luma: vec3<f32>, tg: f32) -> vec3<f32> {
|
||||
if (tg < 1.0) {
|
||||
let yy = clamp(dot(luma, r), 0.0, 1.0);
|
||||
return yy + (r - yy) * tg;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// `finish::out_of_gamut`: `r` needed desaturating by `t` to fit and isn't just a neutral beyond white or below black.
|
||||
fn out_of_gamut(r: vec3<f32>, t: f32) -> bool {
|
||||
let lo = min(r.x, min(r.y, r.z));
|
||||
let hi = max(r.x, max(r.y, r.z));
|
||||
return t < 0.995 && (hi - lo) > 0.02 * max(abs(hi), 1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = pu(F_W);
|
||||
@@ -467,26 +507,27 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- gamut map to the output space (desaturate towards luminance until in range)
|
||||
let om = array<vec3<f32>, 3>(
|
||||
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
|
||||
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
|
||||
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
|
||||
);
|
||||
var r = mul3(om, d);
|
||||
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
|
||||
var tg = 1.0;
|
||||
for (var k = 0u; k < 3u; k++) {
|
||||
let cc = r[k];
|
||||
if (cc < 0.0) {
|
||||
tg = min(tg, yy / max(yy - cc, 1e-9));
|
||||
} else if (cc > 1.0) {
|
||||
tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9));
|
||||
// --- gamut map to the output space (desaturate towards luminance until in range);
|
||||
// soft proofing maps into the proof space first and shows that in the output space
|
||||
// (warn: 0 none, 1 destination gamut, 2 display gamut)
|
||||
var warn = 0u;
|
||||
var r: vec3<f32>;
|
||||
if (pu(F_PROOF) != 0u) {
|
||||
let q0 = mul3(mat_at(F_PROOF_M), d);
|
||||
let tq = gamut_scale(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)));
|
||||
if (pu(F_PROOF_DEST_WARN) != 0u && out_of_gamut(q0, tq)) {
|
||||
warn = 1u;
|
||||
}
|
||||
r = mul3(mat_at(F_PROOF_OUT), gamut_apply(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)), tq));
|
||||
} else {
|
||||
r = mul3(mat_at(F_OUT_M), d);
|
||||
}
|
||||
if (tg < 1.0) {
|
||||
r = yy + (r - yy) * tg;
|
||||
let oy = vec3<f32>(pf(F_OUT_Y), pf(F_OUT_Y + 1u), pf(F_OUT_Y + 2u));
|
||||
let tg = gamut_scale(r, oy);
|
||||
if (pu(F_PROOF) != 0u && pu(F_PROOF_DISP_WARN) != 0u && warn == 0u && out_of_gamut(r, tg)) {
|
||||
warn = 2u;
|
||||
}
|
||||
r = gamut_apply(r, oy, tg);
|
||||
|
||||
// --- encode, curves, grain
|
||||
var e = vec3<f32>(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z));
|
||||
@@ -512,6 +553,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
|
||||
e = e + k;
|
||||
}
|
||||
if (warn == 1u) {
|
||||
e = vec3<f32>(1.0, 0.0, 0.0);
|
||||
} else if (warn == 2u) {
|
||||
e = vec3<f32>(0.0, 0.25, 1.0);
|
||||
}
|
||||
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
|
||||
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings,
|
||||
(mean, max)
|
||||
}
|
||||
|
||||
/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a
|
||||
/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding).
|
||||
/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds.
|
||||
fn check_warned(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) {
|
||||
let cpu = render(src, info, s, req).image;
|
||||
let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render");
|
||||
let (mean, _, over) = diff(&cpu, &gpu.image);
|
||||
let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count();
|
||||
let share = flips as f64 / cpu.data.len() as f64;
|
||||
eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0);
|
||||
assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB");
|
||||
}
|
||||
|
||||
type Edit = fn(&mut DevelopSettings);
|
||||
|
||||
fn typical(s: &mut DevelopSettings) {
|
||||
@@ -877,3 +890,54 @@ fn windows_of_a_big_photo_share_one_uploaded_source() {
|
||||
lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render");
|
||||
assert_eq!(lightcraft_gpu::source_uploads() - start, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_proofs_match() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
// Soft proofing against an output space (matrix proofs): into the proof gamut, back to the
|
||||
// output space, with the destination and display gamut warnings painted alike on both renderers.
|
||||
// Saturated edits put plenty of colours outside the smaller proof spaces.
|
||||
use lightcraft_pipeline::{OutputSpace, Proof};
|
||||
let src = scene(0, 900, 600);
|
||||
let raw = SourceInfo { raw: true, ..Default::default() };
|
||||
for space in [OutputSpace::Srgb, OutputSpace::AdobeRgb, OutputSpace::DisplayP3] {
|
||||
for (dest_warning, display_warning) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for out in [OutputSpace::Srgb, OutputSpace::Rec2020] {
|
||||
let mut s = DevelopSettings::default();
|
||||
saturated(&mut s);
|
||||
let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 });
|
||||
let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) };
|
||||
let name = format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}");
|
||||
if dest_warning || display_warning {
|
||||
check_warned(&name, &src, &raw, &s, &req);
|
||||
} else {
|
||||
check(&name, &src, &raw, &s, &req);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// the case is meaningful: proofing sRGB from Rec. 2020 output warns on some pixels, and the GPU paints the same ones
|
||||
let mut s = DevelopSettings::default();
|
||||
saturated(&mut s);
|
||||
let req = RenderRequest {
|
||||
space: OutputSpace::Rec2020,
|
||||
proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 }),
|
||||
..RenderRequest::fit(640, 640)
|
||||
};
|
||||
let warned = |i: &Rgba8| i.data.iter().filter(|p| p[1] == 0 && p[2] == 0 && p[0] > 200).count();
|
||||
let (cpu, gpu) = (render(&src, &raw, &s, &req).image, lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu render").image);
|
||||
assert!(warned(&cpu) > 100, "{} warned pixels", warned(&cpu));
|
||||
let (a, b) = (warned(&cpu), warned(&gpu));
|
||||
assert!(a.abs_diff(b) as f64 <= 1e-4 * cpu.data.len() as f64, "warned pixels: CPU {a}, GPU {b}");
|
||||
// an output-device profile (a 3D table) stays on the CPU
|
||||
let n = 2;
|
||||
let lut = lightcraft_pipeline::ProofLut::new(n, vec![[0.5, 0.5, 0.5, 0.0]; n * n * n]).unwrap();
|
||||
let id = lightcraft_pipeline::register_proof_profile(0x5eed_9f00, lut);
|
||||
let req = RenderRequest {
|
||||
proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: id }),
|
||||
..RenderRequest::fit(64, 64)
|
||||
};
|
||||
assert!(lightcraft_gpu::render(&src, &raw, &DevelopSettings::default(), &req, None).is_none(), "declined: the CPU renders it");
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ pub struct RenderRequest {
|
||||
pub space: OutputSpace,
|
||||
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
|
||||
pub depth: OutputDepth,
|
||||
/// Soft proofing (CPU only; see [`Proof`]).
|
||||
/// Soft proofing (a proof against an output-device profile renders on the CPU; see [`Proof`]).
|
||||
pub proof: Option<Proof>,
|
||||
/// Render only this window of the output that `max_w × max_h` describes (a zoomed view):
|
||||
/// the result is that window's pixels, as in the whole render. See [`PixelWindow`].
|
||||
|
||||
@@ -407,3 +407,42 @@ fn auto_mask_strokes_match_the_whole_render() {
|
||||
};
|
||||
check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1);
|
||||
}
|
||||
|
||||
/// The margin the loupe's windows carry around what they show at this size (`region::margin_for`:
|
||||
/// 4.5 % of the long edge).
|
||||
const OVERLAY_INSET: usize = W * 45 / 1000;
|
||||
|
||||
/// [`check`] for a diagnostic overlay, with the photo as a raw (Visualize HDR needs one), keeping the
|
||||
/// loupe's margin in from the window's edges.
|
||||
fn check_overlay(name: &str, s: &DevelopSettings, overlay: crate::Overlay, win: PixelWindow, max_ok: u8, mean_ok: f64) {
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let full_req = RenderRequest { overlay, ..RenderRequest::fit(W, H) };
|
||||
let f = render(&scene(), &info, s, &full_req).image;
|
||||
let w = render(&scene(), &info, s, &RenderRequest { window: Some(win), ..full_req }).image;
|
||||
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
|
||||
assert_ne!(f.data, render(&scene(), &info, s, &RenderRequest::fit(W, H)).image.data, "{name}: the overlay draws something");
|
||||
let (max, mean) = compare_inset(&f, &w, win, OVERLAY_INSET);
|
||||
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
|
||||
}
|
||||
|
||||
// Given a diagnostic overlay (Visualize Spots, a Point Color range, Visualize HDR), a window shows
|
||||
// the same pixels as the whole-frame render, so the loupe can draw them as a zoom window
|
||||
#[test]
|
||||
fn diagnostic_overlays_in_a_window_match_the_full_render() {
|
||||
use crate::Overlay;
|
||||
let mut spots = DevelopSettings::default();
|
||||
spots.detail.sharpen_amount = 60.0;
|
||||
// a binary threshold: a pixel at the threshold may flip on a one-level difference underneath
|
||||
check_overlay("spots", &spots, Overlay::Spots(70), MID, 255, 0.05);
|
||||
for (x, y) in [(0, 0), (W - 400, H - 300)] {
|
||||
check_overlay("spots at a corner", &spots, Overlay::Spots(70), PixelWindow { x, y, w: 400, h: 300 }, 255, 0.05);
|
||||
}
|
||||
let mut pc = DevelopSettings::default();
|
||||
pc.point_colors.push(lightcraft_develop::PointColor { lum: 0.6, chroma: 0.08, hue: 60.0, hue_shift: 40.0, ..Default::default() });
|
||||
check_overlay("point colour range", &pc, Overlay::PointColorRange(0), MID, 1, 0.1);
|
||||
let mut hdr = DevelopSettings::default();
|
||||
hdr.hdr.enabled = true;
|
||||
hdr.hdr.max_ev = 3.0;
|
||||
hdr.light.exposure = 1.5;
|
||||
check_overlay("hdr range", &hdr, Overlay::HdrRange, MID, 1, 0.1);
|
||||
}
|
||||
|
||||
@@ -354,13 +354,16 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether an overlay can be rendered on a region tile without changing what the tile means.
|
||||
/// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask
|
||||
/// alphas as the full render; the other diagnostic overlays either need whole-frame context or
|
||||
/// deliberately replace the loupe.
|
||||
/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all)
|
||||
/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize
|
||||
/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high
|
||||
/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin
|
||||
/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels.
|
||||
pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
use lightcraft_pipeline::Overlay;
|
||||
matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. })
|
||||
use lightcraft_pipeline::Overlay as O;
|
||||
match overlay {
|
||||
O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a window render of the loupe needs to know about the view it belongs to.
|
||||
@@ -562,7 +565,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
// a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`;
|
||||
// `ready` first: `available` would wait for a device still being created)
|
||||
let gpu = interacting
|
||||
&& lightcraft_engine::gpu::handles(lightcraft_pipeline::OutputDepth::U8, app.ui.soft_proof, overlay)
|
||||
&& lightcraft_engine::gpu::handles(
|
||||
lightcraft_pipeline::OutputDepth::U8,
|
||||
app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(),
|
||||
overlay,
|
||||
)
|
||||
&& lightcraft_engine::gpu::ready()
|
||||
&& lightcraft_engine::gpu::available();
|
||||
let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms));
|
||||
|
||||
@@ -284,6 +284,25 @@ mod in_the_loupe {
|
||||
assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window");
|
||||
}
|
||||
|
||||
// Given a diagnostic overlay (Visualize Spots), when I zoom to 1:1, then the visible part is a
|
||||
// window render carrying the overlay, over a canvas-sized whole frame, as without it
|
||||
#[test]
|
||||
fn visualize_spots_at_one_to_one_is_a_window() {
|
||||
let (mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
let plain = h.app.region_view.expect("a window render without the overlay");
|
||||
let r = h.request("ui.menu.invoke", json!({"id": "view.visualizeSpots"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.visualize_spots);
|
||||
let main = rendered_long_edge(&h);
|
||||
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
|
||||
let spots = h.app.region_view.expect("a window render with the overlay");
|
||||
assert_eq!(spots.full.0.max(spots.full.1), native, "cut from the photo's own pixels");
|
||||
assert_ne!(spots.settings, plain.settings, "the overlay is part of the window's look");
|
||||
}
|
||||
|
||||
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
|
||||
#[test]
|
||||
fn an_explicit_limit_still_caps_one_to_one() {
|
||||
@@ -530,13 +549,16 @@ mod in_the_loupe {
|
||||
assert!(main <= small, "the draft is {main} px, a small one at most {small}");
|
||||
}
|
||||
|
||||
// Given soft proofing (rendered on the CPU even with a GPU), a drag drafts small (#330)
|
||||
// Given soft proofing against an output-device profile (rendered on the CPU even with a GPU), a
|
||||
// drag drafts small (#330); proofs into an output space run on the GPU like any render
|
||||
#[test]
|
||||
fn a_soft_proofed_drag_drafts_small() {
|
||||
// the GPU device up front (the loupe never waits for it): with one, this is the case where
|
||||
// the GPU is there and the render still goes to the CPU
|
||||
let _ = lightcraft_engine::gpu::available();
|
||||
let (mut h, _) = detail();
|
||||
let lut = lightcraft_pipeline::ProofLut::new(2, vec![[0.5, 0.5, 0.5, 0.0]; 8]).expect("a valid table");
|
||||
h.app.ui.proof.profile = lightcraft_pipeline::register_proof_profile(0x5eed_9f01, lut);
|
||||
h.app.ui.soft_proof = true;
|
||||
h.settle(SETTLE);
|
||||
let (main, small) = drag_exposure(&mut h);
|
||||
|
||||
@@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
| 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h |
|
||||
| 2026-10-19 | minor | Gap 27: the released 0.9.1 x64 MSI is tested on Windows 11 Pro 24H2 (a fresh install and an upgrade from 0.8.0 end on the completion page, so #790 is fixed; the upgrade keeps a custom folder; Launch starts the app; removal leaves nothing behind); uninstalling from Settings → Apps still ends with no confirmation (#892, #931); hours unchanged |
|
||||
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
|
||||
| 2026-10-14 | minor | Gap 4 follow-up: the diagnostic overlays (Visualize Spots, Point Color range, HDR range) render zoom windows too; soft proofing runs on the GPU |
|
||||
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
|
||||
| 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged |
|
||||
| 2026-10-15 | minor | Gap 28: `\` shows / hides the Filter Bar in the grids; the entry drops `[` `]` (#509), flag cycling (#1490) and F1 (Help), already on main; ⌘L is the filter-bar key left; hours unchanged |
|
||||
|
||||
@@ -161,6 +161,8 @@ walks the drive.
|
||||
(log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight
|
||||
sampling), `mask` (linear / radial / luminance / colour range / brush shapes, combine, finalize),
|
||||
`finish` (the whole per-pixel stage) — each mirrors a named CPU function.
|
||||
- Soft proofing against an output space (`RenderRequest::proof`, with its destination and display gamut
|
||||
warnings) runs in `finish`; a proof against an output-device profile (its 3D table) renders on the CPU.
|
||||
- Per-stage hybrid: defringe and spot removal (rare, CPU-only for now) download the resampled image,
|
||||
run on the CPU and upload; heuristic mask shapes (Sky, Subject, Background, …) and brushes of more
|
||||
than 4096 dabs are evaluated on the CPU and uploaded; sources over the buffer limit are resampled
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
|
||||
| Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) |
|
||||
| Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) |
|
||||
| Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions |
|
||||
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 |
|
||||
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like; every view, mask and diagnostic overlays (Visualize Spots, Point Color range, HDR range) and soft proofing included, is sharp at 1:1–8:1 | 60% | zoom stops at 800 % (Adobe's reference goes to 11:1) |
|
||||
| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys |
|
||||
| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer |
|
||||
| Context menus | everywhere | 31 sites (grid, filmstrip, albums, folders, keywords, masks, presets, text fields…), all through `widgets::context_menu` (`crates/ui-egui/src/widgets.rs`): a right-click elsewhere while a menu is open closes it and opens the menu there, also on the same widget (issue #1877: that one flashed at the new spot and closed, as egui counts the opening click as a click while open) | 70% | unaudited against Lightroom's menus item by item |
|
||||
@@ -150,6 +150,7 @@ establish OS keyboard delivery or cross-platform acceptance.
|
||||
|---|---|---|
|
||||
| 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged |
|
||||
| 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left |
|
||||
| 2026-10-14 | minor | Zoom row: diagnostic overlays render zoom windows too |
|
||||
| 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it |
|
||||
| 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h |
|
||||
| 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list |
|
||||
|
||||
Reference in New Issue
Block a user