Merge pull request #975: integration batch 26 (31 reviewed contributor PRs)

Integration batch 26: 31 reviewed contributor PRs
This commit is contained in:
BFlat authored and GitHub committed 2026-10-11 19:30:57 +07:00
commit 0dd6593d9d
51 files changed
+1935 -128

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
//!
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
//! `docs/web.md`). Differences from the desktop host:
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
+12
View File
@@ -98,6 +98,8 @@ fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags);
}
c
}
@@ -1086,6 +1088,16 @@ mod tests {
}
}
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
#[test]
fn window_has_no_indirect_call_validation_pipeline() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
#[test]
+84 -1
View File
@@ -833,6 +833,22 @@ fn to_prophoto() -> Mat3 {
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
}
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
/// its colour; in between the two are blended, so noisy shadows show no step.
const MIN_LUMINANCE_KEPT: f32 = 0.25;
const NO_LUMINANCE_KEPT: f32 = 0.15;
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
fn restore_weight(share: f32) -> f32 {
if !share.is_finite() {
return 0.0;
}
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
pub(crate) struct HueSat<'a> {
@@ -852,7 +868,19 @@ impl<'a> HueSat<'a> {
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
// restoring the luminance divides by the table's: near black, where noise makes some channels
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
return rgb;
}
let w = restore_weight(after / before);
if w >= 1.0 {
return out.map(|v| v * before / after);
}
if w <= 0.0 {
return rgb;
}
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
}
}
@@ -1439,6 +1467,61 @@ mod tests {
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
}
#[test]
fn hue_sat_never_turns_near_black_noise_white() {
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
};
let mut worst = 0.0f32;
for _ in 0..200_000 {
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
if luminance_2020(rgb) <= 0.0 {
continue;
}
let out = hue_sat.apply(rgb);
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
}
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
}
#[test]
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
assert_eq!(restore_weight(0.0), 0.0);
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
assert_eq!(restore_weight(1.0), 1.0);
assert_eq!(restore_weight(f32::NAN), 0.0);
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
let mut last = 0.0f32;
for i in 0..=400 {
let w = restore_weight(i as f32 * 0.001);
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
last = w;
}
}
#[test]
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
assert!(after >= MIN_LUMINANCE_KEPT * before);
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
}
}
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
RawImage {
+5 -1
View File
@@ -239,10 +239,14 @@ pub fn specs() -> Vec<CommandSpec> {
let fresh = match look {
Some(l) => (*l).clone(),
None => {
// the photo's built-in defaults (raws: capture sharpening and colour noise
// reduction, embedded lens corrections), as at import, with the white balance
// of its current as-shot reference
let info = s.source_info(id);
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
..Default::default()
..defaults
}
}
};
+22 -2
View File
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
}
}
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
// an out-of-range offset to zero, returning the first page instead of no photos.
fn pagination_usize(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
s.visible_cloned()
};
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
Ok(json!({"total": ids.len(), "photos": items}))
}),
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
#[cfg(test)]
mod pagination_tests {
use super::*;
#[test]
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
assert_eq!(pagination_usize(0), 0);
assert_eq!(pagination_usize(200), 200);
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
#[cfg(target_pointer_width = "32")]
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 42;
pub const RENDER_CACHE_VERSION: u64 = 50;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
+27
View File
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
}
}
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
/// keeps none.
#[test]
fn reset_edits_restores_a_raws_import_defaults() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
let mut s = demo();
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
let name = format!("p{id}.{format}");
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
if raw {
p.as_shot_wb = Some((5200.0, 4.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
}
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
let d = active_dev(&s);
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
}
}
#[test]
fn demo_library_loads() {
let mut s = demo();
+65 -1
View File
@@ -32,7 +32,9 @@ impl Scratch {
Scratch(dir)
}
fn path(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().to_string()
// Fixtures use `/`; rebuild components so expected paths match the native separators
// returned by directory scans, including for files that have already been removed.
self.0.join(rel).components().collect::<PathBuf>().to_string_lossy().into_owned()
}
}
@@ -103,6 +105,68 @@ fn an_unchanged_folder_has_no_changes() {
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
}
#[cfg(windows)]
#[test]
fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() {
for forward_import in [true, false] {
for import_folder in [true, false] {
let dir = Scratch::new(&format!("separators-{forward_import}-{import_folder}"));
let folder = dir.path("trip");
let a = dir.path("trip/a.png");
let b = dir.path("trip/day1/b.png");
write_png(&a, 1);
write_png(&b, 2);
let spelling = |path: &str, forward: bool| if forward { path.replace('\\', "/") } else { path.to_string() };
let import_path = spelling(&folder, forward_import);
let sync_path = spelling(&folder, !forward_import);
assert_ne!(import_path, sync_path, "exercise distinct Windows path spellings");
let inputs = if import_folder { vec![import_path.clone()] } else { vec![spelling(&a, forward_import), spelling(&b, forward_import)] };
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": inputs})).unwrap();
assert_eq!(imported["imported"].as_array().unwrap().len(), 2, "{imported}");
let before = s.execute("catalog.query", &json!({})).unwrap();
let photos = before["photos"].as_array().unwrap();
let a_id = photos.iter().find(|p| p["fileName"] == "a.png").unwrap()["id"].clone();
let b_id = photos.iter().find(|p| p["fileName"] == "b.png").unwrap()["id"].clone();
let duplicate = s.execute("library.import", &json!({"paths": [sync_path]})).unwrap();
assert!(duplicate["imported"].as_array().unwrap().is_empty(), "{duplicate}");
assert_eq!(duplicate["duplicates"].as_array().unwrap().len(), 2, "{duplicate}");
assert_eq!(s.execute("catalog.query", &json!({})).unwrap(), before, "reimport preserves the photos and their ids");
std::fs::remove_file(&b).unwrap();
let c = dir.path("trip/day2/c.png");
write_png(&c, 3);
let changes = scan(&mut s, &sync_path);
assert_eq!(changes["new"].as_array().unwrap().len(), 1, "{changes}");
assert_eq!(Path::new(changes["new"][0]["path"].as_str().unwrap()), Path::new(&c));
assert_eq!(changes["missing"].as_array().unwrap().len(), 1, "{changes}");
assert_eq!(changes["missing"][0]["id"], b_id, "the missing photo keeps its id");
assert!(changes["moved"].as_array().unwrap().is_empty(), "{changes}");
// Switching spelling again must consume the confirmed scan of this same folder.
let synced = s.execute("folder.synchronize", &json!({"path": import_path, "scanned": true, "removeMissing": true})).unwrap();
assert_eq!(
(synced["imported"].as_u64(), synced["removed"].as_u64(), synced["relinked"].as_u64()),
(Some(1), Some(1), Some(0)),
"{synced}"
);
assert!(synced["failed"].as_array().unwrap().is_empty(), "{synced}");
let after = s.execute("catalog.query", &json!({})).unwrap();
let photos = after["photos"].as_array().unwrap();
assert_eq!(photos.len(), 2, "{after}");
assert!(photos.iter().any(|p| p["id"] == a_id && p["fileName"] == "a.png"), "{after}");
assert!(photos.iter().any(|p| p["fileName"] == "c.png" && p["id"] != a_id && p["id"] != b_id), "{after}");
for path in [&import_path, &sync_path] {
let changes = scan(&mut s, path);
for kind in ["new", "missing", "moved", "metadata"] {
assert!(changes[kind].as_array().unwrap().is_empty(), "{path}: {changes}");
}
}
}
}
}
#[test]
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
let dir = Scratch::new("new");
+30
View File
@@ -145,6 +145,25 @@ pub fn backend_options() -> wgpu::BackendOptions {
o
}
/// wgpu's instance flags for every instance LightCraft creates, from `flags` (wgpu's defaults with its
/// environment variables applied) without `VALIDATION_INDIRECT_CALL`, unless
/// `WGPU_VALIDATION_INDIRECT_CALL` asks for it (any value but `0`). That validation builds an internal
/// compute pipeline when each device is created; Intel Macs' Metal compiler fails on it ("Compiler
/// encountered an internal error"), the device is lost and the window never opens (#250). It only
/// guards indirect draws and dispatches, which neither LightCraft nor egui issue.
pub fn instance_flags(flags: wgpu::InstanceFlags, env: Option<&str>) -> wgpu::InstanceFlags {
if env.is_some_and(|v| v != "0") {
flags | wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
} else {
flags - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
}
}
/// [`instance_flags`] with `WGPU_VALIDATION_INDIRECT_CALL` read from the environment.
pub fn instance_flags_from_env(flags: wgpu::InstanceFlags) -> wgpu::InstanceFlags {
instance_flags(flags, std::env::var("WGPU_VALIDATION_INDIRECT_CALL").ok().as_deref())
}
/// `LIGHTCRAFT_GPU_BACKEND=off`.
pub(crate) fn env_off() -> bool {
env_choice() == BackendChoice::Off
@@ -195,6 +214,17 @@ pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
/// #250: the indirect-call validation pipeline is off unless asked for; other flags are kept.
#[test]
fn indirect_call_validation_is_off_unless_asked_for() {
use wgpu::InstanceFlags as F;
let base = F::VALIDATION_INDIRECT_CALL | F::DEBUG;
assert_eq!(instance_flags(base, None), F::DEBUG);
assert_eq!(instance_flags(base, Some("0")), F::DEBUG);
assert_eq!(instance_flags(F::empty(), Some("1")), F::VALIDATION_INDIRECT_CALL);
assert_eq!(instance_flags(F::empty(), None), F::empty());
}
use super::*;
#[test]
+2
View File
@@ -284,6 +284,8 @@ impl Gpu {
desc.backends = backends;
// DX12 shaders compile with FXC (issue #471)
desc.backend_options = crate::backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
let instance = wgpu::Instance::new(desc);
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
+2
View File
@@ -86,6 +86,8 @@ fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {
desc.backends = backends;
// DX12 shaders compile with FXC (issue #471)
desc.backend_options = crate::backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
let instance = wgpu::Instance::new(desc);
let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty());
let adapter = match &wanted {
+55 -4
View File
@@ -4,8 +4,11 @@
use std::io::{BufRead, BufReader, Cursor, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode};
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode};
use serde_json::{Value, json};
/// Feed a whole session through `Server::serve` and return the replies by id.
@@ -154,8 +157,50 @@ fn remote_unreachable_is_a_tool_error() {
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
}
/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written:
/// it keeps asking the server's progress hook at that photo boundary (which answers pings and
/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or
/// cancel sent after the first progress then lands mid-batch, however fast the machine exports.
struct HoldAfterFirst {
inner: Headless,
hold: Arc<AtomicBool>,
}
impl Backend for HoldAfterFirst {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
fn set_progress(&mut self, hook: Option<ProgressHook>) {
let hold = self.hold.clone();
self.inner.set_progress(hook.map(|mut hook| -> ProgressHook {
Box::new(move |done, total, name| {
// bounded, so a cancel that is never read fails the test instead of hanging it
let until = Instant::now() + Duration::from_secs(20);
while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until {
if !hook(done, total, name) {
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
hook(done, total, name)
})
}));
}
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
/// the cancel is read, so neither can finish first on a fast machine.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
@@ -163,7 +208,9 @@ fn export_progress_and_cancel() {
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap());
let hold = Arc::new(AtomicBool::new(true));
let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() };
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
@@ -173,7 +220,7 @@ fn export_progress_and_cancel() {
}
}
});
let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress");
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next();
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
@@ -197,6 +244,8 @@ fn export_progress_and_cancel() {
}
} else if value["id"] == 3 {
answered = true;
// the ping was answered mid-batch: let the export run to the end
hold.store(false, Ordering::SeqCst);
} else {
assert_eq!(value["id"], 2, "{value}");
assert_eq!(value["result"]["isError"], false, "{value}");
@@ -210,6 +259,8 @@ fn export_progress_and_cancel() {
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
// this export waits after its first photo until the cancel below is read
hold.store(true, Ordering::SeqCst);
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
request["params"]["_meta"] = json!({"progressToken":44});
writeln!(send, "{request}").unwrap();
@@ -234,7 +285,7 @@ fn export_progress_and_cancel() {
}
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}");
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
for path in &photos {
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
}
+135 -2
View File
@@ -170,6 +170,9 @@ fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> {
(0x0103, 8) => "Deflate",
(0x0103, 32773) => "PackBits",
(0x0103, 34892) => "Lossy JPEG",
(0x0103, 34713) => "Nikon NEF Compressed",
(0x0103, 32767) => "Sony ARW Compressed",
(0x0103, 52546) => "JPEG XL",
(0x0106, 0) => "WhiteIsZero",
(0x0106, 1) => "BlackIsZero",
(0x0106, 2) => "RGB",
@@ -269,12 +272,56 @@ fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec<TagRow>) {
}
}
/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw
/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's.
fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> {
ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892)))
}
/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation.
fn nef_compression(v: u32) -> Option<&'static str> {
Some(match v {
1 => "Lossy (type 1)",
2 => "Uncompressed",
3 => "Lossless",
4 => "Lossy (type 2)",
5 => "Striped packed 12 bits",
6 => "Uncompressed (reduced to 12 bit)",
7 => "Unpacked 12 bits",
8 => "Small",
9 => "Packed 12 bits",
10 => "Packed 14 bits",
13 => "High Efficiency",
14 => "High Efficiency*",
_ => return None,
})
}
/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values.
fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> {
let ifd0 = tiff.ifds.first()?;
let make = ifd0.string(0x010f)?;
if !make.to_ascii_uppercase().starts_with("NIKON") {
return None;
}
let e = ifd0.exif.as_ref()?.get(0x927c)?;
let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
nef_compression(mn.ifd.u32(0x0093)?)
}
/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0,
/// then the EXIF, GPS and interoperability IFDs.
/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor
/// image's, not IFD0's thumbnail's.
pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
let mut out = Vec::new();
if let Some(ifd0) = tiff.ifds.first() {
rows_of("TIFF", ifd0, &mut out);
if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103))
&& let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103)
{
// lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy
row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) };
}
if let Some(e) = &ifd0.exif {
rows_of("EXIF", e, &mut out);
if let Some(i) = &e.interop {
@@ -294,7 +341,13 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
if Tiff::sniff(bytes).is_some()
&& let Ok(t) = Tiff::parse(bytes)
{
return tag_rows(&t);
let mut rows = tag_rows(&t);
if let Some(words) = nikon_nef_compression(&t, bytes)
&& let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103)
{
row.value = words.to_string();
}
return rows;
}
let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() };
Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default()
@@ -328,4 +381,84 @@ mod tests {
assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}");
assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out");
}
fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec<u8> {
let mut e = Vec::new();
e.extend(tag.to_le_bytes());
e.extend(ty.to_le_bytes());
e.extend(count.to_le_bytes());
e.extend(val.to_le_bytes());
e
}
fn ifd(entries: &[Vec<u8>]) -> Vec<u8> {
let mut b = (entries.len() as u16).to_le_bytes().to_vec();
entries.iter().for_each(|e| b.extend(e));
b.extend(0u32.to_le_bytes());
b
}
/// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression`
/// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value.
fn synthetic_raw(nef: Option<u16>) -> Vec<u8> {
let make = b"NIKON CORPORATION";
let mut note = b"NikonII*".to_vec();
note.extend(ifd(&[entry(0x0093, 3, 1, nef.unwrap_or(0) as u32)]));
let ifd0_len = 2 + 4 * 12 + 4;
let sub_at = 8 + ifd0_len;
let sub = ifd(&[entry(0x0103, 3, 1, 34713), entry(0x0106, 3, 1, 32803)]);
let exif_at = sub_at + sub.len();
let exif_len = 2 + 12 + 4;
let make_at = exif_at + exif_len;
let note_at = make_at + make.len();
let mut b = b"II*".to_vec();
b.extend(ifd(&[
entry(0x0103, 3, 1, 1),
entry(0x010f, 2, make.len() as u32, make_at as u32),
entry(0x014a, 4, 1, sub_at as u32),
entry(0x8769, 4, 1, exif_at as u32),
]));
b.extend(sub);
b.extend(ifd(&[entry(0x927c, 7, note.len() as u32, if nef.is_some() { note_at as u32 } else { 0 })]));
b.extend(make);
b.extend(note);
b
}
fn compression(rows: &[TagRow]) -> Vec<&str> {
rows.iter().filter(|r| r.group == "TIFF" && r.tag == 0x0103).map(|r| r.value.as_str()).collect()
}
#[test]
fn raw_compression_is_the_sensor_images_not_the_thumbnails() {
let b = synthetic_raw(None);
assert_eq!(compression(&file_tag_rows(&b)), ["Nikon NEF Compressed"]);
}
#[test]
fn nef_compression_is_spelled_out_from_the_maker_note() {
for (v, words) in [(3, "Lossless"), (10, "Packed 14 bits"), (2, "Uncompressed"), (14, "High Efficiency*")] {
let b = synthetic_raw(Some(v));
assert_eq!(compression(&file_tag_rows(&b)), [words]);
}
}
#[test]
fn dng_raw_sub_ifd_jpeg_is_lossless_but_a_plain_jpeg_tiff_is_not() {
let b = synthetic_raw(None);
// switch the sensor sub-IFD's Compression from 34713 to 7
let pos = b.windows(12).position(|w| w == entry(0x0103, 3, 1, 34713).as_slice()).unwrap();
let mut dng = b.clone();
dng[pos + 8] = 7;
dng[pos + 9] = 0;
assert_eq!(compression(&file_tag_rows(&dng)), ["Lossless JPEG"]);
let plain = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 7)])].concat();
assert_eq!(compression(&file_tag_rows(&plain)), ["JPEG"]);
}
#[test]
fn plain_tiff_keeps_its_own_compression() {
let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat();
assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]);
}
}
+4 -3
View File
@@ -275,12 +275,13 @@ impl FinishParams {
/// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited
/// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here.
/// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR)
/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map.
/// every arm is the SDR tone map. An HDR render keeps the camera's look through the midtones and
/// lifts its highlights past white ([`ToneMap::camera_hdr`]), so it matches the SDR render below them.
pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
let l = &s.light;
match s.process.process() {
Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) {
Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks),
Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak),
None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak),
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
},
+21 -2
View File
@@ -71,8 +71,9 @@ impl Frame {
ow,
oh,
crop,
flip_h: apply_crop && s.crop.flip_h,
flip_v: apply_crop && s.crop.flip_v,
// a flip is not part of the crop: the crop tool's uncropped view shows it too
flip_h: s.crop.flip_h,
flip_v: s.crop.flip_v,
warp: (!warp.is_identity()).then_some(warp),
view: None,
};
@@ -387,4 +388,22 @@ mod tests {
assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5);
assert!((out.get(3, 0)[0] - 0.0).abs() < 1e-5);
}
/// The crop tool's uncropped frame keeps the flips: Flip H / Flip V show while cropping, and
/// screen points map back through the mirror (the crop box is drawn and dragged right).
#[test]
fn uncropped_frame_keeps_the_flips() {
let mut s = DevelopSettings::default();
s.crop.flip_h = true;
s.crop.flip_v = true;
s.crop.geometry.rect = Rect::new(0.1, 0.2, 0.6, 0.9);
let src = Rgb32f::from_fn(4, 2, |x, y| [x as f32, y as f32, 0.0]);
let f = Frame::new(4, 2, &s, false);
let out = f.sample(&src, 4, 2);
assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5 && (out.get(0, 0)[1] - 1.0).abs() < 1e-5, "{:?}", out.get(0, 0));
assert!((out.get(3, 1)[0] - 0.0).abs() < 1e-5 && (out.get(3, 1)[1] - 0.0).abs() < 1e-5, "{:?}", out.get(3, 1));
// the whole image, mirrored: output top-left is the image's bottom-right
let p = f.out_to_norm(400, 200).apply(Point::new(0.0, 0.0));
assert!((p.x - 1.0).abs() < 1e-9 && (p.y - 1.0).abs() < 1e-9, "{p:?}");
}
}
+66 -4
View File
@@ -440,25 +440,35 @@ pub struct BrushDabs {
pub auto: bool,
}
/// Output pixels a brush sample must move from the last dab to count as movement: below this the
/// pointer is held still (the same screen position mapped again, up to float rounding).
const STATIONARY_PX: f64 = 1e-6;
/// The dabs of stroke `s` in a `w × h` output.
pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs {
let to_out = frame.norm_to_out(w, h);
let ppl = frame.px_per_long(w);
let r = (s.size * ppl).max(0.5);
let hard = r * (1.0 - (s.feather / 100.0).clamp(0.0, 1.0));
// Densify the path so dabs overlap (spacing r/4).
// Densify the path so dabs overlap (spacing r/4). Coverage builds up by passes, not by how
// long the button is held: a sample where the pointer hasn't moved (a stationary hold records
// one per frame) lays no dab, so waiting in place adds nothing (#518).
let mut dabs: Vec<Point> = Vec::new();
for (i, p) in s.points.iter().enumerate() {
let mut prev: Option<Point> = None;
for p in &s.points {
let q = to_out.apply(*p);
if i > 0 {
let prev = to_out.apply(s.points[i - 1]);
if let Some(prev) = prev {
let dist = prev.dist(q);
if dist.is_nan() || dist <= STATIONARY_PX {
continue;
}
let n = (dist / (r / 4.0).max(0.5)).ceil() as usize;
for k in 1..n {
dabs.push(prev.lerp(q, k as f64 / n as f64));
}
}
dabs.push(q);
prev = Some(q);
}
BrushDabs {
dabs,
@@ -714,6 +724,58 @@ mod tests {
assert!(a.get(40, 5) < 0.01);
}
/// #518: holding the brush still adds no coverage. A press plus any number of stationary
/// pointer samples paints exactly the single dab of the press, whatever the frame rate.
#[test]
fn holding_the_brush_still_adds_no_coverage() {
let (w, h) = (200, 100);
let f = frame(w, h);
let img = Rgb32f::new(w, h);
let l = Plane::new(w, h);
let p = Point::new(0.5, 0.5);
let spot = |samples: usize, flow: f64| {
let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() };
shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None)
};
for flow in [100.0, 40.0] {
let press = spot(1, flow);
// the feathered edge is soft to begin with
let edge = press.get(100 + 14, 50);
assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}");
for n in [2, 5, 60, 600] {
let held = spot(n, flow);
assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50));
}
}
let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() };
assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press");
// a stroke that pauses partway lays the same dabs as one that doesn't
let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5));
let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() };
let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() };
assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs);
}
/// Flow builds up by passes: going back over an area in the same stroke adds coverage.
#[test]
fn a_second_pass_builds_up_with_low_flow() {
let (w, h) = (200, 100);
let f = frame(w, h);
let img = Rgb32f::new(w, h);
let l = Plane::new(w, h);
let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5));
let paint = |points: Vec<Point>| {
let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() };
shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None)
};
let once = paint(vec![a, b]);
let twice = paint(vec![a, b, a]);
let thrice = paint(vec![a, b, a, b]);
let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50));
assert!(o > 0.05 && o < 0.99, "one pass {o}");
assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}");
}
#[test]
fn auto_mask_stops_at_edges() {
// dark left half, bright right half; a stroke along the boundary, centred on the dark side
+32
View File
@@ -61,6 +61,38 @@ fn hdr_midtones_match_the_sdr_render() {
assert!(n > 100, "the scene should have midtones and shadows ({n})");
}
#[test]
fn hdr_keeps_the_camera_tone_curve_in_the_midtones() {
// A raw with a fitted camera look (brighter midtones than the generic curve): its HDR render
// used to drop the look and come out more than a stop darker than the SDR render.
let knots = std::array::from_fn(|i| {
let x = 0.004 * 1.18f32.powi(i as i32);
[x, 1.0 - (-2.0 * x).exp()]
});
let info = SourceInfo { camera_tone: crate::tone::CameraTone::new(knots), ..raw() };
let src = sunset();
let render_at = |s: &DevelopSettings, depth| {
let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) };
match render(&src, &info, s, &req).deep.map(|d| d.samples) {
Some(DeepSamples::F32(v)) => v,
other => panic!("expected float samples, got {other:?}"),
}
};
let hdr = render_at(&hdr_on(3.0), OutputDepth::F32Hdr);
let sdr = render_at(&DevelopSettings::default(), OutputDepth::F32Linear);
let mut n = 0;
for (h, s) in hdr.as_chunks::<3>().0.iter().zip(sdr.as_chunks::<3>().0) {
if s.iter().all(|v| *v < 0.4) {
for (a, b) in h.iter().zip(s) {
assert!((a - b).abs() < 2e-3, "{h:?} vs {s:?}");
}
n += 1;
}
}
assert!(n > 100, "the scene should have midtones and shadows ({n})");
assert!(hdr.iter().any(|v| *v > 1.0), "highlights go past SDR white");
}
#[test]
fn f32hdr_without_hdr_is_the_sdr_float_render() {
let src = sunset();
+39 -1
View File
@@ -103,13 +103,24 @@ pub struct ToneMap {
impl ToneMap {
pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap {
ToneMap::camera_hdr(curve, contrast, whites, blacks, 1.0)
}
/// [`ToneMap::camera`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR
/// exactly): the camera's look up to 1.5 EV over grey, so midtones match the SDR render (and a
/// gain map only lifts highlights), then the highlights gain up to `peak − 1` more as in
/// [`ToneMap::hdr`].
pub fn camera_hdr(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
let adjustment = Self::display(contrast, whites, blacks);
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
let top = (2.9 - 1.6 * (whites as f32 / 100.0) + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5);
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = curve.apply(GREY * 2f32.powf(ev));
if neutral { y } else { adjustment.apply(y) }
let o = if neutral { y } else { adjustment.apply(y) };
if peak > 1.0 { (o + (peak - 1.0) * smooth(HDR_FROM_EV, top, ev)).clamp(0.0, peak) } else { o }
})
.collect();
ToneMap { lut, chroma: curve.chroma }
@@ -353,6 +364,33 @@ mod tests {
assert_eq!(ToneMap::hdr(0.0, 0.0, 0.0, f32::NAN).lut(), ToneMap::new(0.0, 0.0, 0.0).lut());
}
#[test]
fn camera_hdr_keeps_the_camera_look_and_lifts_highlights() {
let curve = CameraTone::new(knots()).unwrap().with_chroma([1.2, 1.1, 1.0, 1.0, 0.9, 0.8, 0.7, 0.6]).unwrap();
for (c, w, b) in [(0.0, 0.0, 0.0), (40.0, -30.0, -20.0)] {
let sdr = ToneMap::camera(&curve, c, w, b);
assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, 1.0).lut(), sdr.lut());
assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, f32::NAN).lut(), sdr.lut());
for peak in [2.0f32, 8.0] {
let t = ToneMap::camera_hdr(&curve, c, w, b, peak);
assert_eq!(t.chroma_lut(), sdr.chroma_lut());
let mut prev = -1.0;
for i in 0..2400 {
let y = 1e-5 * 1.01f32.powi(i);
let o = t.apply(y);
assert!((0.0..=peak).contains(&o), "{peak} at {y}: {o}");
assert!(o >= prev - 1e-6, "{peak} at {y}: {o} < {prev}");
prev = o;
// the camera's midtones, not the generic HDR curve's (issue: dark HDR exports)
if y <= GREY * 2f32.powf(HDR_FROM_EV - 0.01) {
assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}: {o} vs {}", sdr.apply(y));
}
}
assert!(t.apply(4.0) > 1.0 && t.apply(4.0) > sdr.apply(4.0) + 0.5, "{peak}: highlights lifted");
}
}
}
#[test]
fn display_hdr_passes_hdr_sources_and_keeps_sdr() {
assert_eq!(ToneMap::display_hdr(20.0, 10.0, -5.0, 1.0).lut(), ToneMap::display(20.0, 10.0, -5.0).lut());
+38 -1
View File
@@ -167,13 +167,21 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
let pentax_raw = has_cfa || t.exif().is_some_and(|e| e.contains(vendor::EXIF_CFA_PATTERN));
// Samsung's raw IFD uses private compressions 32769..=32773 (`vendor::srw`), 32773 being PackBits elsewhere
let samsung_raw = has_cfa || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| (32769..=32773).contains(&c)));
// Sony raws without a CFA IFD (#702): SRF (DSC-F828), whose IFD0 looks like RGB but holds 14-bit samples (12 or
// 14 count: raw sensor depths; the raw is in Sony's SRF blocks), and the DSLR-A100, whose IFD0 is an old-style
// JPEG thumbnail (compression 6, which today's TIFF writers don't produce) and whose `SubIFDs` tag addresses its
// raw data. A camera-authored or exported TIFF has 8- or 16-bit samples, and its SubIFDs hold ordinary images
let model = t.find(lightcraft_tiff::tags::MODEL).and_then(|e| e.value.as_str()).unwrap_or_default().trim().to_ascii_uppercase();
let sony_raw = has_cfa
|| ifd0.u64s(lightcraft_tiff::tags::BITS_PER_SAMPLE).is_some_and(|b| b.iter().any(|v| matches!(v, 12 | 14)))
|| (model == "DSLR-A100" && ifd0.u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) && ifd0.contains(lightcraft_tiff::tags::SUB_IFDS));
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
return Some(RawFormat::Cr2);
}
if make.starts_with("NIKON") && has_cfa {
return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw });
}
if make.starts_with("SONY") && has_cfa {
if make.starts_with("SONY") && sony_raw {
return Some(RawFormat::Arw);
}
if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw {
@@ -943,6 +951,35 @@ mod tests {
}
}
/// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to
/// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed
/// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model,
/// stays an ordinary image.
#[test]
fn sony_raws_without_a_cfa_ifd_are_still_raws() {
let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option<IfdBuilder>| {
let mut ifd = rgb_ifd(400, 300);
ifd.set(t::MAKE, Value::Ascii("SONY".into()));
ifd.set(t::MODEL, Value::Ascii(model.into()));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression]));
if let Some(child) = sub_ifd {
ifd.set_child(t::SUB_IFDS, child);
}
probe(&write(&[ifd]))
};
let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer
assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF");
assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too");
assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs");
assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF");
assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF");
// TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702)
assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail");
assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail");
assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model");
}
// --- containers that are recognised but not decoded, with a preview ---
#[test]
+22 -8
View File
@@ -108,10 +108,20 @@ fn aspect_crop(maker: Option<&Ifd>, image: Rect) -> Option<Rect> {
fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) {
let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height);
let sensor = sensor_crop(maker, width, height);
let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
// The recommended crop, when the file gives a consistent one. `AspectInfo` is measured from its origin, so it
// is only applied on top of that; without it (the crop doesn't fit the valid area) the whole active area stays.
let recommended = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active));
let mut active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
// The recommended crop. `AspectInfo` is measured from its origin, so it is applied on top of it.
let stated = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor);
let mut recommended = stated.and_then(|c| relative_crop(c, active));
// Some crop-mode files (EOS R5 Mark II APS-C, 5376 x 3574) state an active area that starts in the dark
// columns and stops short of the image, while their recommended crop lies on the image (measured: columns
// 132..~256 sit at the black level, the image runs to the right edge). The recommended crop is then the
// valid area.
if recommended.is_none()
&& let Some(c) = stated.filter(|c| c.width >= 2 && c.height >= 2)
{
active = c;
recommended = Some(Rect::new(0, 0, c.width, c.height));
}
let crop = recommended.map(|c| aspect_crop(maker, c).unwrap_or(c)).unwrap_or(Rect::new(0, 0, active.width, active.height));
(active, crop)
}
@@ -424,8 +434,9 @@ mod tests {
}
}
/// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their valid area. The
/// whole active area stays then, and `AspectInfo`, which is measured from the recommended crop, is not applied.
/// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their stated active
/// area, which starts in dark columns. The recommended crop is then the valid area (no black band on the left),
/// and `AspectInfo` applies from its origin.
#[test]
fn aspect_info_needs_a_consistent_recommended_crop() {
let aspect = lightcraft_tiff::IfdBuilder::new().with(ASPECT_INFO, Value::Long(vec![13, 5088, 3392, 0, 0]));
@@ -433,8 +444,11 @@ mod tests {
let maker = Tiff::parse(&bytes).unwrap().ifds.remove(0);
let area =
|crop| Cr3ImageArea { width: 5376, height: 3574, crop, active: Some([132, 160, 5243, 3567]), masked_left: [0; 4], masked_top: None };
// crop right edge 5359 > active right edge 5243: not a crop of the active area
assert_eq!(geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574).1, Rect::new(0, 0, 5112, 3408));
// crop right edge 5359 > active right edge 5243: the recommended crop becomes the valid area
assert_eq!(
geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574),
(Rect::new(272, 172, 5088, 3392), Rect::new(0, 0, 5088, 3392))
);
// consistent crop: AspectInfo applies from its origin
assert_eq!(geometry(Some(&area([140, 172, 5227, 3563])), Some(&maker), 5376, 3574).1, Rect::new(8, 12, 5088, 3392));
}
+13 -5
View File
@@ -330,8 +330,10 @@ fn tile_qp(sample: &[u8], tile: &Tile) -> Result<Option<QpMap>> {
fn quant_step(value: u8) -> Result<i32> {
// Measured independently with an external decoder after modifying only the
// quantValue field: all forty values 4..43 follow this integer sequence.
if !(4..=43).contains(&value) {
// quantValue field: all forty values 4..43 follow this integer sequence. Value 44 (QP 168)
// continues it (step 102); it was checked against a measured step of about 100 on an EOS R50
// C-RAW file. Nothing above 44 has been seen, so it stays rejected.
if !(4..=44).contains(&value) {
return Err(RawError::Unsupported(format!("Canon CRX quantization value {value}")));
}
let scale = [40i32, 45, 51, 57, 64, 72].get(usize::from(value % 6)).copied().ok_or_else(|| corrupt("quantization table index"))?;
@@ -754,7 +756,7 @@ fn qp_map(src: &[u8], width: usize, height: usize) -> Result<QpMap> {
let right = previous.get(x + 1).copied().unwrap_or(above);
let symbol = bits.qp_rice(k)?;
let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?;
if !(131..=167).contains(&value) {
if !(131..=168).contains(&value) {
return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}")));
}
*current.get_mut(x).ok_or_else(|| corrupt("QP outside row"))? = value;
@@ -968,11 +970,11 @@ mod tests {
#[test]
fn measured_quantization_and_allocation_limits() {
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] {
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90), (44, 102)] {
assert_eq!(quant_step(quant).unwrap(), expected);
}
assert!(matches!(quant_step(3), Err(RawError::Unsupported(_))));
assert!(matches!(quant_step(44), Err(RawError::Unsupported(_))));
assert!(matches!(quant_step(45), Err(RawError::Unsupported(_))));
for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] {
let mut config = coding();
config.width = width;
@@ -1003,6 +1005,12 @@ mod tests {
assert!(matches!(qp_map(&[0, 0, 1, 4, 0x94], 1, 4), Err(RawError::Unsupported(_))));
}
#[test]
fn qp_168_is_accepted_and_169_is_not() {
assert_eq!(qp_map(&[0, 0, 1, 0x50, 0x94], 1, 4).unwrap().data, [168; 4]);
assert!(matches!(qp_map(&[0, 0, 1, 0x52, 0x94], 1, 4), Err(RawError::Unsupported(_))));
}
#[test]
fn odd_height_qp_map_uses_its_last_row_alone() {
// EOS R7 C-RAW: 4732 sensor rows give a 1183-row QP map, so the last
+37 -16
View File
@@ -58,28 +58,38 @@ pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range<usize>,
}
/// White level estimate: the saturation plateau if a noticeable number of samples sit at the maximum value,
/// otherwise the full `bits` range.
/// otherwise the full `bits` range. A few stray samples above the plateau (hot pixels, e.g. on a Canon EOS 70D
/// CR2) don't hide it.
pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 {
let full = ((1u32 << bits.clamp(1, 16)) - 1) as f32;
let full_level = (1u32 << bits.clamp(1, 16)) - 1;
let full = full_level as f32;
let step = (data.len() / 2_000_000).max(1);
// samples above the nominal range are padding / invalid (e.g. 0xFFFF fill), not saturation
let Some(&mx) = data.iter().filter(|&&v| v as f32 <= full).max() else { return full };
let mut hist = vec![0usize; full_level as usize + 1];
for &v in data.iter().step_by(step) {
if let Some(h) = hist.get_mut(v as usize) {
*h += 1;
}
}
let min_plateau = (data.len() / 20_000).max(16);
// the top of the data, skipping up to half a plateau's worth of strays so a real plateau can't be skipped
let strays = min_plateau / 2;
let mut above = 0usize;
let Some(mx) = hist.iter().enumerate().rev().find_map(|(v, &n)| {
above += n * step;
(above > strays).then_some(v as u32)
}) else {
return full;
};
let band = (mx / 256).max(1);
let near = mx.saturating_sub(band);
let below = near.saturating_sub(band);
let step = (data.len() / 2_000_000).max(1);
let (mut top, mut under) = (0usize, 0usize);
for &v in data.iter().step_by(step) {
if v as f32 > full {
continue;
}
if v >= near {
top += 1;
} else if v >= below {
under += 1;
}
}
let count = |from: u32, to: u32| hist.get(from as usize..to as usize).map_or(0, |h| h.iter().sum::<usize>());
// the strays count as saturated too
let top = count(near, full_level + 1);
let under = count(below, near);
// a saturation plateau is a spike: many more samples in the top band than in the band just below it
if mx as f32 > full * 0.5 && top * step >= (data.len() / 20_000).max(16) && top >= 4 * under + 4 {
if mx as f32 > full * 0.5 && top * step >= min_plateau && top >= 4 * under + 4 {
// plateau: use its lower edge so everything at saturation maps to ≥ 1.0
near as f32
} else {
@@ -116,4 +126,15 @@ mod tests {
assert!((14900.0..=15000.0).contains(&w), "{w}");
assert_eq!(white_from_data(&[], 12), 4095.0);
}
/// A Canon EOS 70D CR2 saturates at 13583 but has a handful of samples up to 14426 (out of ~20M): the plateau
/// is still found instead of falling back to 16383 (≈0.3 EV too dark, highlights never seen as clipped).
#[test]
fn white_plateau_with_strays_above() {
let mut d: Vec<u16> = (0..100_000).map(|i| (2048 + i % 10000) as u16).collect();
d.extend(std::iter::repeat_n(13583u16, 5000));
d.extend([13700, 13900, 14100, 14426]);
let w = white_from_data(&d, 14);
assert!((13500.0..=13583.0).contains(&w), "{w}");
}
}
+54 -2
View File
@@ -57,6 +57,30 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
Rect::new(*x as usize, *y as usize, *width as usize, *height as usize)
}
/// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the
/// camera's full-size JPEG sits when the two are aligned) and the level where its channels clip.
struct MeasuredBody {
model: &'static str,
/// Sensor data size and sample depth of the measured files.
size: (usize, usize),
bits: u32,
/// Image area in sensor pixels: x, y, width, height.
crop: (usize, usize, usize, usize),
white: f32,
}
/// * **NIKON D4** (148 uncompressed 14-bit NEFs from one body, 2023–2026, ISO 100–10000): the data is 4992 × 3292,
/// with 2 columns of optical black left of the image and 42 right of it, then 8 empty ones; the camera's
/// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides.
/// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves
/// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance.
const MEASURED_BODIES: &[MeasuredBody] =
&[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }];
fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> {
MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits)
}
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds()
.into_iter()
@@ -120,9 +144,13 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer_static("RGGB"),
};
let white = white_from_data(samples, bits);
let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits);
let white = body.map_or_else(|| white_from_data(samples, bits), |b| b.white);
let active_w = trailing_masked_columns(samples, w, h, white);
let crop = default_crop(mn.as_ref(), active_w, h);
let crop = match body {
Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch),
_ => default_crop(mn.as_ref(), active_w, h),
};
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
@@ -275,6 +303,30 @@ mod tests {
}
}
/// The D4 records no CropArea: its measured image area and saturation level apply to files of its data size and
/// depth only.
#[test]
fn measured_d4_image_area_and_white_level() {
let (w, h) = (4992usize, 3292usize);
let decode_as = |model: &str, bits: u16| {
let mut nef = nef(1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32);
let at = nef.windows(10).position(|b| b == b"NIKON TEST").unwrap();
let name = format!("{model:<10}");
nef[at..at + 10].copy_from_slice(name.as_bytes());
crate::decode(&nef).unwrap()
};
let d4 = decode_as("NIKON D4", 14);
assert_eq!(d4.crop, Rect::new(8, 6, 4928, 3280));
assert_eq!(d4.white, vec![15520.0]);
assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280)));
let developed = d4.develop(crate::Method::Bilinear).unwrap();
assert_eq!((developed.width, developed.height), (4928, 3280));
for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] {
assert_eq!(other.crop, Rect::new(0, 0, w, h));
assert_ne!(other.white, vec![15520.0]);
}
}
#[test]
fn black_level_tag_is_in_14_bit_units() {
let (w, h) = (16usize, 4usize);
+22
View File
@@ -3357,6 +3357,28 @@ mod tests {
assert_eq!(h.app.ui.pan, (0.5, 0.5));
}
#[test]
fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() {
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t);
h.settle(SETTLE);
for (unit, y, modifiers) in [
(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE),
(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL),
(egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL),
] {
h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t);
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers });
for _ in 0..10 {
h.step();
}
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom);
}
}
#[test]
fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() {
use crate::state::Zoom;
+17 -1
View File
@@ -90,10 +90,16 @@ struct Pen<'a> {
r: Rect,
s: Stroke,
k: f32,
/// (cos, sin) of a turn about the grid's centre, clockwise on screen ([`paint_turned`]).
turn: Option<(f32, f32)>,
}
impl Pen<'_> {
fn pt(&self, x: f32, y: f32) -> Pos2 {
let (x, y) = match self.turn {
Some((c, s)) => (10.0 + (x - 10.0) * c - (y - 10.0) * s, 10.0 + (x - 10.0) * s + (y - 10.0) * c),
None => (x, y),
};
pos2(self.r.min.x + x * self.k, self.r.min.y + y * self.k)
}
fn line(&self, pts: &[(f32, f32)]) {
@@ -144,10 +150,20 @@ fn star_pts(cx: f32, cy: f32, r: f32) -> Vec<(f32, f32)> {
/// Paint `icon` centred in `rect` (square area used) with `color`.
pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
paint_with(p, rect, icon, color, None);
}
/// [`paint`], turned `radians` clockwise about its centre. Only for icons drawn with lines, arcs
/// and circles (rectangles stay square to the screen).
pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians: f32) {
paint_with(p, rect, icon, color, Some((radians.cos(), radians.sin())));
}
fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) {
let side = rect.width().min(rect.height());
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let k = side / 20.0;
let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k };
let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn };
use Icon::*;
match icon {
Sidebar => {
+67 -1
View File
@@ -42,16 +42,28 @@ mod tests_activity;
#[cfg(test)]
mod tests_album_picker;
#[cfg(test)]
mod tests_before_after;
#[cfg(test)]
mod tests_compare;
#[cfg(test)]
mod tests_copy_paste_dialogs;
#[cfg(test)]
mod tests_crop_rotate;
#[cfg(test)]
mod tests_crop_tool;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_date_picker;
#[cfg(test)]
mod tests_dialogs;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_info_dialogs;
#[cfg(test)]
mod tests_keymap;
#[cfg(test)]
mod tests_keyword_files;
@@ -90,6 +102,8 @@ mod tests_titlebar;
#[cfg(test)]
mod tests_unsaved;
#[cfg(test)]
mod tests_wb_selector;
#[cfg(test)]
mod tests_zoom;
#[cfg(test)]
mod tests_zoom_keys;
@@ -230,6 +244,9 @@ pub struct LightcraftApp {
/// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut
/// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled.
pub recording_shortcut: Option<String>,
/// A text field had the keyboard at the end of the last frame. egui drops the focus as Esc
/// arrives, before the shortcuts are read, so this is how Esc there stays the field's own.
pub text_focus: bool,
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
pub headless_host: bool,
/// Warnings to show one at a time (damaged settings files…, issue #103).
@@ -342,6 +359,7 @@ impl LightcraftApp {
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
text_focus: false,
headless_host: false,
notices: vec![],
quit_prompt: None,
@@ -427,6 +445,21 @@ impl LightcraftApp {
// Keep it hidden after release; O / the overlay eye can show it again.
self.ui.mask_overlay = false;
}
if r.is_ok() {
// These commands finish synchronously, so their completion would otherwise
// be invisible once the activity stack is gone. Never show success on error.
let completed = match id {
"library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))),
"library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Clear Preview Cache"))),
"develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))),
_ => None,
};
if let Some(message) = completed {
// Menu clicks can arrive before the next frame updates last_time.
let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time);
self.ui.toast = Some((message, now + TOAST_SECONDS, None));
}
}
if r.is_ok() && id == "photo.label" {
let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse);
let text = match label {
@@ -900,7 +933,7 @@ impl LightcraftApp {
{
self.toast(ctx, e);
}
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
let (presets, photos) = split_presets(dropped);
if !presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
}
@@ -1017,6 +1050,7 @@ impl LightcraftApp {
ctx.request_repaint();
return;
}
panels::dialogs::prepare(self, ui);
let t0 = now_ms();
if std::mem::take(&mut self.ui.quit) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -1054,6 +1088,7 @@ impl LightcraftApp {
panels::keyword_list::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.text_focus = ctx.egui_wants_keyboard_input();
self.end_frame(&ctx, t0);
}
}
@@ -1121,8 +1156,39 @@ pub fn is_preset_file(path: &str) -> bool {
["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str())
}
/// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same
/// name (`IMG_1.xmp` or `IMG_1.CR2.xmp` beside `IMG_1.CR2`) is that photo's sidecar, not a preset, and is left out.
pub fn split_presets(dropped: Vec<String>) -> (Vec<String>, Vec<String>) {
let dir_of = |p: &str| std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_lowercase()).unwrap_or_default();
let stem_of = |p: &str| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let name_of = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let is_xmp = |p: &str| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"));
let photo_names: Vec<(String, String)> =
dropped.iter().filter(|p| !is_preset_file(p)).flat_map(|p| [(dir_of(p), stem_of(p)), (dir_of(p), name_of(p))]).collect();
let (mut presets, mut photos) = (Vec::new(), Vec::new());
for p in dropped {
if !is_preset_file(&p) {
photos.push(p);
} else if is_xmp(&p) && photo_names.contains(&(dir_of(&p), stem_of(&p))) {
// a photo's sidecar travels with the photo
} else {
presets.push(p);
}
}
(presets, photos)
}
#[cfg(test)]
mod drop_tests {
#[test]
fn photo_sidecars_are_not_presets() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]);
let (presets, photos) = super::split_presets(dropped);
assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"]));
assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"]));
}
#[test]
fn detection_results_are_summarised_in_words() {
let r =
+9 -1
View File
@@ -68,6 +68,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.reference", "Reference View", Some("Shift+R"), "View"),
("photo.setReference", "Set as Reference Photo", None, ""),
("compare.swap", "Swap Compare Photos", None, "View"),
("compare.set", "Show in Compare", None, ""),
("compare.makeSelect", "Make Candidate the Select", None, "View"),
("view.autoAdvance", "Auto Advance", None, "Photo"),
("view.filmstrip", "Filmstrip", Some("/"), "View"),
@@ -390,6 +391,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"reference": id}))
}
"compare.swap" => crate::panels::compare::swap(app),
"compare.set" => {
let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) };
crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str))
}
"compare.makeSelect" => crate::panels::compare::make_select(app),
"view.autoAdvance" => {
app.ui.auto_advance = !app.ui.auto_advance;
@@ -406,6 +411,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.slideshow = None;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail {
// Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back
panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry");
} else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() {
app.ui.person_page = None;
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
@@ -1623,7 +1631,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"file.backupLibrary" => app.services.backup_library.is_some(),
"app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
"compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare,
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none()
}
+47 -2
View File
@@ -76,8 +76,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
}
/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear.
const PILL_W: f32 = 196.0;
/// Five 19-point stars, three 22-point flags, seven 3-point gaps, a 6-point group gap
/// and 10-point padding at both ends.
const PILL_W: f32 = 208.0;
/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the
/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill.
@@ -456,3 +457,47 @@ mod visual_regressions {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use crate::headless::HeadlessView;
#[test]
fn rating_and_flag_buttons_keep_padding_inside_the_pill() {
for scale in [1.0, 2.0] {
for room in [240.0, 500.0] {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
let mut view = HeadlessView::new();
for frame in 0..3 {
let raw = HeadlessView::raw_input(vec2(700.0, 40.0), scale, frame as f64 / 60.0, vec![]);
view.run(raw, |ui| {
let full = ui.max_rect();
centre(&mut app, ui, full, 20.0, 20.0 + room);
});
let widgets = crate::widgets::take_registry(&view.ctx);
let star = widgets.iter().find(|(id, _)| id == "star:bottom:1").unwrap().1;
let pill = view
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.rect.contains(star.center()) && rect.rect.height() == 30.0 => Some(rect.rect),
_ => None,
})
.unwrap();
let inner = pill.shrink2(vec2(10.0, 4.0));
for (id, rect) in &widgets {
if id.starts_with("star:bottom:") || id.starts_with("flag:") {
assert!(inner.contains_rect(*rect), "{id} {rect:?} exceeds the pill's padded bounds {inner:?} at scale {scale}");
}
}
assert!(pill.left() >= 20.0 && pill.right() <= 20.0 + room);
if let Some((_, copy)) = widgets.iter().find(|(id, _)| id == "button:copySettings") {
assert!(copy.left() >= pill.right() + 10.0);
}
}
}
}
}
}
+24
View File
@@ -73,6 +73,30 @@ pub fn swap(app: &mut LightcraftApp) -> Result<Value, String> {
Ok(Value::Null)
}
/// Show photo `id` on one side of the pair: `side` "select" or "candidate", else the side that is
/// active (the one the Edit panel and the rating keys act on). A photo already on the other side
/// swaps the two. The photo becomes the active one, so what is edited is always on screen.
pub fn set_photo(app: &mut LightcraftApp, id: PhotoId, side: Option<&str>) -> Result<Value, String> {
if app.session.catalog.photo(id).is_none_or(|p| p.deleted) {
return Err(format!("no photo {}", id.0));
}
let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?;
let to_select = match side {
Some("select") => true,
Some("candidate") => false,
Some(other) => return Err(format!("unknown side `{other}` (select or candidate)")),
None => app.session.active() == Some(sel),
};
let (a, b) = match (to_select, id == sel, id == cand) {
(true, _, true) | (false, true, _) => (cand, sel),
(_, true, _) | (_, _, true) => (sel, cand),
(true, false, false) => (id, cand),
(false, false, false) => (sel, id),
};
select_pair(app, a, b, id);
Ok(json!({"select": a.0, "candidate": b.0}))
}
/// The candidate becomes the select; the next photo becomes the candidate.
pub fn make_select(app: &mut LightcraftApp) -> Result<Value, String> {
let (_, b) = compare_pair(app).ok_or("nothing to compare")?;
+204 -28
View File
@@ -153,13 +153,44 @@ pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: e
let _ = app.run("view.navigate", json!({"pan": pan}));
}
/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad.
fn wheel_notches(events: &[egui::Event]) -> f32 {
events
.iter()
.filter_map(|e| match e {
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. }
if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() =>
{
Some(delta.y)
}
_ => None,
})
.sum()
}
/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view.
pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool {
if !resp.hovered() {
return false;
}
let (factor, delta, anchor) =
let (mut factor, mut delta, anchor) =
ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos())));
// A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel
// delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan.
let wheel_id = egui::Id::new("loupe-wheel-zoom");
if ui.input(|i| wheel_notches(&i.events)) != 0.0 {
ui.data_mut(|d| d.insert_temp(wheel_id, true));
}
if ui.data(|d| d.get_temp::<bool>(wheel_id)).unwrap_or(false) {
let smooth = ui.input(|i| i.smooth_scroll_delta.y);
let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed);
if smooth == 0.0 {
ui.data_mut(|d| d.remove_temp::<bool>(wheel_id));
} else {
factor *= (speed * smooth).exp();
delta = egui::Vec2::ZERO;
}
}
if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) {
return false;
}
@@ -531,7 +562,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let p = ui.painter_at(canvas);
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
// between photos, and the full render replaces them in place)
let draw = |slot: Slot, r: Rect| -> &'static str {
let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str {
let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id);
let (tex, what) = if let Some(t) = mine(slot) {
(t, "render")
@@ -543,7 +574,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
return "none";
};
let image_rect = fit_texture_rect(r, tex.size);
p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
what
};
// soft proofing: a paper-white surround and the proof's name, as Lightroom shows it
@@ -554,13 +585,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let shown;
if split {
// each view stays in its pane: zoomed in, a photo reaches past it (and would cover the
// other view's margins with its whole-frame render, magnified and blurred)
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
draw(Slot::Before, br);
shown = draw(Slot::Main, img_rect);
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim);
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
draw(Slot::Before, br, areas[0]);
shown = draw(Slot::Main, img_rect, main_area);
// the labels under each photo, or under its pane when the photo reaches past it
let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0);
p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim);
p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
} else if show_before {
shown = draw(Slot::Before, img_rect);
shown = draw(Slot::Before, img_rect, canvas);
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text);
} else if let Some((_key, label)) = &hover_key
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
@@ -573,7 +608,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
shown = "hover";
} else {
shown = draw(Slot::Main, img_rect);
shown = draw(Slot::Main, img_rect, canvas);
if shown == "none" {
match app.renderer.failure(Slot::Main) {
Some(e) => {
@@ -685,7 +720,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
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, main_area, native, aspect),
_ => {
// side by side / stacked: Before shows the same frame, so a click in its view (white
// balance, colour samples, targeted adjustment) means the same point of the photo.
// Zoomed in, a view's image reaches past its pane: what counts is the pane clicked.
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
let map = match (split, areas.first(), pointer) {
(true, Some(before), Some(q)) if before.contains(q) => {
CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan))
}
_ => map,
};
general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect)
}
}
}
// drawn and hit-tested above the loupe and its tools: clicks on it pan
@@ -1148,16 +1195,7 @@ fn general_interaction(
return;
}
if app.ui.tool == "colorRange" {
// click: sample the colour for the selected mask's colour range; ⇧-click adds (up to 5)
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let add = ui.input(|i| i.modifiers.shift);
if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) {
app.toast(ui.ctx(), e);
}
}
color_range_pick(app, ui, resp, map);
return;
}
if app.ui.tool == "pointColor" {
@@ -1220,11 +1258,13 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let shade = Color32::from_black_alpha(150);
let mut mesh = egui::epaint::Mesh::default();
let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
// the box's corners in screen order (a flipped photo shows its top-left corner elsewhere)
let on_screen = flipped_corners(&pts, frame.flip_h, frame.flip_v);
for i in 0..4 {
let a = outer[i];
let b = outer[(i + 1) % 4];
let c = pts[(i + 1) % 4];
let dd = pts[i];
let c = on_screen[(i + 1) % 4];
let dd = on_screen[i];
let base = mesh.vertices.len() as u32;
for v in [a, b, c, dd] {
mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade });
@@ -1285,7 +1325,7 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
None if inside(hq) && !rotating => egui::CursorIcon::Move,
// a drag here rotates: no system cursor shows that, so draw a curved double arrow
None => {
rotate_cursor(ui, hq);
rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq)));
egui::CursorIcon::None
}
};
@@ -1383,15 +1423,38 @@ fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter {
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new(name)))
}
/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo.
fn rotate_cursor(ui: &egui::Ui, at: Pos2) {
/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo,
/// turned `turn` radians clockwise ([`rotate_cursor_turn`]).
fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) {
let p = top_painter(ui, "crop-rotate-cursor");
let r = Rect::from_center_size(at, vec2(22.0, 22.0));
p.circle_filled(at, 12.0, Color32::from_black_alpha(150));
crate::icons::paint(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE);
crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn);
register(ui.ctx(), "cropRotateCursor", r);
}
/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges
/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge,
/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest
/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it
/// the other way, still away from that edge.
fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 {
let nearest = (0..pts.len())
.map(|i| {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
let ab = b - a;
let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
a + ab * t
})
.min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q)));
let Some(nearest) = nearest else { return 0.0 };
let away = if inside { nearest - q } else { q - nearest };
if away.length() < 0.5 {
return 0.0;
}
away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2
}
/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the
/// pointer's other side when it would run past an edge.
fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) {
@@ -1451,6 +1514,19 @@ fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
Point::new(r.x / w, r.y / h)
}
/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo)
/// in the order they appear on screen when the photo is shown flipped.
fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] {
let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO);
let order = match (flip_h, flip_v) {
(false, false) => [0, 1, 2, 3],
(true, false) => [1, 0, 3, 2],
(false, true) => [3, 2, 1, 0],
(true, true) => [2, 3, 0, 1],
};
order.map(at)
}
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
f.crop_quad_norm()
@@ -1543,6 +1619,21 @@ fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
x * x + y * y <= 1.0
}
/// Colour Range eyedropper: a click samples the colour under it for the selected mask's
/// colour range, at normalized frame coordinates (crop, rotation and zoom aware);
/// ⇧-click adds a sample (up to 5).
fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) {
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let add = ui.input(|i| i.modifiers.shift);
if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) {
app.toast(ui.ctx(), e);
}
}
}
/// 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`]).
@@ -1638,6 +1729,15 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
}
}
}
// Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction
// never runs while it is open), so the eyedropper is handled here too.
if app.ui.tool == "colorRange" {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
color_range_pick(app, ui, resp, map);
return;
}
// Object tool: a click includes what's under it, ⌥-click leaves it out
if app.ui.tool == "object" {
if resp.hovered() {
@@ -1672,6 +1772,8 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
{
let n = map.norm(q);
match &mut app.gesture {
// a held, unmoving pointer records nothing: coverage builds by passes, not time (#518)
Some(Gesture::Brush { points }) if points.last() == Some(&n) => {}
Some(Gesture::Brush { points }) => points.push(n),
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
}
@@ -2030,7 +2132,12 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
}
}
if resp.clicked() {
if resp.clicked() && app.ui.view == crate::state::ViewMode::Compare {
// Compare: the clicked photo replaces the active side (or swaps with the other one)
if let Err(e) = app.run("compare.set", json!({"id": id.0})) {
app.toast(ui.ctx(), e);
}
} else if resp.clicked() {
let m = ui.input(|i| i.modifiers);
let mode = if m.command {
"toggle"
@@ -2143,7 +2250,42 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R
#[cfg(test)]
mod tests {
use super::{film_label, texture_side};
/// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows
/// a box that is itself rotated.
#[test]
fn rotate_cursor_faces_away_from_the_crop_box() {
use egui::{Pos2, pos2};
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
let near = |a: f32, b: f32| ((a - b + PI).rem_euclid(2.0 * PI) - PI).abs() < 1e-3;
let pts = [pos2(100.0, 100.0), pos2(300.0, 100.0), pos2(300.0, 200.0), pos2(100.0, 200.0)];
let turn = |x: f32, y: f32| super::rotate_cursor_turn(&pts, pos2(x, y), false);
assert!(near(turn(200.0, 80.0), 0.0), "above: as drawn");
assert!(near(turn(320.0, 150.0), FRAC_PI_2), "right");
assert!(near(turn(200.0, 230.0), PI), "below");
assert!(near(turn(70.0, 150.0), -FRAC_PI_2), "left");
assert!(near(turn(290.0, 60.0), 0.0), "above the top edge near its end: still up");
// off each corner: diagonally out from it
assert!(near(turn(80.0, 80.0), -FRAC_PI_4), "top-left corner");
assert!(near(turn(320.0, 80.0), FRAC_PI_4), "top-right corner");
assert!(near(turn(320.0, 220.0), 3.0 * FRAC_PI_4), "bottom-right corner");
assert!(near(turn(80.0, 220.0), -3.0 * FRAC_PI_4), "bottom-left corner");
assert!(near(super::rotate_cursor_turn(&pts, pos2(200.0, 110.0), true), 0.0), "dragged back over the top edge: still up");
// a box turned 90°: its top edge is now on the right
let turned = [pts[3], pts[0], pts[1], pts[2]].map(|p| pos2(400.0 - p.y, p.x));
assert!(near(super::rotate_cursor_turn(&turned, pos2(420.0, 200.0), false), FRAC_PI_2));
// a box turned 30°: straight out from its top edge is 30° clockwise of up
let c = pos2(200.0, 150.0);
let rot = |p: Pos2| {
let (s, co) = (30f32.to_radians().sin(), 30f32.to_radians().cos());
let d = p - c;
pos2(c.x + d.x * co - d.y * s, c.y + d.x * s + d.y * co)
};
let tilted = pts.map(rot);
assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians()));
}
use super::{film_label, flipped_corners, texture_side};
use egui::pos2;
/// Issue #652: a native host reports the GPU's texture limit in the first frame's input only.
/// The loupe must still know it in every later frame (it read the raw input, found nothing and
@@ -2172,4 +2314,38 @@ mod tests {
assert_eq!(film_label("DSC_0001"), "DSC_0001");
assert_eq!(film_label(""), "");
}
/// With the photo flipped, the crop box's corners reach the screen mirrored; the dimming
/// outside the box pairs each photo corner with the box corner on the same side of the screen
/// (it used to pair them in data order, so a flipped photo was dimmed inside the box).
#[test]
fn flipped_crop_box_corners_come_in_screen_order() {
let screen = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)];
// data order (top-left, top-right, bottom-right, bottom-left) as each flip shows it
let h = [screen[1], screen[0], screen[3], screen[2]];
let v = [screen[3], screen[2], screen[1], screen[0]];
let hv = [screen[2], screen[3], screen[0], screen[1]];
assert_eq!(flipped_corners(&screen, false, false), screen);
assert_eq!(flipped_corners(&h, true, false), screen);
assert_eq!(flipped_corners(&v, false, true), screen);
assert_eq!(flipped_corners(&hv, true, true), screen);
}
}
#[cfg(test)]
mod wheel_zoom_tests {
use super::*;
fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers }
}
#[test]
fn only_notched_unmodified_wheels_count() {
let m = egui::Modifiers::NONE;
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0);
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0);
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0);
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0);
}
}
+40 -3
View File
@@ -60,6 +60,21 @@ pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "
/// Help ▸ What's New (docs/whats-new.md).
pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md");
fn window_id(dialog: &Dialog) -> egui::Id {
egui::Id::new(if matches!(dialog, Dialog::Import { .. }) { "lightcraft-import-dialog" } else { "lightcraft-dialog" })
}
/// Block the underlying panels from the first frame, before the window establishes its modal
/// layer. Close background menus on entry; popups subsequently opened inside the dialog stay usable.
pub(crate) fn prepare(app: &LightcraftApp, ui: &mut egui::Ui) {
let Some(dialog) = &app.ui.dialog else { return };
ui.disable();
let layer = egui::LayerId::new(egui::Order::Middle, window_id(dialog));
if ui.ctx().memory(|m| m.top_modal_layer()) != Some(layer) {
egui::Popup::close_all(ui.ctx());
}
}
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(mut dlg) = app.ui.dialog.clone() else { return };
let at_start = dlg.clone();
@@ -77,6 +92,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
let mut close = false;
let mut confirm = false;
// Return presses OK in a dialog without a text field of its own (Lightroom's default button);
// a widget that has the keyboard focus (a text field, a button or checkbox reached with Tab)
// takes Return itself. Consumed before the dialog is drawn, so nothing else sees it too.
if return_confirms(&dlg)
&& ctx.memory(|m| m.focused().is_none())
&& !egui::Popup::is_any_open(ctx)
&& ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))
{
confirm = true;
}
let title: String = match &dlg {
Dialog::TextPrompt { title, .. } => title.as_str(),
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
@@ -127,7 +152,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
// own so the size it is given doesn't carry over to the other dialogs. Its content scrolls
// rather than growing the window when the options below the grid get taller (e.g. Copy).
let import = matches!(dlg, Dialog::Import { .. });
let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" });
let window_id = window_id(&dlg);
let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id)
.collapsible(false)
.resizable(import)
@@ -948,9 +973,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
ui.add_space(4.0);
ui.horizontal(|ui| {
// a model file LightCraft cannot use has nothing to confirm
// a model file LightCraft cannot use has nothing to confirm, nor has a dialog that
// only shows something (one Close, no Cancel / OK)
let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported");
let informational = unusable_model || matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
let informational = unusable_model
|| matches!(
dlg,
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew
);
let sam = &app.session.segmenter;
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
// no download location in this build: nothing to offer but the manual install
@@ -1022,6 +1052,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
});
if let Some(w) = shown {
ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id));
ctx.move_to_top(w.response.layer_id);
crate::widgets::register(ctx, "dialog:window", w.response.rect);
}
@@ -1556,6 +1587,12 @@ fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, label: &str, id: &str, option
});
}
/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the
/// field: `dialog_field`).
fn return_confirms(dlg: &Dialog) -> bool {
matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. })
}
/// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens
/// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc
/// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the
+21 -16
View File
@@ -216,7 +216,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)")
.clicked()
{
app.ui.tool = if active { String::new() } else { "wbPicker".into() };
if active {
app.ui.tool.clear();
} else if let Err(e) = app.run("tool.wbPicker", json!({})) {
// the same as W: from Compare, Survey or the grid it opens the photo in Detail
app.toast(ui.ctx(), e);
}
}
});
});
@@ -561,21 +566,21 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// EXIF strip
if let Some(ph) = app.session.catalog.photo(id) {
let m = &ph.meta;
let items = [
m.iso.map(|i| format!("ISO {i}")).unwrap_or_default(),
m.focal_mm.map(|f| format!("{f:.0}mm")).unwrap_or_default(),
m.aperture.map(|a| format!("f/{a:.1}")).unwrap_or_default(),
m.shutter.clone(),
];
let y = r.bottom() - 13.0;
for (i, s) in items.iter().enumerate() {
let x = r.left() + 18.0 + (r.width() - 36.0) * i as f32 / 3.0;
let align = match i {
0 => Align2::LEFT_CENTER,
3 => Align2::RIGHT_CENTER,
_ => Align2::CENTER_CENTER,
};
p.text(pos2(x, y), align, s, t.font(12.5), t.text_label);
let parts: Vec<String> = [
m.iso.map(|i| format!("ISO {i}")),
m.focal_mm.map(|f| format!("{f:.0}mm")),
m.aperture.map(|a| format!("f/{a:.1}")),
if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) },
]
.into_iter()
.flatten()
.collect();
if !parts.is_empty() {
let text = parts.join(" · ");
let y = r.bottom() - 13.0;
let x = r.left() + r.width() / 2.0;
p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label);
}
}
}
+6
View File
@@ -325,6 +325,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
app.recording_shortcut = None;
}
// Dialogs own their keys (including Esc and text editing); background photo actions wait.
if app.ui.dialog.is_some() {
return;
}
// don't steal keys from text fields or from the keymap editor recording a shortcut
if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() {
return;
@@ -340,6 +344,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
// an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back,
// which also closes dialogs) waits until nothing is open
let popup_open = egui::Popup::is_any_open(ctx);
// Esc that just took a text field's focus away (it gives back the field's text) does nothing else
let popup_open = popup_open || app.text_focus;
ctx.input(|i| {
for b in bindable() {
if let Some(sc) = binding(keymap, b.id, b.default)
+19
View File
@@ -393,3 +393,22 @@ fn rows_added_later_grow_the_stack() {
assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id());
}
}
#[test]
fn immediate_library_commands_and_auto_sync_show_completion() {
let mut h = demo();
assert!(h.app.run("library.clearPreviews", json!({})).is_ok());
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Clear Preview Cache"));
assert_eq!(h.app.run("develop.autoSync", json!({"on": true})).unwrap()["on"], true);
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: On"));
assert_eq!(h.app.run("develop.autoSync", json!({"on": false})).unwrap()["on"], false);
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: Off"));
// This demo has no on-disk library to optimize. A failed command must not
// report completion (or accidentally keep its preceding success toast).
h.app.ui.toast = None;
assert!(h.app.run("library.compact", json!({})).is_err());
assert!(h.app.ui.toast.is_none());
}
+71
View File
@@ -0,0 +1,71 @@
//! Headless tests of clicks in the two-view Before / After layouts.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::{BeforeAfter, ViewMode, Zoom};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// Click at a screen position with real egui pointer events (as `ui.pointer` sends them).
fn click_at(h: &mut Headless, pos: egui::Pos2) {
h.app.synthetic.push(egui::Event::PointerMoved(pos));
h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.settle(SETTLE);
}
/// The white-balance selector picks the same point of the photo whichever view of a side-by-side
/// or top/bottom Before / After layout is clicked, at Fit and zoomed in. Clicks on the Before
/// view used to be mapped through the After view's rectangle, so they sampled the photo's edge.
#[test]
fn wb_selector_picks_the_same_point_in_the_before_view() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[2];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
h.app.ui.view = ViewMode::Detail;
let wb = |h: &Headless| h.app.session.catalog.photo(id).map(|p| (p.develop.wb.temp, p.develop.wb.tint));
// lay the view out and arm the selector; returns the After view's image rect and the canvas
let arm = |h: &mut Headless, mode: BeforeAfter, zoom: Zoom| {
h.request("engine.execute", json!({"command": "develop.reset"}), T);
h.app.ui.before_after = mode;
h.app.ui.zoom = zoom;
h.app.ui.pan = Default::default();
h.settle(SETTLE);
let r = h.request("engine.execute", json!({"command": "tool.wbPicker"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
(h.app.image_rect.expect("After view on screen"), h.app.canvas_rect.expect("canvas"))
};
for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] {
// the Before view is the After view moved by one pane: measured at Fit, where both are
// centred in their panes and so mirror each other about the canvas centre
let (img, canvas) = arm(&mut h, mode, Zoom::Fit);
let shift = match mode {
BeforeAfter::SideBySide => egui::vec2(img.left() + img.right() - 2.0 * canvas.center().x, 0.0),
_ => egui::vec2(0.0, img.top() + img.bottom() - 2.0 * canvas.center().y),
};
assert!(shift.length() > 100.0, "{mode:?}: two views, {shift:?} apart");
h.request("engine.execute", json!({"command": "tool.wbPicker"}), T);
for zoom in [Zoom::Fit, Zoom::Percent(100.0)] {
let mut picked = Vec::new();
for before_view in [false, true] {
let (img, canvas) = arm(&mut h, mode, zoom);
// the middle of the After pane (on the photo at Fit and zoomed in), or the same
// point of the photo in the Before view
let after = canvas.center() + shift / 2.0;
assert!(img.contains(after), "{mode:?} {zoom:?}: {after:?} on the photo {img:?}");
click_at(&mut h, if before_view { after - shift } else { after });
assert_eq!(h.app.ui.tool, "", "{mode:?} {zoom:?}: the selector is done after one click");
picked.push(wb(&h));
}
assert_ne!(picked[0], Some((6500.0, 0.0)), "{mode:?} {zoom:?}: the click picked a white balance");
assert_eq!(picked[1], picked[0], "{mode:?} {zoom:?}: the Before view picks the After view's point");
}
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Headless tests of Compare with the filmstrip.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::Zoom;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn demo() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
Headless::new(app, [1400.0, 900.0], 1.0)
}
/// In Compare, the photo the Edit panel and the rating keys act on is always one of the two on
/// screen: a filmstrip click must not leave the pair showing while a third photo becomes active.
#[test]
fn filmstrip_click_in_compare_keeps_the_active_photo_on_screen() {
let mut h = demo();
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).map(|p| p.rating);
for zoom in [Zoom::Fit, Zoom::Percent(100.0)] {
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
assert_eq!(r["ok"], true, "{r}");
h.app.ui.zoom = zoom;
h.settle(SETTLE);
let target = vis[4];
let before = rating(&h, target);
let r = h.request("ui.clickWidget", json!({"id": format!("film:{target}")}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let (sel, cand) = h.app.ui.compare.expect("still comparing");
let active = h.app.session.selection.active.map(|p| p.0).expect("an active photo");
// a rating key must not change a photo that isn't shown
h.request("ui.key", json!({"key": "3"}), T);
if target != sel && target != cand {
assert_eq!(rating(&h, target), before, "zoom {zoom:?}: the hidden photo {target} was rated");
}
assert!(active == sel || active == cand, "zoom {zoom:?}: active photo {active} is not on screen (compare shows {sel} | {cand})");
}
}
/// A filmstrip click in Compare puts the photo into the active side: the candidate after
/// entering Compare, the select after clicking the select's pane; a photo already on the other
/// side swaps the two. `compare.set` does the same for agents, with an explicit `side`.
#[test]
fn filmstrip_click_replaces_the_active_side_in_compare() {
let mut h = demo();
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], vis[1]], "active": vis[0]}}), T);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap());
h.settle(SETTLE);
let click_film = |h: &mut Headless, id: u64| {
let r = h.request("ui.clickWidget", json!({"id": format!("film:{id}")}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
};
let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0);
let others: Vec<u64> = vis.iter().copied().filter(|v| *v != sel && *v != cand).take(3).collect();
// the candidate is active after entering Compare: it is replaced
click_film(&mut h, others[0]);
assert_eq!(h.app.ui.compare, Some((sel, others[0])));
assert_eq!(active(&h), Some(others[0]));
// the select's photo, clicked in the filmstrip: the two swap, it stays active
click_film(&mut h, sel);
assert_eq!(h.app.ui.compare, Some((others[0], sel)));
assert_eq!(active(&h), Some(sel));
// a side chosen explicitly
let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[1], "side": "select"}}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.compare, Some((others[1], sel)));
assert_eq!(active(&h), Some(others[1]));
// the select is active now: the next filmstrip click replaces it
click_film(&mut h, others[2]);
assert_eq!(h.app.ui.compare, Some((others[2], sel)));
let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[0], "side": "left"}}), T);
assert_eq!(r["ok"], false, "unknown side is an error: {r}");
}
@@ -0,0 +1,91 @@
//! Return presses OK in Copy Settings (⇧⌘C) and Paste Selected Settings (⇧⌘V); Esc cancels (#735).
use std::time::Duration;
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::headless::Headless;
use crate::state::Dialog;
const SETTLE: Duration = Duration::from_secs(120);
const T: Duration = Duration::from_secs(10);
/// The demo library with an edited first photo selected; returns the first two photos' ids.
fn edited() -> (Headless, [u64; 2]) {
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1200.0, 900.0], 1.0);
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
let ids = [ids[0], ids[1]];
exec(&mut h, "library.select", json!({"ids": [ids[0]]}));
exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}}));
(h, ids)
}
fn exec(h: &mut Headless, command: &str, params: Value) {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
}
fn key(h: &mut Headless, k: Value) {
let r = h.request("ui.key", k.clone(), T);
assert_eq!(r["ok"], true, "{k}: {r}");
h.settle(SETTLE);
}
fn copy_dialog(h: &mut Headless) {
key(h, json!({"key": "C", "cmd": true, "shift": true}));
assert!(matches!(h.app.ui.dialog, Some(Dialog::CopySettings { .. })), "dialog not open: {:?}", h.app.ui.dialog);
}
/// Copies light and colour from the first photo, selects the second and opens ⇧⌘V.
fn paste_dialog(h: &mut Headless, ids: [u64; 2]) {
exec(h, "develop.copy", json!({"groups": ["light", "color"]}));
exec(h, "library.select", json!({"ids": [ids[1]]}));
key(h, json!({"key": "V", "cmd": true, "shift": true}));
assert!(matches!(h.app.ui.dialog, Some(Dialog::PasteSettings { .. })), "dialog not open: {:?}", h.app.ui.dialog);
}
fn exposure(h: &Headless, id: u64) -> f64 {
h.app.session.develop_of(PhotoId(id)).map(|d| d.light.exposure).unwrap_or_default()
}
#[test]
fn return_copies_the_chosen_settings() {
let (mut h, _) = edited();
copy_dialog(&mut h);
assert!(h.app.session.clipboard.is_none());
key(&mut h, json!({"key": "Enter"}));
assert!(h.app.ui.dialog.is_none(), "Return closes the dialog");
assert!(h.app.session.clipboard.is_some(), "Return copies the settings");
}
#[test]
fn escape_closes_copy_settings_without_copying() {
let (mut h, _) = edited();
copy_dialog(&mut h);
key(&mut h, json!({"key": "Escape"}));
assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog");
assert!(h.app.session.clipboard.is_none(), "Esc copies nothing");
}
#[test]
fn return_pastes_into_the_selected_photos() {
let (mut h, ids) = edited();
paste_dialog(&mut h, ids);
key(&mut h, json!({"key": "Enter"}));
assert!(h.app.ui.dialog.is_none(), "Return closes the dialog");
assert_eq!(exposure(&h, ids[1]), 1.0, "Return pastes the settings");
}
#[test]
fn escape_closes_paste_settings_without_pasting() {
let (mut h, ids) = edited();
paste_dialog(&mut h, ids);
key(&mut h, json!({"key": "Escape"}));
assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog");
assert_eq!(exposure(&h, ids[1]), 0.0, "Esc pastes nothing");
}
+64
View File
@@ -0,0 +1,64 @@
//! Headless tests of leaving the crop tool.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::{RightPanel, ViewMode};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// Esc in the crop tool closes the tool and keeps the photo in Detail (and the crop); only the
/// next Esc goes back to the grid. It used to jump straight to the grid.
#[test]
fn escape_leaves_the_crop_tool_before_the_view() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[1];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
h.app.ui.view = ViewMode::Detail;
let r = h.request("engine.execute", json!({"command": "panel.crop"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("engine.execute", json!({"command": "crop.set", "params": {"rect": [0.1, 0.1, 0.8, 0.9]}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.right, RightPanel::Crop);
let crop = h.app.session.catalog.photo(id).map(|p| p.develop.crop.geometry.rect);
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_ne!(h.app.ui.right, RightPanel::Crop, "Esc closes the crop tool");
assert_eq!(h.app.ui.view, ViewMode::Detail, "and stays on the photo");
assert_eq!(h.app.session.catalog.photo(id).map(|p| p.develop.crop.geometry.rect), crop, "the crop is kept");
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the next Esc goes back to the grid");
}
/// Esc in the crop tool's Angle field only leaves the field: egui drops the field's focus as the
/// key arrives, so Esc used to run Back as well (to the grid; with the crop tool's own Esc step,
/// it would close the tool).
#[test]
fn escape_in_the_angle_field_only_leaves_the_field() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[1];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
h.app.ui.view = ViewMode::Detail;
h.request("engine.execute", json!({"command": "panel.crop"}), T);
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "cropAngleField"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h.request("ui.text", json!({"text": "7"}), T);
h.settle(SETTLE);
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_eq!((h.app.ui.view, h.app.ui.right), (ViewMode::Detail, RightPanel::Crop), "Esc only left the field");
// the field has let go: the next Esc is the crop tool's
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_eq!((h.app.ui.view, h.app.ui.right == RightPanel::Crop), (ViewMode::Detail, false));
}
+98
View File
@@ -0,0 +1,98 @@
//! Modal application dialogs block background input while retaining their own controls.
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use serde_json::json;
use std::time::Duration;
const T: Duration = Duration::from_secs(20);
fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, size, 1.0);
let r = h.request("ui.set", ui, T);
assert_eq!(r["ok"], true, "{r}");
h.settle(Duration::from_secs(120));
h
}
/// Application dialogs block the toolbar, including the gear that would reopen Copy Settings
/// and discard its choices. Their own controls work, and closing restores background input.
#[test]
fn modal_dialogs_block_background_clicks_and_preserve_choices() {
let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"}));
let active = h.app.session.active().unwrap();
let rating = h.app.session.catalog.photo(active).unwrap().rating;
h.app.run("dialog.copySettings", json!({})).unwrap();
// Also exercise the first frame of a newly opened dialog.
let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating, "the dialog blocks background ratings");
let r = h.request("ui.clickWidget", json!({"id": "copyGroup:none"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
let choices = h.app.ui.dialog.clone();
assert!(matches!(&choices, Some(crate::state::Dialog::CopySettings { groups }) if groups.is_empty()));
let r = h.request("ui.clickWidget", json!({"id": "icon:copyGear"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.ui.dialog, choices, "a background click cannot reopen the dialog and reset its choices");
let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.ui.dialog.is_none());
let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores toolbar input");
// A different dialog uses the same barrier.
h.app.run("app.settings", json!({})).unwrap();
h.step();
let r = h.request("ui.clickWidget", json!({"id": "star:bottom:1"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5);
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.ui.dialog.is_none());
}
#[test]
fn modal_dialogs_block_background_shortcuts_and_keep_escape() {
let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"}));
let active = h.app.session.active().unwrap();
let rating = h.app.session.catalog.photo(active).unwrap().rating;
h.app.run("dialog.copySettings", json!({})).unwrap();
h.step();
for key in ["5", "ArrowRight", "J"] {
let r = h.request("ui.key", json!({"key": key}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
}
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating);
assert_eq!(h.app.session.active(), Some(active));
assert!(!h.app.ui.show_clipping);
let r = h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.ui.dialog.is_none());
let r = h.request("ui.key", json!({"key": "5"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores shortcuts");
}
#[test]
fn modal_dialogs_close_background_menus() {
let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"}));
let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(egui::Popup::is_any_open(&h.view.ctx));
h.app.run("dialog.copySettings", json!({})).unwrap();
h.step();
assert!(!egui::Popup::is_any_open(&h.view.ctx), "background menus cannot remain above the modal window");
}
+50
View File
@@ -0,0 +1,50 @@
//! #782: dialogs that only show something (System Info, All Metadata, What's New) end with one
//! Close, like About, instead of a Cancel / OK pair that both just close them.
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use serde_json::json;
use std::time::Duration;
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(60);
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
/// Open the dialog through its menu item; let the new window size itself before reading it.
fn open(h: &mut Headless, id: &str) {
let r = h.request("ui.menu.invoke", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
assert!(h.app.ui.dialog.is_some(), "{id}: no dialog");
h.step();
h.step();
}
#[test]
fn information_dialogs_end_with_one_close() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
h.settle(SETTLE);
for id in ["app.systemInfo", "dialog.allMetadata", "app.whatsNew"] {
open(&mut h, id);
assert!(!has(&h, "button:dialogCancel"), "{id}: a Cancel button, though there is nothing to confirm");
assert!(has(&h, "button:dialogOk"), "{id}: no Close button");
let close = h.app.widgets.iter().filter(|(w, _)| w == "button:dialogOk").count();
assert_eq!(close, 1, "{id}: one footer button");
let text = h.painted_text();
assert!(text.iter().any(|t| t == "Close"), "{id}: the button reads Close: {text:?}");
assert!(!text.iter().any(|t| t == "Cancel" || t == "OK"), "{id}: no Cancel / OK: {text:?}");
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.step();
assert!(h.app.ui.dialog.is_none(), "{id}: Close closes the dialog");
// Esc still closes it too
open(&mut h, id);
let r = h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.step();
assert!(h.app.ui.dialog.is_none(), "{id}: Esc closes the dialog");
}
}
+86
View File
@@ -308,6 +308,32 @@ fn mask_overlay_keys_and_pins() {
h.settle(SETTLE);
}
/// #518: holding the brush still, however long, paints the same feathered spot as a quick press.
#[test]
fn holding_the_brush_still_does_not_harden_the_spot() {
let mut h = detail("panel.masking");
let r = h.request("ui.set", json!({"brushSize": 0.1, "brushFeather": 80.0, "brushFlow": 100.0}), T);
assert_eq!(r["ok"], true, "{r}");
exec(&mut h, "tool.brush", json!({"new": true}));
// a quick press at the left, a long stationary hold (120 frames, ~2 s) at the right
brush_at(&mut h, 0.3, 0.5);
let mut hold = vec![json!({"kind": "down", "x": 0.7, "y": 0.5})];
hold.extend((0..120).map(|_| json!({"kind": "move", "x": 0.7, "y": 0.5})));
hold.push(json!({"kind": "up", "x": 0.7, "y": 0.5}));
pointer(&mut h, json!(hold));
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert_eq!(strokes.len(), 2, "{strokes:?}");
assert_eq!(strokes[1].points.len(), 1, "a stationary hold records one sample: {:?}", strokes[1].points);
// the same edge profile around both spots
for dx in [0usize, 4, 6, 8] {
let (quick, held) = (mask_coverage(&h, 0, 30 + dx, 50), mask_coverage(&h, 0, 70 + dx, 50));
assert!((quick - held).abs() < 0.01, "{dx} px from the centre: quick {quick}, held {held}");
}
assert!(mask_coverage(&h, 0, 70 + 6, 50) < 0.9, "the edge stays feathered");
h.settle(SETTLE);
}
#[test]
fn brush_strokes_carry_auto_mask() {
let mut h = detail("panel.masking");
@@ -1126,3 +1152,63 @@ fn ai_masks_without_the_model_offer_the_download() {
assert!(develop(&h).masks.is_empty());
assert!(t.elapsed() < SETTLE, "{:?}", t.elapsed());
}
fn color_samples(h: &Headless) -> serde_json::Value {
let d = develop(h);
let m = d.masks.last().expect("a mask");
let Some(MaskShape::ColorRange { samples, .. }) = m.components.iter().map(|c| &c.shape).find(|s| matches!(s, MaskShape::ColorRange { .. }))
else {
panic!("colour range")
};
serde_json::to_value(samples).expect("samples")
}
/// Same samples, up to sub-pixel rounding and the preview level sampled.
#[track_caller]
fn assert_close(a: &serde_json::Value, b: &serde_json::Value) {
let flat = |v: &serde_json::Value| -> Vec<f64> {
v.as_array().unwrap().iter().flat_map(|s| s.as_array().unwrap().iter().map(|x| x.as_f64().unwrap())).collect()
};
let (a, b) = (flat(a), flat(b));
assert_eq!(a.len(), b.len());
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 0.05), "{a:?} vs {b:?}");
}
/// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel
/// open the loupe's clicks went to the mask overlay, so no sample was ever taken.
#[test]
fn color_range_pick_button_samples_the_photo() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "colorRange"}));
h.app.ui.tool.clear();
h.settle(SETTLE);
click(&mut h, "button:colorPick0");
assert_eq!(h.app.ui.tool, "colorRange", "Pick arms the eyedropper");
brush_at(&mut h, 0.25, 0.5);
let ui_sample = color_samples(&h);
assert_eq!(ui_sample.as_array().map(Vec::len), Some(1), "a click samples a colour");
// the sample is the colour at that normalized point
exec(&mut h, "mask.sampleColor", json!({"x": 0.25, "y": 0.5}));
assert_close(&color_samples(&h), &ui_sample);
// ⇧-click adds
let r = h.request("ui.pointer", json!({"shift": true, "events": [{"kind": "down", "x": 0.7, "y": 0.2}, {"kind": "up", "x": 0.7, "y": 0.2}]}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(color_samples(&h).as_array().map(Vec::len), Some(2), "⇧-click adds a sample");
// cropped and zoomed: the click samples the photo point under it
exec(&mut h, "crop.set", json!({"rect": [0.2, 0.1, 0.9, 0.8]}));
h.app.ui.zoom = crate::state::Zoom::Percent(200.0);
h.app.ui.pan = (0.5, 0.5);
h.settle(SETTLE);
brush_at(&mut h, 0.5, 0.5);
let ui_sample = color_samples(&h);
assert_eq!(ui_sample.as_array().map(Vec::len), Some(1));
// (the centre of the crop box, in normalized source coordinates)
let c = develop(&h).crop.geometry.rect;
exec(&mut h, "mask.sampleColor", json!({"x": (c.x0 + c.x1) / 2.0, "y": (c.y0 + c.y1) / 2.0}));
assert_close(&color_samples(&h), &ui_sample); // a cropped, zoomed pick maps to the same frame point
// Pick again ends picking
click(&mut h, "button:colorPick0");
assert!(h.app.ui.tool.is_empty());
h.settle(SETTLE);
}
+44
View File
@@ -0,0 +1,44 @@
//! Headless tests of the White Balance Selector's button.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::ViewMode;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// The White Balance Selector button in the Edit panel does what W does: from Compare it opens
/// the active photo in Detail with the selector armed (it used to arm a tool Compare ignores).
/// Clicking it again disarms it.
#[test]
fn wb_selector_button_in_compare_opens_detail_like_w() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
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], vis[1]], "active": vis[0]}}), T);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
assert_eq!(r["ok"], true, "{r}");
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
// open the Color section, where the selector sits
if !h.app.widgets.iter().any(|(w, _)| w == "icon:wbPicker") {
h.request("ui.clickWidget", json!({"id": "section:color"}), T);
h.settle(SETTLE);
}
assert_eq!(h.app.ui.view, ViewMode::Compare);
let active = h.app.session.selection.active;
let r = h.request("ui.clickWidget", json!({"id": "icon:wbPicker"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.view, ViewMode::Detail, "the button leaves Compare like W");
assert_eq!(h.app.ui.tool, "wbPicker");
assert_eq!(h.app.session.selection.active, active, "on the photo that was active in Compare");
let r = h.request("ui.clickWidget", json!({"id": "icon:wbPicker"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.tool, "", "a second click disarms it");
}
+39
View File
@@ -69,3 +69,42 @@ fn a_rotated_photo_at_100_is_its_pixel_size_swapped() {
let (w, hh) = at_100(&mut h);
assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}×{hh} for {nh}×{nw}");
}
/// Zoomed in, side by side and stacked Before / After views each stay in their pane: the margins
/// around the panes and the gap between them show the background, as at Fit. The whole-frame
/// renders used to be drawn unclipped, so the After's (magnified, blurred) covered the Before
/// pane's margins.
#[test]
fn zoomed_before_after_views_stay_in_their_panes() {
use crate::state::{BeforeAfter, Zoom};
let mut h = detail();
for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] {
let mut shots = Vec::new();
for zoom in [Zoom::Fit, Zoom::Percent(100.0)] {
h.app.ui.before_after = mode;
h.app.ui.zoom = zoom;
h.app.ui.pan = (0.3, 0.4);
h.settle(SETTLE);
let img = h.app.image_rect.expect("After view on screen");
let canvas = h.app.canvas_rect.expect("canvas");
shots.push((h.paint(), img, canvas));
}
let (zoomed, img, canvas) = &shots[1];
assert!(!canvas.contains_rect(*img), "{mode:?}: 100 % reaches past the pane ({img:?})");
// outside both panes: the canvas' edges and the gap between the panes
let c = canvas.center();
let points = [
egui::pos2(canvas.left() + 4.0, c.y),
egui::pos2(c.x, canvas.top() + 4.0),
egui::pos2(c.x, canvas.bottom() - 4.0),
match mode {
BeforeAfter::SideBySide => c,
_ => egui::pos2(canvas.left() + 200.0, c.y),
},
];
for q in points {
let px = |shot: &egui::ColorImage| shot[(q.x as usize, q.y as usize)];
assert_eq!(px(zoomed), px(&shots[0].0), "{mode:?}: background at {q:?}, zoomed in as at Fit");
}
}
}
+7 -1
View File
@@ -11,6 +11,12 @@ recommended crop, and the decoder uses it as the default crop, so the developed
framed (PowerShot SX70 HS, EOS 250D, PowerShot G5 X Mark II, EOS M6 Mark II and EOS M50 samples). A rectangle that
is missing or does not fit inside the crop is ignored. The embedded JPEG still shows the whole frame.
The EOS R5 Mark II's APS-C crop-mode files (5376 x 3574) state an active area that starts in dark columns
(132..~256 sit at the black level) and stops ~130 columns short of the image, while their recommended crop
(5088 x 3392, the camera JPEG's size) lies on the image. When the recommended crop doesn't fit inside the stated
active area, it is used as the valid area: on raw.pixls.us 7883/7884 the render matches the camera JPEG
(high-pass correlation 0.99, was -0.13 with a black band on the left). No other of the 110 CC0 CR3s changes.
The decoder is independent Rust code shared by desktop, CLI and WebAssembly;
it has no operating-system imaging dependency or external decoder. Its sources
are the public prose descriptions and patents cited in
@@ -26,7 +32,7 @@ decoded.
CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers
support lossless RAW and three-level C-RAW with horizontal tiles. Uniform
quantization values 4–43 are independently verified. Version `0x200` C-RAW
quantization values 4–43 are independently verified; 44 continues the sequence (step 102, checked against a measured step on an EOS R50 C-RAW file). Version `0x200` C-RAW
supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and
its adaptive quantization map. C-RAW is quantized, so next to clipped highlights
its reconstruction can overshoot the sensor range by a few quantization steps
+1 -1
View File
@@ -14,7 +14,7 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
- 网页版将 Noto Sans CJK SC 作为独立的 `lightcraft_zh_hans.otf` 文件随站点分发,页面启动时加载;
这样可显示简体中文,也避免把约 16 MB 字体放进受单文件大小限制的 WASM。部署时需上传整个
`target/web/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
`target/web-site/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护
+13 -13
View File
@@ -77,11 +77,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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 | ✅ | `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`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Line truncated
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
@@ -130,8 +130,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render |
| 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`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
| 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-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 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 | ✅ | `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 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends |
@@ -188,7 +188,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | |
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | |
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking |
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking |
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) |
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests |
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
@@ -246,11 +246,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|---|---|---|---|---|---|
| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) |
| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) |
| LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) |
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) |
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match |
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |
| LR-CROP-ZOOM | Zoom while cropping | P1 | ✅ | `cmd:view.zoom100`, `cmd:view.zoomIn`, `crates/ui-egui/src/panels/detail.rs` | zoom levels apply with the crop tool open (verified: 100% shows the photo at native size with the crop frame) |
| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | |
@@ -290,7 +290,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; live-preview gaps (#517) and stationary accumulation (#518) remain; 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-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; coverage builds up by passes, not hold time (holding the brush still adds nothing, #518); live-preview gaps (#517) remain; 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`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider |
@@ -506,8 +506,8 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step |
| MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | |
| MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | |
| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | |
| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted |
| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | Return copies, Esc cancels |
| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels |
| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | |
| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | |
| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection |
@@ -565,7 +565,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | |
| MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub |
| MENU-HELP-LOGFOLDER | Open log folder | P2 | ✅ | `cmd:app.openLogFolder` | an extra (the reference app has no such item): Help ▸ Open Log Folder reveals `logs/lightcraft.log` in the system file manager (Finder, Explorer, or the folder on Linux); off on the web and in `--memory` sessions, which keep no log (issue #260) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu |
| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules |
@@ -660,7 +660,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`) | top-bar navigation arrowheads render both halves at 1× and 2× scale |
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`), `crates/ui-egui/src/tests_crop_tool.rs` | top-bar navigation arrowheads render both halves at 1× and 2× scale; in the crop tool Esc closes the tool first (the crop stays), the next Esc goes back to the grid; Esc that ends an edit in a text field does nothing else |
| KEY-COMMIT | Commit tool — Return | P1 | ✅ | `cmd:tool.done` | Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) |
| 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 |
@@ -675,7 +675,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked |
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
+1 -1
View File
@@ -18,7 +18,7 @@ it's missing or a different version.
Build, then serve:
```sh
cargo xtask web # → <target>/web/{index.html, worker.js, lightcraft_web.js, lightcraft_web_bg.wasm}
cargo xtask web # → <target>/web-site/{index.html, worker.js, lightcraft_web.js, lightcraft_web_bg.wasm}
cargo xtask web --serve # build, then serve on http://127.0.0.1:8080/ (or `--serve 9000`)
cargo xtask web --dev # unoptimized build with debug info (faster to compile, slow to run)
```
+6
View File
@@ -97,6 +97,10 @@
Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit
Settings.
### Dialogs
- System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK
(issue #782).
### Synchronize Folder
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize
Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose
@@ -161,6 +165,8 @@
- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and
press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
- Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them
(issue #735).
### Formats
- HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind
+11 -1
View File
@@ -15,8 +15,18 @@ if [ "${1:-}" != "--skip-build" ]; then
(cd "$ROOT" && cargo xtask web)
fi
SITE="$CARGO_TARGET_DIR/web"
SITE="$CARGO_TARGET_DIR/web-site"
[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Guard against Cargo build artifacts leaking into the release (issue #793): cross-compiling writes
# the selected profile's *host* artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/)
# to <target>/<profile>/, so if SITE ever points at a profile directory again the zip would carry
# ~113 MB of build output. Fail loudly instead.
for d in deps build .fingerprint; do
if [ -e "$SITE/$d" ]; then
echo "error: $SITE/$d exists — $SITE holds Cargo build artifacts, not just the site (issue #793)" >&2
exit 1
fi
done
# Paths must be relative so the site works under any prefix.
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
+1 -1
View File
@@ -31,7 +31,7 @@ commands:
Lightroom parity summary; --write refreshes the summary table in the document
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app)
web [--serve [port]] [--dev]
build the browser app (apps/lightcraft-web) into <target>/web/;
build the browser app (apps/lightcraft-web) into <target>/web-site/;
--serve serves it on http://127.0.0.1:<port> (default 8080)
ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure)
corpus [--download]
+25 -3
View File
@@ -1,5 +1,5 @@
//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen`
//! into `<target>/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm),
//! into `<target>/web-site/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm),
//! with gzip and brotli precompressed copies (`*.gz`, `*.br`) next to each file.
//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only) that sends
//! the cross-origin isolation headers (COOP/COEP) and the precompressed files when the browser
@@ -13,6 +13,13 @@ use std::process::Command;
use crate::{cargo, metadata, root, run as step};
const TARGET: &str = "wasm32-unknown-unknown";
/// Cargo profile for the optimized (`--dev` off) browser build; see `[profile.web]` in `Cargo.toml`.
const WEB_PROFILE: &str = "web";
/// Directory under `<target>` that receives the bundle. It must **not** be a Cargo profile name:
/// cross-compiling with `--target wasm32-unknown-unknown` makes Cargo write the selected profile's
/// *host* artifacts (build scripts, proc-macros, `deps/`, `build/`, `.fingerprint/`) to
/// `<target>/<profile>/`, and `packaging/web/package.sh` zips the whole bundle directory (issue #793).
const SITE_DIR: &str = "web-site";
const CHINESE_FONT_FILE: &str = "lightcraft_zh_hans.otf";
const CHINESE_FONT_LICENSE_FILE: &str = "OFL-noto-sans-cjk-sc.txt";
@@ -100,14 +107,14 @@ pub fn run(args: &[&str]) -> Result<(), String> {
check_bindgen(&want)?;
let meta = metadata()?;
let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?);
let profile = if dev { "dev" } else { "web" };
let profile = if dev { "dev" } else { WEB_PROFILE };
let mut c = cargo();
c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]);
step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?;
let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm");
let out = target_dir.join("web");
let out = target_dir.join(SITE_DIR);
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
let mut b = Command::new("wasm-bindgen");
b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out);
@@ -287,6 +294,21 @@ mod tests {
assert!(v.starts_with("0.2."), "{v}");
}
#[test]
fn site_dir_is_not_a_cargo_profile() {
// Cross-compiling writes the selected profile's *host* artifacts (build scripts,
// proc-macros, `deps/`, `build/`, `.fingerprint/`) to `<target>/<profile>/`, so the bundle
// must not live in a profile directory or `packaging/web/package.sh` would zip them
// (issue #793).
let manifest = std::fs::read_to_string(root().join("Cargo.toml")).unwrap();
let is_profile = |name: &str| {
let header = format!("[profile.{name}]");
manifest.lines().any(|l| l.trim() == header)
};
assert!(is_profile(WEB_PROFILE), "`{WEB_PROFILE}` is no longer a profile in Cargo.toml");
assert!(!is_profile(SITE_DIR), "`{SITE_DIR}` is a Cargo profile name; pick a different bundle directory");
}
#[test]
fn wasm_mime() {
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");