Merge track r3-t10

# Conflicts:
#	crates/engine/src/cmd/masks.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/ka.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
aiongg
2026-10-31 02:31:38 +00:00
53 changed files with 1922 additions and 184 deletions
Generated
+1
View File
@@ -3594,6 +3594,7 @@ dependencies = [
"lightcraft-merge",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-testkit",
"lightcraft-ui-egui",
"serde_json",
]
+3
View File
@@ -25,6 +25,9 @@ lightcraft-merge = { workspace = true }
serde_json = { workspace = true }
dhat = { workspace = true, optional = true }
[dev-dependencies]
lightcraft-testkit = { workspace = true }
[features]
default = ["denoise"]
denoise = ["lightcraft-engine/denoise"]
+48 -6
View File
@@ -486,7 +486,12 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
let script = tmp("default-cpu.jsonl");
std::fs::write(
&script,
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})),
format!(
"{}\n{}\n{}\n",
json!({"method": "engine.execute", "params": {"command": "app.gpu", "params": {"wait": false}}}),
json!({"method": "engine.execute", "params": {"command": "app.gpu"}}),
json!({"method": "ui.screenshot"})
),
)
.unwrap();
let image = tmp("default-cpu.png");
@@ -500,15 +505,52 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
let gpu = &replies[0]["result"];
assert_eq!(gpu["enabled"], false, "{gpu}");
assert_eq!(gpu["available"], false, "{gpu}");
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
// `wait: false` (what the UI sends) still says why the GPU is off: no device is being created
for gpu in [&replies[0]["result"], &replies[1]["result"]] {
assert_eq!(gpu["enabled"], false, "{gpu}");
assert_eq!(gpu["available"], false, "{gpu}");
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
}
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), (480, 320));
}
/// Issue #136: `app.gpu {enabled: true, wait: false}` (what the UI sends on its first frame) answers
/// at once while the device is still being created: enabled, and nothing known yet (`null`), instead
/// of waiting for the driver. It starts creating the device (off the calling thread), so this test
/// touches the GPU driver and skips when the environment turns the GPU off.
#[test]
fn enabling_the_gpu_without_waiting_answers_before_the_device_exists() {
if std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")) {
lightcraft_testkit::skip!(Gpu, "LIGHTCRAFT_GPU=0: enabling the GPU starts creating a device");
return;
}
let script = tmp("gpu-starting.jsonl");
std::fs::write(
&script,
format!("{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu", "params": {"enabled": true, "wait": false}}})),
)
.unwrap();
let image = tmp("gpu-starting.png");
let output = Command::new(binary())
.env_remove("LIGHTCRAFT_GPU")
.env_remove("LIGHTCRAFT_GPU_BACKEND")
.env_remove("WGPU_BACKEND")
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
.output()
.unwrap();
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], true, "{replies:?}");
let gpu = &replies[0]["result"];
assert_eq!(gpu["enabled"], true, "{gpu}");
for field in ["available", "adapter", "reason"] {
assert_eq!(gpu[field], Value::Null, "{field} is not known while the device is created: {gpu}");
}
}
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
/// without creating a GPU device (no driver is loaded), and says why.
#[test]
+10 -1
View File
@@ -82,6 +82,11 @@ fn app_icon() -> egui::IconData {
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
// The transparency-capable DirectComposition window is a presentation workaround;
// translucent UI layers (disabled panels, modal dimming) must not reveal the desktop.
egui::Color32::from_rgb(12, 12, 12).to_normalized_gamma_f32()
}
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
if let Some(m) = self.2.as_mut()
@@ -227,8 +232,11 @@ fn main() -> eframe::Result {
lightcraft_engine::gpu::set_enabled(gpu_on);
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
// build egui's pipeline never kills the start-up
let compose_window =
prefs.as_ref().map_or_else(|| lightcraft_ui_egui::state::AppSettings::default().compose_window, |ui| ui.settings.compose_window)
&& std::env::var_os("WGPU_BACKEND").is_none();
let win_files = std::sync::Arc::new(window_files(in_memory));
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files, compose_window);
win_files.arm(window_renderer);
let win_files_app = win_files.clone();
// the window came up: a failure after this is LightCraft stopping, not a start that failed
@@ -241,6 +249,7 @@ fn main() -> eframe::Result {
viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as)
.with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE)
.with_drag_and_drop(true)
.with_transparent(window_gpu::composes_window(compose_window, window_renderer))
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
+15
View File
@@ -1,5 +1,20 @@
use super::*;
#[test]
fn desktop_clear_color_stays_opaque_with_a_modal_open() {
let mut app = App(
LightcraftApp::new(Session::new(), Services::default()),
PrefsWriter::default(),
#[cfg(target_os = "macos")]
None,
);
app.0.run("app.settings", serde_json::json!({})).unwrap();
assert!(app.0.ui.dialog.is_some());
for visuals in [egui::Visuals::dark(), egui::Visuals::light()] {
assert_eq!(eframe::App::clear_color(&app, &visuals)[3], 1.0);
}
}
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
+63 -7
View File
@@ -85,7 +85,7 @@ impl Files {
}
/// The renderer for this start: see the module docs.
pub fn choose(files: &Files) -> Chosen {
pub fn choose(files: &Files, compose_window: bool) -> Chosen {
let forced =
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
let failed = files.take_failed();
@@ -95,7 +95,7 @@ pub fn choose(files: &Files) -> Chosen {
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
let mut problems = Vec::new();
let chosen = if cfg!(windows) {
pick_probed(&order, &mut problems)
pick_probed(&order, &mut problems, compose_window)
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
Some((WindowRenderer::Glow, None))
@@ -142,8 +142,13 @@ fn user_notice(failed: Option<WindowRenderer>, forced: Option<WindowRenderer>, p
}
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
///
/// Taken from `NativeOptions::default()`, whose surface is egui-wgpu's `LOW_LATENCY` (one frame in
/// flight; two drawables on Metal). `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two
/// frames queued, three drawables): every drag, slider and crop handle reached the screen a frame
/// later than eframe's default. Live window resizing still gets three drawables from egui-wgpu.
pub fn base_options() -> WgpuConfiguration {
let mut c = WgpuConfiguration::default();
let mut c = eframe::NativeOptions::default().wgpu_options;
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = backend::window_backends();
n.instance_descriptor.backend_options = backend::backend_options();
@@ -154,12 +159,12 @@ pub fn base_options() -> WgpuConfiguration {
}
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>, compose_window: bool) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
for &r in order {
match r {
WindowRenderer::Glow => return Some((r, None)),
WindowRenderer::Wgpu(b) => {
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
let probed = std::panic::catch_unwind(|| probe(b, compose_window)).unwrap_or_else(|_| Err("panicked".into()));
match probed {
Ok(e) => return Some((r, Some(e))),
Err(e) => {
@@ -174,11 +179,11 @@ fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(
}
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
fn probe(backends: Backends, compose_window: bool) -> Result<WgpuSetupExisting, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
backend_options: backend::backend_options(),
backend_options: presentation_options(backends, compose_window),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
display: None,
});
@@ -242,6 +247,22 @@ pub fn relaunch_after_failure(files: &Files) -> bool {
.is_ok()
}
/// Only DX12 windows use the compositor; fallback renderers retain their normal presentation.
/// Adapted from kmnkv1's PhotoCraft PR: https://github.com/storytold/photocraft/pull/7035.
pub fn composes_window(enabled: bool, renderer: WindowRenderer) -> bool {
cfg!(target_os = "windows") && enabled && renderer == WindowRenderer::Wgpu(Backends::DX12)
}
/// Select DirectComposition for opted-in Windows DX12 windows, preserving
/// the default presentation options for other renderers.
fn presentation_options(backends: Backends, enabled: bool) -> wgpu::BackendOptions {
let mut options = backend::backend_options();
if composes_window(enabled, WindowRenderer::Wgpu(backends)) {
options.dx12.presentation_system = wgpu::Dx12SwapchainKind::DxgiFromVisual;
}
options
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -290,6 +311,23 @@ mod tests {
assert!(died.contains("last time") && !died.contains("Validation Error"), "{died}");
}
/// The window keeps eframe's low-latency surface: one frame queued (two Metal drawables), not
/// the high-throughput `WgpuConfiguration::default()`, which held input a frame longer. The
/// probed (Windows) and forced-backend configurations are built on the same options.
#[test]
fn window_surface_keeps_one_frame_in_flight() {
let surface = base_options().surface;
assert_eq!(surface, eframe::NativeOptions::default().wgpu_options.surface);
assert_eq!(surface, eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY);
assert_eq!(surface.desired_maximum_frame_latency, Some(1));
assert_eq!(surface.present_mode, wgpu::PresentMode::AutoVsync);
assert_ne!(surface, WgpuConfiguration::default().surface, "egui-wgpu's own default is the high-throughput one");
// what `main` hands eframe (on Windows `choose` probes the adapters first; not here)
if !cfg!(windows) {
assert_eq!(choose(&Files::new(None), false).wgpu_options.surface, surface);
}
}
/// The crash report: a pipeline that fails validation must come back as an error, never as
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
#[test]
@@ -334,4 +372,22 @@ mod tests {
// outside a scope: logged by our handler, not a panic
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
}
/// The workaround is opt-in and changes presentation only for Windows DX12.
#[test]
fn composition_only_changes_windows_dx12_presentation() {
let legacy: lightcraft_ui_egui::state::AppSettings = serde_json::from_str("{}").unwrap();
assert!(!legacy.compose_window, "existing settings keep the workaround off until opted in");
assert!(!lightcraft_ui_egui::state::AppSettings::default().compose_window);
let enabled: lightcraft_ui_egui::state::AppSettings = serde_json::from_str(r#"{"composeWindow":true}"#).unwrap();
let saved = serde_json::to_value(&enabled).unwrap();
assert_eq!(saved["composeWindow"], true);
for backend in [Backends::DX12, Backends::VULKAN, Backends::GL] {
let wanted = cfg!(target_os = "windows") && backend == Backends::DX12;
assert_eq!(composes_window(true, WindowRenderer::Wgpu(backend)), wanted);
assert_eq!(presentation_options(backend, true).dx12.presentation_system == wgpu::Dx12SwapchainKind::DxgiFromVisual, wanted);
assert!(!composes_window(false, WindowRenderer::Wgpu(backend)));
}
assert!(!composes_window(true, WindowRenderer::Glow));
}
}
+73 -2
View File
@@ -177,9 +177,20 @@ impl DevelopSettings {
}
}
/// Next free mask id.
/// Next free mask id: one past the highest, or, when that is `u32::MAX` (ids come from files,
/// pastes and agents), the lowest one no mask uses.
pub fn next_mask_id(&self) -> u32 {
self.masks.iter().map(|m| m.id).max().map_or(1, |m| m + 1)
match self.masks.iter().map(|m| m.id).max() {
None => 1,
// fewer masks than ids: one of the first `masks.len() + 1` is free
Some(max) => max
.checked_add(1)
.or_else(|| {
let used: std::collections::HashSet<u32> = self.masks.iter().map(|m| m.id).collect();
(1..=u32::MAX).find(|id| !used.contains(id))
})
.unwrap_or(0),
}
}
}
@@ -287,6 +298,54 @@ mod tests {
assert_eq!(s.grading.blending, 50.0);
}
/// Settings with one colour-range mask of `n` distinct samples (built in code, so not capped).
fn color_range_settings(n: usize) -> DevelopSettings {
let samples = (0..n).map(|i| [i as f64 / 1000.0, 0.1, -0.1]).collect();
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::ColorRange { samples, refine: 50.0 } }],
..Default::default()
});
s
}
fn color_range_samples(s: &DevelopSettings) -> &[[f64; 3]] {
match s.masks.first().and_then(|m| m.components.first()).map(|c| &c.shape) {
Some(MaskShape::ColorRange { samples, .. }) => samples,
other => panic!("expected a colour range, got {other:?}"),
}
}
/// Issue #2556: `mask.add`/`mask.update` capped a colour range's samples, but a sidecar `lc:settings`, a
/// preset, `develop.merge`, `render --settings` or a catalog / history load took any number (render cost is
/// pixels x samples). Every load now keeps the first [`MAX_COLOR_RANGE_SAMPLES`], in order.
#[test]
fn color_range_samples_are_capped_however_settings_load() {
let big = color_range_settings(1000);
let want = &color_range_samples(&big)[..MAX_COLOR_RANGE_SAMPLES];
let v = big.to_json();
let loaded = DevelopSettings::from_json(&v).unwrap();
assert_eq!(color_range_samples(&loaded).len(), MAX_COLOR_RANGE_SAMPLES);
assert_eq!(color_range_samples(&loaded), want);
let from_str: DevelopSettings = serde_json::from_str(&v.to_string()).unwrap();
assert_eq!(color_range_samples(&from_str), want);
// `develop.merge`, `render --settings` and presets merge a partial
let merged = DevelopSettings::default().merged(&json!({"masks": v["masks"]})).unwrap();
assert_eq!(color_range_samples(&merged), want);
let preset =
Preset { id: "p".into(), name: "P".into(), group: "G".into(), settings: json!({"masks": v["masks"]}), favorite: false, builtin: false };
assert_eq!(color_range_samples(&preset.apply(&DevelopSettings::default(), 1.0)), want);
// a capped list round-trips, and a short one is unchanged
assert_eq!(DevelopSettings::from_json(&loaded.to_json()).unwrap(), loaded);
let few = color_range_settings(2);
assert_eq!(DevelopSettings::from_json(&few.to_json()).unwrap(), few);
// a list that isn't samples is still refused
let mut bad = v.clone();
bad["masks"][0]["components"][0]["shape"]["samples"] = json!([[0.1, 0.2]]);
assert!(DevelopSettings::from_json(&bad).is_err());
}
#[test]
fn merge_partial() {
let s = DevelopSettings::default();
@@ -399,4 +458,16 @@ mod tests {
s.reset_section(Section::Detail);
assert_eq!(s.enhance.denoise_on, None);
}
#[test]
fn next_mask_id_after_the_highest_id_is_unused() {
// ids come from files, pastes and agents: one at the limit must not overflow (a panic in
// debug builds) or wrap to an id another mask has
let mut s = DevelopSettings::default();
assert_eq!(s.next_mask_id(), 1);
s.masks = vec![Mask { id: u32::MAX, ..Default::default() }, Mask { id: 1, ..Default::default() }];
let next = s.next_mask_id();
assert!(!s.masks.iter().any(|m| m.id == next), "{next}");
assert_eq!(next, 2);
}
}
+20 -3
View File
@@ -220,14 +220,12 @@ impl Preset {
o.remove("masks");
}
let mut out = apply_partial(s, &rest, amount);
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
for m in masks {
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
continue;
}
next += 1;
m.id = next;
m.id = out.next_mask_id();
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
out.masks.push(m);
}
@@ -293,4 +291,23 @@ mod tests {
deep_merge(&mut a, &json!({"x": {"b": 3}, "y": [2, 3]}));
assert_eq!(a, json!({"x": {"a": 1, "b": 3}, "y": [2, 3]}));
}
#[test]
fn preset_masks_get_unused_ids_after_a_mask_id_at_the_limit() {
let s = DevelopSettings { masks: vec![crate::Mask { id: u32::MAX, name: "Theirs".into(), ..Default::default() }], ..Default::default() };
let p = Preset {
id: "p".into(),
name: "P".into(),
group: "User".into(),
settings: json!({"masks": [{"id": 7, "name": "A"}, {"id": 7, "name": "B"}]}),
favorite: false,
builtin: false,
};
let out = p.apply(&s, 1.0);
let mut ids: Vec<u32> = out.masks.iter().map(|m| m.id).collect();
assert_eq!(ids.len(), 3);
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 3, "{:?}", out.masks);
}
}
+31 -1
View File
@@ -922,6 +922,35 @@ impl Default for BrushStroke {
}
}
/// Most samples a colour-range mask keeps. It compares every pixel against every sample, so render and export cost
/// is pixels x samples; the interactive sampler stops at 5, and a sidecar, preset or `mask.add` with thousands could
/// stall a render or lose the GPU device (issue #2556).
pub const MAX_COLOR_RANGE_SAMPLES: usize = 64;
/// Deserialize a colour range's samples, keeping the first [`MAX_COLOR_RANGE_SAMPLES`] (the rest are skipped without
/// being stored), however the settings arrive: sidecar, catalog, history, preset or merge.
fn de_color_range_samples<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<[f64; 3]>, D::Error> {
struct Capped;
impl<'de> serde::de::Visitor<'de> for Capped {
type Value = Vec<[f64; 3]>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of OkLab colours")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<[f64; 3]>, A::Error> {
let mut out = Vec::new();
while out.len() < MAX_COLOR_RANGE_SAMPLES {
match seq.next_element::<[f64; 3]>()? {
Some(s) => out.push(s),
None => return Ok(out),
}
}
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(out)
}
}
d.deserialize_seq(Capped)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MaskShape {
@@ -942,8 +971,9 @@ pub enum MaskShape {
feather: f64,
invert: bool,
},
/// Colour range: sampled OkLab colours with a refine amount 0..100.
/// Colour range: sampled OkLab colours (at most [`MAX_COLOR_RANGE_SAMPLES`]) with a refine amount 0..100.
ColorRange {
#[serde(deserialize_with = "de_color_range_samples")]
samples: Vec<[f64; 3]>,
refine: f64,
},
+1 -6
View File
@@ -1,6 +1,6 @@
//! Masking and Remove (spot) commands on the active photo.
use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_RED_EYES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_COLOR_RANGE_SAMPLES, MAX_RED_EYES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_geom::Point;
use serde_json::{Value, json};
@@ -8,11 +8,6 @@ use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_p
use crate::segment::{Click, MAX_CLICKS};
use crate::{Result, Session};
/// A colour-range mask compares every pixel against every sample, so render and export cost is
/// pixels x samples. The interactive sampler stops at 5; automation (`mask.add`/`mask.update`)
/// passed its `samples` array straight through, so a few hundred samples on a large render hung.
const MAX_COLOR_RANGE_SAMPLES: usize = 64;
/// Caps a colour-range shape's sample list at [`MAX_COLOR_RANGE_SAMPLES`] (render cost is pixels x samples).
fn cap_color_range_samples(shape: &mut MaskShape) {
if let MaskShape::ColorRange { samples, .. } = shape {
+20 -1
View File
@@ -230,10 +230,29 @@ pub fn specs() -> Vec<CommandSpec> {
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| {
cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool, wait?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}. wait: false never waits for a device still being created (available, adapter and reason are then null and the device is created in the background; with GPU rendering off they say so at once)", always, |_, p| {
if let Some(on) = p.get("enabled").and_then(Value::as_bool) {
lightcraft_gpu::set_enabled(on);
}
// the UI applies the setting on its first frame: it must not wait seconds for the
// device there (issue #136; `warm_up` creates it off the UI thread)
if p.get("wait").and_then(Value::as_bool) == Some(false) && !lightcraft_gpu::ready() {
// off (preference, environment): known at once, and asking the adapter would
// create the device; on: still being created, so nothing is known yet
let on = lightcraft_gpu::enabled();
if on {
// a caller that never waits still gets an answer later (off the UI thread;
// starts at most once)
lightcraft_gpu::warm_up();
}
return Ok(json!({
"enabled": on,
"available": if on { Value::Null } else { Value::Bool(false) },
"adapter": null,
"reason": if on { None } else { lightcraft_gpu::unavailable_reason() },
"lastFallback": lightcraft_gpu::last_fallback(),
}));
}
Ok(json!({
"enabled": lightcraft_gpu::enabled(),
"available": lightcraft_gpu::available(),
+76 -3
View File
@@ -693,6 +693,7 @@ fn mapped_settings(
aspect: f64,
orientation: Orientation,
stored_aspect: f64,
user: Orientation,
) -> Result<(Value, Vec<String>), String> {
let mut root = crate::preset_import::parse_lua(text)?;
let deferred = strip_lua_sentinels(&mut root);
@@ -703,8 +704,13 @@ fn mapped_settings(
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
props.insert("crs:HasCrop".into(), vec!["True".into()]);
}
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
// Mask edges are fractions of the stored image, like the crop's, so radii go to long-edge units
// with its aspect: `aspect` is the photo's in LightCraft, which is the shown one for a photo
// already in the library and only stands in when the catalog has no file size.
let mask_aspect = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { aspect };
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, mask_aspect);
catalog_crop(&mut partial, orientation, stored_aspect);
catalog_masks(&mut partial, in_photo_frame(orientation, user));
if deferred > 0 {
unknown.push("Deferred Lightroom adjustments (-999999)".into());
}
@@ -764,6 +770,71 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6
geometry.insert("angle".into(), json!(angle));
}
/// The catalog's orientation (the stored image to the photo as Lightroom shows it), then the
/// photo's own Rotate Left / Right and Flip in LightCraft (`user`), which an update of a photo
/// already in the library keeps: the frame LightCraft draws masks in.
fn in_photo_frame(catalog: Orientation, user: Orientation) -> Orientation {
let (flip, turns) = user.to_parts();
let o = if flip { catalog.flipped_h() } else { catalog };
(0..turns).fold(o, |o, _| o.rotated(true))
}
/// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the
/// stored image (before orientation), normalized to its size; LightCraft's are on the oriented
/// image. So the points go through the orientation, a radial's radii (in long-edge units, from the
/// stored image's aspect) swap when the axes do, and a mirror turns its angle the other way. A
/// quarter turn keeps the angle: the ellipse turned 90° is the same ellipse with its radii swapped.
fn catalog_masks(partial: &mut Value, orientation: Orientation) {
if orientation == Orientation::Normal {
return;
}
let point = |p: Option<&mut Value>| {
let Some(p) = p.and_then(Value::as_object_mut) else { return };
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { return };
let (u, v) = orientation.map(x, y, 1.0, 1.0);
p.insert("x".into(), json!(u));
p.insert("y".into(), json!(v));
};
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
let components = masks.iter_mut().filter_map(|m| m.get_mut("components").and_then(Value::as_array_mut)).flatten();
for shape in components.filter_map(|c| c.get_mut("shape").and_then(Value::as_object_mut)) {
point(shape.get_mut("start"));
point(shape.get_mut("end"));
point(shape.get_mut("center"));
let strokes = shape.get_mut("strokes").and_then(Value::as_array_mut).into_iter().flatten();
for points in strokes.filter_map(|s| s.get_mut("points").and_then(Value::as_array_mut)) {
points.iter_mut().for_each(|p| point(Some(p)));
}
if shape.get("kind").and_then(Value::as_str) == Some("radial") {
if orientation.swaps_axes()
&& let (Some(rx), Some(ry)) = (shape.get("rx").cloned(), shape.get("ry").cloned())
{
shape.insert("rx".into(), ry);
shape.insert("ry".into(), rx);
}
if orientation.to_parts().0
&& let Some(angle) = shape.get("angle").and_then(Value::as_f64)
{
shape.insert("angle".into(), json!(-angle));
}
}
}
}
/// The catalog's XMP masks in LightCraft's frame, as [`catalog_masks`] puts the develop row's: they
/// are on the same stored image, and they stay when the row has no masks of its own. The packet's
/// radials come fitted to the sidecar default aspect and `sidecar::merge_into` re-fits them to the
/// photo's size, so they are fitted to the stored image to be turned (a quarter turn swaps the
/// radii) and handed back in the default aspect, for that re-fit to undo.
fn catalog_xmp_masks(partial: &mut Value, orientation: Orientation, stored_aspect: f64, photo: &Photo) {
use crate::crs_masks::{DEFAULT_ASPECT, refit_radials};
let merged = if photo.width > 0 && photo.height > 0 { photo.width as f64 / photo.height as f64 } else { DEFAULT_ASPECT };
let fit = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { merged };
refit_radials(partial, DEFAULT_ASPECT, fit);
catalog_masks(partial, orientation);
refit_radials(partial, merged, DEFAULT_ASPECT);
}
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
use crate::preset_import::Lua;
match value {
@@ -1076,11 +1147,14 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
if keep_develop {
preserved += 1;
}
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
if !src.xmp.is_empty() {
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
Ok(mut sc) => {
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
strip_xmp_sentinels(partial);
catalog_xmp_masks(partial, in_photo_frame(orientation, p.develop.orientation), stored_aspect, &p);
}
crate::sidecar::merge_into(&mut p, &sc, &now);
}
@@ -1101,14 +1175,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
p.meta.keywords = src.keywords.clone();
}
if !src.settings.is_empty() && !keep_develop {
let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default();
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
match mapped_settings(
&src.settings,
crate::crs::Target::for_photo(&p),
p.width.max(1) as f64 / p.height.max(1) as f64,
orientation,
stored_aspect,
p.develop.orientation,
) {
Ok((partial, unknown)) => {
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
+171 -13
View File
@@ -421,8 +421,15 @@ fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() {
assert!(apply(&mut s, cyclic, false).is_err());
assert_eq!(s.catalog.to_snapshot(), before);
assert!(
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
.is_err()
mapped_settings(
&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)),
crate::crs::Target::RawAbsolute,
1.0,
Orientation::Normal,
1.5,
Orientation::Normal
)
.is_err()
);
}
#[test]
@@ -439,6 +446,7 @@ fn compressed_xmp_is_bounded_and_settings_are_data_only() {
1.5,
Orientation::Normal,
1.5,
Orientation::Normal,
)
.unwrap();
assert_eq!(partial["light"]["exposure"], 0.75);
@@ -450,11 +458,11 @@ fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
let settings =
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
assert_eq!(partial["light"]["exposure"], 0.5);
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
assert_eq!(
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
(Some("custom"), Some(3578.0), Some(-5.0))
@@ -471,6 +479,7 @@ fn catalog_crops_without_has_crop_are_imported() {
1.5,
Orientation::Normal,
1.5,
Orientation::Normal,
)
.unwrap();
let crop = &partial["crop"]["geometry"];
@@ -481,11 +490,13 @@ fn catalog_crops_without_has_crop_are_imported() {
assert_eq!(crop["angle"], 0.0);
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, unknown) =
mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
// no crop fields: the crop stays untouched
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, _) =
mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap();
assert!(partial.get("crop").is_none());
}
@@ -509,7 +520,7 @@ fn lightroom_orientation_codes() {
fn catalog_crops_follow_the_photo_orientation() {
let crop = |o: Orientation| {
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
let g = &partial["crop"]["geometry"];
let r = &g["rect"];
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
@@ -522,16 +533,74 @@ fn catalog_crops_follow_the_photo_orientation() {
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
// a mirror turns the straighten the other way
let angle = |o: Orientation| {
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
};
assert_eq!(angle(Orientation::Rotate90), -2.0);
assert_eq!(angle(Orientation::FlipH), 2.0);
// no crop: nothing to orient
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
let (partial, _) =
mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap();
assert!(partial.get("crop").is_none());
}
#[test]
fn catalog_masks_follow_the_photo_orientation() {
// #1806, EMY_9996 (EXIF 8, shown a quarter turn counter-clockwise): a horizon gradient along
// the stored image's x axis runs along the shown photo's y axis
let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.1, CorrectionMasks = {
{ What = "Mask/Gradient", MaskValue = 1, ZeroX = 0.362544, ZeroY = 0.481093, FullX = 0.451639, FullY = 0.480645 } } } } }"#;
let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5, Orientation::Normal).unwrap();
let g = &partial["masks"][0]["components"][0]["shape"];
let close = |v: &Value, want: f64| (v.as_f64().unwrap() - want).abs() < 1e-9;
assert!(close(&g["start"]["x"], 0.480645) && close(&g["start"]["y"], 1.0 - 0.451639), "{g}");
assert!(close(&g["end"]["x"], 0.481093) && close(&g["end"]["y"], 1.0 - 0.362544), "{g}");
let masks = |o: Orientation| {
let s = r#"s = {
MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = {
{ What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 },
{ What = "Mask/CircularGradient", Left = 0, Right = 0.2, Top = 0.1, Bottom = 0.3, Angle = 10, Feather = 50, Version = 2, Flipped = true },
{ What = "Mask/Paint", Radius = 0.05, MaskValue = 1, Dabs = { "d 0.1 0.2", "d 0.3 0.6" } } } } } }"#;
let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
let c = &partial["masks"][0]["components"];
let (linear, radial, brush) = (&c[0]["shape"], &c[1]["shape"], &c[2]["shape"]["strokes"][0]["points"]);
[
&linear["start"]["x"],
&linear["start"]["y"],
&linear["end"]["x"],
&linear["end"]["y"],
&radial["center"]["x"],
&radial["center"]["y"],
&radial["rx"],
&radial["ry"],
&radial["angle"],
&brush[0]["x"],
&brush[0]["y"],
&brush[1]["x"],
&brush[1]["y"],
]
.map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9)
};
// (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise;
// a radial's radii (in long-edge units) swap with the axes, and a mirror turns its angle the
// other way
let r = (0.1, (0.1_f64 / 1.5 * 1e9).round() / 1e9);
assert_eq!(masks(Orientation::Normal), [0.1, 0.2, 0.3, 0.6, 0.1, 0.2, r.0, r.1, 10.0, 0.1, 0.2, 0.3, 0.6]);
assert_eq!(masks(Orientation::Rotate90), [0.8, 0.1, 0.4, 0.3, 0.8, 0.1, r.1, r.0, 10.0, 0.8, 0.1, 0.4, 0.3]);
assert_eq!(masks(Orientation::Rotate270), [0.2, 0.9, 0.6, 0.7, 0.2, 0.9, r.1, r.0, 10.0, 0.2, 0.9, 0.6, 0.7]);
assert_eq!(masks(Orientation::Rotate180), [0.9, 0.8, 0.7, 0.4, 0.9, 0.8, r.0, r.1, 10.0, 0.9, 0.8, 0.7, 0.4]);
assert_eq!(masks(Orientation::FlipH), [0.9, 0.2, 0.7, 0.6, 0.9, 0.2, r.0, r.1, -10.0, 0.9, 0.2, 0.7, 0.6]);
assert_eq!(masks(Orientation::Transpose), [0.2, 0.1, 0.6, 0.3, 0.2, 0.1, r.1, r.0, -10.0, 0.2, 0.1, 0.6, 0.3]);
// a photo already in the library has its shown size (portrait here), but Lightroom's edges are
// fractions of the stored image: a circle on a 3:2 stored image stays a circle
let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = {
{ What = "Mask/CircularGradient", Left = 0.3, Right = 0.5, Top = 0.3, Bottom = 0.6, Version = 2, Flipped = true } } } } }"#;
let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap();
let radial = &partial["masks"][0]["components"][0]["shape"];
assert!(close(&radial["rx"], 0.1) && close(&radial["ry"], 0.1), "{radial}");
}
/// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image
/// of aspect `w` (height 1) shown with orientation `o`.
fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String {
@@ -554,7 +623,8 @@ fn straightened_catalog_crops_round_trip() {
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
for angle in [6.5, -6.5] {
let g = CropGeometry { angle, ..g };
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let (partial, _) =
mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap();
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(
@@ -584,7 +654,7 @@ fn straightened_catalog_crops_match_lightroom_exports() {
(6100.0, 9150.0),
),
] {
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0, Orientation::Normal).unwrap();
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
let (w, h) = (r.width() * ow, r.height() * oh);
@@ -607,6 +677,87 @@ fn imported_crops_use_the_catalog_orientation() {
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
}
/// Masks only the catalog's XMP has (the develop row has none to replace them) are on the stored
/// image too: on a vertical shot (`DA`) they turn like the row's, and a radial keeps its shape on
/// the 2:1 stored image, also for a photo already in the library at its shown 10 × 20.
#[test]
fn catalog_xmp_masks_follow_the_photo_orientation() {
let mut data = sample();
data.photos.truncate(1);
data.photos[0].image.remove("masterImage");
data.photos[0].image.insert("orientation".into(), json!("DA"));
data.photos[0].image.insert("fileWidth".into(), json!(20));
data.photos[0].image.insert("fileHeight".into(), json!(10));
data.photos[0].settings = "s = { Exposure2012 = 0.5 }".into();
data.photos[0].xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="-0.25">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Gradient" crs:FullX="0.1" crs:FullY="0.2" crs:ZeroX="0.3" crs:ZeroY="0.6"/>
<rdf:li crs:What="Mask/CircularGradient" crs:Left="0.3" crs:Right="0.5" crs:Top="0.3" crs:Bottom="0.6" crs:Flipped="true" crs:Version="2"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
.into();
let mut s = crate::Session::new();
let id = s.catalog.alloc_photo_id();
let shown = Photo::new(id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 10, 20, "before");
s.commit("Browse folder", Op::AddPhoto { photo: Box::new(shown) }).unwrap();
apply(&mut s, data, true).unwrap();
assert_eq!(s.catalog.len(), 1, "the catalog's photo is the one already in the library");
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.develop.light.exposure, 0.5, "the develop row is applied too");
let c = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"];
let (linear, radial) = (&c[0]["shape"], &c[1]["shape"]);
let r = [&linear["start"]["x"], &linear["start"]["y"], &linear["end"]["x"], &linear["end"]["y"], &radial["center"]["x"], &radial["center"]["y"]]
.map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9);
// (x, y) → (y, 1 − x), a quarter turn counter-clockwise
assert_eq!(r, [0.2, 0.9, 0.6, 0.7, 0.45, 0.6], "{c}");
// 4 × 3 px of a 20 × 10 stored image, in long-edge units, with the axes swapped
let radii = [&radial["rx"], &radial["ry"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9);
assert_eq!(radii, [0.075, 0.1], "{radial}");
}
/// A photo already in the library keeps its Rotate Left / Right on update, so the catalog's masks
/// go through it too: an upright (`AB`) photo turned right in LightCraft gets its gradient turned.
#[test]
fn updated_masks_follow_the_rotation_kept_from_the_library() {
let mut data = sample();
data.photos.truncate(1);
data.photos[0].image.remove("masterImage");
data.photos[0].image.insert("orientation".into(), json!("AB"));
data.photos[0].settings =
r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = {
{ What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 } } } } }"#
.into();
data.photos[0].xmp = String::new();
let mut s = crate::Session::new();
apply(&mut s, data.clone(), false).unwrap();
let id = s.catalog.photos().next().unwrap().id;
let turned = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() };
s.set_develop(id, turned, "Rotate Right").unwrap();
apply(&mut s, data, true).unwrap();
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.develop.orientation, Orientation::Rotate90, "the update keeps the photo's rotation");
let g = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"][0]["shape"];
let r = [&g["start"]["x"], &g["start"]["y"], &g["end"]["x"], &g["end"]["y"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9);
// (x, y) → (1 − y, x), a quarter turn clockwise
assert_eq!(r, [0.8, 0.1, 0.4, 0.3], "{g}");
// the catalog's orientation first, then the photo's own, for every pair
for a in 1..=8 {
for b in 1..=8 {
let (a, b) = (Orientation::from_exif(a), Orientation::from_exif(b));
for (x, y) in [(0.1, 0.2), (0.7, 0.4)] {
let (u, v) = a.map(x, y, 1.0, 1.0);
let ((p, q), (want_p, want_q)) = (in_photo_frame(a, b).map(x, y, 1.0, 1.0), b.map(u, v, 1.0, 1.0));
assert!((p - want_p).abs() < 1e-9 && (q - want_q).abs() < 1e-9, "{a:?} then {b:?}: ({p}, {q}) for ({want_p}, {want_q})");
}
}
}
}
#[test]
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
let mut data = sample();
@@ -622,8 +773,15 @@ fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
assert_eq!(p.develop.light.shadows, 65.0);
assert_eq!(p.develop.light.blacks, -22.0);
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
let (partial, _) =
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5).unwrap();
let (partial, _) = mapped_settings(
"s = { Exposure2012 = -5, Contrast2012 = -100 }",
crate::crs::Target::RawAbsolute,
1.0,
Orientation::Normal,
1.5,
Orientation::Normal,
)
.unwrap();
assert_eq!(partial["light"]["exposure"], -5.0);
assert_eq!(partial["light"]["contrast"], -100.0);
}
+17
View File
@@ -1026,6 +1026,23 @@ mod tests {
assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()]));
}
/// Issue #2556: a sidecar's `lc:settings` colour-range mask keeps at most `MAX_COLOR_RANGE_SAMPLES` samples.
#[test]
fn sidecar_color_range_samples_are_capped() {
use lightcraft_develop::{MAX_COLOR_RANGE_SAMPLES, Mask, MaskComponent, MaskOp, MaskShape};
let samples: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64 / 1000.0, 0.1, -0.1]).collect();
let mut d = DevelopSettings::default();
let shape = MaskShape::ColorRange { samples: samples.clone(), refine: 50.0 };
d.masks.push(Mask { id: 1, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], ..Default::default() });
let xmp = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&d.to_json().to_string()));
let sc = super::parse_sidecar(&xmp, crate::crs::Target::Rendered).unwrap();
let Some(DevelopPatch::Full(s)) = sc.develop else { panic!("expected full settings") };
let Some(MaskShape::ColorRange { samples: got, .. }) = s.masks.first().and_then(|m| m.components.first()).map(|c| &c.shape) else {
panic!("expected a colour range")
};
assert_eq!(got.as_slice(), &samples[..MAX_COLOR_RANGE_SAMPLES]);
}
use super::*;
use lightcraft_catalog::Meta;
+11
View File
@@ -415,6 +415,17 @@ fn connected_brush_strokes_start_where_the_last_one_ended() {
assert_eq!(strokes(&s).len(), 2);
}
#[test]
fn a_mask_id_at_the_limit_does_not_overflow_the_next_masks() {
let mut s = demo();
s.execute("develop.merge", &json!({"settings": {"masks": [{"id": u32::MAX, "name": "Pasted"}]}})).unwrap();
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
s.execute("mask.duplicate", &json!({})).unwrap();
let ids: Vec<u32> = active_dev(&s).masks.iter().map(|m| m.id).collect();
assert_eq!(ids.len(), 3);
assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "distinct ids: {ids:?}");
}
#[test]
fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() {
let mut s = demo();
+230 -43
View File
@@ -1,9 +1,7 @@
//! The wgpu device, the compiled kernels, buffers, dispatch and readback.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use wgpu::util::DeviceExt;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
/// A kernel module: WGSL source, its storage bindings (name, writable, element type) after the
/// parameter block `P` (binding 0), and its entry points.
@@ -77,6 +75,102 @@ struct Kernel {
nbuf: usize,
}
/// A module's shader and layouts, shared by the kernels of its entry points.
struct Prepared {
module: wgpu::ShaderModule,
layout: wgpu::BindGroupLayout,
pipeline_layout: wgpu::PipelineLayout,
nbuf: usize,
}
/// Validation and internal error scopes. They belong to the thread that pushed them.
fn push_scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 2] {
[device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)]
}
/// The error the scopes caught, if any (popped innermost first).
fn pop_scopes(scopes: [wgpu::ErrorScopeGuard; 2]) -> Option<String> {
let [validation, internal] = scopes;
let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop()));
internal.or(validation).map(|e| e.to_string())
}
/// Build every kernel. A kernel the driver's compiler refuses is a validation or an internal error,
/// and then there is no GPU, not a half-built one.
///
/// Issue #2936: built one after another, the 27 pipelines took ~2.2 s on an Intel iGPU under DX12
/// (device start-up 1.7–4.4 s in all, 7 s on the first run after an install), where FXC (issue #471)
/// compiles each one, single-threaded, inside `create_compute_pipeline`. The
/// device takes pipeline creation from any thread, so the entry points are shared out to worker
/// threads. This thread works too: every kernel is built even when no thread can be started.
fn build_kernels(device: &wgpu::Device) -> Result<HashMap<&'static str, Kernel>, String> {
let scopes = push_scopes(device);
let consts = constants();
let prepared: Vec<Prepared> = MODULES
.iter()
.map(|m| {
let src = module_source(m, &consts);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) });
let mut entries = vec![storage_entry(0, true)];
entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w)));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries });
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
Prepared { module, layout, pipeline_layout, nbuf: m.bindings.len() }
})
.collect();
if let Some(e) = pop_scopes(scopes) {
return Err(e);
}
let jobs: Vec<(&Prepared, &'static str)> = MODULES.iter().zip(&prepared).flat_map(|(m, p)| m.entries.iter().map(move |e| (p, *e))).collect();
let next = AtomicUsize::new(0);
let built = std::sync::Mutex::new(HashMap::with_capacity(jobs.len()));
let workers = std::thread::available_parallelism().map_or(1, |n| n.get()).min(jobs.len()).saturating_sub(1);
let errors: Vec<String> = std::thread::scope(|s| {
let work = || build_jobs(device, &jobs, &next, &built);
let threads: Vec<_> =
(0..workers).filter_map(|_| std::thread::Builder::new().name("lc-gpu-kernels".into()).spawn_scoped(s, work).ok()).collect();
let mut errors: Vec<String> = work().into_iter().collect();
for t in threads {
match t.join() {
Ok(e) => errors.extend(e),
Err(_) => errors.push("a kernel build thread panicked".into()),
}
}
errors
});
if let Some(e) = errors.into_iter().next() {
return Err(e);
}
Ok(built.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner))
}
/// One worker of [`build_kernels`]: build kernels until none is left; the first error its scopes caught.
fn build_jobs(
device: &wgpu::Device,
jobs: &[(&Prepared, &'static str)],
next: &AtomicUsize,
built: &std::sync::Mutex<HashMap<&'static str, Kernel>>,
) -> Option<String> {
let scopes = push_scopes(device);
while let Some(&(p, entry)) = jobs.get(next.fetch_add(1, Ordering::Relaxed)) {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: Some(&p.pipeline_layout),
module: &p.module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
});
let kernel = Kernel { pipeline, layout: p.layout.clone(), nbuf: p.nbuf };
built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(entry, kernel);
}
pop_scopes(scopes)
}
/// A device buffer of `len` 32-bit values.
///
/// Dropped buffers are recycled: they first wait in a per-thread list (commands recorded on this
@@ -339,39 +433,15 @@ impl Gpu {
log::error!("gpu: device lost ({reason:?}): {msg}");
crate::mark_broken(&format!("device lost ({reason:?}): {msg}"));
});
// a kernel the driver's compiler refuses is a validation or an internal error: no GPU, not a half-built one
let scopes = [device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)];
let consts = constants();
let mut kernels = HashMap::new();
for m in MODULES {
let src = module_source(m, &consts);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) });
let mut entries = vec![storage_entry(0, true)];
entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w)));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries });
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
for e in m.entries {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(e),
layout: Some(&pl),
module: &module,
entry_point: Some(e),
compilation_options: Default::default(),
cache: None,
});
kernels.insert(*e, Kernel { pipeline, layout: layout.clone(), nbuf: m.bindings.len() });
let started = std::time::Instant::now();
let kernels = match build_kernels(&device) {
Ok(kernels) => kernels,
Err(e) => {
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
return Err(format!("{}: kernels failed to build ({e})", info.name));
}
}
let [validation, internal] = scopes;
let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop()));
if let Some(e) = internal.or(validation) {
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
return Err(format!("{}: kernels failed to build ({e})", info.name));
}
};
let build_ms = started.elapsed().as_millis();
let dummy = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dummy"),
size: 16,
@@ -379,7 +449,7 @@ impl Gpu {
mapped_at_creation: false,
});
let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
log::info!("gpu: {} ({:?}), buffers ≤ {} MiB", info.name, info.backend, max_buffer >> 20);
log::info!("gpu: {} ({:?}), {} kernels in {build_ms} ms, buffers ≤ {} MiB", info.name, info.backend, kernels.len(), max_buffer >> 20);
Ok(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
}
@@ -520,13 +590,41 @@ impl Gpu {
}
// (dropping the view schedules the copy)
drop(view);
return Buf { buf: Some(Tracked::new(buf)), len };
} else {
// recorded now, not only when the error scope pops: the zeroed buffer must not be
// shared before then (`render::shared_source`)
fail(FailKind::OutOfMemory, "out of device memory: the upload's staging buffer couldn't be allocated".into());
}
// no view: the buffer couldn't be allocated (out of device memory). The error scope has recorded why and
// the render fails over to the CPU; allocating it again would only fail again (issue #1764)
return Buf { buf: Some(Tracked::new(buf)), len };
}
let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytes, usage });
let buf = self.init_buffer(bytes, usage);
Buf { buf: Some(Tracked::new(buf)), len }
}
/// A buffer holding `contents` (zero-padded to the copy alignment), written through the queue. Not
/// `create_buffer_init`: it maps the new buffer, and mapping one that couldn't be allocated (out of device
/// memory) panics inside wgpu instead of raising an error the render's scopes catch (issue #1764).
fn init_buffer(&self, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer {
let align = wgpu::COPY_BUFFER_ALIGNMENT as usize;
let size = contents.len().max(align).next_multiple_of(align);
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: size as u64,
usage: usage | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if size == contents.len() {
self.queue.write_buffer(&buf, 0, contents);
} else {
let mut padded = contents.to_vec();
padded.resize(size, 0);
self.queue.write_buffer(&buf, 0, &padded);
}
buf
}
fn over_limit(&self, size: u64) {
fail(
FailKind::Limit,
@@ -570,11 +668,7 @@ impl Gpu {
return;
};
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(if p.is_empty() { &[0u32] } else { p }),
usage: wgpu::BufferUsages::STORAGE,
});
let params = self.init_buffer(bytemuck::cast_slice(if p.is_empty() { &[0u32] } else { p }), wgpu::BufferUsages::STORAGE);
let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }];
for (i, b) in bufs.iter().enumerate() {
let buf = b.map(|b| b.raw()).unwrap_or(&self.dummy);
@@ -793,6 +887,17 @@ pub fn groups1(n: usize) -> [u32; 3] {
#[cfg(test)]
mod tests {
/// Every kernel module, as it is handed to the device (the generated constants, the bindings, `common.wgsl`, the
/// module), parses and validates without a GPU.
#[test]
fn every_kernel_module_validates_without_a_gpu() {
let consts = super::constants();
for m in super::MODULES {
let name = m.entries.first().copied().unwrap_or("?");
assert_eq!(crate::validate_wgsl(name, &super::module_source(m, &consts)), Ok(()));
}
}
#[test]
fn limits_override_parses() {
assert_eq!(super::parse_limits("webgpu"), Some((128 << 20, 256 << 20)));
@@ -803,6 +908,20 @@ mod tests {
assert_eq!(super::parse_limits("lots"), None);
}
/// Issue #2936: the kernels are built on several threads; every entry point of every module has one.
#[test]
fn every_entry_point_has_a_kernel() {
let Some(g) = crate::device() else {
lightcraft_testkit::skip!(Gpu, "no GPU device");
return;
};
let entries: Vec<&str> = super::MODULES.iter().flat_map(|m| m.entries.iter().copied()).collect();
assert_eq!(g.kernels.len(), entries.len());
for e in entries {
assert!(g.kernels.contains_key(e), "no kernel for {e}");
}
}
/// Run `child` (an ignored test in this module) in its own process: it loses the device, which
/// no other test running beside it may see. Returns its stdout, or `None` without a GPU.
fn run_child(child: &str) -> Option<String> {
@@ -902,6 +1021,74 @@ mod tests {
println!("CHILD: survived");
}
/// A render that runs out of device memory falls back to the CPU and leaves the GPU in use: the upload
/// whose buffer couldn't be allocated used to try again through `create_buffer_init`, which panicked
/// mapping the second failed buffer, and the panic stopped GPU use for the session (issue #1764).
#[test]
fn running_out_of_device_memory_falls_back_without_stopping_the_gpu() {
if run_child("out_of_memory_child").is_none() {
lightcraft_testkit::skip!(Gpu, "no GPU device, or its memory never ran out");
}
}
#[test]
#[ignore = "run in its own process by running_out_of_device_memory_falls_back_without_stopping_the_gpu"]
fn out_of_memory_child() {
let Some(g) = crate::device() else {
println!("CHILD: no GPU");
return;
};
// hold device memory until an allocation fails: 1 GiB at a time, then smaller, so little is
// left; no larger than the device allows in one buffer, which would be a validation error
let max = g.device.limits().max_buffer_size & !(wgpu::COPY_BUFFER_ALIGNMENT - 1);
let mut sizes: Vec<u64> = [1u64 << 30, 1 << 28, 1 << 26].into_iter().map(|s| s.min(max)).collect();
sizes.dedup();
let mut held = Vec::new();
let mut full = false;
for size in sizes {
for _ in 0..128 {
let validation = g.device.push_error_scope(wgpu::ErrorFilter::Validation);
let oom = g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let usage = wgpu::BufferUsages::STORAGE;
let buf = g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size, usage, mapped_at_creation: false });
let ran_out = pollster::block_on(oom.pop()).is_some();
if let Some(e) = pollster::block_on(validation.pop()) {
panic!("holding {size} bytes of device memory: {e}");
}
if ran_out {
full = true;
break;
}
held.push(buf);
}
}
if !full {
println!("CHILD: no GPU (its memory never ran out)");
return;
}
// a render that needs more than is left: a larger one when the driver still finds room for the first
let s = lightcraft_develop::DevelopSettings::default();
let mut ran_out = false;
for (w, h) in [(4000, 3000), (8000, 6000)] {
let src = std::sync::Arc::new(lightcraft_raster::Rgb32f { width: w, height: h, data: vec![[0.18, 0.18, 0.18]; w * h] });
let req = lightcraft_pipeline::RenderRequest::fit(w, h);
match crate::try_render(&src, &lightcraft_pipeline::SourceInfo::default(), &s, &req, None) {
Ok(_) => continue,
Err(crate::Fallback::Failed(reason)) => assert!(reason.contains("out of device memory"), "{reason}"),
Err(crate::Fallback::NotTried) => panic!("the GPU wasn't tried"),
}
ran_out = true;
break;
}
assert_eq!(crate::unavailable_reason(), None, "the GPU stays in use");
drop(held);
if !ran_out {
println!("CHILD: no GPU (the renders found room anyway)");
return;
}
println!("CHILD: survived");
}
/// Empties the buffer pool when dropped, as the UI does (`trim_pool(0)`) and as returning a
/// buffer to a full pool does.
struct TrimOnDrop(&'static super::Gpu);
+7
View File
@@ -817,6 +817,13 @@ static PER_CONV: AtomicBool = AtomicBool::new(false);
mod tests {
use super::*;
/// The face detector's kernels validate without a GPU.
#[test]
fn the_face_kernels_validate_without_a_gpu() {
assert_eq!(crate::validate_wgsl("faces_conv", include_str!("wgsl/faces_conv.wgsl")), Ok(()));
assert_eq!(crate::validate_wgsl("faces_ops", include_str!("wgsl/faces_ops.wgsl")), Ok(()));
}
fn gpu() -> Option<Arc<FaceGpu>> {
match accelerator() {
Ok(a) => Some(a),
+53 -5
View File
@@ -193,7 +193,9 @@ pub fn reset_failures() {
/// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the
/// whole process regardless.
pub fn set_enabled(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
// (SeqCst, as `warm_up`'s thread reads it after releasing its start: a switch back on is
// never missed there)
ENABLED.store(on, Ordering::SeqCst);
}
/// Whether GPU rendering is allowed (environment, preference, no earlier failure — real, or
@@ -308,13 +310,33 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> {
/// Create the device and compile the kernels on a background thread now (once the window is up),
/// so the first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. Does nothing
/// while GPU rendering is turned off.
/// while GPU rendering is turned off. Repeated calls start one thread at most.
pub fn warm_up() {
#[cfg(not(target_arch = "wasm32"))]
if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() {
let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| {
let _ = device();
if !env_disabled() && ENABLED.load(Ordering::SeqCst) && GPU.get().is_none() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
let spawned = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| {
loop {
let _ = device();
if GPU.get().is_some() || shutting_down() || env_disabled() {
break;
}
// turned off before the thread ran (nothing created): a later call may start
// again. Released first, then the preference read again: a call made while this
// thread still held the flag (turned back on meanwhile) returned without starting
// one, so this thread tries again itself rather than leave "starting" for good.
STARTED.store(false, Ordering::SeqCst);
if !ENABLED.load(Ordering::SeqCst) || STARTED.swap(true, Ordering::SeqCst) {
break;
}
}
});
if spawned.is_err() {
STARTED.store(false, Ordering::SeqCst);
}
}
}
@@ -520,6 +542,9 @@ pub fn try_render(
format!("{what}: {}; the GPU is not used again", f.reason)
}
ctx::FailKind::OutOfMemory => {
// an upload that ran out of memory left a zeroed buffer; the shared source
// may be one, and the next render of the photo would reuse it
render::release_shared_source();
gpu.trim(0);
format!("{what}: {}", f.reason)
}
@@ -550,10 +575,33 @@ pub fn try_render(
}
}
/// Tests: parse and validate a WGSL module with naga, as wgpu does before it hands a shader to the driver. Needs no
/// GPU, so kernels that don't build (a duplicate definition after a merge, a type error) fail every test run, not only
/// the GPU ones.
#[cfg(test)]
pub(crate) fn validate_wgsl(name: &str, src: &str) -> Result<(), String> {
use wgpu::naga;
let module = naga::front::wgsl::parse_str(src).map_err(|e| format!("{name}: {}", e.emit_to_string(src)))?;
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all())
.validate(&module)
.map(|_| ())
.map_err(|e| format!("{name}: {}", e.emit_to_string(src)))
}
#[cfg(test)]
mod tests {
use lightcraft_pipeline::{OutputDepth, Overlay};
/// The helper refuses what a driver would: a function defined twice (what an automatic merge of two branches
/// touching the same kernel can leave).
#[test]
fn wgsl_validation_catches_a_duplicate_definition() {
let ok = "fn f() -> f32 { return 1.0; }\n@compute @workgroup_size(1) fn main() { _ = f(); }\n";
assert_eq!(super::validate_wgsl("ok", ok), Ok(()));
let twice = format!("fn f() -> f32 {{ return 2.0; }}\n{ok}");
assert!(super::validate_wgsl("twice", &twice).is_err());
}
// Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR),
// the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU
#[test]
+151 -35
View File
@@ -652,15 +652,71 @@ impl Shared {
}
}
/// Workspaces not in use, and how many exist (free or with a tile in flight).
struct Slots<T> {
free: Vec<T>,
made: usize,
}
/// Up to `cap` workspaces shared by the threads that run tiles. The free list and the count share one lock, so a caller
/// that finds none free and waits cannot miss one being given back or forgotten in between (issue #2577).
struct Workspaces<T> {
slots: Mutex<Slots<T>>,
wake: Condvar,
cap: usize,
}
impl<T> Workspaces<T> {
fn new(first: T, cap: usize) -> Self {
Workspaces { slots: Mutex::new(Slots { free: vec![first], made: 1 }), wake: Condvar::new(), cap }
}
fn lock(&self) -> std::sync::MutexGuard<'_, Slots<T>> {
self.slots.lock().unwrap_or_else(|e| e.into_inner())
}
/// A free workspace, a new one from `make` when fewer than `cap` exist, else wait for one to come back or be
/// forgotten.
fn take(&self, make: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
let mut s = self.lock();
loop {
if let Some(w) = s.free.pop() {
return Ok(w);
}
if s.made < self.cap {
s.made += 1;
drop(s);
let w = make();
if w.is_err() {
self.forget();
}
return w;
}
s = self.wake.wait(s).unwrap_or_else(|e| e.into_inner());
}
}
fn give(&self, w: T) {
self.lock().free.push(w);
self.wake.notify_one();
}
/// One workspace fewer exists: a waiting caller may make another.
fn forget(&self) {
let mut s = self.lock();
s.made = s.made.saturating_sub(1);
drop(s);
self.wake.notify_one();
}
}
/// A network on the GPU for one tile size. Cheap to share between threads: each call takes a free workspace.
pub struct NetRunner {
shared: Shared,
tile: usize,
in_channels: usize,
free: Mutex<Vec<Workspace>>,
/// Workspaces made so far (they are made when a call finds none free, up to [`WORKSPACES`]).
made: Mutex<usize>,
wake: Condvar,
/// Workspaces are made when a call finds none free, up to [`WORKSPACES`].
workspaces: Workspaces<Workspace>,
bytes: u64,
}
@@ -711,7 +767,7 @@ pub fn runner(net: &Net, tile: usize) -> Result<NetRunner, String> {
if let Some(e) = errors.into_iter().flatten().next() {
return Err(format!("{}: the denoise kernels could not be set up ({e})", d.info.name));
}
Ok(NetRunner { shared, tile, in_channels: net.in_channels, free: Mutex::new(vec![first]), made: Mutex::new(1), wake: Condvar::new(), bytes })
Ok(NetRunner { shared, tile, in_channels: net.in_channels, workspaces: Workspaces::new(first, WORKSPACES), bytes })
}
impl NetRunner {
@@ -732,45 +788,30 @@ impl NetRunner {
/// A free workspace, a new one when fewer than [`WORKSPACES`] exist, else wait for one to come back.
fn take(&self) -> Result<Workspace, Error> {
let mut free = self.free.lock().unwrap_or_else(|e| e.into_inner());
loop {
if let Some(w) = free.pop() {
return Ok(w);
self.workspaces.take(|| {
// a caller woken by the last tiles failing on a lost device makes nothing on it
if let Some(why) = broken_reason() {
return Err(Error::Runtime(format!("the GPU is not used any more: {why}")));
}
{
let mut made = self.made.lock().unwrap_or_else(|e| e.into_inner());
if *made < WORKSPACES {
*made += 1;
drop(made);
drop(free);
let d = self.shared.dev;
let scopes =
[d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)];
let w = self.shared.workspace();
let [oom, validation] = scopes;
let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop()));
if let Some(e) = oom.or(validation) {
self.forget();
return Err(Error::Runtime(format!("the GPU could not make room for another tile: {e}")));
}
return Ok(w);
}
let d = self.shared.dev;
let scopes = [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)];
let w = self.shared.workspace();
let [oom, validation] = scopes;
let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop()));
if let Some(e) = oom.or(validation) {
return Err(Error::Runtime(format!("the GPU could not make room for another tile: {e}")));
}
free = self.wake.wait(free).unwrap_or_else(|e| e.into_inner());
}
Ok(w)
})
}
fn give(&self, w: Workspace) {
self.free.lock().unwrap_or_else(|e| e.into_inner()).push(w);
self.wake.notify_one();
self.workspaces.give(w);
}
/// A workspace is gone (dropped after a failure): room for a new one, and a waiting caller may make it.
fn forget(&self) {
let mut made = self.made.lock().unwrap_or_else(|e| e.into_inner());
*made = made.saturating_sub(1);
drop(made);
self.wake.notify_one();
self.workspaces.forget();
}
fn execute(&self, w: &Workspace, input: &[f32]) -> Result<Vec<f32>, Error> {
@@ -884,6 +925,25 @@ impl TileRunner for NetRunner {
#[cfg(test)]
mod tests {
use super::*;
/// The denoise kernels validate without a GPU: the pooling kernel, and the convolution for every block size the
/// planner picks for these layer widths and channel counts.
#[test]
fn the_network_kernels_validate_without_a_gpu() {
assert_eq!(crate::validate_wgsl("nn_pool", include_str!("wgsl/nn_pool.wgsl")), Ok(()));
let mut sizes: Vec<(u32, u32, u32)> = Vec::new();
for n in [1usize, 3, 8, 16, 24, 32, 48, 64, 96, 128, 256, 512] {
for cin in [1usize, 3, 4, 8, 16, 32, 48, 64, 128, 256, 512] {
let b = blocks(n, cin);
if !sizes.contains(&b) {
sizes.push(b);
}
}
}
for (bm, bn, bk) in sizes {
assert_eq!(crate::validate_wgsl(&format!("nn_conv {bm}x{bn}x{bk}"), &conv_source(bm, bn, bk)), Ok(()));
}
}
use lightcraft_denoise::{onnx, reference, synthetic};
fn net(tile: u64, ch: u64, depth: usize, seed: u64) -> Net {
@@ -1117,4 +1177,60 @@ mod tests {
// a buffer limit smaller than the activations
assert!(plan(&n, 16, 64).is_err());
}
/// Issue #2577: a tile failing between a caller finding every workspace in use and that caller waiting still wakes
/// it. The caller holds the lock from its check to its wait, as `Workspaces::take` does, so `forget` waits for it. A
/// wait that runs out fails, whatever the count is by then: the wakeup itself has to arrive.
#[test]
fn a_caller_that_finds_every_workspace_in_use_hears_one_being_forgotten() {
let ws = Workspaces::new(0u32, 1);
assert_eq!(ws.lock().free.pop(), Some(0));
std::thread::scope(|scope| {
// where take() is between finding every workspace in use and waiting
let mut s = ws.lock();
assert!(s.free.is_empty() && s.made == ws.cap);
scope.spawn(|| ws.forget());
// the tile in flight fails now
std::thread::sleep(Duration::from_millis(50));
// wait as take() does, and again only after a spurious wakeup that changed nothing
loop {
let (next, r) = ws.wake.wait_timeout(s, Duration::from_secs(10)).unwrap_or_else(|e| e.into_inner());
assert!(!r.timed_out(), "the caller missed the workspace being forgotten");
s = next;
if s.made != ws.cap {
break;
}
}
assert_eq!(s.made, 0);
});
}
#[test]
fn take_waits_for_a_workspace_to_come_back_or_be_forgotten() {
let ws = std::sync::Arc::new(Workspaces::new(0u32, 1));
let fail = || Err(Error::Runtime("no room".into()));
assert_eq!(ws.take(fail).unwrap(), 0);
// a caller on a thread nobody joins: if its wakeup never comes, the test fails on its timeout instead of hanging
let caller = |made: u32| {
let (ws, (tx, rx)) = (ws.clone(), std::sync::mpsc::channel());
std::thread::spawn(move || tx.send(ws.take(|| Ok(made))));
rx
};
// the only workspace is in flight: a caller waits until it is forgotten, then makes a new one
let rx = caller(1);
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err(), "took a workspace while none was free");
ws.forget();
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(), 1);
// or until it comes back, and takes that one
let rx = caller(2);
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err(), "took a workspace while none was free");
ws.give(1);
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(), 1);
// a workspace that could not be made leaves room for the next try
ws.forget();
assert!(ws.take(fail).is_err());
assert_eq!(ws.lock().made, 0);
assert_eq!(ws.take(|| Ok(3)).unwrap(), 3);
assert_eq!(ws.lock().made, 1);
}
}
+20 -2
View File
@@ -466,6 +466,10 @@ fn shared_source(src: &Arc<Rgb32f>, upload: impl FnOnce() -> Buf) -> Arc<Buf> {
*g = None;
SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let buf = Arc::new(upload());
// a failed upload's buffer holds zeros: this render falls back, and no other may reuse it
if crate::ctx::failed() {
return buf;
}
*g = Some((Arc::downgrade(src), buf.clone()));
buf
}
@@ -1028,12 +1032,26 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
MaskShape::ColorRange { samples, refine } => {
let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine);
// at most the CPU's samples (`MAX_COLOR_RANGE_SAMPLES`): cost is pixels x samples
let samples = samples.iter().take(lightcraft_develop::MAX_COLOR_RANGE_SAMPLES);
p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]);
aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32)));
aux.extend(samples.flat_map(|s| s.map(|v| v as f32)));
Some(3)
}
MaskShape::Brush { strokes } => {
let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect();
// The kernel takes at most MAX_DABS across the whole mask, so no more than
// that (plus one, to tell "at it" from "past it") is ever built: each stroke
// may otherwise construct two million dabs before the sum below rejects them
// all (#1520).
let mut room = MAX_DABS + 1;
let dabs: Vec<_> = strokes
.iter()
.map(|st| {
let d = lightcraft_pipeline::masks::brush_dabs_within(st, frame, w, h, room);
room -= d.dabs.len().min(room);
d
})
.collect();
// pen pressure varies radius and flow per dab, which the kernel doesn't take: the CPU rasterizes it
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS || dabs.iter().any(|d| !d.pressure.is_empty()) {
None
+3 -1
View File
@@ -106,7 +106,9 @@ fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
let lab0 = oklab(rgb);
var l = lab0.x;
var c = sqrt(lab0.y * lab0.y + lab0.z * lab0.z);
var h = atan2(lab0.z, lab0.y);
// a neutral (black or grey) pixel has no hue: atan2(0, 0) is undefined in WGSL (NaN on some
// GPUs, which a later lightening such as the vignette then shows); the CPU's atan2 gives 0
var h = select(atan2(lab0.z, lab0.y), 0.0, c == 0.0);
if (pu(F_MIXER) != 0u) {
let w = band_weights(h);
var dh = 0.0;
+28
View File
@@ -494,6 +494,34 @@ fn clipped_highlights_stay_neutral_on_both() {
}
}
/// Black pixels have no hue. The GPU's `atan2(0, 0)` is undefined (NaN on some GPUs), which made
/// every exactly black pixel NaN once a colour tool ran, and a lightening vignette then showed it:
/// the CPU lifted the black corners, the GPU left them black.
#[test]
fn black_pixels_match_under_colour_tools_and_a_light_vignette() {
if !gpu() {
return;
}
let base = scene(0, 320, 240);
// black bars across the frame, reaching into the corners the vignette lightens
let src = Arc::new(Rgb32f::from_fn(320, 240, |x, y| if y % 40 < 8 || x % 64 < 6 { [0.0; 3] } else { base.get(x, y) }));
let info = SourceInfo { raw: true, ..Default::default() };
let req = RenderRequest::fit(320, 240);
for (name, f) in [
("black, light vignette + saturation", (|s: &mut DevelopSettings| s.color.saturation = 40.0) as fn(&mut DevelopSettings)),
("black, light vignette + vibrance", |s| s.color.vibrance = 30.0),
("black, light vignette + mixer", |s| s.mixer.yellow.hue = 30.0),
] {
let mut s = DevelopSettings::default();
s.vignette.amount = 25.0;
s.vignette.style = VignetteStyle::HighlightPriority;
f(&mut s);
check(name, &src, &info, &s, &req);
let gpu = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu render").image;
assert!(gpu.get(0, 0)[1] > 0, "{name}: the black corner is not lightened on the GPU");
}
}
#[test]
fn rendered_sources_and_other_scenes() {
if !gpu() {
+134 -13
View File
@@ -251,7 +251,9 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
}
MaskShape::ColorRange { samples, refine } => {
let tol = color_range_tolerance(*refine);
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
// (a settings value built in code can skip the deserializer's cap: cost is pixels x samples)
let samples: Vec<[f32; 3]> =
samples.iter().take(lightcraft_develop::MAX_COLOR_RANGE_SAMPLES).map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
for (v, c) in out.data.iter_mut().zip(&img.data) {
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
let d = samples
@@ -281,7 +283,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
// 2 % of the long edge, so previews and exports match)
let e = (*edge / 100.0).clamp(-1.0, 1.0) as f32;
let gain = if e < 0.0 { 1.0 - 7.0 * e } else { 1.0 };
sample_seg(seg.as_ref(), detail, gain, frame, w, h, &mut out);
sample_seg(seg.as_ref(), detail, gain, frame, w, h, log_l, &mut out);
if e > 0.0 {
smooth_plane(&mut out, e * 0.02 * frame_px(frame, w));
}
@@ -343,13 +345,40 @@ impl SegGrid {
let bot = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx;
top * (1.0 - ty) + bot * ty
}
/// Output pixels per grid cell (the longer side of a cell: the grid is square over a
/// rectangle).
fn cell_px(&self, frame: &Frame, w: usize) -> f64 {
let px = frame.px_per_long(w) / frame.ow.max(frame.oh).max(1e-9);
((self.r[2] - self.r[0]) * frame.ow).max((self.r[3] - self.r[1]) * frame.oh) * px / self.side.max(1) as f64
}
}
/// [`sample_seg`]'s window, in grid cells (Gaussian sigma): the width of the band bilinear
/// enlargement leaves uncertain, about a cell on either side of the contour.
const SEG_WINDOW: f64 = 1.5;
/// [`sample_seg`]'s guided-filter regularization (EV² of log luminance): an edge of ~0.1 EV
/// contrast counts half.
const SEG_EPS: f32 = 0.01;
/// Stored model segmentations at each output pixel: the zoomed-in `detail` passes inside
/// their rectangles (the highest of them where they overlap), the whole-photo `seg`
/// elsewhere; the logit (times `gain`: > 1 is a harder edge) through a sigmoid. Damaged
/// data selects nothing.
fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, out: &mut Plane) {
///
/// Issue #3272: a model's grid is much coarser than an export (a 288² grid has ~20 px cells on
/// 24 MP), and its bilinear contour passes up to a cell from the photo's edge, so the mask spilled
/// onto the background (a bright outline under a raised Exposure). Where the grid is coarser than
/// the output, the logits are guided-filtered by the photo's log luminance ([`guided_cross_fast`]; a
/// window of [`SEG_WINDOW`] cells, so previews and exports draw the same edge): near the contour
/// the logit follows the photo's tones and its edge lands on the photo's. Filtering logits, not
/// coverage, makes the confident side win (a strip at 0.3 between 1 and 0 goes to 0, not 0.1),
/// and leaves a linear ramp of logits, where the photo has no edge, as it was. Pixels whose
/// window is all inside or all outside (|mean logit| ≥ 7, times `gain`) keep their sample exactly,
/// as do those where the photo has no edge to follow. Refine Edges
/// then works on the combined mask ([`evaluate_one`]) and finds these edges already in place.
#[allow(clippy::too_many_arguments)]
fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, log_l: &Plane, out: &mut Plane) {
let coarse = seg.and_then(SegGrid::new);
// (capped: a hostile document can hold many)
let patches: Vec<SegGrid> = detail.iter().take(lightcraft_develop::segmask::MAX_DETAIL).filter_map(SegGrid::new).collect();
@@ -361,10 +390,15 @@ fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Fram
for (x, v) in row.iter_mut().enumerate() {
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let fine = patches.iter().filter(|p| p.contains(n)).map(|p| p.logit(n)).reduce(f32::max);
let l = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0);
*v = 1.0 / (1.0 + (-l * gain).exp());
*v = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0) * gain;
}
});
// (the coarsest grid sets the window: it leaves the widest uncertain band)
let cell = coarse.iter().chain(&patches).map(|g| g.cell_px(frame, w)).fold(0.0, f64::max);
if cell >= 1.0 {
guided_cross_fast(log_l, out, (SEG_WINDOW * cell) as f32, SEG_EPS, |mean| 1.0 - smooth(3.0 * gain, 7.0 * gain, mean.abs()));
}
out.map_in_place(|l| 1.0 / (1.0 + (-l).exp()));
}
fn frame_px(frame: &Frame, w: usize) -> f32 {
@@ -524,6 +558,19 @@ pub const MAX_STROKE_DABS: usize = 2_000_000;
/// The dabs of stroke `s` in a `w × h` output.
pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs {
brush_dabs_within(s, frame, w, h, MAX_STROKE_DABS)
}
/// [`brush_dabs`] stopping at `max` dabs instead of [`MAX_STROKE_DABS`].
///
/// For a caller that discards a stroke past a threshold of its own: it builds to that threshold
/// rather than to the stroke budget and throws the difference away. The GPU path rejects a brush
/// mask past 4096 dabs in total and the Auto Mask spot window past 200,000, both far below the
/// two million a single stroke may otherwise construct first (#1520).
///
/// Ask for one more than the threshold to tell "at it" from "past it": a result shorter than
/// `max` is the whole stroke.
pub fn brush_dabs_within(s: &BrushStroke, frame: &Frame, w: usize, h: usize, max: usize) -> BrushDabs {
let to_out = frame.norm_to_out(w, h);
let ppl = frame.px_per_long(w);
let r = (s.size * ppl).max(0.5);
@@ -536,10 +583,14 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
let with_pressure = !s.pressure.is_empty();
let mut prev = None;
let mut prev_p = 1.0;
let mut emitted = 0usize;
for (i, p) in s.points.iter().enumerate() {
if emitted >= max {
break;
}
let q = to_out.apply(*p);
let before = dabs.len();
if push_dabs(&mut dabs, prev, q, r) {
if push_dabs_within(&mut dabs, &mut emitted, prev, q, r, max) {
let pq = s.pressure_at(i);
if with_pressure {
// the first dab of the stroke has nothing to interpolate from
@@ -585,7 +636,16 @@ pub fn push_dabs(dabs: &mut Vec<Point>, prev: Option<Point>, q: Point, r: f64) -
/// dabs no longer held in `dabs`. Incremental callers retain `emitted` when clearing their buffer;
/// it advances only by the number actually appended. Acceptance and spacing match [`push_dabs`].
pub fn push_dabs_counted(dabs: &mut Vec<Point>, emitted: &mut usize, prev: Option<Point>, q: Point, r: f64) -> bool {
let room = MAX_STROKE_DABS.saturating_sub(*emitted);
push_dabs_within(dabs, emitted, prev, q, r, MAX_STROKE_DABS)
}
/// [`push_dabs_counted`] against a caller's own budget rather than [`MAX_STROKE_DABS`].
///
/// A caller that will not look at more than `max` dabs should build no more than `max`: a length
/// check after the vector exists bounds nothing, it only throws away what was already paid for
/// (#1520).
pub fn push_dabs_within(dabs: &mut Vec<Point>, emitted: &mut usize, prev: Option<Point>, q: Point, r: f64, max: usize) -> bool {
let room = max.min(MAX_STROKE_DABS).saturating_sub(*emitted);
if room == 0 || !(q.x.is_finite() && q.y.is_finite()) {
return false;
}
@@ -598,7 +658,7 @@ pub fn push_dabs_counted(dabs: &mut Vec<Point>, emitted: &mut usize, prev: Optio
let n = (dist / dab_spacing(r)).ceil();
// a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge
// allocation: 1 M dabs is far more than any on-screen stroke needs
let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 };
let n = if n.is_finite() && n <= 1_000_000.0 { n.max(0.0) as usize } else { 0 };
// the in-between dabs and `q` together fit what is left of the stroke's budget
let n = n.min(room);
for k in 1..n {
@@ -665,9 +725,20 @@ pub fn auto_refine(r: f64) -> (f32, f32) {
((0.25 * r).max(1.0) as f32, 0.02)
}
/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the
/// guide's.
pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
/// A guided filter's linear model at each pixel (`q = a · guide + b`, coefficients averaged over
/// the windows that hold the pixel), the window mean of the filtered plane and, when asked for,
/// how much of an edge the guide has there (`var / (var + eps)`, averaged like the coefficients:
/// 0 where the guide is flat and the model is only a blur).
struct Guided {
a: Plane,
b: Plane,
mean: Plane,
edge: Option<Plane>,
}
/// The guided filter (He, Sun and Tang) of `p` steered by `guide`, both the same size: Gaussian
/// windows of `sigma` px, `eps` the regularization (in guide units squared).
fn guided_coeffs(guide: &Plane, p: &Plane, sigma: f32, eps: f32, with_edge: bool) -> Guided {
use lightcraft_raster::blur::gaussian;
let mi = gaussian(guide, sigma);
let mp = gaussian(p, sigma);
@@ -675,20 +746,70 @@ pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
let cii = gaussian(&guide.map(|a| a * a), sigma);
let mut a = Plane::new(p.width, p.height);
let mut b = Plane::new(p.width, p.height);
let mut edge = Plane::new(if with_edge { p.width } else { 0 }, if with_edge { p.height } else { 0 });
for i in 0..p.data.len() {
let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0);
let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps);
a.data[i] = k;
b.data[i] = mp.data[i] - k * mi.data[i];
if let Some(e) = edge.data.get_mut(i) {
*e = var / (var + eps);
}
}
let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma));
Guided { a: gaussian(&a, sigma), b: gaussian(&b, sigma), mean: mp, edge: with_edge.then(|| gaussian(&edge, sigma)) }
}
/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the
/// guide's.
pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
let g = guided_coeffs(guide, p, sigma, eps, false);
let mut q = Plane::new(p.width, p.height);
for i in 0..q.data.len() {
q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0);
q.data[i] = (g.a.data[i] * guide.data[i] + g.b.data[i]).clamp(0.0, 1.0);
}
q
}
/// [`guided_cross`] in its subsampled form (He and Sun, "Fast Guided Filter", 2015, as
/// [`crate::local::guided_fast`] is of the self-guided filter, at its step), in place and
/// unclamped, for a `p` that is smooth at the window's scale (a mask enlarged from a model's grid):
/// the linear model is fitted on both planes box-reduced to about 3 px of window, sampled
/// bilinearly and applied to the full-resolution guide, which is where the edge comes from. A
/// pixel moves to the filtered value by `weight` of its window mean of `p` times how much of an
/// edge the guide has there (0 keeps it exactly: where the photo is flat the filter could only
/// blur `p`). Extra memory: the reduced planes only (the coefficients are sampled per pixel, not
/// enlarged into planes).
fn guided_cross_fast(guide: &Plane, p: &mut Plane, sigma: f32, eps: f32, weight: impl Fn(f32) -> f32 + Sync + Send) {
use lightcraft_raster::resample::{Filter, resize};
let (w, h) = (p.width, p.height);
if guide.width != w || guide.height != h || w == 0 || h == 0 || !sigma.is_finite() {
return;
}
// (a window past the whole frame fits one model to it all)
let sigma = sigma.clamp(0.5, w.max(h) as f32);
let s = crate::local::guided_fast_step(sigma);
let (lw, lh) = (w.div_ceil(s).max(1), h.div_ceil(s).max(1));
let g = guided_coeffs(&resize(guide, lw, lh, Filter::Box), &resize(p, lw, lh, Filter::Box), sigma / s as f32, eps, true);
let Some(edge) = &g.edge else {
return;
};
// (the reduced planes span the same frame: their pixel per full pixel)
let (kx, ky) = (lw as f32 / w as f32, lh as f32 / h as f32);
for_rows(&mut p.data, w, |y, row| {
let fy = (y as f32 + 0.5) * ky;
for (x, (v, i)) in row.iter_mut().zip(guide.row(y)).enumerate() {
let fx = (x as f32 + 0.5) * kx;
let k = weight(bilinear(&g.mean, fx, fy));
// (no edge in the photo: nothing to follow, keep the sample)
let k = if k > 0.0 { k * smooth(0.05, 1.0, bilinear(edge, fx, fy)) } else { 0.0 };
if k > 0.0 {
let q = bilinear(&g.a, fx, fy) * i + bilinear(&g.b, fx, fy);
*v += (q - *v) * k;
}
}
});
}
/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight
/// each dab by [`auto_similarity`] to the pixel under its centre and are refined with
/// [`guided_cross`] on log luminance.
+177 -3
View File
@@ -89,6 +89,115 @@ fn the_edge_setting_hardens_or_feathers_the_transition() {
assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}");
}
/// A dark disc (radius 0.3 of the frame) on a light background, `n × n`: the photo and its log
/// luminance, and a model's view of it on a `side²` grid (each cell's logit from how much of the
/// cell the disc covers, as a low-resolution segmenter sees it).
fn disc_scene(n: usize, side: usize) -> (Rgb32f, Plane, SegMask) {
const R: f64 = 0.3;
let dark = |x: f64, y: f64| (x - 0.5).hypot(y - 0.5) < R;
let lum = |x: usize, y: usize| if dark((x as f64 + 0.5) / n as f64, (y as f64 + 0.5) / n as f64) { 0.05f32 } else { 0.6 };
let img = Rgb32f::from_fn(n, n, |x, y| [lum(x, y); 3]);
let log_l = img.map(crate::local::log_lum);
let logits: Vec<f32> = (0..side * side)
.map(|i| {
let (cx, cy) = (i % side, i / side);
let sub = 8;
let cover = (0..sub * sub)
.filter(|k| {
dark((cx as f64 + (k % sub) as f64 / sub as f64) / side as f64, (cy as f64 + (k / sub) as f64 / sub as f64) / side as f64)
})
.count() as f32
/ (sub * sub) as f32;
12.0 * (2.0 * cover - 1.0)
})
.collect();
(img, log_l, SegMask::from_logits(side, &logits))
}
/// Alpha along the disc's horizontal radius, `d` px outside (> 0) or inside (< 0) its edge.
fn across_disc_edge(a: &Plane, d: i32) -> f32 {
let n = a.width;
let edge = (0.8 * n as f64).round() as i32; // first background pixel on the right
let x = if d > 0 { edge + d - 1 } else { edge + d };
a.get(x as usize, n / 2)
}
/// Issue #3272: a mask from a low-resolution model grid spilled 1–2 px onto the background (a bright
/// outline once its Exposure was raised). Enlarged along the photo's edges, it is ≈ 0 one and two
/// pixels outside the subject, ≈ 1 inside, and exactly what it was away from the edge.
#[test]
fn ai_masks_follow_the_photos_edge_when_enlarged() {
let (n, side) = (512, 32); // 16 px cells, as a 288² grid on a ~4600 px export
let (img, log_l, seg) = disc_scene(n, side);
let shape = MaskShape::Prompt { text: "disc".into(), seg: Some(seg), detail: vec![], edge: 0.0 };
let f = frame(n, n);
let a = shape_alpha(&shape, &f, n, n, &img, &log_l, 0.0, None);
for d in [1, 2] {
assert!(across_disc_edge(&a, d) < 0.05, "{d} px outside: {}", across_disc_edge(&a, d));
assert!(across_disc_edge(&a, -d) > 0.95, "{d} px inside: {}", across_disc_edge(&a, -d));
}
// all around the disc, not only along one row
let r_px = 0.3 * n as f64;
for k in 0..64 {
let t = k as f64 / 64.0 * std::f64::consts::TAU;
let at = |r: f64| a.get((256.0 + r * t.cos()) as usize, (256.0 + r * t.sin()) as usize);
assert!(at(r_px + 2.5) < 0.05 && at(r_px - 2.5) > 0.95, "at {t:.2} rad: {} / {}", at(r_px + 2.5), at(r_px - 2.5));
}
// away from the edge the selection is the plain sample of the grid, to the bit
let flat = Plane::new(n, n);
let plain = shape_alpha(&shape, &f, n, n, &img, &flat, 0.0, None);
for (x, y) in [(256, 256), (200, 300), (10, 10), (500, 256), (256, 505)] {
assert_eq!(a.get(x, y), plain.get(x, y), "({x}, {y})");
}
}
/// Refine Edges on top of an enlarged AI mask: the second filter finds the edge already in place, so
/// there is no second halo and the edge doesn't move inwards.
#[test]
fn refine_edges_on_an_enlarged_ai_mask_neither_halos_nor_tightens() {
let (n, side) = (512, 32);
let (img, log_l, seg) = disc_scene(n, side);
let f = frame(n, n);
let mask = |refine: f64| Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Prompt { text: "disc".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 },
}],
refine,
..Default::default()
};
let crossing = |a: &Plane| (0..n).rev().find(|x| a.get(*x, n / 2) > 0.5).unwrap_or(0);
let plain = evaluate_one(&mask(0.0), &f, n, n, &img, &log_l, 0.0, None);
for refine in [50.0, 100.0] {
let a = evaluate_one(&mask(refine), &f, n, n, &img, &log_l, 0.0, None);
for d in [1, 2] {
assert!(across_disc_edge(&a, d) < 0.05, "refine {refine}, {d} px outside: {}", across_disc_edge(&a, d));
assert!(across_disc_edge(&a, -d) > 0.95, "refine {refine}, {d} px inside: {}", across_disc_edge(&a, -d));
}
assert!(crossing(&a).abs_diff(crossing(&plain)) <= 1, "refine {refine}: edge at {} vs {}", crossing(&a), crossing(&plain));
}
}
/// The window follows the grid, not the output: a preview and an export put the edge in the same
/// place of the photo (and on its edge).
#[test]
fn enlarged_ai_masks_put_the_edge_in_the_same_place_at_any_size() {
let side = 32;
let mut edges = Vec::new();
for n in [256, 512, 1024] {
let (img, log_l, seg) = disc_scene(n, side);
let shape = MaskShape::Prompt { text: "disc".into(), seg: Some(seg), detail: vec![], edge: 0.0 };
let a = shape_alpha(&shape, &frame(n, n), n, n, &img, &log_l, 0.0, None);
// the edge's sub-pixel position along the row: the integral of alpha past the centre
let covered: f32 = (n / 2..n).map(|x| a.get(x, n / 2)).sum();
edges.push((n / 2) as f64 + covered as f64);
let x = edges.last().copied().unwrap_or(0.0) / n as f64;
assert!((x - 0.8).abs() < 1.0 / n as f64, "{n} px: edge at {x:.4} of the width, the disc's at 0.8");
}
}
#[test]
fn ai_shapes_without_a_segmentation_select_nothing() {
let f = frame(30, 30);
@@ -115,6 +224,52 @@ fn radial_inside_and_invert() {
assert!(e[0].alpha.get(50, 50) < 0.01);
}
/// #1520: a caller that will not look past its own threshold builds to that threshold, not to
/// the stroke budget. Each segment here is admissible on its own and together they want far
/// more than any threshold the callers apply, which is the aggregate the issue describes
/// rather than one hostile segment.
#[test]
fn a_stroke_is_built_only_as_far_as_the_caller_will_look() {
let f = frame(4000, 4000);
// Twenty long sweeps across the output: tens of thousands of dabs between them.
let points: Vec<Point> =
(0..20).map(|i| if i % 2 == 0 { Point::new(0.02, 0.02 + i as f64 * 0.04) } else { Point::new(0.98, 0.02 + i as f64 * 0.04) }).collect();
let s = BrushStroke { points, size: 0.002, feather: 50.0, flow: 100.0, density: 100.0, ..Default::default() };
let whole = brush_dabs(&s, &f, 4000, 4000);
assert!(whole.dabs.len() > 20_000, "the stroke really is large: {}", whole.dabs.len());
assert!(whole.dabs.len() <= MAX_STROKE_DABS);
// Each caller gets what it asked for and no more, however large the stroke is. 4097 and
// 200_001 are what the GPU path and the Auto Mask spot window now ask for.
for max in [1usize, 2, 4097, 200_001] {
let got = brush_dabs_within(&s, &f, 4000, 4000, max);
assert!(got.dabs.len() <= max, "asked for at most {max}, built {}", got.dabs.len());
assert_eq!(got.r, whole.r, "the brush itself is unchanged");
}
// A budget the stroke never reaches changes nothing: it only ever truncates.
let generous = brush_dabs_within(&s, &f, 4000, 4000, whole.dabs.len() + 1000);
assert_eq!(generous.dabs, whole.dabs, "an unreached budget leaves the stroke alone");
// The guarantee the callers rely on: a result short of the budget was never truncated,
// so it is the stroke itself. Both callers ask for one more than they will accept, so
// anything they keep is identical to what they drew before this change, and anything
// truncated is something they were going to reject anyway.
//
// A truncated one is not a prefix: running out of room makes a segment lay its remaining
// dabs further apart rather than stop mid-segment, which is how `push_dabs_counted`
// already behaved at MAX_STROKE_DABS. That only ever reaches a caller that rejects it.
let cut = brush_dabs_within(&s, &f, 4000, 4000, 500);
assert_eq!(cut.dabs.len(), 500, "a stroke past the budget fills it exactly");
for max in [700usize, 5_000, whole.dabs.len()] {
let got = brush_dabs_within(&s, &f, 4000, 4000, max);
if got.dabs.len() < max {
assert_eq!(got.dabs, whole.dabs, "a result short of its budget is the whole stroke");
}
}
}
/// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at
/// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once
/// (the committed one).
@@ -148,7 +303,8 @@ fn a_long_segment_gets_dabs_at_most_the_spacing_apart() {
}
assert_eq!(committed.dabs, again);
assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9));
// many huge segments stop at the stroke budget instead of adding up without bound
// a huge segment lays only its end point, so many of them add one dab each instead of
// filling the stroke budget with intermediate dabs
let mut big = Vec::new();
let mut at = Point::new(0.0, 0.0);
for i in 0..5 {
@@ -157,8 +313,9 @@ fn a_long_segment_gets_dabs_at_most_the_spacing_apart() {
at = q;
}
}
assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len());
assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more");
assert_eq!(big.len(), 5, "an extreme segment lays no intermediate dabs");
let mut spent = MAX_STROKE_DABS;
assert!(!push_dabs_counted(&mut big, &mut spent, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more");
// a hostile segment places no in-between dabs
let mut v = Vec::new();
assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r));
@@ -167,6 +324,23 @@ fn a_long_segment_gets_dabs_at_most_the_spacing_apart() {
assert!(v.len() <= 1_000_002, "{}", v.len());
}
#[test]
fn oversized_brush_segments_skip_intermediate_dabs() {
let mut dabs = vec![Point::new(10.0, 10.0)];
let far = Point::new(10_000_000.0, 10.0);
assert!(push_dabs(&mut dabs, Some(Point::new(10.0, 10.0)), far, 2.0));
assert_eq!(dabs.len(), 2, "an extreme segment must not allocate a million intermediate dabs");
assert_eq!(dabs.last(), Some(&far));
// Ordinary feathered-brush paths still get overlapping dabs and their endpoint.
let mut regular = vec![Point::new(10.0, 10.0)];
let end = Point::new(30.0, 10.0);
assert!(push_dabs(&mut regular, Some(Point::new(10.0, 10.0)), end, 4.0));
assert_eq!(regular.len(), 21);
assert_eq!(regular.last(), Some(&end));
assert!(regular.windows(2).all(|pair| pair[0].dist(pair[1]) <= dab_spacing(4.0)));
}
/// A temporary output buffer must spend the same stroke budget as a retained one.
#[test]
fn incremental_dab_buffers_share_the_stroke_budget() {
+3 -1
View File
@@ -206,7 +206,9 @@ pub fn window_for_reads_checked(
// refine the stroke over a couple of radii: each dab matters to pixels within 2.5 radii
let mut dab_boxes: Vec<[f64; 4]> = Vec::new();
for st in autos {
let dabs = crate::masks::brush_dabs(st, frame, full_w, full_h);
// One more than the window takes, so a stroke past it is still recognised as past it
// without the other 1.8 million dabs being built first (#1520).
let dabs = crate::masks::brush_dabs_within(st, frame, full_w, full_h, MAX_DABS_CONSIDERED + 1);
let r = dabs.r;
if dabs.dabs.len() > MAX_DABS_CONSIDERED {
continue;
+14
View File
@@ -156,6 +156,18 @@ fn windows_outside_or_degenerate_are_clamped() {
}
}
/// A model's segmentation on a `side²` grid: an ellipse in the middle of the photo.
fn middle_seg(side: usize) -> lightcraft_develop::SegMask {
let logits: Vec<f32> = (0..side * side)
.map(|i| {
let (x, y) = (((i % side) as f32 + 0.5) / side as f32, ((i / side) as f32 + 0.5) / side as f32);
let r = (((x - 0.5) / 0.3).powi(2) + ((y - 0.5) / 0.25).powi(2)).sqrt();
(12.0 * (1.0 - r) * 4.0).clamp(-12.0, 12.0)
})
.collect();
lightcraft_develop::SegMask::from_logits(side, &logits)
}
/// A textured, noisy source: what noise reduction and edge refinement act on.
fn noisy_scene() -> Rgb32f {
let base = scene();
@@ -216,6 +228,8 @@ fn a_mask_overlay_window_matches_the_whole_render() {
("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0),
("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0),
("sky", MaskShape::Sky, 0.0),
// a model's grid, enlarged along the photo's edges (issue #3272)
("prompt", MaskShape::Prompt { text: "middle".into(), seg: Some(middle_seg(32)), detail: vec![], edge: 0.0 }, 0.0),
];
for (name, shape, refine) in shapes {
let mut s = DevelopSettings::default();
+34
View File
@@ -149,6 +149,7 @@ impl Sam3 {
return Err(Error::Missing(dir.to_path_buf()));
}
let weights = weights::Weights::open(&dir.join(WEIGHTS_FILE))?;
hugging_face_format(&weights)?;
let backbone = vit::Vit::new(Self::vb(&weights, &device).pp("detector_model.vision_encoder.backbone"))?;
Ok(Self { dir: dir.to_path_buf(), device, weights, backbone, tracker_neck: None, tracker: None, detector: None, tokenizer: None })
}
@@ -260,6 +261,20 @@ impl Encoded {
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Meta's original checkpoint (`sam3.pt`, or a `sam3.safetensors` conversion of it as on community
/// mirrors) names its tensors differently: say so, rather than name the first tensor it lacks (#3378).
fn hugging_face_format(weights: &weights::Weights) -> Result<()> {
let first = "detector_model.vision_encoder.backbone.embeddings.patch_embeddings.projection.weight";
if !weights.contains(first) && (weights.has_prefix("detector.backbone.") || weights.has_prefix("tracker.")) {
return Err(Error::Model(format!(
"{WEIGHTS_FILE} is Meta's original checkpoint (sam3.pt, or a sam3.safetensors conversion of it as on community mirrors); \
LightCraft needs the Hugging Face format: {WEIGHTS_FILE} from facebook/sam3"
)));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
@@ -273,3 +288,22 @@ pub use detector::Output as DetectorOutput;
pub fn sync(model: &Sam3) -> Result<()> {
Ok(model.device.synchronize()?)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn meta_original_checkpoint_is_named_as_such() {
// #3378: a community mirror's `sam3.safetensors` (Meta's key names, C64 RoPE tables) saved as model.safetensors
let dir = std::env::temp_dir().join(format!("lc-sam3-original-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let header = r#"{"detector.backbone.vision_backbone.trunk.blocks.0.attn.freqs_cis":{"dtype":"C64","shape":[2],"data_offsets":[0,16]},"tracker.sam_mask_decoder.iou_token.weight":{"dtype":"F32","shape":[2],"data_offsets":[16,24]}}"#;
std::fs::rename(weights::tests::write("original", header, 24), dir.join(WEIGHTS_FILE)).unwrap();
std::fs::write(dir.join("vocab.json"), "{}").unwrap();
std::fs::write(dir.join("merges.txt"), "").unwrap();
let err = Sam3::load_cpu(&dir).err().unwrap().to_string();
assert!(err.contains("Meta's original checkpoint") && err.contains("facebook/sam3"), "{err}");
let _ = std::fs::remove_dir_all(dir);
}
}
+38 -8
View File
@@ -16,6 +16,9 @@ use candle_nn::var_builder::SimpleBackend;
use crate::{Error, Result};
struct Entry {
/// `Err`: the header's name for a dtype LightCraft doesn't read (Meta's original checkpoint
/// stores its RoPE tables as C64); the tensor is listed, and reading it fails
dtype: std::result::Result<DType, String>,
shape: Vec<usize>,
start: u64,
end: u64,
@@ -48,8 +51,8 @@ impl Weights {
continue;
}
let dtype = match v.get("dtype").and_then(|d| d.as_str()) {
Some("F32") => DType::F32,
other => return Err(Error::Model(format!("{name}: unsupported dtype {other:?} (the SAM 3 checkpoint is F32)"))),
Some("F32") => Ok(DType::F32),
other => Err(format!("{other:?}")),
};
let shape: Vec<usize> = v
.get("shape")
@@ -59,15 +62,18 @@ impl Weights {
let offsets: Vec<u64> =
v.get("data_offsets").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
let [start, end] = offsets[..] else { return Err(Error::Model(format!("{name}: bad data_offsets"))) };
let want = shape.iter().try_fold(dtype.size_in_bytes(), |a, d| a.checked_mul(*d));
if end.checked_sub(start) != want.map(|w| w as u64) {
return Err(Error::Model(format!("{name}: size does not match its shape")));
// an unreadable tensor's size isn't checked: its bytes are never read
if let Ok(dtype) = dtype {
let want = shape.iter().try_fold(dtype.size_in_bytes(), |a, d| a.checked_mul(*d));
if end.checked_sub(start) != want.map(|w| w as u64) {
return Err(Error::Model(format!("{name}: size does not match its shape")));
}
}
// the bytes must be in the file (a damaged or truncated download fails here, not later)
if (8 + n).checked_add(end).is_none_or(|e| e > file_len) {
return Err(Error::Model(format!("{}: tensor {name} lies outside the file (incomplete or damaged download?)", path.display())));
}
entries.insert(name, Entry { shape, start, end });
entries.insert(name, Entry { dtype, shape, start, end });
}
Ok(Arc::new(Self { file: Mutex::new(file), entries, data_start: 8 + n }))
}
@@ -76,6 +82,11 @@ impl Weights {
self.entries.contains_key(name)
}
/// Whether any tensor's name starts with `prefix`.
pub fn has_prefix(&self, prefix: &str) -> bool {
self.entries.keys().any(|k| k.starts_with(prefix))
}
/// The shape stored in the header for `name`, without reading its data.
pub fn shape(&self, name: &str) -> Option<&[usize]> {
self.entries.get(name).map(|e| e.shape.as_slice())
@@ -84,6 +95,9 @@ impl Weights {
/// Tensor `name` as stored, on the CPU.
pub fn read(&self, name: &str) -> Result<Tensor> {
let e = self.entries.get(name).ok_or_else(|| Error::Model(format!("missing tensor `{name}`")))?;
if let Err(dtype) = &e.dtype {
return Err(Error::Model(format!("{name}: unsupported dtype {dtype} (the SAM 3 checkpoint is F32)")));
}
// checked against the file's length in `open`
let len = usize::try_from(e.end.saturating_sub(e.start)).map_err(|_| Error::Model(format!("{name}: too large")))?;
let mut bytes = vec![0u8; len];
@@ -122,10 +136,10 @@ impl SimpleBackend for Backend {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
fn write(name: &str, header: &str, data_len: usize) -> std::path::PathBuf {
pub(crate) fn write(name: &str, header: &str, data_len: usize) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("lc-weights-{name}-{}.safetensors", std::process::id()));
let mut b = (header.len() as u64).to_le_bytes().to_vec();
b.extend_from_slice(header.as_bytes());
@@ -147,6 +161,22 @@ mod tests {
let _ = std::fs::remove_file(p);
}
#[test]
fn a_tensor_of_another_dtype_fails_when_read_not_when_opened() {
// Meta's original checkpoint stores RoPE tables as C64 next to its F32 weights (#3378)
let p = write(
"c64",
r#"{"freqs_cis":{"dtype":"C64","shape":[2],"data_offsets":[0,16]},"a":{"dtype":"F32","shape":[2],"data_offsets":[16,24]}}"#,
24,
);
let w = Weights::open(&p).unwrap();
assert!(w.contains("freqs_cis"));
let err = w.read("freqs_cis").unwrap_err().to_string();
assert!(err.contains("unsupported dtype") && err.contains("C64"), "{err}");
assert_eq!(w.read("a").unwrap().dims(), &[2]);
let _ = std::fs::remove_file(p);
}
#[test]
fn tensors_must_lie_inside_the_file() {
let ok = write("ok", r#"{"a":{"dtype":"F32","shape":[2,2],"data_offsets":[0,16]}}"#, 16);
+1
View File
@@ -2254,6 +2254,7 @@
"Select none": "Zrušit celý výběr",
"Select all": "Vybrat vše",
"Visualize HDR": "Zobrazit rozsah HDR",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zabránit blikání G-SYNC/FreeSync (platí po dalším spuštění)",
"Add a Face Recognition Model": "Přidat model rozpoznávání obličejů",
"Choose Monitor Profile": "Vybrat profil monitoru",
"Display profile not used": "Profil displeje nebyl použit",
+2 -1
View File
@@ -3469,5 +3469,6 @@
"Set up name suggestions…": "Namensvorschläge einrichten…",
"Top bar": "Obere Leiste",
"Tool strip": "Werkzeugleiste",
"Bottom bar": "Untere Leiste"
"Bottom bar": "Untere Leiste",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "G-SYNC-/FreeSync-Flimmern verhindern (gilt ab dem nächsten Start)"
}
+2 -1
View File
@@ -2389,5 +2389,6 @@
"Set up name suggestions…": "Configurar sugerencias de nombres…",
"Top bar": "Barra superior",
"Tool strip": "Barra de herramientas",
"Bottom bar": "Barra inferior"
"Bottom bar": "Barra inferior",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evitar el parpadeo de G-SYNC/FreeSync (se aplica al reiniciar)"
}
+2 -1
View File
@@ -3572,5 +3572,6 @@
"⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-clic et la ligne passe progressivement des anciennes valeurs aux nouvelles. La ligne entière forme une seule étape d'annulation.",
"Top bar": "Barre supérieure",
"Tool strip": "Barre d’outils",
"Bottom bar": "Barre inférieure"
"Bottom bar": "Barre inférieure",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Empêcher le scintillement G-SYNC/FreeSync (au prochain démarrage)"
}
+1
View File
@@ -2317,6 +2317,7 @@
"This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.",
"This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)",
"Unnamed Faces": "Volti senza nome",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evita lo sfarfallio G-SYNC/FreeSync (dal prossimo avvio)",
"smart album value|No": "No",
"smart album value|Yes": "Sì",
"Add Print Cell": "Aggiungi cella di stampa",
+2 -1
View File
@@ -2387,5 +2387,6 @@
"Set up name suggestions…": "名前の候補を設定…",
"Top bar": "上部バー",
"Tool strip": "ツールバー",
"Bottom bar": "下部バー"
"Bottom bar": "下部バー",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "G-SYNC/FreeSyncのちらつきを防ぐ(次回起動時に適用)"
}
+2 -1
View File
@@ -3658,5 +3658,6 @@
"Set up name suggestions…": "სახელების შეთავაზებების დაყენება…",
"Top bar": "ზედა ზოლი",
"Tool strip": "ხელსაწყოების ზოლი",
"Bottom bar": "ქვედა ზოლი"
"Bottom bar": "ქვედა ზოლი",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "G-SYNC/FreeSync-ის ციმციმის თავიდან აცილება (ამოქმედდება შემდეგი გაშვებისას)"
}
+1
View File
@@ -2174,6 +2174,7 @@
"Unnamed Faces": "Nienazwane twarze",
"Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)",
"not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zapobiegaj migotaniu G-SYNC/FreeSync (po ponownym uruchomieniu)",
"Set Folder Color Label": "Ustaw etykietę kolorystyczną folderu",
"Refresh View": "Odśwież widok",
"Hold View Until Refreshed": "Wstrzymaj widok do odświeżenia",
+2 -1
View File
@@ -2233,5 +2233,6 @@
"Set up name suggestions…": "Configurar sugestões de nomes…",
"Top bar": "Barra superior",
"Tool strip": "Barra de ferramentas",
"Bottom bar": "Barra inferior"
"Bottom bar": "Barra inferior",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evitar cintilação de G-SYNC/FreeSync (no próximo início)"
}
+2 -1
View File
@@ -2595,5 +2595,6 @@
"Set up name suggestions…": "Настроить подсказки имён…",
"Top bar": "Верхняя панель",
"Tool strip": "Панель инструментов",
"Bottom bar": "Нижняя панель"
"Bottom bar": "Нижняя панель",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Предотвращать мерцание G-SYNC/FreeSync (после перезапуска)"
}
+2 -1
View File
@@ -2385,5 +2385,6 @@
"Set up name suggestions…": "Налаштувати підказки імен…",
"Top bar": "Верхня панель",
"Tool strip": "Панель інструментів",
"Bottom bar": "Нижня панель"
"Bottom bar": "Нижня панель",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Запобігати мерехтінню G-SYNC/FreeSync (після перезапуску)"
}
+1
View File
@@ -2195,6 +2195,7 @@
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。",
"Checking the GPU for faces": "正在检查用于人脸的 GPU",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 闪烁(下次启动时生效)",
"Include selected photos": "包含所选照片",
"Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。",
"This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。",
+2 -1
View File
@@ -2388,5 +2388,6 @@
"Set up name suggestions…": "設定姓名建議…",
"Top bar": "頂部列",
"Tool strip": "工具列",
"Bottom bar": "底部列"
"Bottom bar": "底部列",
"Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 閃爍(下次啟動時生效)"
}
+4
View File
@@ -232,6 +232,10 @@ impl LightcraftApp {
// menu), it must not keep a search that no longer applies (issue #1648)
self.ui.search.clear();
}
if r.is_ok() && id == "mask.addComponent" && params.get("kind").and_then(Value::as_str) != Some("colorRange") && self.ui.tool == "colorRange"
{
self.ui.tool.clear();
}
if r.is_ok() && id == "mask.adjust" {
// Judge local adjustments on the photo, without the selection overlay obscuring them.
// Keep it hidden after release; O / the overlay eye can show it again.
+1 -1
View File
@@ -34,7 +34,7 @@ impl LightcraftApp {
pub(super) fn apply_settings(&mut self, ctx: &egui::Context) {
if self.gpu_applied != Some(self.ui.settings.gpu) {
self.gpu_applied = Some(self.ui.settings.gpu);
let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu}));
let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu, "wait": false}));
// GPU device + kernels off the UI thread, once the window is up and only when GPU
// rendering is on: a broken driver must not keep the window from appearing (issue #136)
if self.ui.settings.gpu {
@@ -8,8 +8,18 @@ use super::{CACHE_SIZES, LABEL_W, LightcraftApp, PREVIEW_LIMITS, RichText, Token
pub(super) fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use lightcraft_engine::gpu;
heading(ui, t, crate::i18n::tr("Rendering"));
#[cfg(target_os = "windows")]
check(ui, "settings.composeWindow", &mut app.ui.settings.compose_window, "Prevent G-SYNC/FreeSync flicker (applies at next launch)");
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
let status = if !gpu::available() {
// `available` would wait for a device still being created (issue #136)
let status = if gpu::enabled() && !gpu::ready() {
// the device is created on another thread, which doesn't wake the UI when it's done;
// make sure that thread runs (cheap and idempotent: a start lost to an off/on switch
// can't leave "starting" for good)
gpu::warm_up();
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
"GPU: starting…".to_string()
} else if !gpu::available() {
match gpu::unavailable_reason() {
Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why),
None => "No usable GPU found: rendering on the CPU".to_string(),
+87 -9
View File
@@ -187,16 +187,36 @@ pub(super) fn region_overlay(
// ------------------------------------------------------------------------ masks
/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo.
/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo: an
/// Object at its first click, a Describe mask on what its segmentation found (issue #3049).
pub(crate) fn component_pin(shape: &MaskShape) -> Option<Point> {
match shape {
MaskShape::Radial { center, .. } => Some(*center),
MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)),
MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(),
MaskShape::Object { hint, seg, .. } => hint.first().copied().or_else(|| seg.as_ref().and_then(seg_center)),
MaskShape::Prompt { seg, .. } => seg.as_ref().and_then(seg_center),
_ => None,
}
}
/// The middle of what a segmentation selects (the mean of its cells inside the mask), else of the
/// part of the image it covers; `None` when it is damaged.
fn seg_center(seg: &lightcraft_develop::SegMask) -> Option<Point> {
let [x0, y0, x1, y1] = seg.bounds()?;
let logits = seg.logits()?;
let side = seg.side as usize;
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0usize);
for (i, _) in logits.iter().enumerate().filter(|(_, l)| **l > 0.0) {
sx += (i % side) as f64 + 0.5;
sy += (i / side) as f64 + 0.5;
n += 1;
}
let cells = n as f64 * side as f64;
let (u, v) = if n == 0 { (0.5, 0.5) } else { (sx / cells, sy / cells) };
Some(Point::new(x0 + u * (x1 - x0), y0 + v * (y1 - y0)))
}
/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`.
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape {
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
@@ -336,11 +356,11 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg
return;
}
if app.ui.tool == "object" {
object_tool(app, ui, resp, map);
object_tool(app, ui, resp, map, d, &grips);
return;
}
if app.ui.tool == "brush" {
brush_tool(app, ui, resp, map, p, clip);
brush_tool(app, ui, resp, map, p, clip, d);
return;
}
grip_gestures(app, ui, resp, map, d, &grips);
@@ -437,14 +457,52 @@ fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &Dev
}
}
/// Object tool: a click includes what's under it, ⌥-click leaves it out.
fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) {
/// The mask whose pin is under `q` (within the 10 px [`grip_gestures`] grabs at), not counting
/// the `active` mask's own pins.
fn other_pin_at(grips: &[(u32, usize, u8, Pos2)], active: Option<u32>, q: Pos2) -> Option<u32> {
grips
.iter()
.filter(|g| Some(g.0) != active && g.2 == 0 && g.3.distance(q) < 10.0)
.min_by(|a, b| a.3.distance(q).total_cmp(&b.3.distance(q)))
.map(|g| g.0)
}
/// Whether `q` is on one of the `active` mask's own pins (within the same 10 px).
fn own_pin_at(grips: &[(u32, usize, u8, Pos2)], active: Option<u32>, q: Pos2) -> bool {
active.is_some_and(|a| grips.iter().any(|g| g.0 == a && g.2 == 0 && g.3.distance(q) < 10.0))
}
/// Object tool: a click includes what's under it, ⌥-click leaves it out. A click on another
/// mask's pin selects that mask instead of adding a click to the selected one (issue #3049);
/// the tool then stays on only if the newly selected mask has an Object selection to refine. A
/// click on the selected mask's own pin does nothing: its first click is already there, and a
/// near-copy of it would use up one of the mask's limited Object clicks.
fn object_tool(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
resp: &egui::Response,
map: &CanvasMap,
d: &DevelopSettings,
grips: &[(u32, usize, u8, Pos2)],
) {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
if let Some(mid) = other_pin_at(grips, app.session.active_mask, q) {
let _ = app.run("mask.select", json!({"id": mid}));
let has_object =
d.masks.iter().find(|m| m.id == mid).is_some_and(|m| m.components.iter().any(|c| matches!(c.shape, MaskShape::Object { .. })));
if !has_object {
app.ui.tool.clear();
}
return;
}
if own_pin_at(grips, app.session.active_mask, q) {
return;
}
let n = map.norm(q);
let exclude = ui.input(|i| i.modifiers.alt);
match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) {
@@ -459,7 +517,16 @@ fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
}
/// Brush tool: the cursor, the stroke being painted and its commit.
fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) {
fn brush_tool(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
resp: &egui::Response,
map: &CanvasMap,
p: &egui::Painter,
clip: Rect,
d: &DevelopSettings,
) {
let active = app.session.active_mask;
let long = (map.rect.width().max(map.rect.height())) as f64;
let r = (app.ui.brush_size as f64 * long) as f32;
if let Some(h) = resp.hover_pos() {
@@ -487,10 +554,21 @@ fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response,
}
}
// the stroke being painted (the render shows it once it's committed), with the committed
// stroke's dab spacing, feather and flow (issue #517)
if let Some(Gesture::Brush { points, pressure }) = &app.gesture {
// stroke's dab spacing, feather and flow (issue #517), tinted only when the overlay will
// show it: with the overlay hidden (O, or a mask slider hid it) or a colour overlay at
// opacity 0, the photo stays untinted while painting, so dodging and burning is judged on
// the photo (issue #2549)
use lightcraft_pipeline::MaskView;
let colored = matches!(MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), MaskView::Color | MaskView::ColorOnBw);
// the all-masks overlay ([`view_overlay`]) shows only visible masks, and gives way to the
// selected mask's when none has anything in it (a stroke goes into the selected mask, or a new one)
let visible = active.and_then(|id| d.masks.iter().find(|m| m.id == id)).is_none_or(|m| m.visible);
let all = app.ui.mask_overlay_all && (visible || d.masks.iter().any(|m| m.visible && !m.components.is_empty()));
let shown = if all { visible } else { app.ui.mask_overlay } && !(colored && app.ui.mask_overlay_opacity <= 0.0);
if shown && let Some(Gesture::Brush { points, pressure }) = &app.gesture {
let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect();
let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
// opacity 0 only gets here in the image views, which ignore it: a fixed tint there
let opacity = if app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
let params = crate::brush_live::LiveParams {
clip,
scale: ui.ctx().pixels_per_point(),
+3
View File
@@ -136,6 +136,8 @@ pub struct AppSettings {
pub import_dropped_directly: bool,
/// GPU rendering allowed (`app.gpu`).
pub gpu: bool,
/// Use Windows DirectComposition to prevent VRR flicker; applies at next launch.
pub compose_window: bool,
/// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is
/// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old
/// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323).
@@ -220,6 +222,7 @@ impl Default for AppSettings {
close_dialogs_outside: false,
import_dropped_directly: false,
gpu: true,
compose_window: false,
preview_limit: 0,
external_editor: String::new(),
external_file: EditFile::default(),
+246
View File
@@ -363,6 +363,122 @@ fn mask_overlay_keys_and_pins() {
h.settle(SETTLE);
}
/// Issue #3049: Show Pins draws a pin for an Object mask (at its first click) and a Describe
/// mask (on what its segmentation found). Clicking one selects its mask; dragging it leaves the
/// mask and the History alone, since neither can be moved.
#[test]
fn object_and_describe_masks_show_pins_that_select_but_do_not_move() {
use lightcraft_develop::SegMask;
use lightcraft_geom::Point;
let mut h = detail("panel.masking");
// a 16 × 16 segmentation found in columns 2..=5, rows 10..=13: centred on (0.25, 0.75)
let blob: Vec<f32> = (0..16 * 16).map(|i| if (2..=5).contains(&(i % 16)) && (10..=13).contains(&(i / 16)) { 8.0 } else { -8.0 }).collect();
let seg = SegMask::from_logits(16, &blob);
let object = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 };
let prompt = MaskShape::Prompt { text: "the boat".into(), seg: Some(seg), detail: vec![], edge: 0.0 };
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1}));
exec(&mut h, "mask.update", json!({"id": 1, "shape": object}));
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1}));
exec(&mut h, "mask.update", json!({"id": 2, "shape": prompt}));
assert_eq!(h.app.session.active_mask, Some(2));
// pins shown without the pointer over the photo
exec(&mut h, "view.toolOverlay", json!({"mode": "always"}));
h.settle(SETTLE);
let pin_at = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| r.center());
let (Some(object_pin), Some(prompt_pin)) = (pin_at(&h, "maskPin:1:0"), pin_at(&h, "maskPin:2:0")) else {
panic!("both masks have a pin: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("maskPin:")).collect::<Vec<_>>())
};
// the Describe pin sits on what was found, right of and below the Object's
assert!(prompt_pin.x < object_pin.x && prompt_pin.y > object_pin.y, "{object_pin:?} {prompt_pin:?}");
// clicking a pin selects its mask
pointer(&mut h, json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "up", "x": 0.6, "y": 0.5}]));
assert_eq!(h.app.session.active_mask, Some(1));
pointer(&mut h, json!([{"kind": "down", "x": 0.25, "y": 0.75}, {"kind": "up", "x": 0.25, "y": 0.75}]));
assert_eq!(h.app.session.active_mask, Some(2));
// dragging one changes nothing and adds no History step
let id = h.app.session.active().expect("active photo");
let before = h.app.session.catalog.photo(id).expect("photo").clone();
pointer(
&mut h,
json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "drag", "x": 0.65, "y": 0.55}, {"kind": "drag", "x": 0.8, "y": 0.7}, {"kind": "up", "x": 0.8, "y": 0.7}]),
);
assert_eq!(h.app.session.active_mask, Some(1), "the press still selects the mask");
let after = h.app.session.catalog.photo(id).expect("photo").clone();
assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()));
h.settle(SETTLE);
}
/// Issue #3049: with the Object tool on, a click on another mask's pin selects that mask instead
/// of adding an Object click to the selected one. The tool stays on for an Object mask (its
/// clicks refine it) and goes off for a mask with no Object selection to refine.
#[test]
fn the_object_tool_selects_the_mask_whose_pin_is_clicked() {
use lightcraft_geom::Point;
let mut h = detail("panel.masking");
// no SAM 3 model here: a stray Object click would offer the download or show an error
let dir = std::env::temp_dir().join(format!("lc-ui-pin-object-{}", std::process::id()));
h.app.session.segmenter.dir = Some(dir.clone());
h.app.session.segmenter.mirrors_file = Some(dir.join("none.txt"));
let object = |at: Point| MaskShape::Object { hint: vec![at], exclude: vec![], seg: None, detail: vec![], edge: 0.0 };
let radial = json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1});
exec(&mut h, "mask.add", radial.clone());
exec(&mut h, "mask.update", json!({"id": 1, "shape": object(Point::new(0.6, 0.5))}));
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.2, 0.8], "rx": 0.05, "ry": 0.05}));
exec(&mut h, "mask.add", radial);
exec(&mut h, "mask.update", json!({"id": 3, "shape": object(Point::new(0.3, 0.3))}));
assert_eq!(h.app.session.active_mask, Some(3));
exec(&mut h, "view.toolOverlay", json!({"mode": "always"}));
h.app.ui.tool = "object".into();
h.settle(SETTLE);
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone());
let before = develop(&h).masks;
// the Object mask's pin: it is selected, its clicks and the selected mask's stay as they were
pointer(&mut h, json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "up", "x": 0.6, "y": 0.5}]));
h.settle(SETTLE);
assert_eq!(h.app.session.active_mask, Some(1), "the pinned mask is selected");
assert_eq!(develop(&h).masks, before, "no Object click was added to any mask");
assert_eq!(h.app.ui.dialog, None, "no Object click was tried");
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.clone()).filter(|t| Some(t) != toast.as_ref()), None, "no error");
assert_eq!(h.app.ui.tool, "object", "the tool stays on to refine the Object mask");
// the Radial mask's pin: selected, and the Object tool goes off (nothing for it to refine)
pointer(&mut h, json!([{"kind": "down", "x": 0.2, "y": 0.8}, {"kind": "up", "x": 0.2, "y": 0.8}]));
h.settle(SETTLE);
assert_eq!(h.app.session.active_mask, Some(2));
assert_eq!(develop(&h).masks, before);
assert_eq!(h.app.ui.tool, "", "the Object tool is off");
assert_eq!(h.app.ui.dialog, None);
}
/// Issue #3049: with the Object tool on, a click on the selected Object mask's own pin adds no
/// near-copy of its first click (which would use up one of its limited Object clicks): its
/// clicks stay as they were, it stays selected and no error is shown.
#[test]
fn the_object_tool_adds_no_click_on_the_selected_masks_own_pin() {
use lightcraft_geom::Point;
let mut h = detail("panel.masking");
// no SAM 3 model here: a stray Object click would offer the download or show an error
let dir = std::env::temp_dir().join(format!("lc-ui-own-pin-object-{}", std::process::id()));
h.app.session.segmenter.dir = Some(dir.clone());
h.app.session.segmenter.mirrors_file = Some(dir.join("none.txt"));
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1}));
let object = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: None, detail: vec![], edge: 0.0 };
exec(&mut h, "mask.update", json!({"id": 1, "shape": object}));
assert_eq!(h.app.session.active_mask, Some(1));
exec(&mut h, "view.toolOverlay", json!({"mode": "always"}));
h.app.ui.tool = "object".into();
h.settle(SETTLE);
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone());
let before = develop(&h).masks;
// a little off the pin's centre, as a hand clicks it
pointer(&mut h, json!([{"kind": "down", "x": 0.601, "y": 0.501}, {"kind": "up", "x": 0.601, "y": 0.501}]));
h.settle(SETTLE);
assert_eq!(develop(&h).masks, before, "no Object click was added");
assert_eq!(h.app.session.active_mask, Some(1), "the mask stays selected");
assert_eq!(h.app.ui.dialog, None, "no Object click was tried");
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.clone()).filter(|t| Some(t) != toast.as_ref()), None, "no error");
assert_eq!(h.app.ui.tool, "object", "the tool stays on");
}
/// #2210: Ctrl+Shift+O (the all-masks overlay) draws the white outline of every visible
/// Radial/Linear mask, not just the selected one — but only the selected mask registers
/// `maskHandle:` elements, so nothing else becomes draggable.
@@ -1698,6 +1814,28 @@ fn assert_close(a: &serde_json::Value, b: &serde_json::Value) {
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 0.05), "{a:?} vs {b:?}");
}
#[test]
fn adding_radial_component_disarms_color_range_picker() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "colorRange"}));
h.app.ui.tool.clear();
click(&mut h, "button:colorPick0");
brush_at(&mut h, 0.25, 0.5);
assert_eq!(color_samples(&h).as_array().map(Vec::len), Some(1));
exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "intersect", "center": [0.5, 0.5], "rx": 0.22, "ry": 0.22}));
assert!(h.app.ui.tool.is_empty(), "adding a radial component disarms colour picking");
let widget = "maskHandle:1:1:1";
let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1;
let image = h.app.image_rect.unwrap();
let q = rect.center();
let (x, y) = ((q.x - image.left()) / image.width(), (q.y - image.top()) / image.height());
pointer(&mut h, json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.05, "y": y}, {"kind": "up", "x": x + 0.05, "y": y}]));
let MaskShape::Radial { rx, .. } = develop(&h).masks[0].components[1].shape else { panic!("radial") };
assert!(rx > 0.22, "dragging a radial handle resizes the component: {rx}");
}
/// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel
/// open the loupe's clicks went to the mask overlay, so no sample was ever taken.
#[test]
@@ -1883,6 +2021,114 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() {
}
}
/// What the photo shows along the centreline of [`hold_fast_stroke`]'s stroke at height `y`, away
/// from the cursor ring at its end.
fn stroke_centreline(h: &Headless, y: f32) -> Vec<[f32; 3]> {
let im = h.paint();
let img = h.app.image_rect.expect("the photo is on screen");
let ppp = h.pixels_per_point;
(1..=74)
.map(|i| {
let x = 0.17 + (0.85 - 0.17) * i as f32 / 80.0;
let (sx, sy) = (((img.left() + x * img.width()) * ppp) as usize, ((img.top() + y * img.height()) * ppp) as usize);
let c = im.pixels[sy * im.size[0] + sx];
[c.r() as f32, c.g() as f32, c.b() as f32]
})
.collect()
}
/// The fast stroke of the #517 test at height `y`, with the button still held: its centreline
/// before painting and while painting.
fn hold_fast_stroke(h: &mut Headless, y: f32) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
pointer(h, json!([{"kind": "move", "x": 0.02, "y": 0.02}]));
h.settle(SETTLE);
let photo = stroke_centreline(h, y);
pointer(
h,
json!([{"kind": "down", "x": 0.15, "y": y}, {"kind": "drag", "x": 0.17, "y": y}, {"kind": "drag", "x": 0.5, "y": y}, {"kind": "drag", "x": 0.85, "y": y}]),
);
assert!(matches!(h.app.gesture, Some(crate::panels::detail::Gesture::Brush { .. })), "still painting");
(photo, stroke_centreline(h, y))
}
/// The centreline points (photo, held) where the stroke being painted changes the photo.
fn tinted(photo: &[[f32; 3]], held: &[[f32; 3]]) -> Vec<([f32; 3], [f32; 3])> {
photo.iter().zip(held).filter(|(p, l)| (0..3).any(|k| (p[k] - l[k]).abs() > 6.0)).map(|(p, l)| (*p, *l)).collect()
}
/// Issue #2549: with the mask overlay hidden (a mask slider hid it, or O) or a colour overlay at
/// opacity 0, the stroke being painted doesn't tint the photo either, so dodging and burning is
/// judged on the photo; the effect shows once the stroke is committed. The all-masks overlay still
/// shows it, as it will the committed stroke.
#[test]
fn a_brush_stroke_is_not_tinted_while_the_mask_overlay_is_hidden() {
let mut h = detail("panel.masking");
h.settle(SETTLE);
exec(&mut h, "tool.brush", json!({"new": true}));
let r = h.request("ui.set", json!({"brushSize": 0.03, "brushFeather": 50.0, "brushFlow": 100.0}), T);
assert_eq!(r["ok"], true, "{r}");
// dodging: brighten the new, still empty mask first (which hides the overlay), then paint
exec(&mut h, "mask.adjust", json!({"values": {"exposure": 1.0}}));
assert!(!h.app.ui.mask_overlay, "a mask slider hides the overlay");
let (photo, held) = hold_fast_stroke(&mut h, 0.3);
let t = tinted(&photo, &held);
assert!(t.is_empty(), "the hidden overlay tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first());
assert!(h.app.brush_live.is_none(), "nothing is drawn over the photo while painting");
pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.3}]));
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert!(strokes[0].points.len() >= 3, "{strokes:?}");
assert!(!h.app.ui.mask_overlay, "painting leaves the overlay hidden");
// committed: the stroke brightens the photo
h.settle(SETTLE);
let luma = |c: &[[f32; 3]]| c.iter().map(|p| p[0] + p[1] + p[2]).sum::<f32>() / c.len() as f32;
let done = stroke_centreline(&h, 0.3);
assert!(luma(&done) > luma(&photo) + 30.0, "committed {} vs before {}", luma(&done), luma(&photo));
// O shows the overlay again, but a colour overlay at opacity 0 shows nothing of the committed
// strokes, nor of the one being painted
let r = h.request("ui.key", json!({"key": "o"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.mask_overlay, "O shows the overlay");
exec(&mut h, "view.maskOverlayColor", json!({"opacity": 0}));
let (photo, held) = hold_fast_stroke(&mut h, 0.45);
let t = tinted(&photo, &held);
assert!(t.is_empty(), "an overlay at opacity 0 tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first());
pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.45}]));
// O hides it again, at its usual opacity
exec(&mut h, "view.maskOverlayColor", json!({"opacity": 50}));
let r = h.request("ui.key", json!({"key": "o"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.ui.mask_overlay, "O hides the overlay");
let (photo, held) = hold_fast_stroke(&mut h, 0.6);
let t = tinted(&photo, &held);
assert!(t.is_empty(), "the overlay hidden with O tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first());
pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.6}]));
// the all-masks overlay shows every mask's region, so the stroke being painted is tinted as it
// will be once committed
exec(&mut h, "view.maskOverlayAll", json!({}));
assert!(h.app.ui.mask_overlay_all && !h.app.ui.mask_overlay);
let (photo, held) = hold_fast_stroke(&mut h, 0.75);
let [r0, g0, b0] = h.app.ui.mask_overlay_color.map(|v| v as f32);
let op = h.app.ui.mask_overlay_opacity / 100.0;
for (p, got) in photo.iter().zip(&held) {
let want = [p[0] + (r0 - p[0]) * op, p[1] + (g0 - p[1]) * op, p[2] + (b0 - p[2]) * op];
assert!((0..3).all(|k| (got[k] - want[k]).abs() <= 12.0), "the all-masks overlay tints the stroke: {got:?} vs {want:?}");
}
pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.75}]));
assert_eq!(develop(&h).masks.len(), 1, "every stroke went into the one mask");
// a hidden mask isn't in the all-masks overlay: nor is the stroke being painted into it
let id = develop(&h).masks[0].id;
exec(&mut h, "mask.visible", json!({"id": id, "visible": false}));
let (photo, held) = hold_fast_stroke(&mut h, 0.9);
let t = tinted(&photo, &held);
assert!(t.is_empty(), "the all-masks overlay tints a stroke into a hidden mask: {} of 74 points, first {:?}", t.len(), t.first());
pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.9}]));
}
#[test]
fn effect_dropdown_applies_saves_and_shows_the_matching_effect() {
let mut h = detail("panel.masking");
+13 -1
View File
@@ -27,6 +27,16 @@ In the Masking panel (M):
the people out of a sky mask; − ▸ Object then clicks remove one object.
- **Edge** (per selection, −100…100): below 0 a crisper border, above 0 a feathered one (up to 2 %
of the long edge, so previews and exports match).
- **Edges follow the photo** (issue #3272): the model's grid (288² over the whole photo) has
~20 px cells on a 24 MP export, so a plain enlargement put the mask's edge up to a cell off the
subject's and a raised Exposure drew a bright outline around it. Where the grid is coarser than
the output, the mask's logits are guided-filtered by the photo's log luminance (the fast guided
filter, a window of 1.5 grid cells, so a preview and an export put the edge in the same place):
near the edge the selection follows the photo's tones, ≈ 0 a pixel or two outside a subject that
stands out from its background and ≈ 1 inside. Pixels whose surroundings are all in or all out,
and edges the photo has no contrast across, keep the plain enlargement. Refine Edges then works
on the combined mask and finds these edges in place. Cost on an M2 Pro, per AI selection: +22 ms
and +12 MB at 24 MP, +46 ms and +17 MB at 60 MP, +7.5 ms on a 2.7 MP preview.
- **Detail:** about a second after the last click (or right after a description), the photo
around the selection is analyzed again, zoomed in, from a sharper source (~5 s on an M4 Pro,
in the background); the result is kept as a high-resolution patch (at most 4 per selection),
@@ -143,7 +153,9 @@ the Mozilla root certificates; no OpenSSL, `ring` or `aws-lc`, nothing compiled
### By hand
Put `model.safetensors`, `vocab.json` and `merges.txt` from `facebook/sam3` in the model folder:
`models/sam3/` in the settings folder above, or the folder `LIGHTCRAFT_SAM3_DIR` names. Developers
`models/sam3/` in the settings folder above, or the folder `LIGHTCRAFT_SAM3_DIR` names. It must be
the Hugging Face format (3,439,938,512 bytes): `sam3.safetensors` from community mirrors, renamed, is
Meta's original checkpoint, and LightCraft says so instead of loading it. Developers
can use the installer scripts, which download from Hugging Face with your token and verify the
weights:
+6 -3
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -113,6 +113,15 @@ renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACK
first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own
device and falls back to the CPU as before.
**The window's surface: one frame in flight.** The window's wgpu options start from eframe's defaults
(`NativeOptions::default().wgpu_options`, `window_gpu::base_options`), whose surface is egui-wgpu's
`SurfaceConfig::LOW_LATENCY`: vsync, at most one frame queued ahead of the display (two drawables on
Metal). egui-wgpu's bare `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` (two frames queued, three
drawables), and the window used it from #136 until this change, so a drag reached the screen one frame
(8.3 ms at 120 Hz, 16.7 ms at 60 Hz) later. The window's own GPU work is small (egui's meshes and
texture uploads; renders run on the compute device), which is what `LOW_LATENCY` is meant for.
During a live window resize egui-wgpu still uses three drawables on macOS.
**Troubleshooting a crash at startup (Windows).** Start LightCraft from a `.cmd` file or a terminal
with `set LIGHTCRAFT_GPU_BACKEND=dx12` (the default since #136), or `=off` to keep the GPU out of
rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a specific Vulkan driver
@@ -302,6 +311,14 @@ compilation: ~0.4 s once per process — the desktop app starts it on a backgrou
(`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create
the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking.
On Windows / DX12 the kernels cost more: FXC compiles each pipeline single-threaded inside
`create_compute_pipeline` (issue #471). Built one after another, the 27 took ~2.2 s on an Intel Core
Ultra 7 165U iGPU (`app.gpu` 2.4 s median, up to 7 s on the first run after an install), so
`ctx::build_kernels` shares them out to worker threads (issue #2936): ~1.05 s there (`app.gpu` 1.2 s),
most likely the largest kernel (`finish.wgsl`) alone (not timed per kernel). wgpu's error scopes are per thread, so every worker
opens its own; a refused kernel still means no GPU. The `gpu:` info log line gives the kernel count
and build time.
## Opening a photo (M5.4)
- The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its
current settings, else (raws with their import look) the embedded camera JPEG, else a
File diff suppressed because one or more lines are too long
+7
View File
@@ -27,6 +27,13 @@ Blacks at 0) is the identity up to white: the SDR shoulder no longer pulls 1.0 d
255 to 254, issue #1945). It landed in place as a bug fix, since the tone map promised identity at
neutral settings; edited curves keep the shoulder and their V1 look.
**AI mask edges** (Object and Describe selections stored as a model's grid) follow the photo's edges
when they are enlarged to the output (issue #3272): the plain enlargement spilled 1–2 px onto the
background, which a raised Exposure turned into a bright outline around the subject in previews and
exports. Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their
inside, their outside and edges without contrast in the photo are unchanged. It landed in place as
a bug fix: the outline is output that isn't in the photo nor in the edit, not a look anyone chose.
## Adding a process
1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in
+42
View File
@@ -44,6 +44,24 @@
64-bit number no longer crashes LightCraft (debug builds) when it is read: the file is refused, or read as far as
its data go.
### A colour-range mask keeps at most 64 samples, however it arrives
- A colour-range mask compares every pixel with every colour it has sampled, so a mask with thousands of samples
could stall a render or export, or lose the GPU for the rest of the session. `mask.add` and `mask.update` already
kept the first 64; a sidecar's `lc:settings`, a preset, `develop.merge`, `render --settings` and the catalog and
edit history now keep the first 64 too, in order, and the extra ones are skipped as they are read. Masks made in
the app (up to 5 samples) are unchanged.
### A SAM 3 model in the wrong format says so
- Object and Describe need `model.safetensors` from `facebook/sam3` in the Hugging Face format. The
`sam3.safetensors` on community mirrors is Meta's original checkpoint, with other tensor names; installed in its
place, AI masks failed with "unsupported dtype C64". LightCraft now says that the file is Meta's original
checkpoint and which file it needs instead.
### Reliability
- A photo with a mask id at the 32-bit limit (from a damaged or hand-edited sidecar, a paste or an agent) no longer
breaks the masks added after it: new masks, duplicates and the masks of a preset each get an id no other mask has.
Before, the next id wrapped around to 0, so a second new mask shared the first one's id (debug builds crashed).
## 2026-10-29
### A person's page opens in seconds in a large library
@@ -78,6 +96,14 @@
grey chevron. They take the theme's own raised fill, and the chevron stands out from it in Charcoal, Midnight, Silver
and Paper alike.
### Object and Describe masks show their pins
- With Show Pins on, an Object mask now has a pin where you first clicked the object, and a Describe mask has one in
the middle of what its description found. Before, these masks had no pin on the photo, so they couldn't be picked
there. Clicking a pin selects its mask; dragging it changes nothing, as these masks follow what they selected.
With the Object tool on, a click on another mask's pin selects that mask too, instead of adding a click to the
selected Object mask, and a click on the selected mask's own pin adds nothing. Subject, Sky, Background and the
range masks still have no pin.
## 2026-10-28
### The empty photo grid names Import Photos' shortcut as it is bound
@@ -220,6 +246,15 @@
IFD0 DateTime or embedded XMP value instead, while All Metadata continues to show the original tag. This also keeps
malformed timestamps from creating false matches when geotagging from a track log.
### Drags reach the screen a frame sooner
- The window queues one frame ahead of the display again instead of two, as eframe does by default (since #136 it
used egui-wgpu's high-throughput setting). Sliders, crop handles, brushes and every other drag show up one display
frame sooner: 8.3 ms at 120 Hz, 16.7 ms at 60 Hz. On a MacBook Pro at 120 Hz, a heavy develop drag went from
about 18 ms to about 10 ms between a frame being handed to macOS and macOS compositing it, with the same 120 frames
a second. On a Mac the window also holds one drawable less: its width × height × 4 bytes, 15–22 MB for a
1200–1500-point-wide window on a Retina display.
## 2026-10-24
### A right-click on a text field while you carry photos opens no menu
@@ -476,6 +511,13 @@
`IMG_1.jpg` is now refused when no sidecar is there yet, as writing over an existing one already was. The library
would have read the new file back as that photo's edits.
### Dodge and burn with the brush on the photo itself
- With the mask overlay hidden (O, View ▸ Show Mask Overlay or the eye beside Masks, or after a mask slider hid it),
the brush stroke you are painting is no longer drawn in the overlay colour while you hold the button, so the photo
stays visible as you dodge and burn (issue #2549). The stroke's effect still appears when you let go. With the
overlay shown, the stroke is tinted as before; a colour overlay at opacity 0 leaves it untinted too.
### Reliability
- Opening a library no longer writes through a link left at the lock's owner note (`catalog.lock.owner`): the link
is removed and a new note made in its place, so the file it pointed to is never changed (as for the catalog's