Merge slideshow-titles into slideshow-templates (origin/main: integration batch 26, #975; no conflicts)

This commit is contained in:
Jason Baldwin
2026-10-11 15:05:04 +02:00
51 changed files with 1935 additions and 128 deletions
+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
}
@@ -1106,6 +1108,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
@@ -3362,6 +3362,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
@@ -94,10 +94,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)]) {
@@ -148,10 +154,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;
@@ -92,6 +104,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;
@@ -236,6 +250,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).
@@ -348,6 +365,7 @@ impl LightcraftApp {
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
text_focus: false,
headless_host: false,
notices: vec![],
quit_prompt: None,
@@ -433,6 +451,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 {
@@ -887,7 +920,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}));
}
@@ -1004,6 +1037,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);
@@ -1043,6 +1077,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);
}
}
@@ -1110,8 +1145,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"),
@@ -396,6 +397,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;
@@ -412,6 +417,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) {
@@ -1625,7 +1633,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,
"slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(),
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");
@@ -1094,3 +1120,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日 星期日`)。
## 翻译的维护
File diff suppressed because one or more lines are too long
+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
@@ -165,6 +165,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
@@ -229,6 +233,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");