From 994c820becbc2006b89aa52c86310449e352bcee Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:23:57 +1100 Subject: [PATCH 1/5] Include HEIC, HEIF, and NRW when a folder is imported. --- crates/mcp/src/headless.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..10a512faa 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -13,7 +13,7 @@ use crate::backend::{Backend, ProgressHook}; /// File extensions recognised as photos when expanding folders. pub const PHOTO_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", - "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "gif", "bmp", "avif", "heic", "heif", "nrw", // avif is preview-only; heic/heif/nrw match the engine list (#1080) "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", ]; @@ -193,6 +193,29 @@ pub fn expand_paths(paths: &[String]) -> Vec { out } +#[cfg(test)] +mod expand_tests { + use super::expand_paths; + + #[test] + fn a_folder_import_includes_heic_heif_and_nrw() { + let dir = std::env::temp_dir().join(format!("lightcraft-ext-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["a.heic", "b.HEIF", "c.nrw", "d.png", "note.txt"] { + std::fs::write(dir.join(name), b"x").unwrap(); + } + let got = expand_paths(&[dir.to_string_lossy().to_string()]); + let names: Vec = got.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_lowercase()).collect(); + assert!(names.contains(&"a.heic".into()), "{names:?}"); + assert!(names.contains(&"b.heif".into()), "{names:?}"); + assert!(names.contains(&"c.nrw".into()), "{names:?}"); + assert!(names.contains(&"d.png".into()), "{names:?}"); + assert!(!names.iter().any(|n| n == "note.txt"), "{names:?}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + /// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless), /// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile). pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result, String> { From fbfa87d6141b9752f5f7c525a22721583bf46e8c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:24:02 +1100 Subject: [PATCH 2/5] Treat a numeric --connect value as a loopback port. --- apps/lightcraft-cli/src/main.rs | 55 +++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 63a4deb76..e9d8575f6 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -303,16 +303,16 @@ fn mcp(args: &[String]) -> Result<(), String> { while i < args.len() { match args[i].as_str() { "--connect" => { - // Optional address argument. - match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { - Some(a) => { - connect = Some(a.clone()); - i += 1; - } - None => connect = Some(DEFAULT_ADDR.to_string()), + let (addr, took) = connect_value(args.get(i + 1).map(String::as_str)); + connect = Some(addr); + if took { + i += 1; } } - a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + a if a.starts_with("--connect=") => { + let (addr, _) = connect_value(Some(&a["--connect=".len()..])); + connect = Some(addr); + } "--headless" => connect = None, "--demo" => demo = true, "--compact" => compact = true, @@ -525,14 +525,17 @@ fn run(args: &[String]) -> Result<(), String> { i += 1; break; } - "--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) { - Some(a) => { - connect = Some(a.clone()); + "--connect" => { + let (addr, took) = connect_value(args.get(i + 1).map(String::as_str)); + connect = Some(addr); + if took { i += 1; } - None => connect = Some(DEFAULT_ADDR.to_string()), }, - a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + a if a.starts_with("--connect=") => { + let (addr, _) = connect_value(Some(&a["--connect=".len()..])); + connect = Some(addr); + } "--demo" => demo = true, "--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()), "--import" => imports.push(take_value(args, &mut i, "--import")?.to_string()), @@ -627,6 +630,18 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s /// The port an `--connect` address names, for the recovery hint. The whole address when it has /// no port, so a hint is never built from a guess. +/// `--connect` takes an address, or a bare port (`7980` → `127.0.0.1:7980`). A following +/// command id is left alone (#1178). +fn connect_value(next: Option<&str>) -> (String, bool) { + match next { + Some(a) if !a.starts_with('-') && a.contains(':') && !a.contains('=') => (a.to_string(), true), + Some(a) if !a.is_empty() && a.bytes().all(|c| c.is_ascii_digit()) && a.parse::().ok().is_some_and(|p| p > 0) => { + (format!("127.0.0.1:{a}"), true) + } + _ => (DEFAULT_ADDR.to_string(), false), + } +} + fn connect_port(addr: &str) -> &str { addr.rsplit_once(':').map_or(addr, |(_, port)| port) } @@ -965,6 +980,20 @@ fn denoise_serve(args: &[String]) -> Result<(), String> { serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}"))) } +#[cfg(test)] +mod connect_tests { + use super::connect_value; + + #[test] + fn a_bare_port_is_loopback_and_a_command_is_not_consumed() { + assert_eq!(connect_value(Some("7980")), ("127.0.0.1:7980".into(), true)); + assert_eq!(connect_value(Some("127.0.0.1:7980")), ("127.0.0.1:7980".into(), true)); + assert_eq!(connect_value(Some("photo.rate")), ("127.0.0.1:7980".into(), false)); + assert_eq!(connect_value(Some("0")), ("127.0.0.1:7980".into(), false)); + assert_eq!(connect_value(None), ("127.0.0.1:7980".into(), false)); + } +} + #[cfg(not(feature = "denoise"))] fn denoise_serve(_: &[String]) -> Result<(), String> { Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into()) From 7b6b25e6ab2b6739dcd4fc1eaa9cf69bd3e899bd Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:24:15 +1100 Subject: [PATCH 3/5] Composite straight alpha onto white before develop. --- crates/codecs/src/lib.rs | 56 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..1ef6f7908 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -245,11 +245,25 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R /// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space. pub fn to_working(d: &Decoded) -> Rgb32f { - if d.space.is_working() { - return d.image.clone(); + let mut image = if d.space.is_working() { + d.image.clone() + } else { + let m = d.space.to_working().to_f32(); + d.image.map(|p| mat_apply(&m, p)) + }; + // Straight alpha onto white. JPEG and contact sheets have no alpha, and a transparent + // pixel is usually stored as black (#1212). + if let Some(alpha) = &d.alpha { + let w = image.width; + lightcraft_raster::par_rows(&mut image.data, w, |y, row| { + let a = alpha.row(y); + for (p, &alpha) in row.iter_mut().zip(a) { + let a = alpha.clamp(0.0, 1.0); + *p = p.map(|c| c * a + (1.0 - a)); + } + }); } - let m = d.space.to_working().to_f32(); - d.image.map(|p| mat_apply(&m, p)) + image } /// Where a thumbnail came from. @@ -323,3 +337,37 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Date: Tue, 13 Oct 2026 01:24:35 +1100 Subject: [PATCH 4/5] Let the deep-zoom preview margin cover wide local filters. --- crates/ui-egui/src/region.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..d8daaaf93 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -11,7 +11,9 @@ use lightcraft_engine::pipeline::PixelWindow; /// Windows start and end on multiples of this many pixels of the zoomed frame. pub const SNAP: usize = 256; /// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]). -pub const MAX_MARGIN: usize = 768; +/// 4.5 % of an 11:1 view of a 24 MP frame, snapped to [`SNAP`]. The old 768 px cap left Dehaze +/// tens of codes off the full-frame render from 4:1 up (#1146). +pub const MAX_MARGIN: usize = 3072; /// The most a window may span on either axis: a bound on its texture whatever the window size. pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 }; @@ -484,7 +486,8 @@ mod tests { fn the_margin_grows_with_the_zoom_up_to_a_cap() { assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); assert!(margin_for(8000) > margin_for(3000)); - assert_eq!(margin_for(48_000), MAX_MARGIN); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); assert_eq!(margin_for(0), SNAP); assert!(margin_for(usize::MAX) <= MAX_MARGIN); for l in [100, 3000, 8000, 48_000, 400_000] { From 2f0931d3cafe378a6255586c1671fcc57f84a27b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 00:13:49 -0400 Subject: [PATCH 5/5] Batch 32: rustfmt after merges Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index e9d8575f6..87c7f175d 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -531,7 +531,7 @@ fn run(args: &[String]) -> Result<(), String> { if took { i += 1; } - }, + } a if a.starts_with("--connect=") => { let (addr, _) = connect_value(Some(&a["--connect=".len()..])); connect = Some(addr);