Merge main into worktree-agent-ab72a7623afb95196
# Conflicts: # crates/engine/src/import.rs # crates/engine/src/lib.rs # crates/ui-egui/src/headless.rs # crates/ui-egui/src/panels/dialogs.rs # crates/ui-egui/src/panels/grid.rs # docs/parity.md
This commit is contained in:
commit
22e4f0fd8c
|
|
@ -2261,6 +2261,7 @@ dependencies = [
|
|||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
|||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
reveal: None,
|
||||
pick_folder: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub struct WireJob {
|
|||
pub stages: Option<String>,
|
||||
/// The request's diagnostic overlay (`Overlay::to_parts`).
|
||||
#[serde(default)]
|
||||
pub overlay: (u8, f32),
|
||||
pub overlay: (u8, f64),
|
||||
}
|
||||
|
||||
impl WireJob {
|
||||
|
|
@ -88,6 +88,9 @@ impl WireJob {
|
|||
quality: if self.draft { Quality::Draft } else { Quality::Full },
|
||||
apply_crop: self.apply_crop,
|
||||
overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1),
|
||||
// workers render previews (exports run in-process)
|
||||
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
|||
match b.read(&key).await.ok().flatten().and_then(|bytes| lightcraft_preview::decode_jpeg(&bytes)) {
|
||||
Some(image) => {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return Ok((Rendered { image, histogram }, 0));
|
||||
return Ok((Rendered { image, histogram, deep: None }, 0));
|
||||
}
|
||||
None => cache_miss = true,
|
||||
}
|
||||
|
|
@ -275,7 +275,7 @@ impl Workers {
|
|||
Some(image) if hist.len() == 1025 => {
|
||||
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
|
||||
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
|
||||
Ok(Rendered { image, histogram })
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
_ => Err("render worker: bad image".to_string()),
|
||||
}
|
||||
|
|
@ -344,7 +344,7 @@ impl RenderOffload for Workers {
|
|||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
rendered: Ok(Rendered { image, histogram }),
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
|
||||
//!
|
||||
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
|
||||
//! `$LIGHTCRAFT_LIBRARY`, else `~/Pictures/LightCraft Library`; a new library starts with the
|
||||
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
|
||||
//! `~/Pictures/LightCraft Library`; a new library starts with the
|
||||
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
||||
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts).
|
||||
|
|
@ -57,16 +58,13 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut LightcraftApp) {
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any.
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = config_dir().map(|d| d.join("ui.json"))
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
|
||||
{
|
||||
app.ui = ui.sanitized();
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
|
||||
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
|
||||
}
|
||||
|
||||
fn save_prefs(app: &LightcraftApp) {
|
||||
|
|
@ -83,6 +81,7 @@ fn save_prefs(app: &LightcraftApp) {
|
|||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
reveal: Some(Box::new(|path: &str| {
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").args(["-R", path]).status()
|
||||
|
|
@ -192,7 +191,7 @@ fn main() -> eframe::Result {
|
|||
let mut files = Vec::new();
|
||||
let mut seed_demo = true;
|
||||
let mut in_memory = false;
|
||||
let mut library_dir = lightcraft_engine::library::default_dir();
|
||||
let mut library_dir: Option<std::path::PathBuf> = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
|
|
@ -215,6 +214,16 @@ fn main() -> eframe::Result {
|
|||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let prefs = load_prefs();
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
.as_ref()
|
||||
.map(|u| u.settings.library_path.clone())
|
||||
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
|
||||
.map(Into::into)
|
||||
});
|
||||
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
|
|
@ -232,7 +241,9 @@ fn main() -> eframe::Result {
|
|||
Box::new(move |cc| {
|
||||
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
load_prefs(&mut app);
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu
|
|||
use serde_json::Value;
|
||||
|
||||
const QUIT: &str = "app.quit";
|
||||
/// Settings… (the app menu on macOS; Edit elsewhere).
|
||||
const SETTINGS: &str = "app.settings";
|
||||
const SETTINGS_KEY: &str = "Cmd+,";
|
||||
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
|
||||
/// or the active mask): left to the egui handler, which knows the context.
|
||||
const CONTEXTUAL: &[&str] = &["X", "Delete"];
|
||||
|
|
@ -117,6 +120,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
|
|||
"=" => Code::Equal,
|
||||
"-" => Code::Minus,
|
||||
"'" => Code::Quote,
|
||||
"," => Code::Comma,
|
||||
"Delete" => Code::Backspace,
|
||||
_ => return None,
|
||||
};
|
||||
|
|
@ -161,6 +165,14 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
|||
s
|
||||
}
|
||||
|
||||
/// Drop a trailing separator (left behind when an item moved to the app menu).
|
||||
fn tidy_separators(mut nodes: Vec<MenuNode>) -> Vec<MenuNode> {
|
||||
while matches!(nodes.last(), Some(MenuNode::Separator)) {
|
||||
nodes.pop();
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
/// Build the menu bar and install it as the application menu (main thread, after launch).
|
||||
pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu {
|
||||
|
|
@ -185,10 +197,13 @@ impl NativeMenu {
|
|||
// the application menu
|
||||
let app_menu = Submenu::new("LightCraft", true);
|
||||
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
|
||||
|
|
@ -208,8 +223,10 @@ impl NativeMenu {
|
|||
&PredefinedMenuItem::separator(),
|
||||
]);
|
||||
}
|
||||
// About lives in the app menu on macOS
|
||||
let nodes: Vec<MenuNode> = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect();
|
||||
// About and Settings live in the app menu on macOS
|
||||
let nodes: Vec<MenuNode> =
|
||||
nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS)).cloned().collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
|
|
@ -281,6 +298,7 @@ impl NativeMenu {
|
|||
.filter_map(|i| i.shortcut.clone())
|
||||
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
|
||||
.collect::<HashSet<_>>();
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
|
|
@ -291,8 +309,8 @@ impl NativeMenu {
|
|||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
continue;
|
||||
}
|
||||
if key == "app.about" {
|
||||
let _ = app.run("app.about", serde_json::json!({}));
|
||||
if key == "app.about" || key == SETTINGS {
|
||||
let _ = app.run(&key, serde_json::json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some(it) = self.items.get(&key) {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,11 @@ pub struct Photo {
|
|||
/// Virtual copies' name ("Copy 1").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub copy_name: Option<String>,
|
||||
/// The settings import gave the photo when a user default (a raw/JPEG default preset, see
|
||||
/// the engine's import defaults) changed them from [`Photo::camera_defaults`]. They count as
|
||||
/// unedited, and Reset returns to them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub import_look: Option<Arc<DevelopSettings>>,
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
|
|
@ -211,11 +216,20 @@ impl Photo {
|
|||
embedded_lens: None,
|
||||
copy_of: None,
|
||||
copy_name: None,
|
||||
import_look: None,
|
||||
}
|
||||
}
|
||||
/// The develop settings import gives this photo (raws: as-shot white balance and the raw
|
||||
/// defaults; embedded lens corrections on when the file has them).
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
match &self.import_look {
|
||||
Some(l) => (**l).clone(),
|
||||
None => self.camera_defaults(),
|
||||
}
|
||||
}
|
||||
/// The built-in defaults for this photo, before any user default preset: raws start from
|
||||
/// their as-shot white balance; embedded lens corrections on when the file has them.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let mut d = match self.as_shot_wb {
|
||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||
None => DevelopSettings::default(),
|
||||
|
|
|
|||
|
|
@ -290,15 +290,26 @@ pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encode AVIF (8-bit sRGB, 4:4:4) with `ravif` (rav1e). `quality` 1..=100, `speed` 1 (slow) ..= 10.
|
||||
/// EXIF is embedded; ICC is not supported by the muxer (the file is tagged sRGB via `nclx`).
|
||||
/// Returns [`Error::Encode`] on wasm32 or when built without the `avif` feature.
|
||||
/// Encode AVIF (sRGB, 4:4:4, 10-bit AV1) with `ravif` (rav1e). 8-bit samples are widened by the
|
||||
/// encoder; 16-bit samples keep 10 bits of precision (BT.601 full-range YCbCr computed here).
|
||||
/// `quality` 1..=100, `speed` 1 (slow) ..= 10. EXIF is embedded; ICC is not supported by the muxer
|
||||
/// (the file is tagged sRGB via `nclx`). Returns [`Error::Encode`] for float samples, on wasm32 or
|
||||
/// when built without the `avif` feature.
|
||||
pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let Samples::U8(s) = img.samples else {
|
||||
return Err(Error::Encode("AVIF encoder needs 8-bit samples".into()));
|
||||
let mut enc = ravif::Encoder::new()
|
||||
.with_quality(quality.clamp(1, 100) as f32)
|
||||
.with_alpha_quality(quality.clamp(1, 100) as f32)
|
||||
.with_speed(speed.clamp(1, 10));
|
||||
if let Some(exif) = meta.exif {
|
||||
enc = enc.with_exif(exif.to_vec());
|
||||
}
|
||||
let s = match img.samples {
|
||||
Samples::U8(s) => s,
|
||||
Samples::U16(s) => return avif_10bit(&enc, img, s),
|
||||
Samples::F32(_) => return Err(Error::Encode("AVIF encoder needs 8- or 16-bit samples".into())),
|
||||
};
|
||||
let n = img.width as usize * img.height as usize;
|
||||
let ch = img.channels as usize;
|
||||
|
|
@ -312,13 +323,6 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
|
|||
_ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]),
|
||||
})
|
||||
.collect();
|
||||
let mut enc = ravif::Encoder::new()
|
||||
.with_quality(quality.clamp(1, 100) as f32)
|
||||
.with_alpha_quality(quality.clamp(1, 100) as f32)
|
||||
.with_speed(speed.clamp(1, 10));
|
||||
if let Some(exif) = meta.exif {
|
||||
enc = enc.with_exif(exif.to_vec());
|
||||
}
|
||||
let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
let _ = img.has_alpha();
|
||||
Ok(r.avif_file)
|
||||
|
|
@ -329,3 +333,32 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
|
|||
Err(Error::Encode("AVIF encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
|
||||
const K: [f32; 3] = [0.299, 0.587, 0.114];
|
||||
let n = img.width as usize * img.height as usize;
|
||||
let ch = img.channels as usize;
|
||||
let rgb = |c: &[u16]| -> [f32; 3] {
|
||||
let v = |x: u16| x as f32 * (1023.0 / 65535.0);
|
||||
match ch {
|
||||
1 | 2 => [v(c[0]); 3],
|
||||
_ => [v(c[0]), v(c[1]), v(c[2])],
|
||||
}
|
||||
};
|
||||
let planes = s.chunks_exact(ch).take(n).map(|c| {
|
||||
let [r, g, b] = rgb(c);
|
||||
let y = K[0] * r + K[1] * g + K[2] * b;
|
||||
let cb = (b - y) * (0.5 / (1.0 - K[2])) + 512.0;
|
||||
let cr = (r - y) * (0.5 / (1.0 - K[0])) + 512.0;
|
||||
[y, cb, cr].map(|v| v.round().clamp(0.0, 1023.0) as u16)
|
||||
});
|
||||
let alpha_at = |c: &[u16]| if img.has_alpha() { c[ch - 1] } else { u16::MAX };
|
||||
let has_alpha = s.chunks_exact(ch).take(n).any(|c| alpha_at(c) != u16::MAX);
|
||||
let alpha = has_alpha.then(|| s.chunks_exact(ch).take(n).map(move |c| ((alpha_at(c) as u32 * 1023 + 32767) / 65535) as u16));
|
||||
let r = enc
|
||||
.encode_raw_planes_10_bit(img.width as usize, img.height as usize, planes, alpha, ravif::PixelRange::Full, ravif::MatrixCoefficients::BT601)
|
||||
.map_err(|e| Error::Encode(e.to_string()))?;
|
||||
Ok(r.avif_file)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,46 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
|
|||
};
|
||||
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
|
||||
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
|
||||
p.encode().unwrap_or_default()
|
||||
align_tags(&p.encode().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
|
||||
/// back, so a tag of odd length (e.g. a `mluc` description with an odd number of characters)
|
||||
/// misaligns every following tag — which some readers (macOS ImageIO for PNG `iCCP`) reject.
|
||||
fn align_tags(b: &[u8]) -> Vec<u8> {
|
||||
let be = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize);
|
||||
let Some(n) = be(128) else { return b.to_vec() };
|
||||
let table_end = 132 + 12 * n;
|
||||
let tags: Option<Vec<(usize, usize)>> = (0..n).map(|t| Some((be(132 + 12 * t + 4)?, be(132 + 12 * t + 8)?))).collect();
|
||||
let Some(tags) = tags.filter(|t| table_end <= b.len() && t.iter().all(|&(o, l)| o >= table_end && o + l <= b.len())) else {
|
||||
return b.to_vec();
|
||||
};
|
||||
let mut out = b[..table_end].to_vec();
|
||||
let mut placed: Vec<((usize, usize), usize)> = Vec::new();
|
||||
for (t, &(off, len)) in tags.iter().enumerate() {
|
||||
let new_off = match placed.iter().find(|(k, _)| *k == (off, len)) {
|
||||
// tags sharing data keep sharing it
|
||||
Some(&(_, o)) => o,
|
||||
None => {
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
let o = out.len();
|
||||
out.extend_from_slice(&b[off..off + len]);
|
||||
placed.push(((off, len), o));
|
||||
o
|
||||
}
|
||||
};
|
||||
out[132 + 12 * t + 4..132 + 12 * t + 8].copy_from_slice(&(new_off as u32).to_be_bytes());
|
||||
}
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
let size = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&size.to_be_bytes());
|
||||
// the profile ID (MD5) would no longer match: clear it (= "not computed")
|
||||
out[84..100].fill(0);
|
||||
out
|
||||
}
|
||||
|
||||
/// A profile for one of the well-known spaces with its standard curve.
|
||||
|
|
@ -221,6 +260,23 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_are_aligned() {
|
||||
for n in NamedSpace::ALL {
|
||||
for trc in [n.trc(), Trc::Linear] {
|
||||
let b = write_matrix_trc(&n.rgb_space(), &trc);
|
||||
assert_eq!(b.len() % 4, 0, "{n:?}");
|
||||
assert_eq!(u32::from_be_bytes(b[0..4].try_into().unwrap()) as usize, b.len());
|
||||
let count = u32::from_be_bytes(b[128..132].try_into().unwrap()) as usize;
|
||||
for t in 0..count {
|
||||
let off = u32::from_be_bytes(b[132 + 12 * t + 4..132 + 12 * t + 8].try_into().unwrap());
|
||||
assert_eq!(off % 4, 0, "{n:?} tag {t}");
|
||||
}
|
||||
assert!(parse(&b).is_some_and(|i| i.named == Some(n)), "{n:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_profile() {
|
||||
let bytes = write_matrix_trc(&PROPHOTO, &Trc::Linear);
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ pub const PROPHOTO: RgbSpace =
|
|||
RgbSpace { name: "ProPhoto RGB", r: Xy::new(0.734699, 0.265301), g: Xy::new(0.159597, 0.840403), b: Xy::new(0.036598, 0.000105), white: D50 };
|
||||
/// The 1998 "Adobe RGB" primaries (a public colour-space definition; name used descriptively).
|
||||
pub const ADOBE_RGB: RgbSpace =
|
||||
RgbSpace { name: "Adobe RGB (1998)", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
|
||||
RgbSpace { name: "Adobe RGB (1998) compatible", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
|
||||
|
||||
/// The develop pipeline's working space: linear Rec.2020 / D65.
|
||||
pub const WORKING: RgbSpace = REC2020;
|
||||
|
|
|
|||
|
|
@ -128,7 +128,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let fresh = DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() };
|
||||
// back to the photo's import defaults when a default preset gave it its look
|
||||
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
|
|||
s.end_interaction()?;
|
||||
s.selection = Selection::single(vis[next]);
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"active": vis[next].0}))
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
}
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add Remove Spot",
|
||||
[],
|
||||
None,
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}",
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mode: SpotMode =
|
||||
|
|
@ -265,31 +265,113 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
return Err(bad("spot.add", "missing points"));
|
||||
}
|
||||
let d = Spot::default();
|
||||
let spot = Spot {
|
||||
let mut spot = Spot {
|
||||
mode,
|
||||
points: pts,
|
||||
size: f64_or(p, "size", d.size),
|
||||
feather: f64_or(p, "feather", d.feather),
|
||||
opacity: f64_or(p, "opacity", d.opacity),
|
||||
size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0),
|
||||
opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0),
|
||||
source_offset: point(p, "source"),
|
||||
};
|
||||
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if spot.source_offset.is_none() {
|
||||
// resolve the automatic source now: it gets a pin and stays put at every size
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
}
|
||||
dd.spots.push(spot);
|
||||
let n = dd.spots.len();
|
||||
s.set_develop(id, dd, "Remove")?;
|
||||
s.active_spot = Some(n - 1);
|
||||
Ok(json!({"index": n - 1}))
|
||||
}
|
||||
),
|
||||
cmd!("spot.delete", "Delete Spot", [], None, "{index}", has_active, |s, p| {
|
||||
let i = super::f64_req(p, "index", "spot.delete")? as usize;
|
||||
let id = s.active().ok_or_else(|| bad("spot.delete", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if i >= dd.spots.len() {
|
||||
return Err(bad("spot.delete", "no such spot"));
|
||||
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
|
||||
let n = spots_len(s);
|
||||
s.active_spot = match p.get("index").and_then(Value::as_u64) {
|
||||
Some(i) if (i as usize) < n => Some(i as usize),
|
||||
Some(_) => return Err(bad("spot.select", "no such spot")),
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({"activeSpot": s.active_spot}))
|
||||
}),
|
||||
cmd!(
|
||||
"spot.update",
|
||||
"Edit Spot",
|
||||
[],
|
||||
None,
|
||||
"{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.update";
|
||||
let i = spot_index(s, p, c)?;
|
||||
let mode: Option<SpotMode> = match p.get("mode") {
|
||||
Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?),
|
||||
None => None,
|
||||
};
|
||||
spots_edit(s, c, "Edit Spot", |spots| {
|
||||
let sp = &mut spots[i];
|
||||
if let Some(d) = point(p, "move") {
|
||||
sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y));
|
||||
// the source moves along (its offset is relative to the target)
|
||||
}
|
||||
if let Some(o) = point(p, "source") {
|
||||
sp.source_offset = Some(o);
|
||||
}
|
||||
if let Some(d) = point(p, "moveSource") {
|
||||
let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0));
|
||||
sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y));
|
||||
}
|
||||
if let Some(v) = p.get("size").and_then(Value::as_f64) {
|
||||
sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1);
|
||||
}
|
||||
if let Some(v) = p.get("feather").and_then(Value::as_f64) {
|
||||
sp.feather = v.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(v) = p.get("opacity").and_then(Value::as_f64) {
|
||||
sp.opacity = v.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
sp.mode = m;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
dd.spots.remove(i);
|
||||
s.set_develop(id, dd, "Delete Spot")?;
|
||||
),
|
||||
cmd!(
|
||||
"spot.refreshSource",
|
||||
"Refresh Source",
|
||||
[],
|
||||
None,
|
||||
"{index? (the selected spot)} — picks the next-best source away from the current one",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.refreshSource";
|
||||
let i = spot_index(s, p, c)?;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let spot = dd.spots[i].clone();
|
||||
let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?;
|
||||
spots_edit(s, c, "Refresh Source", |spots| {
|
||||
spots[i].source_offset = Some(next);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"index": i, "source": [next.x, next.y]}))
|
||||
}
|
||||
),
|
||||
cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| {
|
||||
let c = "spot.delete";
|
||||
let i = spot_index(s, p, c)?;
|
||||
spots_edit(s, c, "Delete Spot", |spots| {
|
||||
spots.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
s.active_spot = match s.active_spot {
|
||||
Some(a) if a == i => None,
|
||||
Some(a) if a > i => Some(a - 1),
|
||||
a => a,
|
||||
};
|
||||
ok()
|
||||
}),
|
||||
// ---- Red eye / pet eye
|
||||
|
|
@ -358,6 +440,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Spot radius limits (fraction of the long edge).
|
||||
pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25);
|
||||
|
||||
fn spots_len(s: &Session) -> usize {
|
||||
s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `index` from the params, else the selected spot.
|
||||
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
|
||||
if i >= spots_len(s) {
|
||||
return Err(bad(c, "no such spot"));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spot>) -> Result<()>) -> Result<()> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
f(&mut d.spots)?;
|
||||
s.set_develop(id, d, label)
|
||||
}
|
||||
|
||||
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
|
||||
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let info = crate::media::source_info(s.catalog.photo(id)?);
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
|
||||
c(&mut a.exposure, -4.0, 4.0);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ mod manage;
|
|||
mod masks;
|
||||
mod merge;
|
||||
mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
mod query;
|
||||
mod xmp;
|
||||
|
|
@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(query::specs());
|
||||
v.extend(xmp::specs());
|
||||
v.extend(preset_files::specs());
|
||||
v.extend(prefs::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
//! Library preferences (Settings → Import / Performance): develop defaults applied on import and
|
||||
//! the thumbnail cache budget. Saved in the library's `prefs.json` (see [`crate::library`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
use crate::import::{CameraDefault, ImportDefaults};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const ID: &str = "library.preferences";
|
||||
|
||||
fn current(s: &Session) -> Value {
|
||||
json!({
|
||||
"xmp": s.xmp,
|
||||
"import": s.import_defaults,
|
||||
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
|
||||
"persistent": s.library.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `"default"`, `""` or null = no preset; anything else must name an existing preset.
|
||||
fn preset_ref(s: &Session, v: &Value) -> Result<Option<String>> {
|
||||
match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(x) if x.is_empty() || x == "default" => Ok(None),
|
||||
Value::String(x) if s.presets.iter().any(|p| p.id == *x) => Ok(Some(x.clone())),
|
||||
Value::String(x) => Err(bad(ID, format!("unknown preset `{x}` (see preset.list)"))),
|
||||
other => Err(bad(ID, format!("preset must be a preset id or \"default\", not {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d: ImportDefaults = s.import_defaults.clone();
|
||||
let mut changed = false;
|
||||
if let Some(i) = p.get("import") {
|
||||
if let Some(v) = i.get("rawPreset") {
|
||||
d.raw_preset = preset_ref(s, v)?;
|
||||
}
|
||||
if let Some(v) = i.get("otherPreset") {
|
||||
d.other_preset = preset_ref(s, v)?;
|
||||
}
|
||||
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
|
||||
d.per_camera = v;
|
||||
}
|
||||
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
|
||||
d.cameras.clear();
|
||||
for c in a {
|
||||
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
||||
if camera.is_empty() {
|
||||
return Err(bad(ID, "each camera default needs `camera` (make + model)"));
|
||||
}
|
||||
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
|
||||
d.cameras.push(CameraDefault { camera, preset });
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
// set or clear one camera's default: {camera: {camera: "Make Model", preset: id|null}}
|
||||
if let Some(c) = p.get("camera") {
|
||||
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
||||
if camera.is_empty() {
|
||||
return Err(bad(ID, "`camera.camera` (make + model) is required"));
|
||||
}
|
||||
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
|
||||
d.cameras.retain(|x| !x.camera.eq_ignore_ascii_case(&camera));
|
||||
if c.get("remove").and_then(Value::as_bool) != Some(true) {
|
||||
d.cameras.push(CameraDefault { camera, preset });
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
s.import_defaults = d;
|
||||
if let Some(mb) = p.get("cacheMb") {
|
||||
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
|
||||
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
|
||||
} else if changed {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(current(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"library.preferences",
|
||||
"Library Preferences",
|
||||
[],
|
||||
None,
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
|
||||
always,
|
||||
prefs
|
||||
)]
|
||||
}
|
||||
|
|
@ -89,6 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"selection": s.selection,
|
||||
"visibleCount": n,
|
||||
"activeMask": s.active_mask,
|
||||
"activeSpot": s.active_spot,
|
||||
"undo": s.undo.last().map(|u| u.label.clone()),
|
||||
"redo": s.redo.last().map(|u| u.label.clone()),
|
||||
"clipboard": s.clipboard.is_some(),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile,
|
||||
//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
|
||||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF in sRGB, Display P3, Adobe RGB
|
||||
//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the
|
||||
//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
|
||||
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
|
||||
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc};
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, TiffCompression, encode, icc};
|
||||
use lightcraft_meta::{DateTime, Gps, Metadata};
|
||||
pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -103,15 +105,49 @@ impl Default for Watermark {
|
|||
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
|
||||
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
||||
|
||||
/// Draw `wm` onto `img` (straight sRGB alpha blending).
|
||||
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
|
||||
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
||||
let width = img.width;
|
||||
watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
|
||||
let p = &mut img.data[y * width + x];
|
||||
for c in 0..3 {
|
||||
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float
|
||||
/// images are linear and blend in linear light).
|
||||
pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
|
||||
let (width, trc) = (img.width, img.space.trc());
|
||||
match &mut img.samples {
|
||||
DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
|
||||
let i = (y * width + x) * 3;
|
||||
for c in 0..3 {
|
||||
let p = v[i + c] as f32;
|
||||
v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}),
|
||||
DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| {
|
||||
let i = (y * width + x) * 3;
|
||||
for c in 0..3 {
|
||||
let target = trc.decode(col[c] as f32 / 255.0);
|
||||
v[i + c] += (target - v[i + c]) * k;
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for
|
||||
/// every covered pixel (shadow pass first).
|
||||
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
|
||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
|
||||
let text = wm.text.trim();
|
||||
if text.is_empty() || img.width == 0 || img.height == 0 {
|
||||
if text.is_empty() || width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
|
||||
let short = img.width.min(img.height) as f32;
|
||||
let short = width.min(height) as f32;
|
||||
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
|
||||
let sf = font.as_scaled(PxScale::from(px));
|
||||
// Lay out one line.
|
||||
|
|
@ -129,7 +165,7 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
|||
}
|
||||
let (tw, th) = (x, sf.ascent() - sf.descent());
|
||||
let inset = wm.inset.clamp(0.0, 0.4) * short;
|
||||
let (w, h) = (img.width as f32, img.height as f32);
|
||||
let (w, h) = (width as f32, height as f32);
|
||||
use Anchor::*;
|
||||
let ox = match wm.anchor {
|
||||
TopLeft | Left | BottomLeft => inset,
|
||||
|
|
@ -143,14 +179,10 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
|||
};
|
||||
let alpha = wm.opacity.clamp(0.0, 1.0);
|
||||
let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| {
|
||||
if gx < 0 || gy < 0 || gx >= img.width as i32 || gy >= img.height as i32 {
|
||||
if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 {
|
||||
return;
|
||||
}
|
||||
let k = (cov * a).clamp(0.0, 1.0);
|
||||
let p = &mut img.data[gy as usize * img.width + gx as usize];
|
||||
for c in 0..3 {
|
||||
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
|
||||
}
|
||||
blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col);
|
||||
};
|
||||
let passes: &[(f32, [u8; 3], f32)] =
|
||||
if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] };
|
||||
|
|
@ -198,6 +230,11 @@ pub struct ExportOptions {
|
|||
pub remove_location: bool,
|
||||
/// Text watermark (none when absent or the text is empty).
|
||||
pub watermark: Option<Watermark>,
|
||||
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile).
|
||||
pub color_space: OutputSpace,
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
|
||||
pub bit_depth: Option<u8>,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
|
|
@ -213,12 +250,15 @@ impl Default for ExportOptions {
|
|||
metadata: MetadataPolicy::All,
|
||||
remove_location: false,
|
||||
watermark: None,
|
||||
color_space: OutputSpace::Srgb,
|
||||
bit_depth: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`).
|
||||
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`,
|
||||
/// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`).
|
||||
pub fn from_json(p: &serde_json::Value) -> Self {
|
||||
use serde_json::Value;
|
||||
let d = Self::default();
|
||||
|
|
@ -243,9 +283,40 @@ impl ExportOptions {
|
|||
_ => None,
|
||||
}
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty()),
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample format the file is written with (unsupported requests fall back to the closest).
|
||||
pub fn effective_depth(&self) -> OutputDepth {
|
||||
match (self.format, self.bit_depth) {
|
||||
(ExportFormat::Jpeg | ExportFormat::Webp, _) => OutputDepth::U8,
|
||||
(ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Png, _) => OutputDepth::U8,
|
||||
(ExportFormat::Tiff, Some(8)) => OutputDepth::U8,
|
||||
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Tiff, _) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) => OutputDepth::U8,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bit depths `format` offers (value, label); the first is its default.
|
||||
pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] {
|
||||
match format {
|
||||
ExportFormat::Jpeg | ExportFormat::Webp => &[(8, "8-bit")],
|
||||
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
|
||||
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
|
||||
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB).
|
||||
pub fn effective_space(&self) -> OutputSpace {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
}
|
||||
|
||||
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
|
||||
pub fn file_name(&self, stem: &str, seq: usize) -> String {
|
||||
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
|
||||
|
|
@ -257,8 +328,43 @@ impl ExportOptions {
|
|||
|
||||
/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian).
|
||||
pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) {
|
||||
let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
|
||||
if let Some(out) = unsharp(img.width, img.height, &src, target, amount) {
|
||||
for (p, v) in img.data.iter_mut().zip(out) {
|
||||
for c in 0..3 {
|
||||
p[c] = v[c].round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light).
|
||||
pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) {
|
||||
let (w, h) = (img.width, img.height);
|
||||
match &mut img.samples {
|
||||
DeepSamples::U16(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
DeepSamples::F32(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done.
|
||||
fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option<Vec<[f32; 3]>> {
|
||||
let (radius, base) = match target {
|
||||
SharpenFor::None => return,
|
||||
SharpenFor::None => return None,
|
||||
SharpenFor::Screen => (1usize, 0.35f32),
|
||||
SharpenFor::Matte => (2, 0.6),
|
||||
SharpenFor::Glossy => (1, 0.5),
|
||||
|
|
@ -269,11 +375,9 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount
|
|||
SharpenAmount::Standard => 1.0,
|
||||
SharpenAmount::High => 1.5,
|
||||
};
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w < 3 || h < 3 {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
|
||||
let r = radius as isize;
|
||||
let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> {
|
||||
let mut out = vec![[0.0; 3]; inp.len()];
|
||||
|
|
@ -295,16 +399,12 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount
|
|||
}
|
||||
out
|
||||
};
|
||||
let blur = box_pass(&box_pass(&src, true), false);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
for c in 0..3 {
|
||||
let v = src[i][c] + (src[i][c] - blur[i][c]) * k;
|
||||
p[c] = v.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
let blur = box_pass(&box_pass(src, true), false);
|
||||
Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect())
|
||||
}
|
||||
|
||||
/// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the
|
||||
/// Encode a rendered display-referred image according to `o` (its pixels must already be in
|
||||
/// `o.effective_space()`). Resizing to `long_edge` is the
|
||||
/// caller's job (render at that size); sharpening is applied here.
|
||||
pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
encode_with_metadata(img, o, None)
|
||||
|
|
@ -314,10 +414,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
|
|||
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
draw_watermark(&mut img, wm);
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
let profile = icc::write_named(NamedSpace::Srgb);
|
||||
let profile = icc::write_named(named_space(space));
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
|
||||
|
|
@ -358,6 +460,69 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
|||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The codecs' name of an output space (for its ICC profile).
|
||||
pub fn named_space(s: OutputSpace) -> NamedSpace {
|
||||
match s {
|
||||
OutputSpace::Srgb => NamedSpace::Srgb,
|
||||
OutputSpace::DisplayP3 => NamedSpace::DisplayP3,
|
||||
OutputSpace::AdobeRgb => NamedSpace::AdobeRgb,
|
||||
OutputSpace::ProPhoto => NamedSpace::ProPhoto,
|
||||
OutputSpace::Rec2020 => NamedSpace::Rec2020,
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8-bit sRGB colour expressed in `space` (encoded with its curve).
|
||||
pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] {
|
||||
if space == OutputSpace::Srgb {
|
||||
return c;
|
||||
}
|
||||
let lin = c.map(lightcraft_color::transfer::decode_srgb8);
|
||||
let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32();
|
||||
let t = space.trc();
|
||||
std::array::from_fn(|i| {
|
||||
let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2];
|
||||
(t.encode(v) * 255.0 + 0.5) as u8
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF,
|
||||
/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image.
|
||||
pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
match &r.deep {
|
||||
Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta),
|
||||
_ => encode_with_metadata(&r.image, o, meta),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a high-bit-depth image (see [`encode_rendered`]).
|
||||
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
let profile = match img.samples {
|
||||
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
|
||||
DeepSamples::U16(_) => icc::write_named(named_space(img.space)),
|
||||
};
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
|
||||
let (w, h) = (img.width as u32, img.height as u32);
|
||||
let e = match &img.samples {
|
||||
DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)),
|
||||
DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),
|
||||
};
|
||||
let r = match o.format {
|
||||
ExportFormat::Png => encode::encode_png(&e, &meta),
|
||||
ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta),
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
f => return Err(format!("{f:?} export is 8-bit only")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
|
||||
fn parse_shutter(s: &str) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
|
|
@ -414,8 +579,8 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI
|
|||
let full = p.width.max(p.height).max(1) as usize;
|
||||
let size = o.long_edge.map_or(full, |l| l as usize);
|
||||
let meta = export_metadata(p, o);
|
||||
let r = session.render_now(id, size, size)?;
|
||||
let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?;
|
||||
let r = session.render_export(id, size, o.effective_space(), o.effective_depth())?;
|
||||
let bytes = encode_rendered(&r, o, meta.as_ref())?;
|
||||
Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height })
|
||||
}
|
||||
|
||||
|
|
@ -516,4 +681,173 @@ mod tests {
|
|||
assert_eq!(o.long_edge, Some(2048));
|
||||
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
|
||||
}
|
||||
|
||||
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
|
||||
fn p3_green(space: OutputSpace) -> Rgba8 {
|
||||
use lightcraft_color::{DISPLAY_P3, REC2020};
|
||||
let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]);
|
||||
let src = lightcraft_raster::Rgb32f::filled(16, 16, c);
|
||||
let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) };
|
||||
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image
|
||||
}
|
||||
|
||||
/// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the
|
||||
/// recognised space of the embedded profile.
|
||||
fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option<NamedSpace>) {
|
||||
let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes");
|
||||
let m = d.space.to_space(&lightcraft_color::SRGB);
|
||||
(m.apply_f32(d.image.get(8, 8)), d.space.named)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() {
|
||||
let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() };
|
||||
let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap());
|
||||
assert_eq!(named, Some(NamedSpace::DisplayP3));
|
||||
assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}");
|
||||
let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap());
|
||||
assert_eq!(named, Some(NamedSpace::Srgb));
|
||||
assert!(s.iter().all(|v| *v > -0.002), "{s:?}");
|
||||
// the green itself is about the same brightness either way
|
||||
assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_space_embeds_its_own_profile_and_round_trips() {
|
||||
let grey = {
|
||||
let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]);
|
||||
let base = lightcraft_pipeline::RenderRequest::fit(16, 16);
|
||||
move |space| {
|
||||
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base })
|
||||
.image
|
||||
}
|
||||
};
|
||||
let reference =
|
||||
decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0;
|
||||
for space in OutputSpace::ALL {
|
||||
for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
let o = ExportOptions { format, color_space: space, ..Default::default() };
|
||||
let bytes = encode_image(&grey(space), &o).unwrap();
|
||||
let (px, named) = decoded_in_srgb(&bytes);
|
||||
assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}");
|
||||
// a neutral grey decodes to the same linear value whatever the output space
|
||||
for c in 0..3 {
|
||||
assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}");
|
||||
}
|
||||
}
|
||||
let icc = icc::write_named(named_space(space));
|
||||
let info = icc::parse(&icc).expect("our profile parses");
|
||||
assert_eq!(info.named, Some(named_space(space)));
|
||||
assert!(info.description.as_deref().is_some_and(|d| !d.is_empty()));
|
||||
}
|
||||
let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() };
|
||||
assert_eq!(o.effective_space(), OutputSpace::Srgb);
|
||||
let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"}));
|
||||
assert_eq!(o.color_space, OutputSpace::DisplayP3);
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_watermark_colour_is_converted() {
|
||||
assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]);
|
||||
for s in OutputSpace::ALL {
|
||||
assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}");
|
||||
assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}");
|
||||
}
|
||||
let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]);
|
||||
assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}");
|
||||
}
|
||||
|
||||
/// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`.
|
||||
fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered {
|
||||
let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]);
|
||||
let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) };
|
||||
lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req)
|
||||
}
|
||||
|
||||
fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) {
|
||||
let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes");
|
||||
let mut v: Vec<u32> = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect();
|
||||
v.dedup();
|
||||
(v.len(), d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_tiff_and_png_have_no_banding() {
|
||||
let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() };
|
||||
assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit");
|
||||
let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb);
|
||||
assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_)));
|
||||
let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap());
|
||||
let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb);
|
||||
let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap());
|
||||
eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}");
|
||||
assert!(levels8 < 40, "{levels8}");
|
||||
assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}");
|
||||
assert_eq!(d16.space.named, Some(NamedSpace::Srgb));
|
||||
// the 8-bit preview of a deep render matches the 8-bit render
|
||||
for (a, b) in r16.image.data.iter().zip(&r8.image.data) {
|
||||
assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}");
|
||||
}
|
||||
let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() };
|
||||
let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3);
|
||||
let bytes = encode_rendered(&rp, &png, None).unwrap();
|
||||
assert_eq!(bytes[24], 16, "PNG IHDR bit depth");
|
||||
let (levels, d) = distinct_levels(&bytes);
|
||||
assert!(levels > 10 * levels8, "{levels}");
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
// sharpening and watermarking work on deep images too
|
||||
let o = ExportOptions {
|
||||
sharpen: SharpenFor::Matte,
|
||||
watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }),
|
||||
..tiff(None)
|
||||
};
|
||||
assert!(encode_rendered(&r16, &o, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_is_linear_with_a_linear_profile() {
|
||||
let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() };
|
||||
assert_eq!(o.effective_depth(), OutputDepth::F32Linear);
|
||||
let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto);
|
||||
let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap());
|
||||
assert!(levels > 500, "{levels}");
|
||||
assert_eq!(df.space.named, Some(NamedSpace::ProPhoto));
|
||||
assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc);
|
||||
// decodes to the same light as the 16-bit gamma-encoded export
|
||||
let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() };
|
||||
let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap());
|
||||
for x in [0, 300, 700, 1023] {
|
||||
let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]);
|
||||
assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_depth_options_per_format() {
|
||||
let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth();
|
||||
assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8);
|
||||
assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8);
|
||||
assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8);
|
||||
assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16);
|
||||
assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16);
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16));
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None);
|
||||
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] {
|
||||
let first = ExportOptions::bit_depths(f)[0].0;
|
||||
let def = d(f, None);
|
||||
assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn avif_ten_bit() {
|
||||
let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() };
|
||||
let r = ramp(OutputDepth::U16, OutputSpace::Srgb);
|
||||
match encode_rendered(&r, &o, None) {
|
||||
Ok(b) => assert_eq!(&b[4..8], b"ftyp"),
|
||||
Err(e) => assert!(e.contains("not available"), "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,14 +95,74 @@ pub struct ImportReport {
|
|||
|
||||
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
|
||||
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
|
||||
/// camera intended).
|
||||
/// camera intended); a user default preset ([`ImportDefaults`]) goes on top.
|
||||
pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings {
|
||||
p.import_defaults()
|
||||
}
|
||||
|
||||
/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)?
|
||||
/// Does the photo still look as the camera rendered it (its embedded camera preview is then a
|
||||
/// fair stand-in)? Not when a default preset changed it on import.
|
||||
pub fn has_import_look(p: &Photo) -> bool {
|
||||
*p.develop == import_defaults(p)
|
||||
*p.develop == p.camera_defaults()
|
||||
}
|
||||
|
||||
/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`).
|
||||
/// Presets are referenced by id; a preset that no longer exists is ignored.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ImportDefaults {
|
||||
/// Preset applied to raw files (`None` = the LightCraft default).
|
||||
pub raw_preset: Option<String>,
|
||||
/// Use a camera's own default (below) when the photo's camera has one.
|
||||
pub per_camera: bool,
|
||||
/// Per-camera raw defaults, keyed by the camera's make + model as shown in Info
|
||||
/// (`"Canon EOS R5"`).
|
||||
pub cameras: Vec<CameraDefault>,
|
||||
/// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none).
|
||||
pub other_preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CameraDefault {
|
||||
/// Make + model (`Meta::camera`).
|
||||
pub camera: String,
|
||||
/// Preset id; `None` = the LightCraft default for this camera.
|
||||
pub preset: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportDefaults {
|
||||
/// The preset id that applies to a photo of this kind and camera, if any.
|
||||
pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> {
|
||||
if !raw {
|
||||
return self.other_preset.as_deref();
|
||||
}
|
||||
if self.per_camera
|
||||
&& let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera))
|
||||
{
|
||||
return c.preset.as_deref();
|
||||
}
|
||||
self.raw_preset.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Give a freshly imported photo its default settings: the camera defaults, then the matching
|
||||
/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look.
|
||||
pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
|
||||
let base = p.camera_defaults();
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
|
||||
match preset {
|
||||
Some(pr) => {
|
||||
let look = std::sync::Arc::new(pr.apply(&base, 1.0));
|
||||
p.develop = look.clone();
|
||||
p.import_look = (*look != base).then_some(look);
|
||||
}
|
||||
None => {
|
||||
p.develop = std::sync::Arc::new(base);
|
||||
p.import_look = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_supported(path: &Path) -> bool {
|
||||
|
|
@ -351,7 +411,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
|||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.develop = std::sync::Arc::new(import_defaults(&p));
|
||||
apply_import_defaults(s, &mut p);
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ pub struct Session {
|
|||
depth: u32,
|
||||
/// Selected mask (Masking panel), by mask id.
|
||||
pub active_mask: Option<u32>,
|
||||
/// Selected spot (Remove panel), by index into the active photo's spots.
|
||||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
pub library: Option<library::Library>,
|
||||
/// XMP sidecar preferences (persisted with the library).
|
||||
|
|
@ -105,6 +107,10 @@ pub struct Session {
|
|||
pub last_export: Option<serde_json::Value>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// Develop defaults applied on import (persisted in prefs.json).
|
||||
pub import_defaults: import::ImportDefaults,
|
||||
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
|
||||
pub cache_mb: u32,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -137,10 +143,13 @@ impl Session {
|
|||
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
|
||||
depth: 0,
|
||||
active_mask: None,
|
||||
active_spot: None,
|
||||
library: None,
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
import_probes: Default::default(),
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -385,4 +394,8 @@ mod tests_merge;
|
|||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! LightCraft Library/
|
||||
//! catalog.snap catalog.log (lightcraft-catalog journal)
|
||||
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
|
||||
//! prefs.json (library preferences: XMP sidecars)
|
||||
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
|
||||
//! thumbs/ (rendered thumbnail cache, safe to delete)
|
||||
//! Originals/ (photos imported with "copy into library")
|
||||
//! ```
|
||||
|
|
@ -99,11 +99,16 @@ impl Library {
|
|||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct PrefsFile {
|
||||
xmp: crate::sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (for Export with Previous).
|
||||
#[serde(alias = "last_export")]
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// Develop defaults for imported photos.
|
||||
import: crate::import::ImportDefaults,
|
||||
/// Thumbnail disk cache budget (MB, 0 = default).
|
||||
cache_mb: u32,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
|
|
@ -165,6 +170,8 @@ impl Session {
|
|||
let prefs = read_json::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
|
|
@ -180,8 +187,10 @@ impl Session {
|
|||
self.selection = Selection::single(*first);
|
||||
}
|
||||
let presets_written = presets_json(self);
|
||||
// photo ids are per library: drop decoded sources of the previous one
|
||||
self.media.clear_sources();
|
||||
if on_disk {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"));
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
|
|
@ -262,11 +271,33 @@ impl Session {
|
|||
|
||||
/// Save the library preferences (no-op for in-memory sessions).
|
||||
pub fn save_prefs(&mut self) -> Result<()> {
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone() }).unwrap_or_default();
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
import: self.import_defaults.clone(),
|
||||
cache_mb: self.cache_mb,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
}
|
||||
|
||||
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
|
||||
/// trims it to the new size. Saved with the library preferences.
|
||||
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
|
||||
self.cache_mb = mb;
|
||||
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
|
||||
let dir = lib.thumbs_dir();
|
||||
self.media.attach_disk_cache(&dir, self.cache_bytes());
|
||||
}
|
||||
self.save_prefs()
|
||||
}
|
||||
|
||||
/// Compact the log into a snapshot now.
|
||||
pub fn compact_library(&mut self) -> Result<()> {
|
||||
self.persist()?;
|
||||
|
|
|
|||
|
|
@ -143,8 +143,15 @@ fn source_bytes(img: &Rgb32f) -> usize {
|
|||
|
||||
impl MediaCache {
|
||||
/// Keep rendered thumbnails on disk in `dir` as well.
|
||||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path) {
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, DISK_CACHE_BYTES));
|
||||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
|
||||
}
|
||||
|
||||
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
|
||||
pub fn clear_sources(&mut self) {
|
||||
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
|
||||
self.previews.clear();
|
||||
self.full = None;
|
||||
}
|
||||
|
||||
/// Bytes the caches may hold together.
|
||||
|
|
@ -355,7 +362,7 @@ impl RenderJob {
|
|||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(Rendered { image, histogram }),
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
|
|
@ -434,7 +441,7 @@ impl QuickJob {
|
|||
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
|
||||
let rendered = |image: Rgba8| {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
Ok(Rendered { image, histogram })
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
};
|
||||
for (cache, k) in &self.cached {
|
||||
if let Some(img) = cache.get(*k) {
|
||||
|
|
@ -636,6 +643,24 @@ impl crate::Session {
|
|||
r.rendered
|
||||
}
|
||||
|
||||
/// Synchronous render for export: like [`Self::render_now`], into colour space `space` with
|
||||
/// sample format `depth`.
|
||||
pub fn render_export(
|
||||
&mut self,
|
||||
id: PhotoId,
|
||||
max: usize,
|
||||
space: lightcraft_pipeline::OutputSpace,
|
||||
depth: lightcraft_pipeline::OutputDepth,
|
||||
) -> Result<Rendered, String> {
|
||||
let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?;
|
||||
job.request.space = space;
|
||||
job.request.depth = depth;
|
||||
job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db);
|
||||
let r = job.run();
|
||||
self.accept(&r);
|
||||
r.rendered
|
||||
}
|
||||
|
||||
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
|
||||
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
//! Library preferences: import defaults (raw / other / per camera) applied by import, and their
|
||||
//! persistence in prefs.json.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::MediaKind;
|
||||
use lightcraft_develop::Preset;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-prefs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn user_preset(id: &str, settings: serde_json::Value) -> Preset {
|
||||
Preset { id: id.into(), name: id.into(), group: "User Presets".into(), settings, favorite: false, builtin: false }
|
||||
}
|
||||
|
||||
fn with_presets(s: &mut Session) {
|
||||
s.presets.push(user_preset("user.rawlook", json!({"light": {"exposure": 0.5}, "effects": {"clarity": 20.0}})));
|
||||
s.presets.push(user_preset("user.canonlook", json!({"light": {"contrast": 35.0}})));
|
||||
s.presets.push(user_preset("user.jpeglook", json!({"color": {"vibrance": 15.0}})));
|
||||
}
|
||||
|
||||
fn dng(dir: &Path, name: &str, make: &str, model: &str) {
|
||||
let meta = lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
|
||||
std::fs::write(dir.join(name), crate::tests_xmp::synthetic_dng_with(None, meta)).unwrap();
|
||||
}
|
||||
|
||||
fn png(path: &Path) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i * 7) as u8, (i * 3) as u8, 90, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn photo_named<'a>(s: &'a Session, name: &str) -> &'a lightcraft_catalog::Photo {
|
||||
s.catalog.photos().find(|p| p.file_name == name).unwrap_or_else(|| panic!("{name} not imported"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_and_other_defaults_are_applied_on_import() {
|
||||
let src = temp_dir("apply");
|
||||
dng(&src, "nikon.dng", "Nikon", "Z 9");
|
||||
dng(&src, "canon.dng", "Canon", "EOS R5");
|
||||
png(&src.join("plain.png"));
|
||||
let mut s = Session::new().with_fs();
|
||||
with_presets(&mut s);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.preferences",
|
||||
&json!({"import": {"rawPreset": "user.rawlook", "otherPreset": "user.jpeglook", "perCamera": true,
|
||||
"cameras": [{"camera": "Canon EOS R5", "preset": "user.canonlook"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["import"]["rawPreset"], "user.rawlook", "{r}");
|
||||
assert!(s.execute("library.preferences", &json!({"import": {"rawPreset": "nope"}})).is_err(), "unknown presets are rejected");
|
||||
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 3);
|
||||
let nikon = photo_named(&s, "nikon.dng");
|
||||
assert_eq!(nikon.kind, MediaKind::Raw);
|
||||
assert_eq!(nikon.meta.camera, "Nikon Z 9");
|
||||
assert_eq!((nikon.develop.light.exposure, nikon.develop.effects.clarity), (0.5, 20.0), "global raw default");
|
||||
assert_eq!(nikon.develop.detail.sharpen_amount, 40.0, "raw camera defaults stay underneath the preset");
|
||||
assert!(!nikon.is_edited(), "the default look counts as unedited");
|
||||
assert!(!crate::import::has_import_look(nikon), "a preset look isn't what the embedded preview shows");
|
||||
let canon = photo_named(&s, "canon.dng");
|
||||
assert_eq!((canon.develop.light.contrast, canon.develop.light.exposure), (35.0, 0.0), "per-camera default wins");
|
||||
let plain = photo_named(&s, "plain.png");
|
||||
assert_eq!((plain.develop.color.vibrance, plain.develop.light.exposure), (15.0, 0.0), "non-raw default");
|
||||
|
||||
// editing then Reset returns to the default look
|
||||
let id = nikon.id;
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().is_edited());
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
assert_eq!(p.develop.light.exposure, 0.5);
|
||||
assert!(!p.is_edited());
|
||||
|
||||
// per-camera off: the Canon gets the global raw default; clearing it gives the plain defaults
|
||||
let mut s2 = Session::new().with_fs();
|
||||
with_presets(&mut s2);
|
||||
s2.execute("library.preferences", &json!({"camera": {"camera": "canon eos r5", "preset": "user.canonlook"}})).unwrap();
|
||||
s2.execute("library.import", &json!({"paths": [src.join("canon.dng").to_string_lossy()]})).unwrap();
|
||||
let c = s2.catalog.photos().next().unwrap();
|
||||
assert_eq!(c.develop.light.contrast, 0.0, "per-camera defaults are off unless enabled");
|
||||
assert!(c.import_look.is_none());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_persist_with_the_library() {
|
||||
let lib = temp_dir("lib");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
with_presets(&mut s);
|
||||
s.persist().unwrap(); // presets.json
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute(
|
||||
"library.preferences",
|
||||
&json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}),
|
||||
)
|
||||
.unwrap();
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.xmp.auto_write);
|
||||
assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook"));
|
||||
assert!(s.import_defaults.per_camera);
|
||||
assert_eq!(s.import_defaults.cameras.len(), 1);
|
||||
assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook"));
|
||||
assert_eq!(s.cache_mb, 512);
|
||||
assert_eq!(s.cache_bytes(), 512 << 20);
|
||||
let r = s.execute("library.preferences", &json!({})).unwrap();
|
||||
assert_eq!(r["cacheMb"], 512);
|
||||
assert_eq!(r["persistent"], true);
|
||||
// older prefs.json files (snake_case `last_export`, no import section) still load
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_export, Some(json!({"format": "png"})));
|
||||
assert_eq!(s.import_defaults, Default::default());
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
//! Remove tool commands: add (automatic source), select, move target/source, edit, refresh, delete.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
|
||||
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spots_select_move_edit_refresh_and_delete() {
|
||||
let mut s = Session::with_demo();
|
||||
// a remove spot gets a resolved source (a pin to show) and is selected
|
||||
let r = s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.02})).unwrap();
|
||||
assert_eq!(r["index"], 0);
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
let src0 = spots(&s)[0].source_offset.expect("automatic source resolved");
|
||||
assert!(src0.x.hypot(src0.y) > 0.02, "the source is outside the spot: {src0:?}");
|
||||
s.execute("spot.add", &json!({"mode": "clone", "points": [[0.7, 0.3]], "source": [0.1, 0.0]})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(1));
|
||||
assert_eq!(s.execute("library.state", &json!({})).unwrap()["activeSpot"], 1);
|
||||
// select
|
||||
s.execute("spot.select", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
assert!(s.execute("spot.select", &json!({"index": 9})).is_err());
|
||||
// move the target (the source follows: its offset is relative), then the source alone
|
||||
s.execute("spot.update", &json!({"move": [0.05, -0.1]})).unwrap();
|
||||
let sp = &spots(&s)[0];
|
||||
assert!((sp.points[0].x - 0.45).abs() < 1e-9 && (sp.points[0].y - 0.4).abs() < 1e-9);
|
||||
assert_eq!(sp.source_offset, Some(src0));
|
||||
s.execute("spot.update", &json!({"moveSource": [0.01, 0.02]})).unwrap();
|
||||
let o = spots(&s)[0].source_offset.unwrap();
|
||||
assert!((o.x - src0.x - 0.01).abs() < 1e-9 && (o.y - src0.y - 0.02).abs() < 1e-9);
|
||||
// size / feather / opacity of the selected spot (clamped), one undo step each
|
||||
s.execute("spot.update", &json!({"size": 0.03, "feather": 20, "opacity": 150})).unwrap();
|
||||
let sp = &spots(&s)[0];
|
||||
assert_eq!((sp.size, sp.feather, sp.opacity), (0.03, 20.0, 100.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spots(&s)[0].size, 0.02);
|
||||
// refresh: a different source, at least a spot radius away
|
||||
let before = spots(&s)[0].source_offset.unwrap();
|
||||
let r = s.execute("spot.refreshSource", &json!({})).unwrap();
|
||||
let after = spots(&s)[0].source_offset.unwrap();
|
||||
assert!((after.x - before.x).hypot(after.y - before.y) > 0.01, "{r}");
|
||||
// delete the selected spot: the selection clears; deleting before a selected one shifts it
|
||||
s.execute("spot.delete", &json!({})).unwrap();
|
||||
assert_eq!((spots(&s).len(), s.active_spot), (1, None));
|
||||
assert_eq!(spots(&s)[0].mode, lightcraft_develop::SpotMode::Clone);
|
||||
assert!(s.execute("spot.delete", &json!({})).is_err(), "nothing selected");
|
||||
s.execute("spot.add", &json!({"points": [[0.2, 0.2]], "source": [0.05, 0.0]})).unwrap();
|
||||
s.execute("spot.delete", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
// another photo: no selection
|
||||
s.execute("library.next", &json!({})).unwrap();
|
||||
assert_eq!(s.active_spot, None);
|
||||
}
|
||||
|
|
@ -174,6 +174,11 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
|||
}
|
||||
|
||||
fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
||||
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
|
||||
}
|
||||
|
||||
/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata).
|
||||
pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec<u8> {
|
||||
use lightcraft_raw::*;
|
||||
let (w, h) = (32usize, 24usize);
|
||||
let cfa = Cfa::bayer("RGGB").unwrap();
|
||||
|
|
@ -203,9 +208,9 @@ fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
|||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: lightcraft_meta::Metadata::default(),
|
||||
metadata,
|
||||
};
|
||||
write_dng(&raw, &DngWriteOptions { xmp: Some(xmp.to_string()), ..Default::default() }).unwrap()
|
||||
write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -38,7 +38,22 @@ const MODULES: &[Module] = &[
|
|||
Module {
|
||||
src: include_str!("wgsl/map.wgsl"),
|
||||
bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")],
|
||||
entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"],
|
||||
entries: &[
|
||||
"log_lum_k",
|
||||
"dark_k",
|
||||
"guided_pre",
|
||||
"guided_ab",
|
||||
"guided_apply",
|
||||
"wb_k",
|
||||
"nr_lum",
|
||||
"chroma_k",
|
||||
"nr_col",
|
||||
"subsample",
|
||||
"redeye_k",
|
||||
"xguided_pre",
|
||||
"xguided_ab",
|
||||
"xguided_apply",
|
||||
],
|
||||
},
|
||||
Module {
|
||||
src: include_str!("wgsl/mask.wgsl"),
|
||||
|
|
@ -214,12 +229,11 @@ pub struct Gpu {
|
|||
|
||||
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
|
||||
fn constants() -> String {
|
||||
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_TERMS, SRGB_LUT_N};
|
||||
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
|
||||
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
|
||||
let mut s = String::new();
|
||||
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
|
||||
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
|
||||
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab), ("TO_SRGB", to_srgb)] {
|
||||
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] {
|
||||
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
|
||||
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
|
||||
}
|
||||
|
|
@ -227,9 +241,14 @@ fn constants() -> String {
|
|||
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
|
||||
let b = lightcraft_pipeline::geometry::BLANK;
|
||||
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
|
||||
s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N);
|
||||
s += &format!(
|
||||
"const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\nconst MASK_SUMS: u32 = {MASK_SUMS}u;\n",
|
||||
crate::params::CURVE_N
|
||||
);
|
||||
s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS);
|
||||
s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS);
|
||||
use lightcraft_pipeline::masks::{AUTO_TOL_CHROMA, AUTO_TOL_EV};
|
||||
s += &format!("const AUTO_TOL_EV: f32 = {AUTO_TOL_EV:?};\nconst AUTO_TOL_CHROMA: f32 = {AUTO_TOL_CHROMA:?};\n");
|
||||
s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT);
|
||||
for (i, h) in GRAIN_HASH.iter().enumerate() {
|
||||
s += &format!("const GRAIN_H{i}: u32 = {h}u;\n");
|
||||
|
|
|
|||
|
|
@ -184,7 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
|
|||
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if !enabled() {
|
||||
// the kernel writes 8-bit output: high-bit-depth exports render on the CPU
|
||||
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 {
|
||||
return None;
|
||||
}
|
||||
let gpu = device()?;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const FIELDS: &[(&str, usize)] = &[
|
|||
("HAS_CLAR", 1),
|
||||
("HAS_TEX", 1),
|
||||
("HAS_DARK", 1),
|
||||
("HAS_CHROMA", 1),
|
||||
("OPS_IDENTITY", 1),
|
||||
("VIBRANCE", 1),
|
||||
("SATURATION", 1),
|
||||
|
|
@ -66,6 +67,10 @@ const FIELDS: &[(&str, usize)] = &[
|
|||
("CALIB", 1),
|
||||
("CALIB_M", 9),
|
||||
("SHADOW_TINT", 1),
|
||||
("OUT_M", 9),
|
||||
("OUT_Y", 3),
|
||||
("OUT_TRC", 1),
|
||||
("OUT_GAMMA", 1),
|
||||
];
|
||||
|
||||
/// `(name, index)` of every field (for the WGSL constants).
|
||||
|
|
@ -123,6 +128,8 @@ pub struct Present {
|
|||
pub clarity: bool,
|
||||
pub texture: bool,
|
||||
pub dark: bool,
|
||||
/// The blurred chromaticity follows the mask planes in the `masks` buffer.
|
||||
pub chroma: bool,
|
||||
}
|
||||
|
||||
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs |
|
||||
|
|
@ -166,6 +173,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
|
|||
p.b("HAS_CLAR", present.clarity);
|
||||
p.b("HAS_TEX", present.texture);
|
||||
p.b("HAS_DARK", present.dark);
|
||||
p.b("HAS_CHROMA", present.chroma);
|
||||
|
||||
let ops = &fp.ops;
|
||||
p.b("OPS_IDENTITY", ops.is_identity());
|
||||
|
|
@ -212,6 +220,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
|
|||
p.fs("CALIB_M", m.as_flattened());
|
||||
}
|
||||
p.f("SHADOW_TINT", fp.shadow_tint);
|
||||
p.fs("OUT_M", fp.to_out.as_flattened());
|
||||
p.fs("OUT_Y", &fp.out_luma);
|
||||
let (trc, gamma) = fp.out_trc.code();
|
||||
p.u("OUT_TRC", trc);
|
||||
p.f("OUT_GAMMA", gamma);
|
||||
if let Some((amt, cell, rough, seed)) = fp.grain {
|
||||
p.b("GRAIN", true);
|
||||
p.f("GRAIN_AMT", amt);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ struct Planes {
|
|||
clarity: Option<(u32, Arc<Buf>)>,
|
||||
texture: Option<(u32, Arc<Buf>)>,
|
||||
dark: Option<(u32, Arc<Buf>, f32)>,
|
||||
chroma: Option<(u32, Arc<Buf>)>,
|
||||
}
|
||||
|
||||
impl GpuStages {
|
||||
|
|
@ -170,6 +171,20 @@ impl<'a> Cx<'a> {
|
|||
out
|
||||
}
|
||||
|
||||
/// `len` values of `b` from `offset` (both in 32-bit words), as a new buffer (recorded).
|
||||
pub fn slice(&mut self, b: &Buf, offset: usize, len: usize) -> Buf {
|
||||
let out = self.gpu.buffer(len);
|
||||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||||
enc.copy_buffer_to_buffer(b.raw(), (offset * 4) as u64, out.raw(), 0, (len * 4) as u64);
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy all of `src` into `dst` at `offset` (32-bit words; recorded).
|
||||
pub fn copy_into(&mut self, src: &Buf, dst: &Buf, offset: usize) {
|
||||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||||
enc.copy_buffer_to_buffer(src.raw(), 0, dst.raw(), (offset * 4) as u64, (src.len * 4) as u64);
|
||||
}
|
||||
|
||||
pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f {
|
||||
Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) }
|
||||
}
|
||||
|
|
@ -495,8 +510,13 @@ pub fn render(
|
|||
lap("masks", &mut t, &mut cx);
|
||||
|
||||
// 5. per-pixel stage
|
||||
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air);
|
||||
let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() };
|
||||
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
|
||||
let present = Present {
|
||||
clarity: prep.clarity.is_some(),
|
||||
texture: prep.texture.is_some(),
|
||||
dark: prep.dark.is_some(),
|
||||
chroma: masks.is_some() && prep.chroma.is_some(),
|
||||
};
|
||||
let (p, aux) = finish_block(&fp, &terms, &present);
|
||||
let aux = gpu.upload(&aux);
|
||||
let out = gpu.buffer(n);
|
||||
|
|
@ -516,14 +536,31 @@ pub fn render(
|
|||
],
|
||||
groups2(w, h, [16, 16]),
|
||||
);
|
||||
// the alpha a mask overlay shows: copied out before the readback below submits
|
||||
let overlay_mask = req.overlay.mask(s).map(|m| {
|
||||
let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty());
|
||||
match (list.map(|m| m.id).position(|id| id == m.id), masks.as_ref()) {
|
||||
(Some(mi), Some(all)) => Ok(cx.slice(all, mi * n, n)),
|
||||
_ => Err(m),
|
||||
}
|
||||
});
|
||||
let data: Vec<[u8; 4]> = cx.read(&out, n);
|
||||
let image = Rgba8 { width: w, height: h, data };
|
||||
lap("finish + readback", &mut t, &mut cx);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("histogram", &mut t, &mut cx);
|
||||
let mut image = image;
|
||||
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan);
|
||||
Some(Rendered { image, histogram })
|
||||
let overlay_mask = overlay_mask.map(|r| match r {
|
||||
Ok(b) => cx.read_plane(&b, w, h),
|
||||
// a hidden mask: evaluate it on the CPU
|
||||
Err(m) => {
|
||||
let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone();
|
||||
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
|
||||
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32)
|
||||
}
|
||||
});
|
||||
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
|
||||
Some(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
|
||||
/// The white-balanced, retouched, denoised image.
|
||||
|
|
@ -590,6 +627,8 @@ struct Prep {
|
|||
clarity: Option<Arc<Buf>>,
|
||||
texture: Option<Arc<Buf>>,
|
||||
dark: Option<Arc<Buf>>,
|
||||
/// Blurred chromaticity (local Moiré / Noise).
|
||||
chroma: Option<Arc<Buf>>,
|
||||
air: f32,
|
||||
}
|
||||
|
||||
|
|
@ -644,14 +683,58 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
|
|||
}
|
||||
},
|
||||
};
|
||||
Prep { log_l, base, clarity, texture, dark, air }
|
||||
let chroma = sig.chroma.map(|sg| {
|
||||
plane_at(&mut planes.chroma, sg, || {
|
||||
let ch = cx.gpu.buffer(n * 3);
|
||||
map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch);
|
||||
gaussian(cx, &ch, w, h, 3, sg)
|
||||
})
|
||||
});
|
||||
Prep { log_l, base, clarity, texture, dark, chroma, air }
|
||||
}
|
||||
|
||||
/// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush).
|
||||
const MAX_DABS: usize = 4096;
|
||||
|
||||
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated)
|
||||
/// and their adjustment terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
|
||||
/// The `shape` kernel's brush data for `dabs`: stroke records (12 words: dab offset, dab count,
|
||||
/// r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto) then the dab centres.
|
||||
fn brush_aux(dabs: &[&lightcraft_pipeline::masks::BrushDabs], erase: bool) -> Vec<f32> {
|
||||
let mut aux = Vec::new();
|
||||
let mut off = 12 * dabs.len();
|
||||
for d in dabs {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for q in &d.dabs {
|
||||
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
|
||||
}
|
||||
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, (erase && d.erase) as u8 as f32]);
|
||||
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
|
||||
aux.push(d.auto as u8 as f32);
|
||||
off += 2 * d.dabs.len();
|
||||
}
|
||||
for d in dabs {
|
||||
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
|
||||
}
|
||||
aux
|
||||
}
|
||||
|
||||
/// Cross guided filter (`masks::guided_cross`) of `p` steered by `guide`, max-combined with `p`.
|
||||
fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
|
||||
let n = w * h;
|
||||
let (m0, m1) = (cx.gpu.buffer(2 * n), cx.gpu.buffer(2 * n));
|
||||
map(cx, "xguided_pre", n, &[0], [Some(guide), Some(p), None], &m0);
|
||||
map(cx, "xguided_pre", n, &[1], [Some(guide), Some(p), None], &m1);
|
||||
let (b0, b1) = (gaussian(cx, &m0, w, h, 2, sigma), gaussian(cx, &m1, w, h, 2, sigma));
|
||||
let ab = cx.gpu.buffer(2 * n);
|
||||
map(cx, "xguided_ab", n, &[eps.to_bits()], [Some(&b0), Some(&b1), None], &ab);
|
||||
let ab = gaussian(cx, &ab, w, h, 2, sigma);
|
||||
let q = cx.gpu.buffer(n);
|
||||
map(cx, "xguided_apply", n, &[], [Some(guide), Some(&ab), Some(p)], &q);
|
||||
q
|
||||
}
|
||||
|
||||
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated,
|
||||
/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment
|
||||
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
|
||||
fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
|
||||
use lightcraft_develop::{MaskOp, MaskShape};
|
||||
let s = &*plan.settings;
|
||||
|
|
@ -667,7 +750,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
|||
let [a, b, c, d, e, f] = frame.out_to_norm(w, h).0;
|
||||
let (kx, ky) = (frame.ow / long, frame.oh / long);
|
||||
let aff = [a * kx, b * ky, c * kx, d * ky, e * kx, f * ky].map(|v| (v as f32).to_bits());
|
||||
let alpha = cx.zeroed(n * list.len());
|
||||
let alpha = cx.zeroed(n * list.len() + if prep.chroma.is_some() { 3 * n } else { 0 });
|
||||
if let Some(ch) = &prep.chroma {
|
||||
cx.copy_into(ch, &alpha, n * list.len());
|
||||
}
|
||||
let comp_plane = cx.gpu.buffer(n);
|
||||
for (mi, m) in list.iter().enumerate() {
|
||||
let mut first = true;
|
||||
|
|
@ -683,6 +769,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
|||
p.extend_from_slice(&aff);
|
||||
let f32s = |v: &[f64]| v.iter().map(|x| (*x as f32).to_bits()).collect::<Vec<u32>>();
|
||||
let mut aux: Vec<f32> = Vec::new();
|
||||
let mut own: Option<Buf> = None;
|
||||
let kind = match &comp.shape {
|
||||
MaskShape::Linear { start, end } => {
|
||||
let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end));
|
||||
|
|
@ -710,29 +797,46 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
|||
}
|
||||
MaskShape::Brush { strokes } => {
|
||||
let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect();
|
||||
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() <= MAX_DABS {
|
||||
p.push(dabs.len() as u32);
|
||||
let mut off = 11 * dabs.len();
|
||||
for d in &dabs {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for q in &d.dabs {
|
||||
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
|
||||
}
|
||||
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, d.erase as u8 as f32]);
|
||||
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
|
||||
off += 2 * d.dabs.len();
|
||||
}
|
||||
for d in &dabs {
|
||||
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
|
||||
}
|
||||
Some(4)
|
||||
} else {
|
||||
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS {
|
||||
None
|
||||
} else if dabs.iter().any(|d| d.auto) {
|
||||
// Auto Mask: stroke by stroke (each one refined before it's combined)
|
||||
let brush = cx.zeroed(n);
|
||||
let stroke = cx.gpu.buffer(n);
|
||||
for d in &dabs {
|
||||
let mut q = p.clone();
|
||||
q[2] = 4;
|
||||
q[3] = 0;
|
||||
q.push(1);
|
||||
let aux = cx.gpu.upload(&brush_aux(&[d], false));
|
||||
cx.run("shape", &q, &[Some(lin), Some(&prep.log_l), Some(&aux), Some(&stroke), Some(&brush)], groups2(w, h, [16, 16]));
|
||||
let refined = d.auto.then(|| {
|
||||
let (sigma, eps) = lightcraft_pipeline::masks::auto_refine(d.r);
|
||||
guided_cross_max(cx, &prep.log_l, &stroke, w, h, sigma, eps)
|
||||
});
|
||||
let op = if d.erase { 2 } else { 1 };
|
||||
cx.run(
|
||||
"combine",
|
||||
&[n as u32, 0, 0, op],
|
||||
&[None, None, None, Some(refined.as_ref().unwrap_or(&stroke)), Some(&brush)],
|
||||
groups1(n),
|
||||
);
|
||||
}
|
||||
if comp.invert {
|
||||
cx.run("finalize", &[n as u32, 0, 0, 1, 1f32.to_bits()], &[None, None, None, Some(&stroke), Some(&brush)], groups1(n));
|
||||
}
|
||||
own = Some(brush);
|
||||
None
|
||||
} else {
|
||||
p.push(dabs.len() as u32);
|
||||
aux = brush_aux(&dabs.iter().collect::<Vec<_>>(), true);
|
||||
Some(4)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let plane = match kind {
|
||||
_ if own.is_some() => own,
|
||||
Some(k) => {
|
||||
p[2] = k;
|
||||
let aux = (!aux.is_empty()).then(|| cx.gpu.upload(&aux));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it).
|
||||
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes), aux
|
||||
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the
|
||||
// blurred chromaticity when HAS_CHROMA), aux
|
||||
// (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
|
||||
|
||||
fn tone_apply(y: f32) -> f32 {
|
||||
|
|
@ -218,6 +219,26 @@ fn enc8(v: f32) -> u32 {
|
|||
return u32(clamp(v, 0.0, 1.0) * 255.0 + 0.5);
|
||||
}
|
||||
|
||||
// sRGB-curve-encoded value → the output space's own curve (`OutputTrc`: 0 sRGB, 1 gamma, 2 Rec.709).
|
||||
fn out_encode(v: f32) -> f32 {
|
||||
let kind = pu(F_OUT_TRC);
|
||||
if (kind == 0u) {
|
||||
return v;
|
||||
}
|
||||
let e = clamp(v, 0.0, 1.0);
|
||||
var l = e / 12.92;
|
||||
if (e > 0.04045) {
|
||||
l = pow((e + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
if (kind == 1u) {
|
||||
return pow(l, 1.0 / pf(F_OUT_GAMMA));
|
||||
}
|
||||
if (l < 0.018) {
|
||||
return l * 4.5;
|
||||
}
|
||||
return 1.099 * pow(l, 0.45) - 0.099;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = pu(F_W);
|
||||
|
|
@ -239,7 +260,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let l0 = l_pre + ev;
|
||||
|
||||
// --- local (mask) contributions
|
||||
var lt: array<f32, 14>;
|
||||
var lt: array<f32, MASK_SUMS>;
|
||||
var tint_on = false;
|
||||
var tint_dir = vec2<f32>(0.0, 0.0);
|
||||
var tint_amt = 0.0;
|
||||
|
|
@ -250,18 +271,42 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
continue;
|
||||
}
|
||||
let t = pu(F_MASK_OFF) + m * MASK_TERMS;
|
||||
for (var k = 0u; k < 14u; k++) {
|
||||
for (var k = 0u; k < MASK_SUMS; k++) {
|
||||
lt[k] += a * aux[t + k];
|
||||
}
|
||||
if (aux[t + 14u] > 0.0) {
|
||||
if (aux[t + MASK_SUMS] > 0.0) {
|
||||
tint_on = true;
|
||||
tint_dir = vec2<f32>(aux[t + 15u], aux[t + 16u]);
|
||||
tint_amt = a * aux[t + 17u];
|
||||
tint_dir = vec2<f32>(aux[t + MASK_SUMS + 1u], aux[t + MASK_SUMS + 2u]);
|
||||
tint_amt = a * aux[t + MASK_SUMS + 3u];
|
||||
}
|
||||
}
|
||||
let l_exp = lt[0];
|
||||
let l_temp = lt[1];
|
||||
let l_tint = lt[2];
|
||||
let l_noise = lt[14];
|
||||
|
||||
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
|
||||
let mt = clamp(lt[15] + 0.5 * max(l_noise, 0.0), -1.0, 1.0);
|
||||
if (mt != 0.0 && pu(F_HAS_CHROMA) != 0u) {
|
||||
let raw = vec3<f32>(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]);
|
||||
let y = lum2020(c);
|
||||
let ch0 = raw / max(lum2020(raw), 1e-6);
|
||||
let o = nm * n + 3u * i;
|
||||
let cb = vec3<f32>(masks[o], masks[o + 1u], masks[o + 2u]);
|
||||
c = max((ch0 + (cb - ch0) * mt) * y, vec3<f32>(0.0));
|
||||
}
|
||||
// --- local Defringe: desaturate purple / green fringes along edges
|
||||
let df = clamp(lt[16], 0.0, 1.0);
|
||||
if (df > 0.0 && pu(F_HAS_TEX) != 0u) {
|
||||
let yd = max(lum2020(c), 1e-6);
|
||||
let purple = (min(c.x, c.z) - c.y) / yd;
|
||||
let green = (c.y - max(c.x, c.z)) / yd;
|
||||
let k = df * sstep(0.04, 0.3, abs(l_pre - tex[i])) * max(sstep(0.02, 0.2, purple), sstep(0.02, 0.2, green));
|
||||
if (k > 0.0) {
|
||||
let y = lum2020(c);
|
||||
c = c + (y - c) * k;
|
||||
}
|
||||
}
|
||||
|
||||
// --- dehaze (scene linear)
|
||||
let dz = pf(F_DEHAZE) + lt[10];
|
||||
|
|
@ -334,6 +379,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk;
|
||||
}
|
||||
}
|
||||
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
|
||||
if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) {
|
||||
let det = l_pre - tex[i];
|
||||
delta -= clamp(l_noise, -1.0, 1.0) * 0.9 * det * (1.0 - sstep(0.1, 0.5, abs(det)));
|
||||
}
|
||||
if (delta != 0.0) {
|
||||
c = c * exp2(delta);
|
||||
}
|
||||
|
|
@ -399,9 +449,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- gamut map to sRGB (desaturate towards luminance until in range)
|
||||
var r = mul3(TO_SRGB, d);
|
||||
let yy = clamp(0.2126 * r.x + 0.7152 * r.y + 0.0722 * r.z, 0.0, 1.0);
|
||||
// --- gamut map to the output space (desaturate towards luminance until in range)
|
||||
let om = array<vec3<f32>, 3>(
|
||||
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
|
||||
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
|
||||
vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)),
|
||||
);
|
||||
var r = mul3(om, d);
|
||||
let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0);
|
||||
var tg = 1.0;
|
||||
for (var k = 0u; k < 3u; k++) {
|
||||
let cc = r[k];
|
||||
|
|
@ -439,5 +494,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
|
||||
e = e + k;
|
||||
}
|
||||
e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z));
|
||||
out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgr
|
|||
dst[i] = b[2u * i] * a[i] + b[2u * i + 1u];
|
||||
}
|
||||
|
||||
// Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input);
|
||||
// 1: (guide·input, guide²).
|
||||
@compute @workgroup_size(256)
|
||||
fn xguided_pre(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
|
||||
let i = lin_index(g, nw);
|
||||
if (i >= pu(0u)) {
|
||||
return;
|
||||
}
|
||||
let gi = a[i];
|
||||
let p = b[i];
|
||||
if (pu(1u) == 0u) {
|
||||
dst[2u * i] = gi;
|
||||
dst[2u * i + 1u] = p;
|
||||
} else {
|
||||
dst[2u * i] = gi * p;
|
||||
dst[2u * i + 1u] = gi * gi;
|
||||
}
|
||||
}
|
||||
|
||||
// Coefficients (a, b) from blurred means (a: guide, input) and correlations (b: guide·input,
|
||||
// guide²). P[1] = eps.
|
||||
@compute @workgroup_size(256)
|
||||
fn xguided_ab(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
|
||||
let i = lin_index(g, nw);
|
||||
if (i >= pu(0u)) {
|
||||
return;
|
||||
}
|
||||
let mi = a[2u * i];
|
||||
let mp = a[2u * i + 1u];
|
||||
let v = max(b[2u * i + 1u] - mi * mi, 0.0);
|
||||
let k = (b[2u * i] - mi * mp) / (v + pf(1u));
|
||||
dst[2u * i] = k;
|
||||
dst[2u * i + 1u] = mp - k * mi;
|
||||
}
|
||||
|
||||
// `max(p, clamp(a·guide + b, 0, 1))` (a: guide, b: blurred coefficients, c: the input p).
|
||||
@compute @workgroup_size(256)
|
||||
fn xguided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
|
||||
let i = lin_index(g, nw);
|
||||
if (i >= pu(0u)) {
|
||||
return;
|
||||
}
|
||||
dst[i] = max(c[i], clamp(b[2u * i] * a[i] + b[2u * i + 1u], 0.0, 1.0));
|
||||
}
|
||||
|
||||
// White balance: 3×3 matrix (P[1] = has matrix, P[2..11] row-major), clamped at 0.
|
||||
@compute @workgroup_size(256)
|
||||
fn wb_k(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2<f32> {
|
|||
// 2 luminance range: lo, hi, lo feather, hi feather, ev
|
||||
// 3 colour range: tol, gain, sample count (OkLab triples in aux)
|
||||
// 4 brush: stroke count (stroke records + dabs in aux)
|
||||
// `masks::chromaticity` of pixel i.
|
||||
fn chroma_at(i: u32) -> vec3<f32> {
|
||||
let c = vec3<f32>(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]);
|
||||
return c / max(lum2020(c), 1e-6);
|
||||
}
|
||||
|
||||
// `masks::auto_similarity`.
|
||||
fn auto_similarity(l: f32, ch: vec3<f32>, rl: f32, rch: vec3<f32>) -> f32 {
|
||||
let dl = (l - rl) / AUTO_TOL_EV;
|
||||
let dc = length(ch - rch) / AUTO_TOL_CHROMA;
|
||||
return 1.0 - sstep(0.5, 1.0, sqrt(dl * dl + dc * dc));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
|
||||
let w = pu(0u);
|
||||
|
|
@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
|
|||
}
|
||||
v = 1.0 - sstep(tol * 0.5, tol, best);
|
||||
} else if (kind == 4u) {
|
||||
// stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1
|
||||
// stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto
|
||||
let px = f32(g.x) + 0.5;
|
||||
let py = f32(g.y) + 0.5;
|
||||
let lp = log_l[i];
|
||||
let chp = chroma_at(i);
|
||||
for (var k = 0u; k < pu(10u); k++) {
|
||||
let o = 11u * k;
|
||||
let o = 12u * k;
|
||||
let auto_mask = aux[o + 11u] != 0.0;
|
||||
if (px < aux[o + 7u] || py < aux[o + 8u] || px > aux[o + 9u] || py > aux[o + 10u]) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -89,6 +105,12 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
|
|||
if (dd > hard) {
|
||||
a = 1.0 - sstep(hard, r, dd);
|
||||
}
|
||||
if (auto_mask) {
|
||||
let rx = min(u32(max(floor(aux[off + 2u * j]), 0.0)), w - 1u);
|
||||
let ry = min(u32(max(floor(aux[off + 2u * j + 1u]), 0.0)), h - 1u);
|
||||
let ri = ry * w + rx;
|
||||
a *= auto_similarity(lp, chp, log_l[ri], chroma_at(ri));
|
||||
}
|
||||
keep *= 1.0 - a * flow;
|
||||
}
|
||||
let sa = min(1.0 - keep, aux[o + 5u]);
|
||||
|
|
|
|||
|
|
@ -275,6 +275,37 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
|||
},
|
||||
];
|
||||
}),
|
||||
("masks (local noise, moiré, defringe)", |s| {
|
||||
let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false };
|
||||
let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) };
|
||||
let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() };
|
||||
use lightcraft_develop::LocalAdjustments as L;
|
||||
s.masks = vec![
|
||||
m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }),
|
||||
m(linear, L { noise: -60.0, moire: -40.0, ..Default::default() }),
|
||||
];
|
||||
}),
|
||||
("masks (auto mask brush)", |s| {
|
||||
let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke {
|
||||
points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(),
|
||||
size: 0.06,
|
||||
feather: 40.0,
|
||||
flow: 80.0,
|
||||
auto_mask,
|
||||
erase,
|
||||
..Default::default()
|
||||
};
|
||||
let strokes = vec![
|
||||
st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false),
|
||||
st(&[(0.3, 0.2), (0.6, 0.25)], false, false),
|
||||
st(&[(0.5, 0.58)], true, true),
|
||||
];
|
||||
s.masks = vec![Mask {
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
|
||||
adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
|
||||
..Default::default()
|
||||
}];
|
||||
}),
|
||||
("calibration", |s| {
|
||||
s.calibration.shadows_tint = 40.0;
|
||||
s.calibration.red_hue = 50.0;
|
||||
|
|
@ -414,6 +445,43 @@ fn overlays_match() {
|
|||
eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0);
|
||||
assert!(differ < 0.002, "spots t={t}: {differ}");
|
||||
}
|
||||
// Mask overlays: the alpha the GPU evaluated (or, for a hidden mask, the CPU's) drawn the same way.
|
||||
let mut s = DevelopSettings::default();
|
||||
let stroke = BrushStroke { points: vec![Point::new(0.2, 0.3), Point::new(0.6, 0.5)], size: 0.05, ..Default::default() };
|
||||
s.masks = vec![
|
||||
Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent {
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false },
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Mask {
|
||||
id: 2,
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }],
|
||||
..Default::default()
|
||||
},
|
||||
Mask {
|
||||
id: 3,
|
||||
visible: false,
|
||||
components: vec![MaskComponent {
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
s.masks[0].adjust.exposure = 0.5;
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
for (id, view) in
|
||||
[(1, MaskView::Color), (2, MaskView::ColorOnBw), (2, MaskView::WhiteOnBlack), (3, MaskView::ImageOnWhite), (1, MaskView::ImageOnBlack)]
|
||||
{
|
||||
let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) };
|
||||
check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -462,3 +530,32 @@ fn red_eye_matches() {
|
|||
};
|
||||
check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600));
|
||||
}
|
||||
|
||||
fn saturated(s: &mut DevelopSettings) {
|
||||
typical(s);
|
||||
s.color.saturation = 70.0;
|
||||
s.color.vibrance = 40.0;
|
||||
s.curve.highlights = 30.0;
|
||||
s.curve.shadows = -20.0;
|
||||
s.grain.amount = 30.0;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_spaces_match() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
// Export colour spaces: matrix to the target primaries, gamut mapping into the target gamut and
|
||||
// its encoding curve, on both renderers. Saturated edits push colours to the gamut edges.
|
||||
use lightcraft_pipeline::OutputSpace;
|
||||
let src = scene(0, 900, 600);
|
||||
let raw = SourceInfo { raw: true, ..Default::default() };
|
||||
for space in OutputSpace::ALL {
|
||||
for (name, edit) in [("typical", typical as Edit), ("saturated + curves + grain", saturated as Edit)] {
|
||||
let mut s = DevelopSettings::default();
|
||||
edit(&mut s);
|
||||
let req = RenderRequest { space, ..RenderRequest::fit(640, 640) };
|
||||
check(&format!("{space:?} {name}"), &src, &raw, &s, &req);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
|
||||
}),
|
||||
&[],
|
||||
|
|
@ -534,6 +536,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
|||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ serde = { workspace = true }
|
|||
lightcraft-scenes = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass.
|
||||
|
||||
use lightcraft_color::luminance_2020;
|
||||
use lightcraft_color::spline::{Lut1, MonotoneCurve};
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_color::{REC2020, SRGB, luminance_2020};
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
|
@ -10,6 +10,7 @@ use lightcraft_raster::Rgba8;
|
|||
use crate::colorops::ColorOps;
|
||||
use crate::geometry::Frame;
|
||||
use crate::local::log_lum;
|
||||
use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
|
||||
use crate::tone::ToneMap;
|
||||
use crate::{Prepared, SourceInfo, for_rows};
|
||||
|
||||
|
|
@ -105,12 +106,15 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
|
|||
}
|
||||
|
||||
/// Number of per-mask terms in [`mask_terms`].
|
||||
pub const MASK_TERMS: usize = 18;
|
||||
pub const MASK_TERMS: usize = 21;
|
||||
|
||||
/// Number of alpha-weighted terms at the start of [`mask_terms`].
|
||||
pub const MASK_SUMS: usize = 17;
|
||||
|
||||
/// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order:
|
||||
/// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze,
|
||||
/// saturation, hue, sharpness, then the colour overlay: on (0/1), cos and sin of its OkLCh hue, and
|
||||
/// its strength.
|
||||
/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and
|
||||
/// sin of its OkLCh hue, and its strength.
|
||||
pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
|
||||
let (on, cos, sin, amt) = if j.color_sat > 0.0 {
|
||||
let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue);
|
||||
|
|
@ -133,6 +137,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
|
|||
(j.saturation / 100.0) as f32,
|
||||
(j.hue / 100.0) as f32 * 0.6,
|
||||
(j.sharpness / 100.0) as f32,
|
||||
(j.noise / 100.0) as f32,
|
||||
(j.moire / 100.0) as f32,
|
||||
(j.defringe / 100.0) as f32,
|
||||
on,
|
||||
cos,
|
||||
sin,
|
||||
|
|
@ -153,7 +160,11 @@ pub struct FinishParams {
|
|||
/// Refine Saturation as 0..1 (1 = the curves' own saturation).
|
||||
pub refine_sat: f32,
|
||||
pub vig: Option<Vig>,
|
||||
pub to_srgb: [[f32; 3]; 3],
|
||||
/// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its
|
||||
/// encoding curve (see [`crate::output`]).
|
||||
pub to_out: [[f32; 3]; 3],
|
||||
pub out_luma: [f32; 3],
|
||||
pub out_trc: OutputTrc,
|
||||
pub hl: f32,
|
||||
pub sh: f32,
|
||||
pub clar: f32,
|
||||
|
|
@ -178,8 +189,18 @@ pub struct FinishParams {
|
|||
}
|
||||
|
||||
impl FinishParams {
|
||||
/// Parameters for a `w × h` render; `ev`/`air_pre` as in [`crate::Prepared`].
|
||||
pub fn new(s: &DevelopSettings, frame: &Frame, info: &SourceInfo, w: usize, h: usize, px_per_long: f64, air_pre: f32) -> FinishParams {
|
||||
/// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
s: &DevelopSettings,
|
||||
frame: &Frame,
|
||||
info: &SourceInfo,
|
||||
w: usize,
|
||||
h: usize,
|
||||
px_per_long: f64,
|
||||
air_pre: f32,
|
||||
space: OutputSpace,
|
||||
) -> FinishParams {
|
||||
let effects = s.section_enabled("effects");
|
||||
let (clar, tex, dehaze) = if effects {
|
||||
((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32)
|
||||
|
|
@ -205,7 +226,9 @@ impl FinishParams {
|
|||
curves: curve_luts(&s.curve),
|
||||
refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32,
|
||||
vig: if effects { vignette(s) } else { None },
|
||||
to_srgb: REC2020.to_space(&SRGB).to_f32(),
|
||||
to_out: space.from_working(),
|
||||
out_luma: space.luma(),
|
||||
out_trc: space.trc(),
|
||||
hl: (s.light.highlights / 100.0) as f32,
|
||||
sh: (s.light.shadows / 100.0) as f32,
|
||||
clar,
|
||||
|
|
@ -228,10 +251,74 @@ impl FinishParams {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 {
|
||||
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air);
|
||||
let FinishParams { tone, ops, curves, vig, to_srgb, hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev, grain, .. } = &fp;
|
||||
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
let trc = fp.out_trc;
|
||||
let data = finish_with(p, &fp, false, |e| match trc {
|
||||
OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255],
|
||||
t => {
|
||||
let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0)))));
|
||||
[x[0], x[1], x[2], 255]
|
||||
}
|
||||
});
|
||||
Rgba8 { width: w, height: h, data }
|
||||
}
|
||||
|
||||
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
|
||||
pub(crate) fn finish_deep(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, depth: OutputDepth) -> DeepImage {
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
let trc = fp.out_trc;
|
||||
let samples = match depth {
|
||||
OutputDepth::F32Linear => {
|
||||
let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0))));
|
||||
DeepSamples::F32(v.into_flattened())
|
||||
}
|
||||
_ => {
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
|
||||
let v = finish_with(p, &fp, true, |e| match trc {
|
||||
OutputTrc::Srgb => e.map(q),
|
||||
t => e.map(|v| q(t.encode(srgb_to_linear(v.clamp(0.0, 1.0))))),
|
||||
});
|
||||
DeepSamples::U16(v.into_flattened())
|
||||
}
|
||||
};
|
||||
DeepImage { width: w, height: h, space, samples }
|
||||
}
|
||||
|
||||
/// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB
|
||||
/// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding.
|
||||
/// `exact`: encode with the exact sRGB curve instead of the (8/10-bit accurate) table.
|
||||
pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
p: &Prepared,
|
||||
fp: &FinishParams,
|
||||
exact: bool,
|
||||
store: impl Fn([f32; 3]) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let FinishParams {
|
||||
tone,
|
||||
ops,
|
||||
curves,
|
||||
vig,
|
||||
to_out,
|
||||
out_luma,
|
||||
hl,
|
||||
sh,
|
||||
clar,
|
||||
tex,
|
||||
dehaze,
|
||||
sharpen,
|
||||
sharpen_mask,
|
||||
air,
|
||||
air_pre,
|
||||
gain,
|
||||
ev,
|
||||
grain,
|
||||
..
|
||||
} = fp;
|
||||
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
|
||||
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
|
||||
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
|
||||
|
|
@ -240,8 +327,8 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
let aspect = w as f32 / h as f32;
|
||||
|
||||
let srgb = srgb_lut();
|
||||
let mut out = Rgba8::new(w, h);
|
||||
for_rows(&mut out.data, w, |y, row| {
|
||||
let mut out = vec![T::default(); w * h];
|
||||
for_rows(&mut out, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let raw = p.img.data[i];
|
||||
|
|
@ -250,21 +337,42 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
let l0 = l_pre + ev;
|
||||
|
||||
// --- local (mask) contributions: alpha-weighted sums of the masks' terms
|
||||
let mut l = [0.0f32; 14];
|
||||
let mut l = [0.0f32; MASK_SUMS];
|
||||
let mut tint_col: Option<([f32; 3], f32)> = None;
|
||||
for (m, t) in p.masks.iter().zip(&terms) {
|
||||
let a = m.alpha.data[i];
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for k in 0..14 {
|
||||
for k in 0..MASK_SUMS {
|
||||
l[k] += a * t[k];
|
||||
}
|
||||
if t[14] > 0.0 {
|
||||
tint_col = Some(([t[15], t[16], 0.0], a * t[17]));
|
||||
if t[MASK_SUMS] > 0.0 {
|
||||
tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3]));
|
||||
}
|
||||
}
|
||||
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] =
|
||||
l;
|
||||
|
||||
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
|
||||
let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
|
||||
if mt != 0.0
|
||||
&& let Some(cb) = &p.chroma_blur
|
||||
{
|
||||
let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]);
|
||||
c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0));
|
||||
}
|
||||
// --- local Defringe: desaturate purple / green fringes along edges
|
||||
let df = l_defringe.clamp(0.0, 1.0);
|
||||
if df > 0.0
|
||||
&& let Some(b) = &p.texture_blur
|
||||
{
|
||||
let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]);
|
||||
if k > 0.0 {
|
||||
let y = luminance_2020(c);
|
||||
c = c.map(|v| v + (y - v) * k);
|
||||
}
|
||||
}
|
||||
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp] = l;
|
||||
|
||||
// --- dehaze (scene linear)
|
||||
let dz = dehaze + l_dehaze;
|
||||
|
|
@ -334,6 +442,13 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
|
||||
}
|
||||
}
|
||||
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
|
||||
if l_noise != 0.0
|
||||
&& let Some(b) = &p.texture_blur
|
||||
{
|
||||
let det = l_pre - b.data[i];
|
||||
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs()));
|
||||
}
|
||||
if delta != 0.0 {
|
||||
let g = delta.exp2();
|
||||
c = c.map(|v| v * g);
|
||||
|
|
@ -388,14 +503,14 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
}
|
||||
}
|
||||
|
||||
// --- gamut map to sRGB (desaturate towards luminance until in range)
|
||||
let m = to_srgb;
|
||||
// --- gamut map to the output space (desaturate towards luminance until in range)
|
||||
let m = to_out;
|
||||
let mut r = [
|
||||
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
|
||||
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
|
||||
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
|
||||
];
|
||||
let yy = (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]).clamp(0.0, 1.0);
|
||||
let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0);
|
||||
let mut t = 1.0f32;
|
||||
for c in r {
|
||||
if c < 0.0 {
|
||||
|
|
@ -409,7 +524,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
}
|
||||
|
||||
// --- encode, curves, grain
|
||||
let mut e = r.map(|v| encode_srgb(srgb, v));
|
||||
let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) };
|
||||
if let Some(l) = curves {
|
||||
let e0 = e;
|
||||
e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])];
|
||||
|
|
@ -427,12 +542,22 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
|
||||
e = e.map(|v| v + k);
|
||||
}
|
||||
*px = [enc(e[0]), enc(e[1]), enc(e[2]), 255];
|
||||
*px = store(e);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from
|
||||
/// its fine blur: high on strong edges with a purple or green cast.
|
||||
#[inline]
|
||||
pub fn defringe_weight(c: [f32; 3], det: f32) -> f32 {
|
||||
let y = luminance_2020(c).max(1e-6);
|
||||
let purple = (c[0].min(c[2]) - c[1]) / y;
|
||||
let green = (c[1] - c[0].max(c[2])) / y;
|
||||
smooth(0.04, 0.3, det.abs()) * smooth(0.02, 0.2, purple).max(smooth(0.02, 0.2, green))
|
||||
}
|
||||
|
||||
/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its
|
||||
/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is
|
||||
/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
//! clarity, local adjustments (masks)
|
||||
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
|
||||
//! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh)
|
||||
//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain
|
||||
//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain
|
||||
//!
|
||||
//! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a
|
||||
//! 60 MP export look alike.
|
||||
|
|
@ -29,6 +29,7 @@ pub mod geometry;
|
|||
pub mod local;
|
||||
pub mod masks;
|
||||
pub mod optics;
|
||||
pub mod output;
|
||||
pub mod profiles;
|
||||
pub mod redeye;
|
||||
pub mod spots;
|
||||
|
|
@ -37,7 +38,8 @@ pub mod transform;
|
|||
pub mod upright;
|
||||
pub mod visualize;
|
||||
|
||||
pub use visualize::Overlay;
|
||||
pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
|
||||
pub use visualize::{MaskView, Overlay};
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Treatment};
|
||||
use std::any::Any;
|
||||
|
|
@ -84,17 +86,24 @@ pub struct RenderRequest {
|
|||
pub apply_crop: bool,
|
||||
/// A diagnostic view drawn over the result (e.g. Point Color's "visualize range").
|
||||
pub overlay: Overlay,
|
||||
/// Colour space of the result (exports; previews stay sRGB).
|
||||
pub space: OutputSpace,
|
||||
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
|
||||
pub depth: OutputDepth,
|
||||
}
|
||||
|
||||
impl RenderRequest {
|
||||
pub fn fit(max_w: usize, max_h: usize) -> Self {
|
||||
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None }
|
||||
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rendered {
|
||||
/// The result, 8-bit (for deep renders: `deep` reduced to 8 bits, display-encoded).
|
||||
pub image: Rgba8,
|
||||
pub histogram: Histogram,
|
||||
/// The high-bit-depth result when [`RenderRequest::depth`] asks for one.
|
||||
pub deep: Option<DeepImage>,
|
||||
}
|
||||
|
||||
/// Everything the per-pixel stage needs, precomputed at output resolution.
|
||||
|
|
@ -110,6 +119,8 @@ pub(crate) struct Prepared {
|
|||
pub clarity_blur: Option<Arc<Plane>>,
|
||||
pub texture_blur: Option<Arc<Plane>>,
|
||||
pub dark: Option<Arc<Plane>>,
|
||||
/// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise.
|
||||
pub chroma_blur: Option<Arc<Rgb32f>>,
|
||||
/// Airlight of `dark` (before exposure).
|
||||
pub air: f32,
|
||||
pub masks: Vec<masks::Evaluated>,
|
||||
|
|
@ -377,13 +388,32 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
|
|||
if let Some((a, c)) = shared {
|
||||
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
|
||||
}
|
||||
let image = finish::finish(&prep, s, frame, info);
|
||||
if req.depth != OutputDepth::U8 {
|
||||
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth);
|
||||
let image = deep.to_rgba8();
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("finish (deep)", &mut t);
|
||||
return Rendered { image, histogram, deep: Some(deep) };
|
||||
}
|
||||
let image = finish::finish(&prep, s, frame, info, req.space);
|
||||
lap("finish", &mut t);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("histogram", &mut t);
|
||||
let mut image = image;
|
||||
visualize::apply(&mut image, req.overlay, &plan);
|
||||
Rendered { image, histogram }
|
||||
let mask = overlay_alpha(req.overlay, &plan, &prep);
|
||||
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
|
||||
Rendered { image, histogram, deep: None }
|
||||
}
|
||||
|
||||
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
|
||||
/// fresh evaluation.
|
||||
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
|
||||
let m = o.mask(&plan.settings)?;
|
||||
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
|
||||
return Some(e.alpha.clone());
|
||||
}
|
||||
let ev = plan.settings.light.exposure as f32;
|
||||
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev))
|
||||
}
|
||||
|
||||
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
|
||||
|
|
@ -423,3 +453,5 @@ pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
|
|||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_geometry;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ pub(crate) struct Planes {
|
|||
pub texture: Option<(u32, Arc<Plane>)>,
|
||||
/// Dark channel and its airlight.
|
||||
pub dark: Option<(u32, Arc<Plane>, f32)>,
|
||||
/// Blurred chromaticity (local Moiré / Noise).
|
||||
pub chroma: Option<(u32, Arc<Rgb32f>)>,
|
||||
}
|
||||
|
||||
/// Reuse `slot` if it was computed at `sigma`, else compute and store it.
|
||||
|
|
@ -232,6 +234,9 @@ pub fn dark_of(c: [f32; 3]) -> f32 {
|
|||
c[0].min(c[1]).min(c[2])
|
||||
}
|
||||
|
||||
/// Radius of the local Moiré / colour-noise chromaticity blur, as a fraction of the long edge.
|
||||
pub const CHROMA_SIGMA: f32 = 0.004;
|
||||
|
||||
/// Radii (px) of the spatial planes the settings need (`None` = not needed).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PlaneSigmas {
|
||||
|
|
@ -243,6 +248,8 @@ pub struct PlaneSigmas {
|
|||
pub texture: Option<f32>,
|
||||
/// Dehaze dark channel (Gaussian).
|
||||
pub dark: Option<f32>,
|
||||
/// Chromaticity blur for local Moiré / colour noise (Gaussian).
|
||||
pub chroma: Option<f32>,
|
||||
}
|
||||
|
||||
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
|
||||
|
|
@ -256,10 +263,17 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|
|||
if q == Quality::Draft { sigma.min(24.0) } else { sigma }
|
||||
});
|
||||
let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0));
|
||||
let texture = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness))
|
||||
.then(|| (0.0018 * ppl).max(0.6));
|
||||
// local Noise and Defringe read the fine detail band too
|
||||
let texture = (s.effects.texture != 0.0
|
||||
|| s.detail.sharpen_amount != 0.0
|
||||
|| local_any(|a| a.texture)
|
||||
|| local_any(|a| a.sharpness)
|
||||
|| local_any(|a| a.noise)
|
||||
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
|
||||
.then(|| (0.0018 * ppl).max(0.6));
|
||||
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
|
||||
PlaneSigmas { base, clarity, texture, dark }
|
||||
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
|
||||
PlaneSigmas { base, clarity, texture, dark, chroma }
|
||||
}
|
||||
|
||||
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
|
||||
|
|
@ -267,9 +281,18 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|
|||
/// one alone scales poorly: the guided filters work on small subsampled grids).
|
||||
pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared {
|
||||
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
|
||||
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma } = plane_sigmas(s, px_per_long, q);
|
||||
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
|
||||
plane_sigmas(s, px_per_long, q);
|
||||
|
||||
let Planes { base: sb, clarity: sc, texture: st, dark: sd, .. } = planes;
|
||||
let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes;
|
||||
let chroma_blur = chroma_sigma.map(|sg| match sch {
|
||||
Some((k, c)) if *k == sg.to_bits() => c.clone(),
|
||||
_ => {
|
||||
let c = timed("chroma", || Arc::new(gaussian(&img.map(masks::chromaticity), sg)));
|
||||
*sch = Some((sg.to_bits(), c.clone()));
|
||||
c
|
||||
}
|
||||
});
|
||||
let l = &log_l;
|
||||
let (base, (clarity_blur, (texture_blur, dark))) = par_join(
|
||||
|| base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, BASE_EPS)))),
|
||||
|
|
@ -302,7 +325,7 @@ pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_p
|
|||
};
|
||||
let ev = s.light.exposure as f32;
|
||||
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev));
|
||||
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, air, masks, px_per_long }
|
||||
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
|
||||
}
|
||||
|
||||
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ use crate::for_rows;
|
|||
use crate::geometry::Frame;
|
||||
|
||||
pub struct Evaluated {
|
||||
/// The mask's id ([`Mask::id`]).
|
||||
pub id: u32,
|
||||
pub alpha: Plane,
|
||||
pub adjust: LocalAdjustments,
|
||||
}
|
||||
|
|
@ -21,44 +23,49 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
|||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// The visible masks with components, evaluated in order.
|
||||
pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec<Evaluated> {
|
||||
masks
|
||||
.iter()
|
||||
.filter(|m| m.visible && !m.components.is_empty())
|
||||
.map(|m| {
|
||||
let mut alpha = Plane::new(w, h);
|
||||
let mut first = true;
|
||||
for comp in &m.components {
|
||||
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev);
|
||||
if comp.invert {
|
||||
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
if first && comp.op != MaskOp::Intersect {
|
||||
alpha = c;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
for (a, c) in alpha.data.iter_mut().zip(&c.data) {
|
||||
*a = match comp.op {
|
||||
MaskOp::Add => a.max(*c),
|
||||
MaskOp::Subtract => *a * (1.0 - c),
|
||||
MaskOp::Intersect => *a * c,
|
||||
};
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if m.invert {
|
||||
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
let amt = (m.adjust.amount / 100.0) as f32;
|
||||
if (amt - 1.0).abs() > 1e-6 {
|
||||
alpha.data.iter_mut().for_each(|v| *v *= amt);
|
||||
}
|
||||
Evaluated { alpha, adjust: m.adjust }
|
||||
})
|
||||
.map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev), adjust: m.adjust })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and
|
||||
/// scaled by its amount.
|
||||
pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane {
|
||||
let mut alpha = Plane::new(w, h);
|
||||
let mut first = true;
|
||||
for comp in &m.components {
|
||||
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev);
|
||||
if comp.invert {
|
||||
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
if first && comp.op != MaskOp::Intersect {
|
||||
alpha = c;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
for (a, c) in alpha.data.iter_mut().zip(&c.data) {
|
||||
*a = match comp.op {
|
||||
MaskOp::Add => a.max(*c),
|
||||
MaskOp::Subtract => *a * (1.0 - c),
|
||||
MaskOp::Intersect => *a * c,
|
||||
};
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if m.invert {
|
||||
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
let amt = (m.adjust.amount / 100.0) as f32;
|
||||
if (amt - 1.0).abs() > 1e-6 {
|
||||
alpha.data.iter_mut().for_each(|v| *v *= amt);
|
||||
}
|
||||
alpha
|
||||
}
|
||||
|
||||
/// Per-pixel positions in long-edge units for output pixel centres.
|
||||
fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) {
|
||||
let m = frame.out_to_norm(w, h);
|
||||
|
|
@ -100,7 +107,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
|||
if inv { 1.0 - a } else { a }
|
||||
});
|
||||
}
|
||||
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, &mut out),
|
||||
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out),
|
||||
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
|
||||
let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3));
|
||||
for (v, l) in out.data.iter_mut().zip(&log_l.data) {
|
||||
|
|
@ -181,6 +188,8 @@ pub struct BrushDabs {
|
|||
pub flow: f32,
|
||||
pub density: f32,
|
||||
pub erase: bool,
|
||||
/// Auto Mask: paint only pixels like the one under each dab, then snap to edges.
|
||||
pub auto: bool,
|
||||
}
|
||||
|
||||
/// The dabs of stroke `s` in a `w × h` output.
|
||||
|
|
@ -203,15 +212,90 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
|
|||
}
|
||||
dabs.push(q);
|
||||
}
|
||||
BrushDabs { dabs, r, hard, flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase }
|
||||
BrushDabs {
|
||||
dabs,
|
||||
r,
|
||||
hard,
|
||||
flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32,
|
||||
density: (s.density / 100.0).clamp(0.0, 1.0) as f32,
|
||||
erase: s.erase,
|
||||
auto: s.auto_mask,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp brush strokes into `out` (max-combine; erase strokes subtract).
|
||||
fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, out: &mut Plane) {
|
||||
/// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour
|
||||
/// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`).
|
||||
pub const AUTO_TOL_EV: f32 = 0.5;
|
||||
pub const AUTO_TOL_CHROMA: f32 = 0.25;
|
||||
|
||||
/// Chromaticity `rgb / Y` (as colour noise reduction uses it).
|
||||
#[inline]
|
||||
pub fn chromaticity(c: [f32; 3]) -> [f32; 3] {
|
||||
let y = lightcraft_color::luminance_2020(c).max(1e-6);
|
||||
[c[0] / y, c[1] / y, c[2] / y]
|
||||
}
|
||||
|
||||
/// Auto Mask: how much a pixel (log luminance `l`, chromaticity `ch`) is like the reference.
|
||||
#[inline]
|
||||
pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 {
|
||||
let dl = (l - rl) / AUTO_TOL_EV;
|
||||
let dc = ((ch[0] - rch[0]).powi(2) + (ch[1] - rch[1]).powi(2) + (ch[2] - rch[2]).powi(2)).sqrt() / AUTO_TOL_CHROMA;
|
||||
1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt())
|
||||
}
|
||||
|
||||
/// The output pixel a dab at `d` samples its reference colour from.
|
||||
#[inline]
|
||||
pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize {
|
||||
let x = (d.x.floor().max(0.0) as usize).min(w - 1);
|
||||
let y = (d.y.floor().max(0.0) as usize).min(h - 1);
|
||||
y * w + x
|
||||
}
|
||||
|
||||
/// Guided-filter refinement of an Auto Mask stroke of radius `r` px: (sigma px, epsilon EV²). The
|
||||
/// stroke keeps the larger of its own and the refined alpha.
|
||||
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 {
|
||||
use lightcraft_raster::blur::gaussian;
|
||||
let mi = gaussian(guide, sigma);
|
||||
let mp = gaussian(p, sigma);
|
||||
let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma);
|
||||
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);
|
||||
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];
|
||||
}
|
||||
let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma));
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, out: &mut Plane) {
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
for s in strokes {
|
||||
let BrushDabs { dabs, r, hard, flow, density: dens, erase } = brush_dabs(s, frame, w, h);
|
||||
let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h);
|
||||
let mut stroke_alpha = Plane::new(w, h);
|
||||
for d in &dabs {
|
||||
let refc = auto.then(|| {
|
||||
let j = dab_pixel(*d, w, h);
|
||||
(log_l.data[j], chromaticity(img.data[j]))
|
||||
});
|
||||
let (x0, x1) = (((d.x - r).floor().max(0.0)) as usize, ((d.x + r).ceil().max(0.0) as usize).min(w));
|
||||
let (y0, y1) = (((d.y - r).floor().max(0.0)) as usize, ((d.y + r).ceil().max(0.0) as usize).min(h));
|
||||
for y in y0..y1 {
|
||||
|
|
@ -220,13 +304,23 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, o
|
|||
if dd > r {
|
||||
continue;
|
||||
}
|
||||
let a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
|
||||
let v = &mut stroke_alpha.data[y * w + x];
|
||||
let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
|
||||
let i = y * w + x;
|
||||
if let Some((rl, rch)) = refc {
|
||||
a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch);
|
||||
}
|
||||
let v = &mut stroke_alpha.data[i];
|
||||
// flow accumulates within a stroke up to density
|
||||
*v = (*v + a * flow * (1.0 - *v)).min(dens);
|
||||
}
|
||||
}
|
||||
}
|
||||
if auto {
|
||||
let (sigma, eps) = auto_refine(r);
|
||||
let q = guided_cross(log_l, &stroke_alpha, sigma, eps);
|
||||
// the refinement fills gaps along edges; it never takes paint away
|
||||
stroke_alpha = stroke_alpha.zip_map(&q, f32::max);
|
||||
}
|
||||
for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) {
|
||||
*o = if erase { *o * (1.0 - a) } else { o.max(*a) };
|
||||
}
|
||||
|
|
@ -282,4 +376,35 @@ mod tests {
|
|||
assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50));
|
||||
assert!(a.get(40, 5) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_mask_stops_at_edges() {
|
||||
// dark left half, bright right half; a stroke along the boundary, centred on the dark side
|
||||
let (w, h) = (200, 100);
|
||||
let f = frame(w, h);
|
||||
let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] });
|
||||
let l = img.map(crate::local::log_lum);
|
||||
let stroke = |auto_mask| BrushStroke {
|
||||
points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)],
|
||||
size: 0.08,
|
||||
feather: 0.0,
|
||||
auto_mask,
|
||||
..Default::default()
|
||||
};
|
||||
let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] };
|
||||
let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0);
|
||||
let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0);
|
||||
// without Auto Mask the brush spills over the edge; with it, it stays on the dark side
|
||||
assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50));
|
||||
assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50));
|
||||
assert!(auto.get(110, 50) < 0.02);
|
||||
assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50));
|
||||
assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50));
|
||||
// on a flat area Auto Mask paints like the plain brush
|
||||
let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]);
|
||||
let fl = flat.map(crate::local::log_lum);
|
||||
let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0);
|
||||
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0);
|
||||
assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
//! Output encoding: the colour space (primaries + transfer curve) and sample format a render is
|
||||
//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or
|
||||
//! 16-bit or linear float samples.
|
||||
//!
|
||||
//! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's
|
||||
//! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on
|
||||
//! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is
|
||||
//! sRGB); afterwards the values are re-encoded with the target's own curve.
|
||||
|
||||
use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The RGB space an image is rendered into.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OutputSpace {
|
||||
#[default]
|
||||
Srgb,
|
||||
DisplayP3,
|
||||
/// Primaries and gamma of the published Adobe RGB (1998) specification ("compatible": our own
|
||||
/// profile, built from the published numbers).
|
||||
AdobeRgb,
|
||||
ProPhoto,
|
||||
Rec2020,
|
||||
}
|
||||
|
||||
/// The encoding curve of an [`OutputSpace`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum OutputTrc {
|
||||
/// IEC 61966-2-1 (sRGB, Display P3).
|
||||
Srgb,
|
||||
/// Pure power law: encoded = linear^(1/g).
|
||||
Gamma(f32),
|
||||
/// ITU-R BT.709 / BT.2020 OETF.
|
||||
Rec709,
|
||||
}
|
||||
|
||||
impl OutputTrc {
|
||||
/// Linear → encoded (input clamped to 0..1).
|
||||
pub fn encode(self, v: f32) -> f32 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
match self {
|
||||
OutputTrc::Srgb => lightcraft_color::transfer::linear_to_srgb(v),
|
||||
OutputTrc::Gamma(g) => v.powf(1.0 / g),
|
||||
OutputTrc::Rec709 => {
|
||||
if v < 0.018 {
|
||||
v * 4.5
|
||||
} else {
|
||||
1.099 * v.powf(0.45) - 0.099
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoded → linear (input clamped to 0..1).
|
||||
pub fn decode(self, e: f32) -> f32 {
|
||||
let e = e.clamp(0.0, 1.0);
|
||||
match self {
|
||||
OutputTrc::Srgb => lightcraft_color::transfer::srgb_to_linear(e),
|
||||
OutputTrc::Gamma(g) => e.powf(g),
|
||||
OutputTrc::Rec709 => {
|
||||
if e < 0.081 {
|
||||
e / 4.5
|
||||
} else {
|
||||
((e + 0.099) / 1.099).powf(1.0 / 0.45)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Code for the GPU kernel: 0 sRGB, 1 gamma, 2 Rec.709.
|
||||
pub fn code(self) -> (u32, f32) {
|
||||
match self {
|
||||
OutputTrc::Srgb => (0, 1.0),
|
||||
OutputTrc::Gamma(g) => (1, g),
|
||||
OutputTrc::Rec709 => (2, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputSpace {
|
||||
pub const ALL: [OutputSpace; 5] = [OutputSpace::Srgb, OutputSpace::DisplayP3, OutputSpace::AdobeRgb, OutputSpace::ProPhoto, OutputSpace::Rec2020];
|
||||
|
||||
pub fn rgb_space(self) -> RgbSpace {
|
||||
match self {
|
||||
OutputSpace::Srgb => SRGB,
|
||||
OutputSpace::DisplayP3 => DISPLAY_P3,
|
||||
OutputSpace::AdobeRgb => ADOBE_RGB,
|
||||
OutputSpace::ProPhoto => PROPHOTO,
|
||||
OutputSpace::Rec2020 => REC2020,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trc(self) -> OutputTrc {
|
||||
match self {
|
||||
OutputSpace::Srgb | OutputSpace::DisplayP3 => OutputTrc::Srgb,
|
||||
OutputSpace::AdobeRgb => OutputTrc::Gamma(563.0 / 256.0),
|
||||
OutputSpace::ProPhoto => OutputTrc::Gamma(1.8),
|
||||
OutputSpace::Rec2020 => OutputTrc::Rec709,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing name.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
OutputSpace::Srgb => "sRGB",
|
||||
OutputSpace::DisplayP3 => "Display P3",
|
||||
OutputSpace::AdobeRgb => "Adobe RGB (1998) compatible",
|
||||
OutputSpace::ProPhoto => "ProPhoto RGB",
|
||||
OutputSpace::Rec2020 => "Rec. 2020",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<OutputSpace> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
Some(match k.as_str() {
|
||||
"srgb" => OutputSpace::Srgb,
|
||||
"displayp3" | "p3" => OutputSpace::DisplayP3,
|
||||
"adobergb" | "adobergb1998" | "adobergb1998compatible" | "widegamutrgb" => OutputSpace::AdobeRgb,
|
||||
"prophoto" | "prophotorgb" | "rommrgb" => OutputSpace::ProPhoto,
|
||||
"rec2020" | "bt2020" | "itur2020" => OutputSpace::Rec2020,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Linear Rec.2020 → linear target RGB (Bradford-adapted for D50 targets), row-major.
|
||||
pub fn from_working(self) -> [[f32; 3]; 3] {
|
||||
REC2020.to_space(&self.rgb_space()).to_f32()
|
||||
}
|
||||
|
||||
/// Luminance weights of the target's linear RGB, as the gamut mapper uses them. sRGB keeps the
|
||||
/// rounded BT.709 weights the pipeline has always used (byte-identical previews and exports).
|
||||
pub fn luma(self) -> [f32; 3] {
|
||||
match self {
|
||||
OutputSpace::Srgb => [0.2126, 0.7152, 0.0722],
|
||||
o => o.rgb_space().luma().map(|v| v as f32),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample format of a render.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OutputDepth {
|
||||
/// 8-bit display-encoded ([`crate::Rendered::image`] only).
|
||||
#[default]
|
||||
U8,
|
||||
/// 16-bit display-encoded RGB in [`crate::Rendered::deep`].
|
||||
U16,
|
||||
/// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`].
|
||||
F32Linear,
|
||||
}
|
||||
|
||||
/// High-bit-depth RGB samples (3 per pixel, interleaved, row-major).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DeepSamples {
|
||||
U16(Vec<u16>),
|
||||
F32(Vec<f32>),
|
||||
}
|
||||
|
||||
/// A high-bit-depth render ([`OutputDepth::U16`] / [`OutputDepth::F32Linear`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DeepImage {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub space: OutputSpace,
|
||||
pub samples: DeepSamples,
|
||||
}
|
||||
|
||||
impl DeepImage {
|
||||
/// The same image reduced to 8 bits, display-encoded with the space's curve.
|
||||
pub fn to_rgba8(&self) -> lightcraft_raster::Rgba8 {
|
||||
let mut out = lightcraft_raster::Rgba8::new(self.width, self.height);
|
||||
let trc = self.space.trc();
|
||||
match &self.samples {
|
||||
DeepSamples::U16(v) => {
|
||||
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
|
||||
*p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba();
|
||||
}
|
||||
}
|
||||
DeepSamples::F32(v) => {
|
||||
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
|
||||
*p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
trait IntoRgba {
|
||||
fn into_rgba(self) -> [u8; 4];
|
||||
}
|
||||
|
||||
impl IntoRgba for [u8; 3] {
|
||||
fn into_rgba(self) -> [u8; 4] {
|
||||
[self[0], self[1], self[2], 255]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trcs_invert() {
|
||||
for o in OutputSpace::ALL {
|
||||
let t = o.trc();
|
||||
for i in 0..=100 {
|
||||
let x = i as f32 / 100.0;
|
||||
assert!((t.decode(t.encode(x)) - x).abs() < 1e-5, "{o:?} {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_serde_names() {
|
||||
for o in OutputSpace::ALL {
|
||||
let s = serde_json::to_value(o).unwrap();
|
||||
assert_eq!(OutputSpace::parse(s.as_str().unwrap()), Some(o));
|
||||
assert_eq!(OutputSpace::parse(o.label()), Some(o), "{}", o.label());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_maps_to_white() {
|
||||
for o in OutputSpace::ALL {
|
||||
let m = o.from_working();
|
||||
for row in m {
|
||||
assert!((row.iter().sum::<f32>() - 1.0).abs() < 1e-4, "{o:?}");
|
||||
}
|
||||
assert!((o.luma().iter().sum::<f32>() - 1.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,20 +54,56 @@ fn score(img: &Rgb32f, target: (f32, f32), source: (f32, f32), r: f32) -> f32 {
|
|||
s
|
||||
}
|
||||
|
||||
/// Pick a source offset (in output pixels) for a remove/heal spot.
|
||||
pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) {
|
||||
let mut best = (f32::MAX, (r * 2.5, 0.0));
|
||||
/// Candidate source offsets (output pixels) for a spot of radius `r` at `target`, best first
|
||||
/// (candidates reaching outside the image are left out).
|
||||
pub fn ranked_sources(img: &Rgb32f, target: (f32, f32), r: f32) -> Vec<(f32, f32)> {
|
||||
let mut c: Vec<(f32, (f32, f32))> = Vec::new();
|
||||
for ring in [2.4f32, 3.2, 4.2] {
|
||||
for i in 0..16 {
|
||||
let a = i as f32 / 16.0 * std::f32::consts::TAU;
|
||||
let d = (a.cos() * r * ring, a.sin() * r * ring);
|
||||
let s = score(img, target, (target.0 + d.0, target.1 + d.1), r);
|
||||
if s < best.0 {
|
||||
best = (s, d);
|
||||
if s < f32::MAX {
|
||||
c.push((s, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.1
|
||||
c.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
c.into_iter().map(|(_, d)| d).collect()
|
||||
}
|
||||
|
||||
/// Pick a source offset (in output pixels) for a remove/heal spot.
|
||||
pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) {
|
||||
ranked_sources(img, target, r).first().copied().unwrap_or((r * 2.5, 0.0))
|
||||
}
|
||||
|
||||
/// A source offset (normalized, as [`Spot::source_offset`]) for `spot` on `src` developed with
|
||||
/// `s`: the best match, or with `avoid` (the current offset) the best one at least a spot radius
|
||||
/// away from it ("refresh source"). `None` when no candidate fits in the image.
|
||||
pub fn pick_source(
|
||||
src: &Rgb32f,
|
||||
info: &crate::SourceInfo,
|
||||
s: &lightcraft_develop::DevelopSettings,
|
||||
spot: &Spot,
|
||||
avoid: Option<Point>,
|
||||
) -> Option<Point> {
|
||||
let frame = crate::frame_for(src, info, s, true);
|
||||
let (w, h) = frame.fit(512, 512);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let img = frame.sample(src, w, h);
|
||||
let (to_out, to_norm) = (frame.norm_to_out(w, h), frame.out_to_norm(w, h));
|
||||
let t = to_out.apply(*spot.points.first()?);
|
||||
let r = (spot.size * frame.px_per_long(w)).max(1.0) as f32;
|
||||
let avoid = avoid.map(|o| {
|
||||
let (a, b) = (to_out.apply(Point::new(0.0, 0.0)), to_out.apply(o));
|
||||
((b.x - a.x) as f32, (b.y - a.y) as f32)
|
||||
});
|
||||
let far = |d: &(f32, f32)| avoid.is_none_or(|a| ((d.0 - a.0).powi(2) + (d.1 - a.1).powi(2)).sqrt() > r);
|
||||
let d = ranked_sources(&img, (t.x as f32, t.y as f32), r).into_iter().find(far)?;
|
||||
let (n0, n1) = (to_norm.apply(t), to_norm.apply(Point::new(t.x + d.0 as f64, t.y + d.1 as f64)));
|
||||
Some(Point::new(n1.x - n0.x, n1.y - n0.y))
|
||||
}
|
||||
|
||||
/// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
//! Pixel tests of the local (mask) Noise, Moiré and Defringe sliders.
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
use crate::{RenderRequest, SourceInfo, render};
|
||||
|
||||
const W: usize = 240;
|
||||
const H: usize = 120;
|
||||
|
||||
/// A deterministic hash in −1..1.
|
||||
fn noise(x: usize, y: usize, k: u32) -> f32 {
|
||||
let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ k.wrapping_mul(0xcb1a_b31f);
|
||||
v ^= v >> 13;
|
||||
v = v.wrapping_mul(0x5bd1_e995);
|
||||
v ^= v >> 15;
|
||||
(v & 0xffff) as f32 / 32768.0 - 1.0
|
||||
}
|
||||
|
||||
/// Settings with one mask covering the left half (x < 0.45), with `adjust`.
|
||||
fn left_mask(adjust: LocalAdjustments) -> DevelopSettings {
|
||||
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) };
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
|
||||
s
|
||||
}
|
||||
|
||||
fn shot(src: &Rgb32f, s: &DevelopSettings) -> Rgba8 {
|
||||
render(src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image
|
||||
}
|
||||
|
||||
/// Standard deviation of `f(pixel)` over columns `x0..x1` (rows 4..H-4).
|
||||
fn spread(img: &Rgba8, x0: usize, x1: usize, f: impl Fn([u8; 4]) -> f32) -> f32 {
|
||||
let v: Vec<f32> = (4..H - 4).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f(img.data[y * W + x])).collect();
|
||||
let m = v.iter().sum::<f32>() / v.len() as f32;
|
||||
(v.iter().map(|a| (a - m).powi(2)).sum::<f32>() / v.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
fn luma(p: [u8; 4]) -> f32 {
|
||||
0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32
|
||||
}
|
||||
|
||||
fn chroma(p: [u8; 4]) -> f32 {
|
||||
p[0] as f32 - p[2] as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_noise_smooths_luminance_noise_inside_the_mask() {
|
||||
let src = Rgb32f::from_fn(W, H, |x, y| [0.18 * (1.0 + 0.12 * noise(x, y, 1)); 3]);
|
||||
let plain = shot(&src, &DevelopSettings::default());
|
||||
let base = spread(&plain, 10, 90, luma);
|
||||
let on = shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() }));
|
||||
let off = shot(&src, &left_mask(LocalAdjustments { noise: -100.0, ..Default::default() }));
|
||||
let (smoothed, boosted) = (spread(&on, 10, 90, luma), spread(&off, 10, 90, luma));
|
||||
assert!(smoothed < 0.6 * base, "noise +100: {smoothed} vs {base}");
|
||||
assert!(boosted > 1.2 * base, "noise −100: {boosted} vs {base}");
|
||||
// outside the mask nothing changes
|
||||
assert_eq!(spread(&on, 150, 230, luma), spread(&plain, 150, 230, luma));
|
||||
// the mean brightness is kept
|
||||
let mean = |img: &Rgba8| (4..H - 4).flat_map(|y| (10..90).map(move |x| luma(img.data[y * W + x]))).sum::<f32>() / (80 * (H - 8)) as f32;
|
||||
assert!((mean(&on) - mean(&plain)).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_moire_removes_colour_speckle_inside_the_mask() {
|
||||
let src = Rgb32f::from_fn(W, H, |x, y| {
|
||||
let n = 0.25 * noise(x, y, 2);
|
||||
[0.18 * (1.0 + n), 0.18, 0.18 * (1.0 - n)]
|
||||
});
|
||||
let plain = shot(&src, &DevelopSettings::default());
|
||||
let base = spread(&plain, 10, 90, chroma);
|
||||
let on = shot(&src, &left_mask(LocalAdjustments { moire: 100.0, ..Default::default() }));
|
||||
let reduced = spread(&on, 10, 90, chroma);
|
||||
assert!(reduced < 0.4 * base, "moiré +100: {reduced} vs {base}");
|
||||
assert_eq!(spread(&on, 150, 230, chroma), spread(&plain, 150, 230, chroma));
|
||||
// luminance is kept
|
||||
assert!((spread(&on, 10, 90, luma) - spread(&plain, 10, 90, luma)).abs() < 1.0);
|
||||
// positive Noise also calms colour noise (less than Moiré)
|
||||
let n = spread(&shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })), 10, 90, chroma);
|
||||
assert!(n < 0.8 * base && n > reduced, "{n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_defringe_desaturates_purple_edges_only() {
|
||||
// dark | bright edge at x = 40 (inside the mask) and x = 200 (outside), each with a purple
|
||||
// fringe column; a flat purple patch (no edge) at x 60..80
|
||||
let src = Rgb32f::from_fn(W, H, |x, _| {
|
||||
let fringe = x == 39 || x == 40 || x == 199 || x == 200;
|
||||
if fringe {
|
||||
[0.25, 0.04, 0.3]
|
||||
} else if (60..80).contains(&x) {
|
||||
[0.2, 0.08, 0.22]
|
||||
} else if (40..120).contains(&x) || x >= 200 {
|
||||
[0.7; 3]
|
||||
} else {
|
||||
[0.02; 3]
|
||||
}
|
||||
});
|
||||
let s = left_mask(LocalAdjustments { defringe: 100.0, ..Default::default() });
|
||||
let (plain, on) = (shot(&src, &DevelopSettings::default()), shot(&src, &s));
|
||||
let sat = |p: [u8; 4]| p[0].max(p[2]) as f32 - p[1] as f32;
|
||||
let at = |img: &Rgba8, x: usize| img.data[60 * W + x];
|
||||
assert!(sat(at(&plain, 40)) > 40.0, "a purple fringe to start with: {:?}", at(&plain, 40));
|
||||
assert!(sat(at(&on, 40)) < 0.5 * sat(at(&plain, 40)), "fringe in the mask: {:?} vs {:?}", at(&on, 40), at(&plain, 40));
|
||||
assert_eq!(at(&on, 200), at(&plain, 200), "outside the mask");
|
||||
let flat = |img: &Rgba8| sat(at(img, 70));
|
||||
assert!((flat(&on) - flat(&plain)).abs() <= 2.0, "flat purple kept: {} vs {}", flat(&on), flat(&plain));
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
//! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white,
|
||||
//! so dust spots and specks stand out as white dots. The scale is relative to the image (the
|
||||
//! same spots show at any preview size); the threshold slider (0..100) raises sensitivity.
|
||||
//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour
|
||||
//! tint on a black-and-white image, the image on black / white, or the alpha as white on black
|
||||
//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
|
|
@ -13,7 +16,7 @@ use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
|
|||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use lightcraft_raster::{Plane, Rgba8};
|
||||
|
||||
use crate::colorops::PointK;
|
||||
use crate::{Plan, for_rows};
|
||||
|
|
@ -27,23 +30,99 @@ pub enum Overlay {
|
|||
PointColorRange(u8),
|
||||
/// Visualize Spots with a threshold 0..100 (higher = more sensitive).
|
||||
Spots(u8),
|
||||
/// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in
|
||||
/// `color` at `opacity` (0..100; the colour views only).
|
||||
Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 },
|
||||
}
|
||||
|
||||
/// How [`Overlay::Mask`] draws the mask.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MaskView {
|
||||
/// The photo with the mask tinted in the overlay colour.
|
||||
#[default]
|
||||
Color,
|
||||
/// A black-and-white photo with the mask tinted in the overlay colour.
|
||||
ColorOnBw,
|
||||
/// The photo where the mask is, black elsewhere.
|
||||
ImageOnBlack,
|
||||
/// The photo where the mask is, white elsewhere.
|
||||
ImageOnWhite,
|
||||
/// The mask itself: white on black.
|
||||
WhiteOnBlack,
|
||||
}
|
||||
|
||||
impl MaskView {
|
||||
pub const ALL: [MaskView; 5] = [MaskView::Color, MaskView::ColorOnBw, MaskView::ImageOnBlack, MaskView::ImageOnWhite, MaskView::WhiteOnBlack];
|
||||
|
||||
/// Stable name (control channel, settings).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MaskView::Color => "color",
|
||||
MaskView::ColorOnBw => "colorOnBw",
|
||||
MaskView::ImageOnBlack => "imageOnBlack",
|
||||
MaskView::ImageOnWhite => "imageOnWhite",
|
||||
MaskView::WhiteOnBlack => "whiteOnBlack",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
MaskView::Color => "Color Overlay",
|
||||
MaskView::ColorOnBw => "Color Overlay on B&W",
|
||||
MaskView::ImageOnBlack => "Image on Black",
|
||||
MaskView::ImageOnWhite => "Image on White",
|
||||
MaskView::WhiteOnBlack => "White on Black",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<MaskView> {
|
||||
MaskView::ALL.into_iter().find(|v| v.name() == s)
|
||||
}
|
||||
|
||||
/// The next view (Shift+O cycles them).
|
||||
pub fn next(self) -> MaskView {
|
||||
let i = MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0);
|
||||
MaskView::ALL[(i + 1) % MaskView::ALL.len()]
|
||||
}
|
||||
|
||||
fn index(self) -> u64 {
|
||||
MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0) as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Overlay::Mask`]'s fields in 51 bits (exact in an `f64`): id 16, view 4, colour 24, opacity 7.
|
||||
fn pack_mask(id: u16, view: MaskView, color: [u8; 3], opacity: u8) -> u64 {
|
||||
let rgb = (color[0] as u64) << 16 | (color[1] as u64) << 8 | color[2] as u64;
|
||||
id as u64 | view.index() << 16 | rgb << 20 | (opacity.min(100) as u64) << 44
|
||||
}
|
||||
|
||||
fn unpack_mask(v: u64) -> Overlay {
|
||||
let rgb = (v >> 20) & 0xff_ffff;
|
||||
Overlay::Mask {
|
||||
id: (v & 0xffff) as u16,
|
||||
view: MaskView::ALL.get(((v >> 16) & 0xf) as usize).copied().unwrap_or_default(),
|
||||
color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8],
|
||||
opacity: ((v >> 44) & 0x7f).min(100) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// A plain (kind, value) pair, e.g. for sending a request to a web worker.
|
||||
pub fn to_parts(self) -> (u8, f32) {
|
||||
pub fn to_parts(self) -> (u8, f64) {
|
||||
match self {
|
||||
Overlay::None => (0, 0.0),
|
||||
Overlay::PointColorRange(i) => (1, i as f32),
|
||||
Overlay::Spots(t) => (2, t as f32),
|
||||
Overlay::PointColorRange(i) => (1, i as f64),
|
||||
Overlay::Spots(t) => (2, t as f64),
|
||||
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay).
|
||||
pub fn from_parts(kind: u8, v: f32) -> Overlay {
|
||||
pub fn from_parts(kind: u8, v: f64) -> Overlay {
|
||||
match kind {
|
||||
1 => Overlay::PointColorRange(v as u8),
|
||||
2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8),
|
||||
3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64),
|
||||
_ => Overlay::None,
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +133,15 @@ impl Overlay {
|
|||
Overlay::None => 0,
|
||||
Overlay::PointColorRange(i) => 0x1000 + i as u64,
|
||||
Overlay::Spots(t) => 0x2000 + t as u64,
|
||||
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components).
|
||||
pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> {
|
||||
match self {
|
||||
Overlay::Mask { id, .. } => s.masks.iter().find(|m| m.id == id as u32 && !m.components.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,8 +158,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Draw overlay `o` over `img` (rendered with `plan`).
|
||||
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) {
|
||||
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
|
||||
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]).
|
||||
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) {
|
||||
match o {
|
||||
Overlay::None => {}
|
||||
Overlay::PointColorRange(i) => {
|
||||
|
|
@ -80,9 +169,37 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) {
|
|||
}
|
||||
}
|
||||
Overlay::Spots(t) => spots(img, t, plan.px_per_long),
|
||||
Overlay::Mask { view, color, opacity, .. } => {
|
||||
if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) {
|
||||
mask_view(img, a, view, color, opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw mask alpha `a` over `img` as `view`.
|
||||
pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) {
|
||||
let op = opacity.min(100) as f32 / 100.0;
|
||||
let col = color.map(|c| c as f32);
|
||||
let w = img.width;
|
||||
for_rows(&mut img.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let a = a.data[y * w + x].clamp(0.0, 1.0);
|
||||
let p = [px[0] as f32, px[1] as f32, px[2] as f32];
|
||||
let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
|
||||
let out: [f32; 3] = match view {
|
||||
MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op),
|
||||
MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op),
|
||||
MaskView::ImageOnBlack => p.map(|v| v * a),
|
||||
MaskView::ImageOnWhite => p.map(|v| v * a + 255.0 * (1.0 - a)),
|
||||
MaskView::WhiteOnBlack => [255.0 * a; 3],
|
||||
};
|
||||
let o = out.map(|v| v.round().clamp(0.0, 255.0) as u8);
|
||||
*px = [o[0], o[1], o[2], 255];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge.
|
||||
const SPOT_SIGMA: f64 = 0.006;
|
||||
|
||||
|
|
@ -184,4 +301,45 @@ mod tests {
|
|||
let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4];
|
||||
assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_overlay_shows_the_evaluated_alpha() {
|
||||
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_geom::Point;
|
||||
let src = Rgb32f::from_fn(80, 40, |x, _| if x < 40 { [0.18, 0.18, 0.18] } else { [0.05, 0.1, 0.3] });
|
||||
let info = SourceInfo::default();
|
||||
let shape = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask { id: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() });
|
||||
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
|
||||
let shot = |s: &DevelopSettings, view: MaskView| {
|
||||
let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 };
|
||||
render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image
|
||||
};
|
||||
let (inside, outside) = (20 * 80 + 20, 20 * 80 + 70);
|
||||
let c = shot(&s, MaskView::Color);
|
||||
assert_eq!(c.data[inside], [255, 0, 0, 255], "full tint inside the mask");
|
||||
assert_eq!(c.data[outside], plain.data[outside], "untouched outside");
|
||||
let bw = shot(&s, MaskView::WhiteOnBlack);
|
||||
assert_eq!((bw.data[inside], bw.data[outside]), ([255; 4], [0, 0, 0, 255]));
|
||||
let ib = shot(&s, MaskView::ImageOnBlack);
|
||||
assert_eq!((ib.data[inside], ib.data[outside]), (plain.data[inside], [0, 0, 0, 255]));
|
||||
let iw = shot(&s, MaskView::ImageOnWhite);
|
||||
assert_eq!(iw.data[outside], [255; 4]);
|
||||
let g = shot(&s, MaskView::ColorOnBw).data[outside];
|
||||
assert!(g[0] == g[1] && g[1] == g[2], "grey outside: {g:?}");
|
||||
// a hidden mask still shows (it's the one being edited)
|
||||
s.masks[0].visible = false;
|
||||
assert_eq!(shot(&s, MaskView::WhiteOnBlack).data[inside], [255; 4]);
|
||||
// an unknown mask draws nothing
|
||||
let o = Overlay::Mask { id: 9, view: MaskView::WhiteOnBlack, color: [0; 3], opacity: 50 };
|
||||
assert_eq!(render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image, plain);
|
||||
// parts round trip (web worker requests) and distinct cache keys
|
||||
let o = Overlay::Mask { id: 513, view: MaskView::ImageOnWhite, color: [12, 200, 255], opacity: 65 };
|
||||
let (k, v) = o.to_parts();
|
||||
assert_eq!(Overlay::from_parts(k, v), o);
|
||||
assert_ne!(o.key(), Overlay::Mask { id: 513, view: MaskView::Color, color: [12, 200, 255], opacity: 65 }.key());
|
||||
assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color);
|
||||
assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ fn key_from(name: &str) -> Option<egui::Key> {
|
|||
"tab" => Some(egui::Key::Tab),
|
||||
"\\" | "backslash" => Some(egui::Key::Backslash),
|
||||
"/" | "slash" => Some(egui::Key::Slash),
|
||||
"[" => Some(egui::Key::OpenBracket),
|
||||
"]" => Some(egui::Key::CloseBracket),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -657,6 +657,133 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
|
||||
/// the engine; the delete confirmation guards ⌫.
|
||||
#[test]
|
||||
fn settings_dialog_by_keyboard_and_clicks() {
|
||||
let mut h = demo([1300.0, 820.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() }));
|
||||
for tab in ["import", "performance", "interface", "general"] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t);
|
||||
assert_eq!(r["ok"], true, "{tab}: {r}");
|
||||
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() }));
|
||||
}
|
||||
// General: confirm before delete, startup view
|
||||
h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t);
|
||||
assert!(h.app.ui.settings.confirm_delete);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t);
|
||||
assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail);
|
||||
// Interface: filmstrip names off, grid badges always
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t);
|
||||
h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t);
|
||||
assert!(!h.app.ui.settings.film_names);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t);
|
||||
assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always);
|
||||
// Performance: the thumbnail cache size goes to the library preferences
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t);
|
||||
assert_eq!(h.app.session.cache_mb, 512);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t);
|
||||
assert_eq!(h.app.ui.settings.preview_edge, 3840);
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);
|
||||
assert_eq!(h.app.ui.settings.memory_mb, 1024);
|
||||
h.step();
|
||||
assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget");
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);
|
||||
h.step();
|
||||
assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic");
|
||||
// Import: per-camera defaults
|
||||
h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t);
|
||||
h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t);
|
||||
assert!(h.app.session.import_defaults.per_camera);
|
||||
let img = h.snapshot(SETTLE);
|
||||
assert_eq!(img.size, [1300, 820]);
|
||||
// Escape closes; ⌫ now asks first
|
||||
h.request("ui.key", json!({"key": "escape"}), t);
|
||||
assert_eq!(h.app.ui.dialog, None);
|
||||
let active = h.app.session.active().unwrap();
|
||||
h.request("ui.key", json!({"key": "delete"}), t);
|
||||
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 }));
|
||||
assert!(!h.app.session.catalog.photo(active).unwrap().deleted);
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.session.catalog.photo(active).unwrap().deleted);
|
||||
// app settings survive a save/load round trip of the UI state (ui.json)
|
||||
let saved = serde_json::to_string(&h.app.ui).unwrap();
|
||||
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap().sanitized();
|
||||
assert_eq!(back.settings, h.app.ui.settings);
|
||||
assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the
|
||||
/// overlay in Detail; ⇧⌘F asks the host for window full screen.
|
||||
#[test]
|
||||
fn full_screen_preview_and_info_overlay_by_keyboard() {
|
||||
use crate::state::InfoOverlay;
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t);
|
||||
h.request("ui.key", json!({"key": "f"}), t);
|
||||
assert!(h.app.ui.fullscreen);
|
||||
h.request("ui.key", json!({"key": "right"}), t);
|
||||
assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1]));
|
||||
let right = h.app.ui.right;
|
||||
h.request("ui.key", json!({"key": "i"}), t);
|
||||
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic);
|
||||
assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen");
|
||||
let img = h.snapshot(SETTLE);
|
||||
// no chrome: the frame's corners are black
|
||||
for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] {
|
||||
let c = img.pixels[y * 1000 + x];
|
||||
assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}");
|
||||
}
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"));
|
||||
h.request("ui.key", json!({"key": "escape"}), t);
|
||||
assert!(!h.app.ui.fullscreen);
|
||||
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered");
|
||||
h.request("ui.set", json!({"view": "detail"}), t);
|
||||
h.request("ui.key", json!({"key": "i", "cmd": true}), t);
|
||||
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure);
|
||||
h.request("ui.key", json!({"key": "i", "cmd": true}), t);
|
||||
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off);
|
||||
h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t);
|
||||
assert!(h.app.window_is_fullscreen);
|
||||
h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t);
|
||||
assert!(!h.app.window_is_fullscreen);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// The Navigator appears when zoomed in; clicking it pans to that point.
|
||||
#[test]
|
||||
fn navigator_pans_the_zoomed_loupe() {
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
|
||||
h.settle(SETTLE);
|
||||
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit");
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed");
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t);
|
||||
let (u, v) = h.app.ui.pan;
|
||||
assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan);
|
||||
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)");
|
||||
h.settle(SETTLE);
|
||||
let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap();
|
||||
let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7);
|
||||
h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t);
|
||||
let (u, v) = h.app.ui.pan;
|
||||
assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan);
|
||||
h.request("engine.execute", json!({"command": "view.navigator"}), t);
|
||||
h.settle(SETTLE);
|
||||
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_request_is_answered_without_a_window() {
|
||||
let mut h = demo([800.0, 500.0]);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ pub mod state;
|
|||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
|
|
@ -33,6 +36,8 @@ pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
|
|||
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
|
||||
/// A folder chooser (`None` = cancelled).
|
||||
pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
|
||||
/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux).
|
||||
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
|
||||
|
|
@ -50,6 +55,8 @@ pub struct Services {
|
|||
pub png: Option<PngEncode>,
|
||||
/// Show a file in the system file manager (desktop only).
|
||||
pub reveal: Option<RevealFn>,
|
||||
/// Choose a folder (Settings → General → Open Library…; desktop only).
|
||||
pub pick_folder: Option<PickFolder>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -100,6 +107,12 @@ pub struct LightcraftApp {
|
|||
/// The look the loupe shows while the pointer rests on a preset or profile (set by the
|
||||
/// panels each frame; nothing is committed, no history entry).
|
||||
pub hover_preview: Option<HoverPreview>,
|
||||
/// The window is in full screen (as last reported by the host, or as last requested).
|
||||
pub window_is_fullscreen: bool,
|
||||
/// The GPU preference last applied (`app.gpu`), to apply Settings changes once.
|
||||
gpu_applied: Option<bool>,
|
||||
/// The memory budget setting last applied (MB, 0 = automatic).
|
||||
memory_applied: Option<u32>,
|
||||
}
|
||||
|
||||
impl LightcraftApp {
|
||||
|
|
@ -132,6 +145,9 @@ impl LightcraftApp {
|
|||
merge: merge::MergeState::default(),
|
||||
import: None,
|
||||
hover_preview: None,
|
||||
window_is_fullscreen: false,
|
||||
gpu_applied: None,
|
||||
memory_applied: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -304,6 +320,7 @@ impl LightcraftApp {
|
|||
}
|
||||
self.last_time = now;
|
||||
self.drain_control(ctx);
|
||||
self.apply_settings(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
|
@ -326,6 +343,28 @@ impl LightcraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply settings that act outside the UI state: GPU rendering and window full screen.
|
||||
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 mb = self.ui.settings.memory_mb;
|
||||
// automatic at startup: leave the engine's default alone
|
||||
if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) {
|
||||
let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb };
|
||||
let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb}));
|
||||
}
|
||||
self.memory_applied = Some(mb);
|
||||
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
|
||||
self.window_is_fullscreen = fs;
|
||||
}
|
||||
if let Some(on) = self.ui.window_fullscreen.take() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
|
||||
self.window_is_fullscreen = on;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (pointer events one per frame; key sequences up to the release).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
|
|
@ -351,6 +390,15 @@ impl LightcraftApp {
|
|||
let t0 = now_ms();
|
||||
// panels set it again this frame while the pointer rests on a preset or profile
|
||||
self.hover_preview = None;
|
||||
if self.ui.fullscreen {
|
||||
// full-screen preview: the photo alone on black
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
|
||||
// right panels and left panel run to the bottom; the bottom bar sits between them).
|
||||
panels::topbar::show(self, ui);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
&[
|
||||
"file.addPhotos",
|
||||
"---",
|
||||
"app.openLibrary",
|
||||
"---",
|
||||
"dialog.newAlbum",
|
||||
"dialog.newFolder",
|
||||
"dialog.newSmartAlbum",
|
||||
|
|
@ -70,7 +72,19 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
),
|
||||
(
|
||||
"Edit",
|
||||
&["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"],
|
||||
&[
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"---",
|
||||
"develop.copy",
|
||||
"dialog.copySettings",
|
||||
"develop.paste",
|
||||
"---",
|
||||
"library.selectAll",
|
||||
"library.selectNone",
|
||||
"---",
|
||||
"app.settings",
|
||||
],
|
||||
),
|
||||
(
|
||||
"View",
|
||||
|
|
@ -84,6 +98,11 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
"view.leftPanel",
|
||||
"view.filmstrip",
|
||||
"view.histogram",
|
||||
"view.navigator",
|
||||
"view.infoOverlay",
|
||||
"---",
|
||||
"view.fullScreenPreview",
|
||||
"view.enterFullScreen",
|
||||
"---",
|
||||
"view.showOriginal",
|
||||
"view.beforeAfter",
|
||||
|
|
@ -97,6 +116,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
"---",
|
||||
"view.clipping",
|
||||
"view.maskOverlay",
|
||||
"view.maskOverlayMode",
|
||||
"view.maskPins",
|
||||
"view.cropOverlay",
|
||||
"---",
|
||||
"@Sort",
|
||||
|
|
@ -199,11 +220,15 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
|
|||
"view.histogram" => Some(u.histogram),
|
||||
"view.clipping" => Some(u.show_clipping),
|
||||
"view.maskOverlay" => Some(u.mask_overlay),
|
||||
"view.maskPins" => Some(u.mask_pins),
|
||||
"view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original),
|
||||
"view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide),
|
||||
"view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split),
|
||||
"view.autoAdvance" => Some(u.auto_advance),
|
||||
"view.filterBar" => Some(u.filter_bar),
|
||||
"view.navigator" => Some(u.navigator),
|
||||
"view.fullScreenPreview" => Some(u.fullscreen),
|
||||
"view.enterFullScreen" => Some(app.window_is_fullscreen),
|
||||
"panel.edit" => panel(RightPanel::Edit),
|
||||
"panel.profiles" => panel(RightPanel::Profiles),
|
||||
"panel.crop" => panel(RightPanel::Crop),
|
||||
|
|
@ -421,6 +446,9 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Valu
|
|||
if culling_cmd {
|
||||
crate::panels::compare::target_active(app, &mut params);
|
||||
}
|
||||
if id == "photo.delete" && crate::menus::confirm_delete(app) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let r = app.run(id, params);
|
||||
if culling_cmd && r.is_ok() && app.ui.auto_advance {
|
||||
crate::panels::compare::advance(app);
|
||||
|
|
|
|||
|
|
@ -34,10 +34,18 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("view.clipping", "Show Clipping", Some("J"), "View"),
|
||||
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
|
||||
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
|
||||
// Shift+O in the Masking panel (elsewhere it cycles the crop overlay)
|
||||
("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"),
|
||||
("view.maskOverlayColor", "Mask Overlay Color", None, ""),
|
||||
("view.maskPins", "Show Mask Pins", None, "View"),
|
||||
("view.visualizeSpots", "Visualize Spots", Some("A"), "View"),
|
||||
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
|
||||
("view.back", "Back to Grid", Some("Escape"), ""),
|
||||
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
|
||||
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
|
||||
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
|
||||
("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"),
|
||||
("view.navigator", "Navigator", None, "View"),
|
||||
("panel.edit", "Edit", Some("E"), "Window"),
|
||||
("panel.profiles", "Profile Browser", None, "Window"),
|
||||
("panel.crop", "Crop & Rotate", Some("C"), "Window"),
|
||||
|
|
@ -60,6 +68,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"),
|
||||
("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"),
|
||||
("tool.none", "No Tool", None, ""),
|
||||
// brush size / feather of the active brush (Masking brush, Remove tool and its selected spot)
|
||||
("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"),
|
||||
("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"),
|
||||
("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"),
|
||||
("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"),
|
||||
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
|
||||
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
|
||||
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
|
||||
|
|
@ -73,6 +86,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
|
||||
("file.importPresets", "Import Presets…", None, "File"),
|
||||
("file.exportPresets", "Export Presets…", None, "File"),
|
||||
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
|
||||
("app.openLibrary", "Open Library…", None, "File"),
|
||||
("app.about", "About LightCraft", None, "Help"),
|
||||
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
|
||||
("app.export", "Export Now", None, ""),
|
||||
|
|
@ -100,6 +115,45 @@ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str
|
|||
let _ = app.session.end_interaction();
|
||||
}
|
||||
|
||||
/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's
|
||||
/// (and its selected spot's) or the Masking brush's.
|
||||
fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value {
|
||||
if app.ui.right == RightPanel::Remove {
|
||||
app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25);
|
||||
app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0);
|
||||
if app.session.active_spot.is_some() {
|
||||
let mut p = json!({});
|
||||
if k != 1.0 {
|
||||
p["size"] = json!(app.ui.remove_size);
|
||||
}
|
||||
if df != 0.0 {
|
||||
p["feather"] = json!(app.ui.remove_feather);
|
||||
}
|
||||
let _ = app.run("spot.update", p);
|
||||
}
|
||||
json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather})
|
||||
} else {
|
||||
app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5);
|
||||
app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0);
|
||||
json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather})
|
||||
}
|
||||
}
|
||||
|
||||
/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255).
|
||||
pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
||||
if let Some(s) = v.as_str() {
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
if h.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok();
|
||||
return Some([c(0)?, c(2)?, c(4)?]);
|
||||
}
|
||||
let a = v.as_array()?;
|
||||
let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8);
|
||||
Some([c(0)?, c(1)?, c(2)?])
|
||||
}
|
||||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
let ctx = egui::Context::default();
|
||||
|
|
@ -131,6 +185,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
"view.back" => {
|
||||
if app.ui.dialog.is_some() {
|
||||
app.ui.dialog = None;
|
||||
} else if app.ui.fullscreen {
|
||||
app.ui.fullscreen = false;
|
||||
} else if !app.ui.tool.is_empty() {
|
||||
app.ui.tool.clear();
|
||||
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
|
||||
|
|
@ -140,6 +196,50 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.fullScreenPreview" => {
|
||||
app.ui.fullscreen = !app.ui.fullscreen;
|
||||
if app.ui.fullscreen {
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
app.ui.tool.clear();
|
||||
let _ = app.session.end_interaction();
|
||||
}
|
||||
Ok(json!({"fullscreen": app.ui.fullscreen}))
|
||||
}
|
||||
"view.enterFullScreen" => {
|
||||
// applied by the host's frame logic (it knows the window's current state)
|
||||
let on = p.get("on").and_then(Value::as_bool);
|
||||
app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen));
|
||||
Ok(json!({"windowFullscreen": app.ui.window_fullscreen}))
|
||||
}
|
||||
"view.infoOverlay" => {
|
||||
app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) {
|
||||
Some(m) => match serde_json::from_value(json!(m)) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))),
|
||||
},
|
||||
None => app.ui.info_overlay.next(),
|
||||
};
|
||||
let label = match app.ui.info_overlay {
|
||||
crate::state::InfoOverlay::Off => "Info Overlay Off",
|
||||
crate::state::InfoOverlay::Basic => "Info Overlay: File",
|
||||
crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure",
|
||||
};
|
||||
app.toast(&ctx, label);
|
||||
Ok(json!({"infoOverlay": app.ui.info_overlay}))
|
||||
}
|
||||
"view.navigator" => {
|
||||
app.ui.navigator = !app.ui.navigator;
|
||||
Ok(json!({"navigator": app.ui.navigator}))
|
||||
}
|
||||
"app.settings" => {
|
||||
let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general");
|
||||
if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) {
|
||||
return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface)")));
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::Settings { tab: tab.into() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.openLibrary" => crate::panels::settings::open_library(app, p),
|
||||
"view.filmstrip" => {
|
||||
app.ui.filmstrip = !app.ui.filmstrip;
|
||||
Ok(Value::Null)
|
||||
|
|
@ -203,8 +303,52 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"view.maskOverlay" => {
|
||||
app.ui.mask_overlay = !app.ui.mask_overlay;
|
||||
Ok(Value::Null)
|
||||
app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay);
|
||||
Ok(json!({"maskOverlay": app.ui.mask_overlay}))
|
||||
}
|
||||
"view.maskOverlayMode" => {
|
||||
use lightcraft_engine::pipeline::MaskView;
|
||||
let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
|
||||
let next = match p.get("mode").and_then(Value::as_str) {
|
||||
Some(m) => match MaskView::parse(m) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect();
|
||||
return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|"))));
|
||||
}
|
||||
},
|
||||
None => cur.next(),
|
||||
};
|
||||
app.ui.mask_overlay_mode = next.name().into();
|
||||
app.ui.mask_overlay = true;
|
||||
app.toast(&ctx, next.label());
|
||||
Ok(json!({"mode": next.name()}))
|
||||
}
|
||||
"view.maskOverlayColor" => {
|
||||
if let Some(c) = p.get("color") {
|
||||
match parse_rgb(c) {
|
||||
Some(rgb) => app.ui.mask_overlay_color = rgb,
|
||||
None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())),
|
||||
}
|
||||
}
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32;
|
||||
}
|
||||
let [r, g, b] = app.ui.mask_overlay_color;
|
||||
Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity}))
|
||||
}
|
||||
"brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => {
|
||||
let (k, df) = match id {
|
||||
"brush.smaller" => (1.0 / 1.2, 0.0),
|
||||
"brush.larger" => (1.2, 0.0),
|
||||
"brush.featherLess" => (1.0, -10.0),
|
||||
_ => (1.0, 10.0),
|
||||
};
|
||||
Ok(adjust_brush(app, k, df))
|
||||
}
|
||||
"view.maskPins" => {
|
||||
app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins);
|
||||
Ok(json!({"maskPins": app.ui.mask_pins}))
|
||||
}
|
||||
"view.visualizeSpots" => {
|
||||
// like Lightroom's A: opens the Remove tool with the view on, or toggles it there
|
||||
|
|
@ -484,6 +628,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
|||
}
|
||||
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" => app.services.pick_folder.is_some(),
|
||||
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
|
||||
s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(),
|
||||
_ => true,
|
||||
|
|
@ -526,6 +672,20 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
|||
v
|
||||
}
|
||||
|
||||
/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead
|
||||
/// of deleting; true when it did (the dialog's OK runs `photo.delete`).
|
||||
pub fn confirm_delete(app: &mut LightcraftApp) -> bool {
|
||||
if !app.ui.settings.confirm_delete {
|
||||
return false;
|
||||
}
|
||||
let count = app.session.targets(&json!({})).len();
|
||||
if count == 0 {
|
||||
return false;
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::ConfirmDelete { count });
|
||||
true
|
||||
}
|
||||
|
||||
/// Reveal the active photo's original in the system file manager.
|
||||
fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
|
||||
let id = app.session.active().ok_or("no photo selected")?;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ pub enum Gesture {
|
|||
Spot {
|
||||
points: Vec<Point>,
|
||||
},
|
||||
/// Remove tool: dragging spot `spot`'s target (or its source).
|
||||
SpotMove {
|
||||
spot: usize,
|
||||
source: bool,
|
||||
},
|
||||
CropHandle {
|
||||
handle: u8,
|
||||
start: lightcraft_geom::Rect,
|
||||
|
|
@ -40,8 +45,11 @@ pub enum Gesture {
|
|||
start: (f32, f32),
|
||||
at: Pos2,
|
||||
},
|
||||
/// Dragging component `comp` of `mask`: its pin (`handle` 0) or a linear gradient's start (1)
|
||||
/// or end (2).
|
||||
MaskHandle {
|
||||
mask: u32,
|
||||
comp: usize,
|
||||
handle: u8,
|
||||
},
|
||||
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
|
||||
|
|
@ -119,10 +127,12 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2],
|
|||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.max_rect();
|
||||
let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 };
|
||||
let fullscreen = app.ui.fullscreen;
|
||||
let show_film = app.ui.filmstrip && !fullscreen;
|
||||
let film_h = if show_film { t.film_h } else { 0.0 };
|
||||
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
|
||||
app.canvas_rect = Some(canvas);
|
||||
if app.ui.filmstrip {
|
||||
if show_film {
|
||||
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
|
||||
}
|
||||
let Some(id) = app.session.active() else {
|
||||
|
|
@ -131,11 +141,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
};
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
let d = (*photo.develop).clone();
|
||||
let crop_tool = app.ui.right == RightPanel::Crop;
|
||||
// the full-screen preview shows the photo only: no tool overlays
|
||||
let right = if fullscreen { RightPanel::None } else { app.ui.right };
|
||||
let crop_tool = right == RightPanel::Crop;
|
||||
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());
|
||||
let aspect = frame.aspect() as f32;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });
|
||||
let area = canvas.shrink(if fullscreen {
|
||||
0.0
|
||||
} else if crop_tool {
|
||||
48.0
|
||||
} else {
|
||||
24.0
|
||||
});
|
||||
let max_edge = app.ui.settings.preview_edge.clamp(512, 8192) as f32;
|
||||
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
|
||||
// two views (before, after): side by side or stacked
|
||||
let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom);
|
||||
|
|
@ -163,7 +182,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
let interacting = app.session.interaction.is_some();
|
||||
let scale = if interacting { 0.6 } else { 1.0 };
|
||||
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
|
||||
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(max_edge) as usize;
|
||||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||||
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
|
||||
let job = if interacting { job.draft() } else { job };
|
||||
|
|
@ -193,7 +212,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
|
||||
let na = nf.aspect() as f32;
|
||||
let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan);
|
||||
let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize;
|
||||
let nw = (nr.width().max(nr.height()) * ppp).min(max_edge) as usize;
|
||||
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
|
||||
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
|
||||
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
|
||||
|
|
@ -272,22 +291,119 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
if app.ui.show_clipping && !show_before {
|
||||
if app.ui.show_clipping && !show_before && !fullscreen {
|
||||
clipping_overlay(app, &p, img_rect);
|
||||
}
|
||||
register(ui.ctx(), "canvas:image", img_rect);
|
||||
let map = CanvasMap::new(&frame, img_rect);
|
||||
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
|
||||
match app.ui.right {
|
||||
info_overlay(app, &p, canvas, &photo);
|
||||
match right {
|
||||
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
|
||||
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d),
|
||||
_ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect),
|
||||
}
|
||||
// drawn and hit-tested above the loupe and its tools: clicks on it pan
|
||||
navigator(app, ui, canvas, img_rect, id);
|
||||
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date
|
||||
/// and size, or with the camera and exposure.
|
||||
fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) {
|
||||
use crate::state::InfoOverlay;
|
||||
let lines: Vec<String> = match app.ui.info_overlay {
|
||||
InfoOverlay::Off => return,
|
||||
InfoOverlay::Basic => {
|
||||
let date = photo.captured.as_deref().unwrap_or(&photo.imported);
|
||||
vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)]
|
||||
}
|
||||
InfoOverlay::Exposure => {
|
||||
let m = &photo.meta;
|
||||
let mut exp = Vec::new();
|
||||
if !m.shutter.is_empty() {
|
||||
exp.push(format!("{} s", m.shutter));
|
||||
}
|
||||
if let Some(a) = m.aperture {
|
||||
exp.push(format!("f/{a:.1}"));
|
||||
}
|
||||
if let Some(i) = m.iso {
|
||||
exp.push(format!("ISO {i}"));
|
||||
}
|
||||
if let Some(f) = m.focal_mm {
|
||||
exp.push(format!("{f:.0} mm"));
|
||||
}
|
||||
let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" · ");
|
||||
let mut v = vec![photo.file_name.clone()];
|
||||
v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") });
|
||||
if !camera.is_empty() {
|
||||
v.push(camera);
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let t = Tokens::get(p.ctx());
|
||||
let mut y = canvas.top() + 12.0;
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) };
|
||||
let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE);
|
||||
let at = pos2(canvas.left() + 14.0, y);
|
||||
// a soft shadow keeps the text readable on bright photos
|
||||
p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200));
|
||||
p.galley(at, g.clone(), Color32::WHITE);
|
||||
y += g.size().y + 3.0;
|
||||
}
|
||||
register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
|
||||
}
|
||||
|
||||
/// `2026-09-30T12:00:00` → `2026-09-30 12:00`.
|
||||
fn pretty_date(iso: &str) -> String {
|
||||
let d = iso.replacen('T', " ", 1);
|
||||
d.get(..16).map(str::to_string).unwrap_or(d)
|
||||
}
|
||||
|
||||
/// Size of the Navigator mini map (points).
|
||||
const NAV_W: f32 = 180.0;
|
||||
|
||||
/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the
|
||||
/// visible region outlined; click or drag on it to pan.
|
||||
fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) {
|
||||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||||
if !app.ui.navigator || !zoomed || app.ui.fullscreen {
|
||||
return;
|
||||
}
|
||||
let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id));
|
||||
let aspect = img.width() / img.height().max(1.0);
|
||||
let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) };
|
||||
let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h));
|
||||
let p = ui.painter_at(canvas);
|
||||
p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190));
|
||||
match tex {
|
||||
Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE),
|
||||
None => p.rect_filled(frame, 0.0, Color32::from_gray(40)),
|
||||
};
|
||||
// the visible part of the photo, in normalized image coordinates
|
||||
let vis = canvas.intersect(img);
|
||||
let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height());
|
||||
let (a, b) = (n(vis.min), n(vis.max));
|
||||
let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height());
|
||||
let view = Rect::from_min_max(to_nav(a), to_nav(b));
|
||||
p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside);
|
||||
p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
|
||||
register(ui.ctx(), "canvas:navigator", frame);
|
||||
let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag());
|
||||
if (resp.clicked() || resp.dragged())
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
// centre the view on the point under the pointer
|
||||
let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0);
|
||||
let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0);
|
||||
app.ui.pan = (u, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes.
|
||||
const PREFETCH_PRIORITY: u32 = 4;
|
||||
|
||||
|
|
@ -332,9 +448,25 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon
|
|||
}
|
||||
}
|
||||
|
||||
/// The diagnostic overlay the loupe shows (Point Color's visualized range).
|
||||
fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
|
||||
use lightcraft_pipeline::Overlay;
|
||||
/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range,
|
||||
/// Visualize Spots).
|
||||
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
if app.ui.fullscreen {
|
||||
return Overlay::None;
|
||||
}
|
||||
if app.ui.right == RightPanel::Masking
|
||||
&& app.ui.mask_overlay
|
||||
&& let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id))
|
||||
&& !m.components.is_empty()
|
||||
{
|
||||
return Overlay::Mask {
|
||||
id: m.id.min(u16::MAX as u32) as u16,
|
||||
view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(),
|
||||
color: app.ui.mask_overlay_color,
|
||||
opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8,
|
||||
};
|
||||
}
|
||||
if app.ui.right == RightPanel::Remove && app.ui.visualize_spots {
|
||||
return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8);
|
||||
}
|
||||
|
|
@ -613,26 +745,55 @@ fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
|
|||
|
||||
// ------------------------------------------------------------------------ masks
|
||||
|
||||
/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo.
|
||||
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(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `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) -> MaskShape {
|
||||
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
|
||||
match shape.clone() {
|
||||
MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
|
||||
MaskShape::Linear { start, end } => match handle {
|
||||
1 => MaskShape::Linear { start: at, end },
|
||||
2 => MaskShape::Linear { start, end: at },
|
||||
_ => MaskShape::Linear { start: mv(start), end: mv(end) },
|
||||
},
|
||||
MaskShape::Brush { mut strokes } => {
|
||||
for st in &mut strokes {
|
||||
st.points.iter_mut().for_each(|p| *p = mv(*p));
|
||||
}
|
||||
MaskShape::Brush { strokes }
|
||||
}
|
||||
s => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask
|
||||
/// component (click selects its mask, drag moves the component), and brush painting. The mask
|
||||
/// itself shows as a rendered overlay ([`view_overlay`]).
|
||||
fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90);
|
||||
let tint = {
|
||||
let [r, g, b] = app.ui.mask_overlay_color;
|
||||
Color32::from_rgba_unmultiplied(r, g, b, 110)
|
||||
};
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let active = app.session.active_mask;
|
||||
let clip = app.canvas_rect.unwrap_or(map.rect);
|
||||
let p = &ui.painter_at(clip);
|
||||
// (mask, component, handle, screen position) of everything that can be grabbed
|
||||
let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new();
|
||||
for m in &d.masks {
|
||||
let sel = Some(m.id) == active;
|
||||
for c in &m.components {
|
||||
for (ci, c) in m.components.iter().enumerate() {
|
||||
match &c.shape {
|
||||
MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => {
|
||||
for s in strokes {
|
||||
let r = (s.size * long) as f32;
|
||||
for q in &s.points {
|
||||
p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red });
|
||||
}
|
||||
}
|
||||
}
|
||||
MaskShape::Radial { center, rx, ry, angle, .. } => {
|
||||
let c0 = map.screen(*center);
|
||||
MaskShape::Radial { center, rx, ry, angle, .. } if sel => {
|
||||
let pts: Vec<Pos2> = (0..64)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||||
|
|
@ -642,35 +803,31 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
|||
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
|
||||
})
|
||||
.collect();
|
||||
if sel && app.ui.mask_overlay {
|
||||
p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE));
|
||||
}
|
||||
p.add(egui::Shape::closed_line(
|
||||
pts,
|
||||
Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })),
|
||||
));
|
||||
pin(p, c0, sel);
|
||||
if sel {
|
||||
register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0)));
|
||||
}
|
||||
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230))));
|
||||
}
|
||||
MaskShape::Linear { start, end } => {
|
||||
MaskShape::Linear { start, end } if sel => {
|
||||
let (a, b) = (map.screen(*start), map.screen(*end));
|
||||
let dir = (b - a).normalized();
|
||||
let perp = vec2(-dir.y, dir.x) * 3000.0;
|
||||
for (q, alpha) in [(a, 230), (b, 110)] {
|
||||
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 })));
|
||||
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha)));
|
||||
}
|
||||
pin(p, a + (b - a) * 0.5, sel);
|
||||
if sel {
|
||||
for (i, q) in [a, b].iter().enumerate() {
|
||||
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
|
||||
}
|
||||
for (i, q) in [a, b].iter().enumerate() {
|
||||
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
|
||||
grips.push((m.id, ci, i as u8 + 1, *q));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if app.ui.mask_pins
|
||||
&& let Some(at) = component_pin(&c.shape)
|
||||
{
|
||||
let q = map.screen(at);
|
||||
pin(p, q, sel);
|
||||
register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0)));
|
||||
grips.push((m.id, ci, 0, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
// brush tool
|
||||
|
|
@ -690,9 +847,10 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
|||
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
|
||||
}
|
||||
}
|
||||
// the stroke being painted (the render shows it once it's committed)
|
||||
if let Some(Gesture::Brush { points }) = &app.gesture {
|
||||
for q in points {
|
||||
p.circle_filled(map.screen(*q), r, red);
|
||||
p.circle_filled(map.screen(*q), r, tint);
|
||||
}
|
||||
}
|
||||
if (resp.drag_stopped() || resp.clicked())
|
||||
|
|
@ -701,57 +859,53 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
|||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
let _ = app.run(
|
||||
"mask.brushStroke",
|
||||
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}),
|
||||
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
|
||||
"erase": app.ui.brush_erase, "autoMask": app.ui.brush_auto_mask}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// drag pins/handles of the active mask
|
||||
let Some(mid) = active else { return };
|
||||
let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return };
|
||||
let Some(comp) = m.components.first() else { return };
|
||||
if resp.drag_started()
|
||||
// the grip under the pointer: the selected mask's first, then the nearest
|
||||
let hit = |q: Pos2| {
|
||||
grips
|
||||
.iter()
|
||||
.filter(|g| g.3.distance(q) < 10.0)
|
||||
.min_by(|a, b| {
|
||||
(Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& let Some((mid, ..)) = hit(q)
|
||||
&& Some(mid) != active
|
||||
{
|
||||
let handle = match &comp.shape {
|
||||
MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0),
|
||||
MaskShape::Linear { start, end } => {
|
||||
if map.screen(*start).distance(q) < 14.0 {
|
||||
Some(1)
|
||||
} else if map.screen(*end).distance(q) < 14.0 {
|
||||
Some(2)
|
||||
} else {
|
||||
Some(0)
|
||||
}
|
||||
let _ = app.run("mask.select", json!({"id": mid}));
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| {
|
||||
// anywhere on the photo drags the selected linear gradient
|
||||
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
|
||||
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
|
||||
});
|
||||
if let Some((mask, comp, handle)) = grip {
|
||||
if Some(mask) != active {
|
||||
let _ = app.run("mask.select", json!({"id": mask}));
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h) = handle {
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
|
||||
app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h });
|
||||
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle });
|
||||
}
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
||||
&& let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
||||
&& let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape)
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let dn = {
|
||||
let q0 = q - resp.drag_delta();
|
||||
let n0 = map.norm(q0);
|
||||
Point::new(n.x - n0.x, n.y - n0.y)
|
||||
};
|
||||
let new_shape = match comp.shape.clone() {
|
||||
MaskShape::Radial { center, rx, ry, angle, feather, invert } => {
|
||||
MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert }
|
||||
}
|
||||
MaskShape::Linear { start, end } => match handle {
|
||||
1 => MaskShape::Linear { start: n, end },
|
||||
2 => MaskShape::Linear { start, end: n },
|
||||
_ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) },
|
||||
},
|
||||
s => s,
|
||||
};
|
||||
let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape}));
|
||||
let n0 = map.norm(q - resp.drag_delta());
|
||||
let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n);
|
||||
let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape}));
|
||||
}
|
||||
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
|
||||
app.gesture = None;
|
||||
|
|
@ -776,19 +930,89 @@ fn pin(p: &egui::Painter, c: Pos2, sel: bool) {
|
|||
|
||||
// ------------------------------------------------------------------------ remove
|
||||
|
||||
/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source),
|
||||
/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one.
|
||||
fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let p = ui.painter();
|
||||
for s in &d.spots {
|
||||
let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect));
|
||||
let active = app.session.active_spot.filter(|i| *i < d.spots.len());
|
||||
// (spot, is source, screen centre, radius) of everything that can be grabbed
|
||||
let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new();
|
||||
for (i, s) in d.spots.iter().enumerate() {
|
||||
let r = (s.size * long) as f32;
|
||||
let sel = Some(i) == active;
|
||||
let col = Color32::from_white_alpha(if sel { 230 } else { 150 });
|
||||
for q in &s.points {
|
||||
p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200)));
|
||||
p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col));
|
||||
}
|
||||
let Some(&t) = s.points.first() else { continue };
|
||||
let tq = map.screen(t);
|
||||
if let Some(o) = s.source_offset.filter(|_| sel) {
|
||||
let sq = map.screen(Point::new(t.x + o.x, t.y + o.y));
|
||||
for q in &s.points {
|
||||
p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170)));
|
||||
}
|
||||
let dir = (tq - sq).normalized();
|
||||
p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200)));
|
||||
register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0)));
|
||||
grips.push((i, true, sq, r));
|
||||
}
|
||||
pin(&p, tq, sel);
|
||||
register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0)));
|
||||
grips.push((i, false, tq, r));
|
||||
}
|
||||
let r = (app.ui.remove_size as f64 * long) as f32;
|
||||
// a target or source under `q`: the selected spot's first, then the nearest pin
|
||||
let hit = |q: Pos2| {
|
||||
grips
|
||||
.iter()
|
||||
.filter(|g| g.2.distance(q) < g.3.max(8.0))
|
||||
.min_by(|a, b| {
|
||||
(Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
let hover = resp.hover_pos().and_then(hit);
|
||||
if let Some(h) = resp.hover_pos() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
||||
if hover.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
} else {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
||||
p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110)));
|
||||
}
|
||||
}
|
||||
let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos());
|
||||
if resp.drag_started()
|
||||
&& let Some((spot, source, ..)) = press.and_then(hit)
|
||||
{
|
||||
if Some(spot) != active {
|
||||
let _ = app.run("spot.select", json!({"index": spot}));
|
||||
}
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"}));
|
||||
app.gesture = Some(Gesture::SpotMove { spot, source });
|
||||
}
|
||||
if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() {
|
||||
if resp.dragged()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta()));
|
||||
let dn = [n.x - n0.x, n.y - n0.y];
|
||||
if dn != [0.0, 0.0] {
|
||||
let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn}));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some((spot, ..)) = press.and_then(hit)
|
||||
{
|
||||
let _ = app.run("spot.select", json!({"index": spot}));
|
||||
return;
|
||||
}
|
||||
if let Some(q) = resp.interact_pointer_pos()
|
||||
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
||||
|
|
@ -813,7 +1037,10 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo
|
|||
_ => "remove",
|
||||
};
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size}));
|
||||
let _ = app.run(
|
||||
"spot.add",
|
||||
json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -909,7 +1136,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
|||
} else if resp.hovered() {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
|
||||
}
|
||||
if let Some(ph) = app.session.catalog.photo(*id) {
|
||||
let names = app.ui.settings.film_names;
|
||||
if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) {
|
||||
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
|
||||
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
|
||||
|
|
@ -925,6 +1153,11 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
|||
if sel {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
|
||||
}
|
||||
if app.ui.settings.film_badges
|
||||
&& let Some(ph) = app.session.catalog.photo(*id)
|
||||
{
|
||||
film_badges(p, &t, fr, ph);
|
||||
}
|
||||
if let Some(st) = app.session.catalog.stack_of(*id) {
|
||||
let text =
|
||||
if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) };
|
||||
|
|
@ -950,6 +1183,32 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface).
|
||||
fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) {
|
||||
use crate::icons::{Icon, paint};
|
||||
use lightcraft_catalog::Flag;
|
||||
let edited = ph.is_edited();
|
||||
if ph.rating == 0 && ph.flag == Flag::None && !edited {
|
||||
return;
|
||||
}
|
||||
let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom());
|
||||
p.rect_filled(bar, 0.0, Color32::from_black_alpha(130));
|
||||
let y = bar.center().y;
|
||||
let mut x = bar.left() + 3.0;
|
||||
for i in 0..ph.rating {
|
||||
paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star);
|
||||
}
|
||||
x += ph.rating as f32 * 9.0 + 2.0;
|
||||
match ph.flag {
|
||||
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick),
|
||||
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject),
|
||||
Flag::None => {}
|
||||
}
|
||||
if edited {
|
||||
paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label);
|
||||
}
|
||||
}
|
||||
|
||||
/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto.
|
||||
/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle.
|
||||
fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
|
||||
Dialog::Export { .. } => "Export",
|
||||
Dialog::Merge { opts } => opts.title(),
|
||||
Dialog::Settings { .. } => "Settings",
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::About => "About LightCraft",
|
||||
Dialog::Shortcuts => "Keyboard Shortcuts",
|
||||
};
|
||||
|
|
@ -236,6 +238,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
let n = app.session.selection.ids.len().max(1);
|
||||
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
let before = opts.format;
|
||||
choices(
|
||||
ui,
|
||||
"Format",
|
||||
|
|
@ -243,6 +246,34 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")],
|
||||
&mut opts.format,
|
||||
);
|
||||
if opts.format != before {
|
||||
// each format starts at its own default depth (TIFF 16-bit, others 8-bit)
|
||||
opts.bit_depth = None;
|
||||
}
|
||||
let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format);
|
||||
if depths.len() > 1 {
|
||||
let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0);
|
||||
choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd);
|
||||
opts.bit_depth = Some(bd);
|
||||
}
|
||||
if opts.format == F::Avif {
|
||||
ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim));
|
||||
} else {
|
||||
use lightcraft_engine::export::OutputSpace as C;
|
||||
choices(
|
||||
ui,
|
||||
"Color space",
|
||||
"exportColorSpace",
|
||||
&[
|
||||
(C::Srgb, "sRGB"),
|
||||
(C::DisplayP3, "P3"),
|
||||
(C::AdobeRgb, "Adobe RGB"),
|
||||
(C::ProPhoto, "ProPhoto"),
|
||||
(C::Rec2020, "Rec.2020"),
|
||||
],
|
||||
&mut opts.color_space,
|
||||
);
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Avif) {
|
||||
let mut q = opts.quality as f64;
|
||||
if num(ui, &QUALITY, &mut q) {
|
||||
|
|
@ -324,6 +355,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
Dialog::Merge { opts } => crate::merge::body(app, ui, opts),
|
||||
Dialog::Import { opts } => crate::import::body(app, ui, opts),
|
||||
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
|
||||
Dialog::ConfirmDelete { count } => {
|
||||
let what = if *count == 1 { "this photo".to_string() } else { format!("these {count} photos") };
|
||||
ui.label(format!("Move {what} to Recently Deleted?"));
|
||||
ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim));
|
||||
}
|
||||
Dialog::About => {
|
||||
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
|
||||
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
|
|
@ -366,7 +403,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts);
|
||||
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
|
||||
if !informational && ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
|
|
@ -378,6 +415,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
add_label.as_str()
|
||||
}
|
||||
Dialog::Merge { .. } => "Merge",
|
||||
Dialog::ConfirmDelete { .. } => "Delete",
|
||||
_ if informational => "Close",
|
||||
_ => "OK",
|
||||
};
|
||||
|
|
@ -456,11 +494,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
|||
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
|
||||
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
|
||||
"metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark,
|
||||
"colorSpace": opts.color_space, "bitDepth": opts.bit_depth,
|
||||
}),
|
||||
),
|
||||
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
|
||||
Dialog::Import { opts } => crate::import::start(app, opts),
|
||||
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),
|
||||
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
|
||||
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -312,7 +312,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
|||
p.rect_filled(br, 2.0, Color32::from_gray(26));
|
||||
p.galley(br.min + vec2(4.0, 1.5), g, t.text_label);
|
||||
}
|
||||
let show_badges = resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some();
|
||||
let show_badges = match app.ui.settings.grid_badges {
|
||||
crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(),
|
||||
crate::state::GridBadges::Always => true,
|
||||
crate::state::GridBadges::Never => false,
|
||||
};
|
||||
if show_badges {
|
||||
let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom());
|
||||
if resp.hovered() || selected {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ pub const LOCAL: &[ControlSpec] = &[
|
|||
spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),
|
||||
spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
];
|
||||
|
||||
pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 {
|
||||
|
|
@ -142,6 +144,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
let _ = app.run("mask.select", json!({"id": m.id}));
|
||||
}
|
||||
}
|
||||
if !d.masks.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
overlay_options(app, ui);
|
||||
}
|
||||
});
|
||||
let Some(mid) = app.session.active_mask else { return };
|
||||
let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return };
|
||||
|
|
@ -202,6 +208,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
let _ = Stroke::NONE;
|
||||
}
|
||||
|
||||
/// Overlay colours offered as swatches (the colour of the selected one is used for the tint).
|
||||
const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]];
|
||||
|
||||
/// How the selected mask is shown: overlay mode, colour, opacity, pins.
|
||||
fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
use lightcraft_pipeline::MaskView;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Overlay").color(t.text_dim));
|
||||
let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for v in MaskView::ALL {
|
||||
if ui.selectable_label(v == view, v.label()).clicked() {
|
||||
let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let colored = matches!(view, MaskView::Color | MaskView::ColorOnBw);
|
||||
if colored {
|
||||
ui.horizontal(|ui| {
|
||||
for c in OVERLAY_COLORS {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r);
|
||||
ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
|
||||
if app.ui.mask_overlay_color == c {
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.maskOverlayColor", json!({"color": c}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let spec = ControlSpec {
|
||||
id: "ui.maskOverlayOpacity",
|
||||
label: "Opacity",
|
||||
section: Section::Light,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
default: 50.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Track::Plain,
|
||||
};
|
||||
let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None);
|
||||
if let Some(v) = out.value {
|
||||
app.ui.mask_overlay_opacity = v as f32;
|
||||
}
|
||||
let mut pins = app.ui.mask_pins;
|
||||
if ui.checkbox(&mut pins, "Show Pins").changed() {
|
||||
let _ = app.run("view.maskPins", json!({"show": pins}));
|
||||
}
|
||||
}
|
||||
|
||||
fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) {
|
||||
for (kind, label) in [
|
||||
("brush", "Brush"),
|
||||
|
|
@ -234,6 +296,10 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
app.ui.brush_erase = true;
|
||||
}
|
||||
});
|
||||
let mut auto = app.ui.brush_auto_mask;
|
||||
if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() {
|
||||
app.ui.brush_auto_mask = auto;
|
||||
}
|
||||
});
|
||||
for (id, label, min, max, get) in [
|
||||
("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub mod masking;
|
|||
pub mod presets;
|
||||
pub mod profiles;
|
||||
pub mod right;
|
||||
pub mod settings;
|
||||
pub mod strip;
|
||||
pub mod topbar;
|
||||
|
||||
|
|
|
|||
|
|
@ -214,20 +214,59 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim));
|
||||
});
|
||||
let spec = lightcraft_develop::ControlSpec {
|
||||
id: "ui.removeSize",
|
||||
label: "Size",
|
||||
// brush settings; with a spot selected they edit that spot too
|
||||
let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone())));
|
||||
if let Some((i, sp)) = &sel {
|
||||
(app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32);
|
||||
divider(ui);
|
||||
let mode = match sp.mode {
|
||||
lightcraft_develop::SpotMode::Heal => "Heal",
|
||||
lightcraft_develop::SpotMode::Clone => "Clone",
|
||||
lightcraft_develop::SpotMode::Remove => "Remove",
|
||||
};
|
||||
super::edit::sub_title(ui, &format!("{mode} spot {} of {}", i + 1, d.spots.len()));
|
||||
}
|
||||
let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec {
|
||||
id,
|
||||
label,
|
||||
section: lightcraft_develop::Section::Detail,
|
||||
min: 1.0,
|
||||
max: 100.0,
|
||||
default: 20.0,
|
||||
min,
|
||||
max,
|
||||
default,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: lightcraft_develop::Track::Plain,
|
||||
};
|
||||
let out = slider(ui, &spec, (app.ui.remove_size * 1000.0) as f64, true, None);
|
||||
if let Some(v) = out.value {
|
||||
app.ui.remove_size = (v / 1000.0) as f32;
|
||||
let sliders = [
|
||||
(plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64),
|
||||
(plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64),
|
||||
(plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64),
|
||||
];
|
||||
for (spec, key, v) in sliders {
|
||||
let out = slider(ui, &spec, v, true, None);
|
||||
let scale = if key == "size" { 1000.0 } else { 1.0 };
|
||||
if let Some(v) = out.value {
|
||||
match key {
|
||||
"size" => app.ui.remove_size = (v / scale) as f32,
|
||||
"feather" => app.ui.remove_feather = v as f32,
|
||||
_ => app.ui.remove_opacity = v as f32,
|
||||
}
|
||||
}
|
||||
if sel.is_some() {
|
||||
super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale})));
|
||||
}
|
||||
}
|
||||
if sel.is_some() {
|
||||
padded(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if text_button(ui, "spotRefresh", "Refresh Source (/)", false).clicked() {
|
||||
let _ = app.run("spot.refreshSource", json!({}));
|
||||
}
|
||||
if text_button(ui, "spotDelete", "Delete (⌫)", false).clicked() {
|
||||
let _ = app.run("spot.delete", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out
|
||||
padded(ui, |ui| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
//! The Settings dialog (⌘,): General, Import, Performance, Interface.
|
||||
//!
|
||||
//! Changes apply immediately (no OK/Cancel). Where they are stored:
|
||||
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
|
||||
//! preview size, filmstrip/grid badges, last library) and Auto Advance live in the UI state,
|
||||
//! saved by the host in its config folder (`ui.json`);
|
||||
//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the
|
||||
//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`,
|
||||
//! so they travel with the library.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::state::{GridBadges, PREVIEW_EDGES, StartupView};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// (id, label) of the tabs, in order.
|
||||
pub const TABS: &[(&str, &str)] = &[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface")];
|
||||
|
||||
/// Thumbnail cache sizes offered (MB).
|
||||
const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
|
||||
|
||||
const LABEL_W: f32 = 150.0;
|
||||
|
||||
/// The dialog body for `tab` (the tab bar switches `tab`).
|
||||
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_min_width(560.0);
|
||||
ui.set_min_height(330.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (id, label) in TABS {
|
||||
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() {
|
||||
*tab = id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.add_space(2.0);
|
||||
match tab.as_str() {
|
||||
"import" => import_tab(app, ui, &t),
|
||||
"performance" => performance_tab(app, ui, &t),
|
||||
"interface" => interface_tab(app, ui, &t),
|
||||
_ => general_tab(app, ui, &t),
|
||||
}
|
||||
}
|
||||
|
||||
fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(text).font(t.semibold(12.5)).color(t.text));
|
||||
}
|
||||
|
||||
fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(LABEL_W);
|
||||
ui.label(RichText::new(label).color(t.text_label));
|
||||
});
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
ui.label(RichText::new(text).size(11.0).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// A checkbox addressable as `check:{id}`; true when toggled.
|
||||
fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool {
|
||||
let r = ui.checkbox(value, label);
|
||||
register(ui.ctx(), format!("check:{id}"), r.rect);
|
||||
r.changed()
|
||||
}
|
||||
|
||||
/// Mutually exclusive buttons (`button:{id}-{index}`).
|
||||
fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool {
|
||||
let mut changed = false;
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (i, (v, l)) in options.iter().enumerate() {
|
||||
if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v {
|
||||
*value = *v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- General
|
||||
|
||||
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
heading(ui, t, "Library");
|
||||
let location = match &app.session.library {
|
||||
Some(l) => l.dir.display().to_string(),
|
||||
None => "In memory — nothing is saved".to_string(),
|
||||
};
|
||||
row(ui, t, "Location", |ui| {
|
||||
ui.label(RichText::new(location).color(t.text));
|
||||
});
|
||||
row(ui, t, "", |ui| {
|
||||
let can = app.services.pick_folder.is_some();
|
||||
let r = ui.add_enabled(can, egui::Button::new("Open Library…"));
|
||||
register(ui.ctx(), "button:settingsOpenLibrary", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.openLibrary", json!({}));
|
||||
}
|
||||
if !can {
|
||||
hint(ui, t, "not available here");
|
||||
}
|
||||
});
|
||||
heading(ui, t, "Startup");
|
||||
row(ui, t, "Open in", |ui| {
|
||||
choices(
|
||||
ui,
|
||||
"settingsStartup",
|
||||
&[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")],
|
||||
&mut app.ui.settings.startup_view,
|
||||
);
|
||||
});
|
||||
heading(ui, t, "Culling");
|
||||
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
|
||||
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------- Import
|
||||
|
||||
/// A preset picker: `None` = `none_label`. Returns the new choice when it changed.
|
||||
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
|
||||
let name = |pid: &str| app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| format!("{pid} (missing)"));
|
||||
let text = current.map(name).unwrap_or_else(|| none_label.to_string());
|
||||
let mut out = None;
|
||||
let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(current.is_none(), none_label).clicked() {
|
||||
out = Some(None);
|
||||
}
|
||||
let mut group = "";
|
||||
for p in &app.session.presets {
|
||||
if p.group != group {
|
||||
group = &p.group;
|
||||
ui.label(RichText::new(group).size(10.5).weak());
|
||||
}
|
||||
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
|
||||
out = Some(Some(p.id.clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
|
||||
out.filter(|v| v.as_deref() != current)
|
||||
}
|
||||
|
||||
/// Cameras of the raws in the library plus those with a stored default, sorted.
|
||||
fn cameras(app: &LightcraftApp) -> Vec<String> {
|
||||
let mut v: Vec<String> = app
|
||||
.session
|
||||
.catalog
|
||||
.photos()
|
||||
.filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty())
|
||||
.map(|p| p.meta.camera.clone())
|
||||
.chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone()))
|
||||
.collect();
|
||||
v.sort_by_key(|c| c.to_lowercase());
|
||||
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
v
|
||||
}
|
||||
|
||||
fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let d = app.session.import_defaults.clone();
|
||||
heading(ui, t, "Raw defaults");
|
||||
hint(ui, t, "Settings new raw photos start from. Changing them doesn't touch photos already in the library.");
|
||||
row(ui, t, "Raw photos", |ui| {
|
||||
if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") {
|
||||
let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}}));
|
||||
}
|
||||
});
|
||||
let mut per = d.per_camera;
|
||||
row(ui, t, "", |ui| {
|
||||
if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") {
|
||||
let _ = app.run("library.preferences", json!({"import": {"perCamera": per}}));
|
||||
}
|
||||
});
|
||||
if per {
|
||||
let cams = cameras(app);
|
||||
if cams.is_empty() {
|
||||
hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library.");
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| {
|
||||
for (i, cam) in cams.iter().enumerate() {
|
||||
let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam));
|
||||
row(ui, t, cam, |ui| {
|
||||
// "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default)
|
||||
const RAW_DEFAULT: &str = "\u{1}raw";
|
||||
let current = match entry {
|
||||
None => Some(RAW_DEFAULT),
|
||||
Some(e) => e.preset.as_deref(),
|
||||
};
|
||||
let mut pick = None;
|
||||
let label = match current {
|
||||
Some(RAW_DEFAULT) => "Same as raw default".to_string(),
|
||||
None => "LightCraft Default".to_string(),
|
||||
Some(pid) => app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| pid.to_string()),
|
||||
};
|
||||
let id = format!("settingsCamera-{i}");
|
||||
let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() {
|
||||
pick = Some(json!({"camera": cam, "remove": true}));
|
||||
}
|
||||
if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() {
|
||||
pick = Some(json!({"camera": cam, "preset": null}));
|
||||
}
|
||||
for p in &app.session.presets {
|
||||
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
|
||||
pick = Some(json!({"camera": cam, "preset": p.id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
|
||||
if let Some(c) = pick {
|
||||
let _ = app.run("library.preferences", json!({"camera": c}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)");
|
||||
row(ui, t, "Non-raw photos", |ui| {
|
||||
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") {
|
||||
let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}}));
|
||||
}
|
||||
});
|
||||
heading(ui, t, "XMP sidecars");
|
||||
let mut xmp = app.session.xmp;
|
||||
if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") {
|
||||
let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write}));
|
||||
}
|
||||
row(ui, t, "Sidecar names", |ui| {
|
||||
use lightcraft_engine::sidecar::SidecarNaming as N;
|
||||
let mut n = xmp.naming;
|
||||
if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) {
|
||||
let naming = if n == N::Full { "full" } else { "stem" };
|
||||
let _ = app.run("library.xmpPreferences", json!({"naming": naming}));
|
||||
}
|
||||
});
|
||||
if app.session.library.is_none() {
|
||||
hint(ui, t, "In-memory session: these settings last until LightCraft quits.");
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------- Performance
|
||||
|
||||
fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
use lightcraft_engine::gpu;
|
||||
heading(ui, t, "Rendering");
|
||||
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
|
||||
let status = if !gpu::available() {
|
||||
"No usable GPU found: rendering on the CPU".to_string()
|
||||
} else {
|
||||
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
|
||||
};
|
||||
hint(ui, t, &status);
|
||||
row(ui, t, "Preview size", |ui| {
|
||||
let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect();
|
||||
let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect();
|
||||
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge);
|
||||
});
|
||||
hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.");
|
||||
row(ui, t, "Memory for caches", |ui| {
|
||||
let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
|
||||
let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")];
|
||||
choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb);
|
||||
});
|
||||
heading(ui, t, "Thumbnail cache");
|
||||
let cur = (app.session.cache_bytes() >> 20) as u32;
|
||||
row(ui, t, "Size limit", |ui| {
|
||||
let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")];
|
||||
let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 };
|
||||
if choices(ui, "settingsCache", &opts, &mut v) {
|
||||
let _ = app.run("library.preferences", json!({"cacheMb": v}));
|
||||
}
|
||||
});
|
||||
let used = app.session.media.rendered.disk().map(|d| d.size());
|
||||
row(ui, t, "In use", |ui| {
|
||||
ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text));
|
||||
let r = ui.button("Clear Cache");
|
||||
register(ui.ctx(), "button:settingsClearCache", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("library.clearPreviews", json!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------- Interface
|
||||
|
||||
fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
heading(ui, t, "Filmstrip");
|
||||
check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names");
|
||||
check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges");
|
||||
heading(ui, t, "Grid");
|
||||
row(ui, t, "Ratings & flags", |ui| {
|
||||
choices(
|
||||
ui,
|
||||
"settingsGridBadges",
|
||||
&[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")],
|
||||
&mut app.ui.settings.grid_badges,
|
||||
);
|
||||
});
|
||||
check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats");
|
||||
heading(ui, t, "Detail");
|
||||
check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in");
|
||||
row(ui, t, "Info overlay", |ui| {
|
||||
use crate::state::InfoOverlay as I;
|
||||
choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- Open Library…
|
||||
|
||||
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
|
||||
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
|
||||
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => x.to_string(),
|
||||
None => match app.services.pick_folder.as_mut() {
|
||||
Some(pick) => match pick() {
|
||||
Some(x) => x,
|
||||
None => return Ok(Value::Null),
|
||||
},
|
||||
None => return Err("no folder dialog on this platform".into()),
|
||||
},
|
||||
};
|
||||
app.session.close_library().map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
|
||||
app.renderer.forget_all();
|
||||
app.ui.compare = None;
|
||||
app.ui.settings.library_path = path.clone();
|
||||
Ok(json!({"path": path, "photos": app.session.catalog.len()}))
|
||||
}
|
||||
|
|
@ -211,6 +211,16 @@ impl Renderer {
|
|||
}
|
||||
|
||||
/// Is a request for `slot` queued or running?
|
||||
/// Drop every texture and cached stage (another library was opened: photo ids changed meaning).
|
||||
pub fn forget_all(&mut self) {
|
||||
self.textures.clear();
|
||||
self.stages.clear();
|
||||
self.quick_tried.clear();
|
||||
self.prefetched.clear();
|
||||
self.pending.clear();
|
||||
self.queue.clear();
|
||||
}
|
||||
|
||||
pub fn is_pending(&self, slot: Slot) -> bool {
|
||||
self.pending.contains_key(&slot)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ pub const ALIASES: &[(&str, &str, &str)] = &[
|
|||
("Shift+M", "version.create", "{}"),
|
||||
("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#),
|
||||
("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#),
|
||||
// Shift+[ / Shift+] arrive as { / } on most layouts
|
||||
("Shift+{", "brush.featherLess", "{}"),
|
||||
("Shift+}", "brush.featherMore", "{}"),
|
||||
];
|
||||
|
||||
pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
||||
|
|
@ -41,6 +44,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
|||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
@ -156,6 +160,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
_ => app.toast(ctx, "Unflagged"),
|
||||
}
|
||||
} else {
|
||||
// in the full-screen preview (no panels) I cycles the info overlay instead
|
||||
if f == "panel.info" && app.ui.fullscreen {
|
||||
let _ = app.run("view.infoOverlay", json!({}));
|
||||
continue;
|
||||
}
|
||||
// Delete acts on what's being edited: the active mask in the Masking panel; never the
|
||||
// photo while retouching (spots are removed from their own panel).
|
||||
if f == "photo.delete" {
|
||||
|
|
@ -167,9 +176,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
R::Remove | R::RedEye => continue,
|
||||
R::Remove => {
|
||||
if app.session.active_spot.is_some() {
|
||||
let _ = app.run("spot.delete", json!({}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
R::RedEye => continue,
|
||||
_ => {}
|
||||
}
|
||||
if crate::menus::confirm_delete(app) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// X is both reject (library) and swap crop aspect (crop tool)
|
||||
if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop {
|
||||
|
|
@ -179,6 +197,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop {
|
||||
continue;
|
||||
}
|
||||
// / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere)
|
||||
if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() {
|
||||
let _ = app.run("spot.refreshSource", json!({}));
|
||||
continue;
|
||||
}
|
||||
// Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere)
|
||||
if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking {
|
||||
let _ = app.run("view.maskOverlayMode", json!({}));
|
||||
continue;
|
||||
}
|
||||
let _ = app.run(&f, json!({}));
|
||||
match f.as_str() {
|
||||
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,94 @@ pub enum CropOverlay {
|
|||
None,
|
||||
}
|
||||
|
||||
/// Which view the app opens in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StartupView {
|
||||
/// Whatever was showing at quit.
|
||||
#[default]
|
||||
Last,
|
||||
Grid,
|
||||
Detail,
|
||||
}
|
||||
|
||||
/// When grid cells show their rating / flag / edited badges.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GridBadges {
|
||||
/// On hover, on the selection, and on rated or flagged photos.
|
||||
#[default]
|
||||
Auto,
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InfoOverlay {
|
||||
#[default]
|
||||
Off,
|
||||
/// File name, capture date and dimensions.
|
||||
Basic,
|
||||
/// File name, camera, lens and exposure (shutter, aperture, ISO, focal length).
|
||||
Exposure,
|
||||
}
|
||||
|
||||
impl InfoOverlay {
|
||||
pub fn next(self) -> InfoOverlay {
|
||||
match self {
|
||||
InfoOverlay::Off => InfoOverlay::Basic,
|
||||
InfoOverlay::Basic => InfoOverlay::Exposure,
|
||||
InfoOverlay::Exposure => InfoOverlay::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-level preferences (Settings dialog). They belong to the app, not a library, and are
|
||||
/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library
|
||||
/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppSettings {
|
||||
/// Library opened at launch when no `--library` is given (empty = the default location).
|
||||
pub library_path: String,
|
||||
pub startup_view: StartupView,
|
||||
/// Ask before moving photos to Recently Deleted (keyboard and menu).
|
||||
pub confirm_delete: bool,
|
||||
/// GPU rendering allowed (`app.gpu`).
|
||||
pub gpu: bool,
|
||||
/// Largest long edge (pixels) the loupe renders at.
|
||||
pub preview_edge: u32,
|
||||
/// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`).
|
||||
pub memory_mb: u32,
|
||||
/// Filmstrip: file names above the thumbnails.
|
||||
pub film_names: bool,
|
||||
/// Filmstrip: rating / flag / edited badges on the thumbnails.
|
||||
pub film_badges: bool,
|
||||
/// Grid: when to show the rating / flag / edited badges.
|
||||
pub grid_badges: GridBadges,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
AppSettings {
|
||||
library_path: String::new(),
|
||||
startup_view: StartupView::Last,
|
||||
confirm_delete: false,
|
||||
gpu: true,
|
||||
preview_edge: 2560,
|
||||
memory_mb: 0,
|
||||
film_names: true,
|
||||
film_badges: true,
|
||||
grid_badges: GridBadges::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview sizes offered in Settings → Performance.
|
||||
pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
|
|
@ -101,7 +189,13 @@ pub struct UiState {
|
|||
pub single_panel: bool,
|
||||
pub show_clipping: bool,
|
||||
pub histogram: bool,
|
||||
/// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles),
|
||||
/// in which colour and opacity (0..100, colour views), and whether pins are drawn.
|
||||
pub mask_overlay: bool,
|
||||
pub mask_overlay_mode: String,
|
||||
pub mask_overlay_color: [u8; 3],
|
||||
pub mask_overlay_opacity: f32,
|
||||
pub mask_pins: bool,
|
||||
pub crop_overlay: CropOverlay,
|
||||
pub show_filenames: bool,
|
||||
pub search: String,
|
||||
|
|
@ -117,7 +211,12 @@ pub struct UiState {
|
|||
pub brush_feather: f32,
|
||||
pub brush_flow: f32,
|
||||
pub brush_erase: bool,
|
||||
/// Brush Auto Mask: dabs stick to areas like the one under the brush centre.
|
||||
pub brush_auto_mask: bool,
|
||||
/// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100).
|
||||
pub remove_size: f32,
|
||||
pub remove_feather: f32,
|
||||
pub remove_opacity: f32,
|
||||
/// Selected Point Color sample.
|
||||
pub point_color: usize,
|
||||
/// Point Color "Visualize range": the selected sample's range in colour, the rest grey.
|
||||
|
|
@ -132,6 +231,17 @@ pub struct UiState {
|
|||
pub filter_bar: bool,
|
||||
/// Culling: after a rating, flag or colour-label key, move to the next photo.
|
||||
pub auto_advance: bool,
|
||||
/// Full-screen preview (F): the photo alone on black, no chrome.
|
||||
pub fullscreen: bool,
|
||||
/// Info overlay on the loupe.
|
||||
pub info_overlay: InfoOverlay,
|
||||
/// Navigator mini map in the loupe while zoomed in.
|
||||
pub navigator: bool,
|
||||
/// App preferences (Settings dialog).
|
||||
pub settings: AppSettings,
|
||||
/// Window full screen requested (⇧⌘F); the host applies it (`None` = no change pending).
|
||||
#[serde(skip)]
|
||||
pub window_fullscreen: Option<bool>,
|
||||
/// Compare view: (select, candidate) photo ids.
|
||||
#[serde(skip)]
|
||||
pub compare: Option<(u64, u64)>,
|
||||
|
|
@ -230,6 +340,14 @@ pub enum Dialog {
|
|||
Merge {
|
||||
opts: crate::merge::MergeDialog,
|
||||
},
|
||||
/// Settings (preferences): `tab` = general | import | performance | interface.
|
||||
Settings {
|
||||
tab: String,
|
||||
},
|
||||
/// Confirm moving photos to Recently Deleted.
|
||||
ConfirmDelete {
|
||||
count: usize,
|
||||
},
|
||||
About,
|
||||
Shortcuts,
|
||||
}
|
||||
|
|
@ -253,6 +371,10 @@ impl Default for UiState {
|
|||
show_clipping: false,
|
||||
histogram: true,
|
||||
mask_overlay: true,
|
||||
mask_overlay_mode: "color".into(),
|
||||
mask_overlay_color: [230, 30, 40],
|
||||
mask_overlay_opacity: 50.0,
|
||||
mask_pins: true,
|
||||
crop_overlay: CropOverlay::Thirds,
|
||||
show_filenames: true,
|
||||
search: String::new(),
|
||||
|
|
@ -264,7 +386,10 @@ impl Default for UiState {
|
|||
brush_feather: 50.0,
|
||||
brush_flow: 60.0,
|
||||
brush_erase: false,
|
||||
brush_auto_mask: false,
|
||||
remove_size: 0.02,
|
||||
remove_feather: 50.0,
|
||||
remove_opacity: 100.0,
|
||||
point_color: 0,
|
||||
point_color_visualize: false,
|
||||
eye: 0,
|
||||
|
|
@ -272,6 +397,11 @@ impl Default for UiState {
|
|||
visualize_spots: false,
|
||||
spots_threshold: 50.0,
|
||||
auto_advance: false,
|
||||
fullscreen: false,
|
||||
info_overlay: InfoOverlay::Off,
|
||||
navigator: true,
|
||||
settings: AppSettings::default(),
|
||||
window_fullscreen: None,
|
||||
filter_bar: false,
|
||||
compare: None,
|
||||
toast: None,
|
||||
|
|
@ -310,6 +440,15 @@ impl UiState {
|
|||
self.thumb_size = self.thumb_size.clamp(90.0, 480.0);
|
||||
self.brush_size = self.brush_size.clamp(0.002, 0.5);
|
||||
self.dialog = None;
|
||||
self.fullscreen = false;
|
||||
if !crate::state::PREVIEW_EDGES.contains(&self.settings.preview_edge) {
|
||||
self.settings.preview_edge = AppSettings::default().preview_edge;
|
||||
}
|
||||
match self.settings.startup_view {
|
||||
StartupView::Last => {}
|
||||
StartupView::Grid => self.view = ViewMode::PhotoGrid,
|
||||
StartupView::Detail => self.view = ViewMode::Detail,
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
//! Headless tests of the Masking and Remove tools on the photo: overlay keys, pins, spot editing.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_develop::MaskShape;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::RightPanel;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn detail(panel: &str) -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("engine.execute", json!({"command": panel}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h
|
||||
}
|
||||
|
||||
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
fn pointer(h: &mut Headless, events: serde_json::Value) {
|
||||
let r = h.request("ui.pointer", json!({"events": events}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
|
||||
fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings {
|
||||
let id = h.app.session.active().expect("active photo");
|
||||
(*h.app.session.develop_of(id).unwrap_or_default()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_overlay_keys_and_pins() {
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
let mut h = detail("panel.masking");
|
||||
assert_eq!(h.app.ui.right, RightPanel::Masking);
|
||||
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1}));
|
||||
exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]}));
|
||||
assert_eq!(h.app.session.active_mask, Some(2));
|
||||
// the loupe asks the renderer for the selected mask's overlay
|
||||
let d = develop(&h);
|
||||
let o = crate::panels::detail::view_overlay(&h.app, &d);
|
||||
assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 });
|
||||
// O toggles it, Shift+O cycles the mode (and leaves the crop overlay alone)
|
||||
h.request("ui.key", json!({"key": "o"}), T);
|
||||
assert!(!h.app.ui.mask_overlay);
|
||||
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
|
||||
h.request("ui.key", json!({"key": "o"}), T);
|
||||
let crop = h.app.ui.crop_overlay;
|
||||
h.request("ui.key", json!({"key": "o", "shift": true}), T);
|
||||
assert_eq!(h.app.ui.mask_overlay_mode, "colorOnBw");
|
||||
assert_eq!(h.app.ui.crop_overlay, crop);
|
||||
exec(&mut h, "view.maskOverlayMode", json!({"mode": "whiteOnBlack"}));
|
||||
exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0", "opacity": 80}));
|
||||
assert_eq!((h.app.ui.mask_overlay_color, h.app.ui.mask_overlay_opacity), ([0x28, 0x70, 0xf0], 80.0));
|
||||
let r = h.request("engine.execute", json!({"command": "view.maskOverlayMode", "params": {"mode": "nope"}}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
// clicking another mask's pin selects that mask
|
||||
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "up", "x": 0.3, "y": 0.4}]));
|
||||
assert_eq!(h.app.session.active_mask, Some(1));
|
||||
// dragging a pin moves its component (one undo step)
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "drag", "x": 0.35, "y": 0.45}, {"kind": "drag", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]),
|
||||
);
|
||||
let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") };
|
||||
assert!((center.x - 0.5).abs() < 0.02 && (center.y - 0.6).abs() < 0.02, "{center:?}");
|
||||
// dragging the linear gradient's pin (a non-selected mask) selects and moves it
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "drag", "x": 0.72, "y": 0.4}, {"kind": "drag", "x": 0.8, "y": 0.4}, {"kind": "up", "x": 0.8, "y": 0.4}]),
|
||||
);
|
||||
assert_eq!(h.app.session.active_mask, Some(2));
|
||||
let MaskShape::Linear { start, end } = develop(&h).masks[1].components[0].shape.clone() else { panic!("linear") };
|
||||
assert!((start.x - 0.8).abs() < 0.02 && (end.x - 0.8).abs() < 0.02 && (start.y - 0.2).abs() < 0.02, "{start:?} {end:?}");
|
||||
// hidden pins can't be grabbed
|
||||
exec(&mut h, "view.maskPins", json!({"show": false}));
|
||||
pointer(&mut h, json!([{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]));
|
||||
assert_eq!(h.app.session.active_mask, Some(2));
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_carry_auto_mask() {
|
||||
let mut h = detail("panel.masking");
|
||||
let r = h.request("ui.set", json!({"brushAutoMask": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
exec(&mut h, "tool.brush", json!({}));
|
||||
assert_eq!(h.app.ui.tool, "brush");
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]),
|
||||
);
|
||||
let d = develop(&h);
|
||||
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
|
||||
assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_spots_by_pointer_and_keyboard() {
|
||||
let mut h = detail("panel.remove");
|
||||
assert_eq!(h.app.ui.right, RightPanel::Remove);
|
||||
let spots = |h: &Headless| develop(h).spots;
|
||||
// paint a spot: it's added with the brush's size/feather/opacity and selected
|
||||
let r = h.request("ui.set", json!({"removeFeather": 30.0, "removeOpacity": 80.0}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}]));
|
||||
assert_eq!(spots(&h).len(), 1);
|
||||
assert_eq!((spots(&h)[0].feather, spots(&h)[0].opacity), (30.0, 80.0));
|
||||
assert_eq!(h.app.session.active_spot, Some(0));
|
||||
// [ / ] resize the selected spot, Shift+[ / Shift+] feather it
|
||||
let s0 = spots(&h)[0].size;
|
||||
h.request("ui.key", json!({"key": "]"}), T);
|
||||
assert!(spots(&h)[0].size > s0 * 1.1, "{} vs {s0}", spots(&h)[0].size);
|
||||
h.request("ui.key", json!({"key": "["}), T);
|
||||
h.request("ui.key", json!({"key": "["}), T);
|
||||
assert!(spots(&h)[0].size < s0 * 0.9);
|
||||
h.request("ui.key", json!({"key": "[", "shift": true}), T);
|
||||
assert_eq!(spots(&h)[0].feather, 20.0);
|
||||
h.request("ui.key", json!({"key": "]", "shift": true}), T);
|
||||
assert_eq!(spots(&h)[0].feather, 30.0);
|
||||
// / picks another source (and leaves the filmstrip alone)
|
||||
let (src, film) = (spots(&h)[0].source_offset, h.app.ui.filmstrip);
|
||||
h.request("ui.key", json!({"key": "/"}), T);
|
||||
assert_ne!(spots(&h)[0].source_offset, src);
|
||||
assert_eq!(h.app.ui.filmstrip, film);
|
||||
// a second spot elsewhere; clicking the first one's pin selects it
|
||||
pointer(&mut h, json!([{"kind": "down", "x": 0.7, "y": 0.3}, {"kind": "up", "x": 0.7, "y": 0.3}]));
|
||||
assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(1)));
|
||||
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}]));
|
||||
assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(0)));
|
||||
// drag its target: it moves, its source offset stays
|
||||
let src = spots(&h)[0].source_offset;
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "drag", "x": 0.32, "y": 0.6}, {"kind": "drag", "x": 0.4, "y": 0.6}, {"kind": "up", "x": 0.4, "y": 0.6}]),
|
||||
);
|
||||
let sp = spots(&h)[0].clone();
|
||||
assert!((sp.points[0].x - 0.4).abs() < 0.01 && (sp.points[0].y - 0.6).abs() < 0.01, "{:?}", sp.points);
|
||||
assert_eq!(sp.source_offset, src);
|
||||
// drag its source to a fixed place
|
||||
let o = sp.source_offset.unwrap();
|
||||
let (sx, sy) = (sp.points[0].x + o.x, sp.points[0].y + o.y);
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": sx, "y": sy}, {"kind": "drag", "x": sx + 0.01, "y": sy}, {"kind": "drag", "x": 0.5, "y": 0.8}, {"kind": "up", "x": 0.5, "y": 0.8}]),
|
||||
);
|
||||
let sp = spots(&h)[0].clone();
|
||||
let o = sp.source_offset.unwrap();
|
||||
assert!((sp.points[0].x + o.x - 0.5).abs() < 0.01 && (sp.points[0].y + o.y - 0.8).abs() < 0.01, "{o:?}");
|
||||
// ⌫ deletes the selected spot, not the photo
|
||||
let photos = h.app.session.visible_cloned().len();
|
||||
h.request("ui.key", json!({"key": "delete"}), T);
|
||||
assert_eq!((spots(&h).len(), h.app.session.active_spot), (1, None));
|
||||
assert_eq!(h.app.session.visible_cloned().len(), photos);
|
||||
h.request("ui.key", json!({"key": "delete"}), T);
|
||||
assert_eq!(spots(&h).len(), 1, "nothing selected: nothing deleted");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh
|
|||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
|
||||
| `export {path, id?, longEdge?, quality?, colorSpace?, bitDepth?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
|
||||
|
||||
Tools taking `id` make that photo active first; without it they act on the active photo.
|
||||
|
||||
|
|
|
|||
101
docs/parity.md
101
docs/parity.md
|
|
@ -21,17 +21,17 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
<!-- parity:summary -->
|
||||
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) |
|
||||
| B. Library management (LIB) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) |
|
||||
| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) |
|
||||
| A. Import (IMP) | 5 | 3 | 5 | 1 | 2/4 (50%) | 2/4 (50%) |
|
||||
| B. Library management (LIB) | 16 | 2 | 7 | 2 | 8/9 (89%) | 7/9 (78%) |
|
||||
| C. Views & navigation (VIEW) | 13 | 1 | 3 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
|
||||
| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) |
|
||||
| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) |
|
||||
| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) |
|
||||
| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) |
|
||||
| I. Remove / healing (REM) | 5 | 1 | 4 | 2 | 4/4 (100%) | 1/3 (33%) |
|
||||
| J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) |
|
||||
| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) |
|
||||
| K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) |
|
||||
| L. Presets (PRE) | 3 | 2 | 2 | 1 | 2/2 (100%) | 1/2 (50%) |
|
||||
| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) |
|
||||
| N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) |
|
||||
|
|
@ -39,39 +39,27 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
|
||||
| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — |
|
||||
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
|
||||
| S. Export (EXP) | 4 | 6 | 8 | 0 | 2/7 (29%) | 2/7 (29%) |
|
||||
| S. Export (EXP) | 6 | 6 | 6 | 0 | 3/7 (43%) | 3/7 (43%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) |
|
||||
| V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) |
|
||||
| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) |
|
||||
| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) |
|
||||
| **Total** | 213 | 101 | 186 | 37 | 155/198 (78%) | 49/144 (34%) |
|
||||
| Y. Menus | 53 | 17 | 15 | 8 | 38/47 (81%) | 13/23 (57%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 53 | 14 | 13 | 1 | 44/52 (85%) | 9/23 (39%) |
|
||||
| Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) |
|
||||
| **Total** | 250 | 87 | 163 | 37 | 171/198 (86%) | 69/144 (48%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
||||
Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on.
|
||||
|
||||
1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible /
|
||||
Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort.
|
||||
2. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
|
||||
kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort.
|
||||
3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
|
||||
Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort.
|
||||
4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
|
||||
feather/opacity sliders, `[`/`]` size keys. Medium effort.
|
||||
7. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
|
||||
XMP prefs, cache, GPU. Medium effort.
|
||||
9. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
|
||||
1. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
|
||||
`crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi.
|
||||
10. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
|
||||
2. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
|
||||
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
|
||||
11. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
|
||||
13. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
|
||||
14. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
3. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
|
||||
## Shortcuts: conflicts and missing bindings
|
||||
|
||||
|
|
@ -100,10 +88,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
|
||||
**Still missing / broken:**
|
||||
- No command yet: F full-screen preview, ⇧⌘F window full screen, ⇧⌘V paste selected,
|
||||
⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, `[` `]` / ⇧`[` ⇧`]`
|
||||
brush size/feather, ⌃H / ⌃M merges, F1 help, ⇧6–9 label + advance.
|
||||
- `⌫` in the Masking panel deletes the active mask; spots have no pin selection yet (⌫ does nothing in Remove).
|
||||
- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — no pin toggle yet.
|
||||
⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, ⌃H / ⌃M merges, F1 help,
|
||||
⇧6–9 label + advance.
|
||||
- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins.
|
||||
- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified).
|
||||
|
||||
<!-- Sections below hold one row per id. Keep the column order: Id | Feature | Tier | Status | Evidence | Notes. -->
|
||||
|
|
@ -118,7 +105,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection |
|
||||
| LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | |
|
||||
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
|
||||
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ⬜ | `crates/engine/src/import.rs` | raws get embedded lens corrections on import; no user raw-default setting |
|
||||
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
|
||||
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `cmd:library.importPreview`, `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; the import review shows a folder's photos before adding (“Add in place” or “Copy into library”); no browse-a-folder-without-adding view in the main grid |
|
||||
|
|
@ -167,14 +154,14 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid |
|
||||
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | |
|
||||
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
|
||||
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | |
|
||||
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
|
||||
| LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) |
|
||||
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
|
||||
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
|
||||
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | |
|
||||
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
|
||||
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | |
|
||||
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | |
|
||||
| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | |
|
||||
|
|
@ -303,8 +290,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | |
|
||||
| LR-REM-GEN | Generative remove | OOS | 🚫 | | |
|
||||
| LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | |
|
||||
| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | 🟡 | `cmd:spot.add` (`size`, `feather`, `opacity`), `crates/ui-egui/src/panels/right.rs` | UI has size only; no `[` `]` keys |
|
||||
| LR-REM-SPOT-EDIT | Edit existing spots | P0 | 🟡 | `cmd:spot.delete` | no pin selection, no moving target/source, ⌫ deletes the photo instead |
|
||||
| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) |
|
||||
| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added |
|
||||
| LR-REM-VISUALIZE | Visualize spots | P1 | ⬜ | | |
|
||||
| LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | |
|
||||
| LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | |
|
||||
|
|
@ -329,7 +316,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI |
|
||||
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
|
||||
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
|
||||
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
|
||||
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | |
|
||||
| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified |
|
||||
|
|
@ -339,9 +326,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | |
|
||||
| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | |
|
||||
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | |
|
||||
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets |
|
||||
| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options |
|
||||
| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option |
|
||||
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
|
||||
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover |
|
||||
| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
|
||||
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |
|
||||
| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |
|
||||
| LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | |
|
||||
|
|
@ -424,9 +411,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) |
|
||||
| LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge |
|
||||
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | |
|
||||
| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only |
|
||||
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
|
||||
| LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate |
|
||||
| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only |
|
||||
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) |
|
||||
| LR-EXP-HDR | HDR output | P2 | ⬜ | | |
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
|
|
@ -459,11 +446,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-PREF-GENERAL | General settings | P0 | ⬜ | `cmd:library.xmpPreferences` | no settings dialog; only XMP preferences (by command) |
|
||||
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | 🟡 | `cmd:library.clearPreviews`, `cmd:library.compact` | bounded thumbnail cache; no UI for size/location |
|
||||
| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs` | General / Import / Performance / Interface tabs; app settings in ui.json, library settings in prefs.json |
|
||||
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
|
||||
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
|
||||
| LR-PREF-INTERFACE | Interface options | P1 | ⬜ | | |
|
||||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | 🟡 | `cmd:app.gpu` | by command only |
|
||||
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
|
||||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
|
||||
| LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra |
|
||||
|
|
@ -512,7 +499,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
|||
|---|---|---|---|---|---|
|
||||
| MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu |
|
||||
| MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | |
|
||||
| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL |
|
||||
| MENU-APP-SETTINGS | Settings… | P0 | ✅ | `cmd:app.settings` | app menu on macOS, Edit menu elsewhere |
|
||||
| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | |
|
||||
| MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | |
|
||||
| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | |
|
||||
|
|
@ -546,8 +533,8 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
|||
| MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | |
|
||||
| MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | |
|
||||
| MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | |
|
||||
| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ⬜ | | |
|
||||
| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ⬜ | | |
|
||||
| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | `cmd:view.fullScreenPreview` | |
|
||||
| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | `cmd:view.enterFullScreen` | |
|
||||
| MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | |
|
||||
| MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | |
|
||||
| MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | |
|
||||
|
|
@ -555,7 +542,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
|||
| MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` |
|
||||
| MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | `cmd:panel.versions` | |
|
||||
| MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | `cmd:view.histogram` | |
|
||||
| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ⬜ | | |
|
||||
| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ✅ | `cmd:view.infoOverlay` | cycles |
|
||||
| MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | |
|
||||
| MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | |
|
||||
| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | |
|
||||
|
|
@ -614,7 +601,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
|||
| KEY-CROP | Crop & rotate — C | P0 | ✅ | `cmd:panel.crop` | |
|
||||
| KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | |
|
||||
| KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | |
|
||||
| KEY-FULLSCREEN | Full-screen preview — F | P1 | ⬜ | | no command |
|
||||
| KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | `cmd:view.fullScreenPreview` | |
|
||||
| KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.photoGrid` | |
|
||||
| KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | |
|
||||
| KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | |
|
||||
|
|
@ -628,10 +615,10 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
|||
| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | |
|
||||
| KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) |
|
||||
| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command |
|
||||
| KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command |
|
||||
| KEY-PREFS | Settings — ⌘, | P0 | ✅ | `cmd:app.settings` | |
|
||||
| KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command |
|
||||
| KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | |
|
||||
| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O |
|
||||
| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.cropOverlay` | O toggles the mask overlay; ⇧O cycles mask overlay modes while masking, crop overlays elsewhere |
|
||||
| KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | `cmd:view.leftPanel` | bound to ⌘⇧L; P = pick |
|
||||
| KEY-LINEAR | Linear gradient — L | P0 | ✅ | `cmd:tool.linear` | |
|
||||
| KEY-RADIAL | Radial gradient — R | P0 | ✅ | `cmd:tool.radial` | |
|
||||
|
|
@ -643,7 +630,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
|||
| KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays |
|
||||
| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound |
|
||||
| KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) |
|
||||
| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | |
|
||||
| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ✅ | `cmd:view.enterFullScreen` | |
|
||||
| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack |
|
||||
| KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid |
|
||||
| KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) |
|
||||
|
|
@ -656,8 +643,8 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
|||
| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | 🟡 | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | bound to ⌘⌥1–5 |
|
||||
| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | |
|
||||
| KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit |
|
||||
| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ⬜ | | brush size is UI state, no command |
|
||||
| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ⬜ | | |
|
||||
| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) |
|
||||
| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ✅ | `cmd:brush.featherLess`, `cmd:brush.featherMore` | |
|
||||
| KEY-BRUSH | Brush — B | P0 | ✅ | `cmd:tool.brush` | |
|
||||
| KEY-HEAL | Remove / heal — H | P0 | ✅ | `cmd:panel.remove` | |
|
||||
| KEY-MERGE | HDR / panorama merges — ⌃H ⇧⌃H ⌃M ⇧⌃M | P2 | ⬜ | | |
|
||||
|
|
@ -690,7 +677,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
|||
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
|
||||
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | |
|
||||
| KEY-COMMIT | Commit tool — Return | P1 | 🟡 | | edits apply live; no explicit commit step |
|
||||
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | 🟡 | `cmd:mask.delete` | ⌫ deletes the active mask in the Masking panel and never the photo while retouching; no spot pin selection yet |
|
||||
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching |
|
||||
| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel |
|
||||
|
||||
## Lightroom Classic extras
|
||||
|
|
|
|||
Loading…
Reference in New Issue