mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
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:
Generated
+1
@@ -3594,6 +3594,7 @@ dependencies = [
|
||||
"lightcraft-merge",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-ui-egui",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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のちらつきを防ぐ(次回起動時に適用)"
|
||||
}
|
||||
|
||||
@@ -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-ის ციმციმის თავიდან აცილება (ამოქმედდება შემდეგი გაშვებისას)"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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 (после перезапуска)"
|
||||
}
|
||||
|
||||
@@ -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 (після перезапуску)"
|
||||
}
|
||||
|
||||
@@ -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.": "此版本无法运行识别模型:可以添加和选择,但无法使用。",
|
||||
|
||||
@@ -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 閃爍(下次啟動時生效)"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user