Merge track r2-t05 into next

This commit is contained in:
aiongg
2026-10-22 02:41:40 +00:00
35 changed files with 1272 additions and 102 deletions
+54
View File
@@ -0,0 +1,54 @@
# Security Policy
LightCraft reads camera raw files, image formats, metadata and sidecars, imports other photo catalogs, downloads and runs machine-learning models, and exposes CLI, JSON control, and MCP automation. Treat files, paths, metadata, catalogs, model files, automation requests, and command parameters as potentially malicious.
## Reporting a vulnerability
Please give the repository maintainers a reasonable opportunity to investigate and coordinate a fix before publishing exploitable details, proof-of-concept code, or weaponized files.
This repository does not currently document a dedicated private security email or private reporting channel. Contact the repository maintainers through the repository owner/maintainer channels already listed in the project, provide only a minimal non-exploitable summary in public, and ask for an appropriate private transfer method before sharing sensitive details. Do not open a public issue containing a working exploit, secret, sensitive crash dump, or malicious attachment.
If no private channel can be established, withhold weaponized material and report the smallest safe description that allows maintainers to make contact.
## What to include
A useful report includes:
- affected LightCraft version, commit, or release artifact;
- operating system, architecture, build profile (desktop or web), and relevant feature flags;
- the affected component or path;
- security impact and required attacker access;
- exact reproduction steps;
- expected behavior and actual behavior;
- crash logs, backtraces, sanitizer output, or resource measurements when applicable;
- whether the problem reproduces from a clean checkout;
- any known workaround or containment.
Remove credentials, personal paths, private photos, and unrelated system information from logs.
## Malicious test files
Do not attach a potentially harmful or non-redistributable file to a public issue. Initially provide its format, size, cryptographic hash, observed effect, provenance category (for example, synthetic or fuzz-generated), and the command needed to reproduce the problem. Coordinate a private transfer method with maintainers before sending the sample.
Test files accepted into the repository must be minimized, free of personal or proprietary data, legally redistributable, and documented with the expected failure behavior. A fixed parser defect should receive a permanent regression test whenever safe and practical.
## Scope
Security reports may cover:
- camera raw, DNG, TIFF, HEIF, JPEG and other image decoding, and EXIF/XMP metadata and sidecar parsing;
- decompression bombs, oversized dimensions, integer overflow, memory exhaustion, recursion, or hangs;
- the LightCraft catalog, and importing other catalogs and libraries (such as Lightroom catalogs);
- import/export and filesystem path handling, including traversal, symlinks, and unsafe overwrite or move behavior;
- downloading, verifying, unpacking and running machine-learning models (denoise, faces, masks);
- engine command validation and serialization/deserialization;
- the desktop control channel, the MCP server, and the MCP-to-app connection;
- the WebAssembly build;
- GPU resource validation or driver-facing input handling;
- dependencies, CI, packaging, signing, checksums, and release artifact integrity.
General bugs without a security impact should use the project's normal issue and contribution process.
## Current limitations
The control channel (`lightcraft --control PORT`) listens on loopback only and has no authentication, so any local process can drive the app while it is enabled; only enable it when you need it ([control-protocol.md](docs/control-protocol.md)).
+159 -17
View File
@@ -39,22 +39,33 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
for stream in listener.incoming().flatten() {
let _ = spawn_connection(stream, tx.clone(), ctx.clone(), Arc::clone(&open), IDLE_TIMEOUT);
}
}
/// Admit and serve one connection, releasing its slot when serving ends.
fn spawn_connection(
mut stream: TcpStream,
tx: Sender<ControlRequest>,
ctx: egui::Context,
open: Arc<AtomicUsize>,
idle_timeout: Duration,
) -> Option<std::thread::JoinHandle<()>> {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
return None;
}
Some(std::thread::spawn(move || {
serve(stream, &tx, &ctx, idle_timeout);
open.fetch_sub(1, Ordering::SeqCst);
}))
}
/// Five minutes allows pauses between automation commands without retaining abandoned slots forever.
const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// How long a request waits for its reply.
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
/// A request the app has not picked up while it drew no frame for this long is given up: the
@@ -66,9 +77,17 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
/// Reads and writes go through the one `stream` (`&TcpStream` is `Read` and `Write`): no
/// `try_clone`. On Windows a cloned socket handle is inheritable, so every program the app starts
/// while a client is connected (a link in the browser, Show in Explorer, an external editor) kept a
/// copy of the connection: closing it then reached the client only when that program exited, as a
/// reset instead of a clean close.
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, idle_timeout: Duration) {
if let Err(e) = stream.set_read_timeout(Some(idle_timeout)) {
log::warn!("control connection closed: could not set idle read timeout: {e}");
return;
}
serve_lines(&stream, &stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
@@ -157,7 +176,10 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "control connection idle read timed out; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
@@ -209,6 +231,95 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
/// Own the client and worker together: even a failing timeout assertion closes the socket
/// before joining, so the regression cannot leave a worker stuck in a read.
struct Connection {
client: BufReader<TcpStream>,
requests: Receiver<ControlRequest>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl Connection {
fn new(open: &Arc<AtomicUsize>, idle: Duration) -> Self {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let (stream, _) = listener.accept().unwrap();
let (tx, requests) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap();
Self { client: BufReader::new(client), requests, worker: Some(worker) }
}
fn join(&mut self) {
if let Some(worker) = self.worker.take() {
worker.join().unwrap();
}
}
}
impl Drop for Connection {
fn drop(&mut self) {
let _ = self.client.get_ref().shutdown(std::net::Shutdown::Both);
while let Ok(req) = self.requests.try_recv() {
let _ = req.reply.send(json!({"ok": false, "error": "test closing"}));
}
self.join();
}
}
#[test]
fn idle_connections_release_their_slots_without_dispatching() {
for input in ["", "{\"method\":\"engine.execute\""] {
let open = Arc::new(AtomicUsize::new(0));
let mut connection = Connection::new(&open, Duration::from_millis(100));
assert_eq!(open.load(Ordering::SeqCst), 1);
connection.client.get_mut().write_all(input.as_bytes()).unwrap();
let mut reply = String::new();
let closed = connection.client.read_to_string(&mut reply);
assert!(closed.is_ok(), "idle connection stayed open: {closed:?}");
connection.join();
assert_eq!(open.load(Ordering::SeqCst), 0, "the production worker releases its slot");
assert!(connection.requests.try_recv().is_err(), "incomplete input never runs");
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(reply["ok"], false);
assert!(reply["error"].as_str().unwrap().contains("idle read timed out"));
// A subsequent connection is admitted through the same accounting path.
let mut next = Connection::new(&open, Duration::from_secs(1));
next.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
let mut eof = String::new();
next.client.read_to_string(&mut eof).unwrap();
next.join();
assert!(eof.is_empty());
assert_eq!(open.load(Ordering::SeqCst), 0);
}
}
#[test]
fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() {
let open = Arc::new(AtomicUsize::new(0));
let mut connection = Connection::new(&open, Duration::from_millis(100));
for id in [1, 2] {
writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap();
let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap();
assert_eq!(req.method, "engine.commands");
assert!(req.take());
// A dispatched command waits for its reply, not for socket input.
std::thread::sleep(Duration::from_millis(250));
req.reply.send(json!({"ok": true, "result": id})).unwrap();
let mut reply = String::new();
connection.client.read_line(&mut reply).unwrap();
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(reply, json!({"id": id, "ok": true, "result": id}));
}
connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
let mut eof = String::new();
connection.client.read_to_string(&mut eof).unwrap();
connection.join();
assert!(eof.is_empty());
assert_eq!(open.load(Ordering::SeqCst), 0);
}
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
/// up with a reason, quickly, and the app then skips it instead of running it late.
#[test]
@@ -337,6 +448,37 @@ mod tests {
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
/// A program the app starts while a client is connected doesn't keep the connection open:
/// when the server closes it, the client sees that at once, not when the program exits.
#[cfg(windows)]
#[test]
fn a_program_started_meanwhile_does_not_hold_the_connection_open() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), IDLE_TIMEOUT);
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
// the connection is being served: start a program that lives about 4 s
std::thread::sleep(Duration::from_millis(200));
let mut child = std::process::Command::new("cmd").args(["/c", "ping -n 5 127.0.0.1 >nul"]).spawn().unwrap();
std::thread::sleep(Duration::from_millis(200));
// junk makes the server answer once and close
c.write_all(b"hello\n").unwrap();
let t0 = std::time::Instant::now();
let mut text = String::new();
let read = c.read_to_string(&mut text);
let waited = t0.elapsed();
server.join().unwrap();
let _ = child.wait();
assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}");
assert!(text.contains("closing the connection"), "{text}");
assert!(waited < Duration::from_secs(2), "the client saw the close only after {waited:?}, when the program exited");
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
+3
View File
@@ -61,3 +61,6 @@ sync_github_name = true
[people.mahmatias]
display_name = "Márcio Matias de Andrade"
sync_github_name = true
[people.ronibalthazar]
sync_github_name = true
+15 -1
View File
@@ -114,6 +114,16 @@ fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -
forgotten
}
impl Session {
/// Record that the folder `root` was browsed now, with the folders of `files` (the photos it showed): what
/// `library.browse` does after reading a folder, for a UI that reads it on a worker thread instead. Untouched
/// Local photos are forgotten only from folders not browsed for the set number of days, so a browse that
/// isn't recorded lets a folder in daily use be forgotten.
pub fn note_browsed(&mut self, root: &str, files: &[String]) {
stamp_browsed(self, root, files);
}
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
/// growing the log; far finer than the days the eviction counts in).
const RESTAMP_SECS: i64 = 3600;
@@ -148,7 +158,11 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep = held(s);
keep.extend(s.visible().iter().copied());
// Missing Photos lists library photos only, never Local records, so it keeps nothing here; and
// working it out checks every original (minutes on a sleeping NAS when the library opens on it)
if s.source != crate::LibrarySource::Missing {
keep.extend(s.visible().iter().copied());
}
keep
}
+14 -1
View File
@@ -1709,12 +1709,21 @@ fn has_library(s: &Session) -> std::result::Result<(), String> {
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
// typographic minus signs and dashes mean a negative value, like ASCII '-'
let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-");
let t = t.trim();
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
let (a, b) = match t.split_once(',') {
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
None => {
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
let lead = t.starts_with(['N', 'S', 'n', 's']);
let i = if lead {
// hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter
t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i)
} else {
t.find(['N', 'S', 'n', 's']).map(|i| i + 1)
}
.or_else(|| t.find(char::is_whitespace))?;
(t[..i].trim().to_string(), t[i..].trim().to_string())
}
};
@@ -1746,6 +1755,10 @@ mod gps_tests {
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1)));
assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276)));
assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093)));
assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093)));
assert_eq!(p("95, 10"), None);
assert_eq!(p("hello"), None);
}
+4 -3
View File
@@ -32,10 +32,11 @@ use serde::{Deserialize, Serialize};
use crate::Session;
use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
/// File extensions offered by import discovery and file pickers (lower case).
/// This includes preview-only raws; the file probe decides whether sensor decoding is available.
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
"jxl", "gif", "bmp",
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef",
"srw", "psd", "jxl", "gif", "bmp",
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
// failed with the reason ("HEIC/HEIF support isn't included in this build").
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
+3
View File
@@ -558,7 +558,10 @@ impl Session {
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some());
}
// not on Missing Photos: working that view out checks every original (minutes on a
// sleeping NAS), and the app checks them in the background once it runs
if self.selection.active.is_none()
&& self.source != LibrarySource::Missing
&& let Some(first) = self.visible_cloned().first()
{
self.selection = Selection::single(*first);
+4 -2
View File
@@ -404,10 +404,12 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> {
}
impl Session {
/// Plan a merge of `ids` (catalog order of the given ids is kept).
/// Plan a merge of `ids` (catalog order of the given ids is kept). A photo named more than once is merged once
/// (issue #1947): it adds nothing to the merge, and each copy would be decoded and held in memory.
pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result<MergeJob> {
let mut sources = Vec::new();
for id in ids {
let mut seen = std::collections::HashSet::new();
for id in ids.iter().filter(|id| seen.insert(**id)) {
let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?;
match &p.source {
Source::File { path } => sources.push((*id, path.clone())),
+55
View File
@@ -259,3 +259,58 @@ fn browsing_again_forgets_untouched_records_of_deleted_files() {
assert_eq!(s.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&root);
}
/// Opens `lib` with a probe that counts the originals it checks.
fn open_counting(lib: &Path, clock: &Arc<Mutex<String>>) -> (Session, Arc<std::sync::atomic::AtomicUsize>) {
let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut s = Session::new().with_fs();
let c = clock.clone();
s.clock = Box::new(move || c.lock().unwrap().clone());
let n = checks.clone();
s.media.availability.set_probe(Arc::new(move |p| {
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Path::new(p).exists()
}));
s.open_library(lib, false).unwrap();
(s, checks)
}
/// A library last shown on Missing Photos opens without checking its originals: that view holds
/// no Local records, so forgetting them needn't work it out, and no photo is picked from it (each
/// check can wait for a sleeping NAS; a big library on one froze the window for minutes).
#[test]
fn opening_on_missing_photos_checks_no_originals() {
let root = temp_dir("missing");
let (a, lib) = (root.join("a"), root.join("lib"));
for i in 0..5 {
png(&a.join(format!("m{i}.png")), i);
}
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
let mut s = open(&lib, &clock);
s.execute("library.import", &json!({"paths": [a.to_string_lossy()], "mode": "add"})).unwrap();
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
// every original is there: the view is empty and nothing is selected
assert!(s.visible().is_empty());
assert!(s.selection.active.is_none());
s.close_library().unwrap();
drop(s);
let (s, checks) = open_counting(&lib, &clock);
assert_eq!(s.source, LibrarySource::Missing, "the view is restored");
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening an empty Missing view checked originals");
assert!(s.library.as_ref().unwrap().forgot_local.is_some(), "forgetting still ran");
drop(s);
// the originals go: the view lists them, one is active
std::fs::remove_dir_all(&a).unwrap();
let mut s = open(&lib, &clock);
s.execute("library.source", &json!({"kind": "all"})).unwrap();
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
assert_eq!(s.visible().len(), 5, "all five are missing");
assert!(s.selection.active.is_some());
s.close_library().unwrap();
drop(s);
let (s, checks) = open_counting(&lib, &clock);
assert_eq!(s.source, LibrarySource::Missing);
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening a full Missing view checked originals");
let _ = std::fs::remove_dir_all(&root);
}
+19
View File
@@ -30,6 +30,25 @@ fn ids(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
#[test]
fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() {
let src = temp_dir("sony-discovery");
std::fs::create_dir_all(src.join("trip/day2")).unwrap();
let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"];
let mut expected: Vec<String> = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect();
for path in &expected {
std::fs::write(path, b"discovery only, not a decoded photo").unwrap();
}
std::fs::write(src.join("trip/notes.txt"), b"not a photo").unwrap();
let mut found = crate::import::expand(&[src.to_string_lossy().into_owned()], None);
found.sort();
expected.sort();
assert_eq!(found, expected);
assert_eq!(crate::import::expand(&expected, None), expected, "folder and direct discovery agree");
std::fs::remove_dir_all(src).unwrap();
}
#[test]
fn recursive_import_with_duplicates() {
let src = temp_dir("src");
+24
View File
@@ -226,3 +226,27 @@ fn panorama_size_is_held_to_memory() {
assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits");
assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB");
}
/// A photo named twice is merged once (issue #1947): `[a, a, a]` is one photo, too few to merge, and `[a, b, a, b]`
/// merges two frames rather than holding four in memory.
#[test]
fn a_photo_named_twice_is_merged_once() {
let dir = temp_dir("dup-ids");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(96, 64, &[-1.0, 1.0]).unwrap().into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let (a, b) = (ids[0], ids[1]);
for cmd in ["merge.hdr", "merge.panorama"] {
let e = s.execute(cmd, &json!({"ids": [a, a, a], "preview": true})).unwrap_err();
assert!(e.to_string().contains("at least 2"), "{cmd}: {e}");
}
let (kind, finish) = crate::merge::parse("merge.hdr", &json!({})).unwrap();
let job = s.plan_merge(kind, finish, &[a, b, a, b, a].map(lightcraft_catalog::PhotoId), true).unwrap();
assert_eq!(job.sources.iter().map(|(p, _)| p.0).collect::<Vec<_>>(), vec![a, b]);
let _ = std::fs::remove_dir_all(&dir);
}
+83 -3
View File
@@ -58,6 +58,43 @@ pub struct Url {
pub path: String,
}
/// Remove `.` and `..` segments from the path part of `path` (RFC 3986 section 5.2.4); the query is kept as is.
fn remove_dot_segments(path: &str) -> String {
let (p, query) = match path.find('?') {
Some(i) => (path.get(..i).unwrap_or_default(), path.get(i..).unwrap_or_default()),
None => (path, ""),
};
let mut out: Vec<&str> = Vec::new();
let mut segments = p.split('/').skip(1).peekable();
let mut trailing_slash = false;
while let Some(seg) = segments.next() {
let last = segments.peek().is_none();
match seg {
"." => trailing_slash = last,
".." => {
out.pop();
trailing_slash = last;
}
_ => {
out.push(seg);
trailing_slash = false;
}
}
}
let mut result = format!("/{}", out.join("/"));
if trailing_slash && !result.ends_with('/') {
result.push('/');
}
result.push_str(query);
result
}
/// `s` without the leading `prefix`, matched ASCII case-insensitively (RFC 3986 §3.1).
fn strip_scheme<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..).unwrap_or_default())
}
impl Url {
pub fn parse(s: &str) -> Result<Url, HttpError> {
let bad = |why: &str| HttpError::BadUrl(why.to_string());
@@ -68,9 +105,9 @@ impl Url {
if s.chars().any(|c| c.is_control() || c == ' ') {
return Err(bad("contains spaces or control characters"));
}
let (tls, rest) = if let Some(r) = s.strip_prefix("https://") {
let (tls, rest) = if let Some(r) = strip_scheme(s, "https://") {
(true, r)
} else if let Some(r) = s.strip_prefix("http://") {
} else if let Some(r) = strip_scheme(s, "http://") {
(false, r)
} else {
return Err(bad("only http:// and https:// are supported"));
@@ -81,6 +118,7 @@ impl Url {
None => (rest, ""),
};
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
let path = remove_dot_segments(&path);
if authority.contains('@') {
return Err(bad("user names and passwords in URLs are not supported"));
}
@@ -112,7 +150,7 @@ impl Url {
/// `Location` of a redirect, resolved against this URL.
pub fn join(&self, location: &str) -> Result<Url, HttpError> {
let l = location.trim();
if l.starts_with("http://") || l.starts_with("https://") {
if strip_scheme(l, "http://").is_some() || strip_scheme(l, "https://").is_some() {
let url = Url::parse(l)?;
if self.tls && !url.tls {
return Err(HttpError::BadUrl("redirect from https to http refused".into()));
@@ -135,8 +173,16 @@ impl Url {
if l.starts_with('/') {
return Url::parse(&format!("{base}{l}"));
}
// empty or fragment-only: the same document
if l.is_empty() || l.starts_with('#') {
return Ok(self.clone());
}
// relative to the current directory
let dir = self.path.split('?').next().unwrap_or("/");
// a query-only reference keeps the whole path, file name included (RFC 3986 §5.2.2)
if l.starts_with('?') {
return Url::parse(&format!("{base}{dir}{l}"));
}
let dir = dir.rsplit_once('/').map_or("", |(d, _)| d);
Url::parse(&format!("{base}{dir}/{l}"))
}
@@ -612,12 +658,46 @@ mod tests_connect {
mod tests {
use super::*;
#[test]
fn fragment_only_or_empty_location_keeps_file_name_and_query() {
let base = Url::parse("https://a.example/x/y/file?q").unwrap();
for l in ["#top", "", " "] {
let u = base.join(l).unwrap();
assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/x/y/file?q"), "{l:?}");
}
}
#[test]
fn rejects_oversized_and_control_character_urls() {
assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err());
assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err());
}
#[test]
fn join_query_only_keeps_file_name() {
let u = Url::parse("https://a.example/x/y/file?q=1").unwrap();
assert_eq!(u.join("?page=2").unwrap().path, "/x/y/file?page=2");
}
#[test]
fn join_removes_dot_segments() {
let base = Url::parse("https://a.example/x/y/file").unwrap();
for (loc, want) in
[("../z", "/x/z"), ("./z", "/x/y/z"), ("../../../z", "/z"), ("/a/./b/../c", "/a/c"), ("../z?q=1", "/x/z?q=1"), ("..", "/x/")]
{
assert_eq!(base.join(loc).unwrap().path, want, "{loc}");
}
}
#[test]
fn url_scheme_is_matched_case_insensitively() {
let u = Url::parse("HTTPS://A.example/z").unwrap();
assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/z"));
assert!(Url::parse("Http://a.example/").is_ok());
let j = Url::parse("https://a.example/start").unwrap().join("HTTPS://cdn.example/file").unwrap();
assert_eq!((j.tls, j.host.as_str(), j.path.as_str()), (true, "cdn.example", "/file"));
}
#[test]
fn parses_urls() {
let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap();
+47
View File
@@ -380,6 +380,53 @@ mod tests {
assert_eq!(seen.load(Ordering::SeqCst), 2);
}
#[test]
fn idle_close_reconnects_without_replaying_a_command() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (closed, expired) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let mut ids = Vec::new();
for _ in 0..2 {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("accept: {e}"),
}
};
stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
ids.push(request["id"].as_u64().unwrap());
writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": ids.len()})).unwrap();
writer.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
line.clear();
let error = reader.read_line(&mut line).unwrap_err();
assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(line.is_empty(), "no command is resent while idle");
writeln!(writer, "{}", json!({"ok": false, "error": "control connection idle read timed out; closing the connection"})).unwrap();
drop(reader);
drop(writer);
closed.send(()).unwrap();
}
ids
});
let mut remote = Remote::lazy(&addr);
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(1));
expired.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(2));
expired.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(server.join().unwrap(), [1, 2], "each command ran once on a separate connection");
}
#[test]
fn unreachable_app_is_reported() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+22
View File
@@ -203,6 +203,28 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String>
mod tests {
use super::*;
#[test]
fn folder_discovery_matches_engine_extensions() {
let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let nested = dir.join("trip/day2");
std::fs::create_dir_all(&nested).unwrap();
let mut expected = Vec::new();
for ext in lightcraft_engine::import::EXTENSIONS {
for (folder, name) in [(&dir, format!("lower.{ext}")), (&nested, format!("upper.{}", ext.to_ascii_uppercase()))] {
let path = folder.join(name);
std::fs::write(&path, b"discovery only, not a decoded photo").unwrap();
expected.push(path.to_string_lossy().into_owned());
}
}
std::fs::write(nested.join("notes.txt"), b"not a photo").unwrap();
let mut found = expand_paths(&[dir.to_string_lossy().into_owned()]);
expected.sort();
found.sort();
assert_eq!(found, expected);
std::fs::remove_dir_all(dir).unwrap();
}
/// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619).
#[test]
fn headless_sessions_get_the_sam3_model_folder() {
+43 -19
View File
@@ -13,13 +13,49 @@ use crate::ransac::{self, Model};
/// Long edge of the images features are detected on.
pub const WORK_EDGE: usize = 1200;
/// The luminance merges align and weigh by (camera or linear RGB; not a colorimetric one).
#[inline]
pub(crate) fn luma(c: [f32; 3]) -> f32 {
0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]
}
/// The motion models a fit chooses among, simplest first: (model, name, fewest inliers).
pub type Models = [(Model, &'static str, usize)];
/// Of `models` fitted by RANSAC to `a` → `b` (`thresh` px; at least `share` of the points and each
/// model's minimum as inliers; plausible for a `w × h` frame), the simplest that explains (almost) as
/// many points as the one explaining the most: extra degrees of freedom fitted to clustered points
/// extrapolate badly.
pub(crate) fn choose_model(
models: &Models,
a: &[Point],
b: &[Point],
thresh: f64,
share: f64,
(w, h): (f64, f64),
seed: u64,
) -> Option<(&'static str, ransac::Fit)> {
let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new();
for &(model, name, min) in models {
let need = ((a.len() as f64 * share).ceil() as usize).max(min);
if let Some(f) = ransac::ransac(model, a, b, thresh, need, seed)
&& plausible(&f.h, w, h, 0.12)
{
fits.push((name, f));
}
}
let most = fits.iter().map(|(_, f)| f.inliers.len()).max()?;
// the fit with `most` inliers always qualifies
fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97)
}
/// A grey, perceptually encoded work image: `(luminance · gain)^(1/2.2)`, clamped to 0..1, and
/// the scale from full-resolution pixels to work pixels.
pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) {
let small = if img.width.max(img.height) > max_edge { fit(img, max_edge, max_edge, Filter::Box) } else { img.clone() };
let scale = small.width as f64 / img.width as f64;
let p = small.map(|c| {
let y = (0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]) * gain;
let y = luma(c) * gain;
y.clamp(0.0, 1.0).powf(1.0 / 2.2)
});
(p, scale)
@@ -29,8 +65,7 @@ pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) {
/// without EXIF.
pub fn median_luma(img: &Rgb32f, clip: f32) -> f32 {
let step = (img.data.len() / 100_000).max(1);
let mut v: Vec<f32> =
img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]).collect();
let mut v: Vec<f32> = img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| luma(*c)).collect();
if v.is_empty() {
return clip;
}
@@ -86,24 +121,13 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
let a: Vec<Point> = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect();
let b: Vec<Point> = m.iter().map(|(_, j)| Point::new(feat.points[*j].x as f64, feat.points[*j].y as f64)).collect();
let (ww, wh) = (full_w as f64 * scale, full_h as f64 * scale);
// Fit every model; keep the simplest one that explains (almost) as many matches as the most
// general one — extra degrees of freedom fitted to clustered points extrapolate badly.
let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new();
for (model, name, min) in [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)] {
if let Some(f) = ransac::ransac(model, &a, &b, 1.5, min, seed)
&& plausible(&f.h, ww, wh, 0.12)
{
fits.push((name, f));
}
}
let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() };
// the fit with `most` inliers always qualifies
let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() };
let models = [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)];
let Some((name, f)) = choose_model(&models, &a, &b, 1.5, 0.0, (ww, wh), seed) else { return Alignment::identity() };
Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale }
}
/// Resample `img` into the reference geometry: `out(p) = img(h(p))`. Returns the warped image and
/// a validity mask (false where `h(p)` falls outside `img`).
/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped
/// image and a validity mask (false where `h(p)` falls outside `img`).
pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
let (w, ht) = (img.width, img.height);
if *h == Homography::IDENTITY {
@@ -116,7 +140,7 @@ pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) {
let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let (sx, sy) = (p.x as f32, p.y as f32);
if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 {
row[x] = img.sample_bilinear(sx, sy);
row[x] = img.sample_lanczos3(sx, sy);
vrow[x] = true;
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ fn stretch(img: &Rgb32f, alpha: &Plane, amount: f64, vertical: bool) -> (Rgb32f,
let src = lo[i] + t * (hi[i] - lo[i]);
let s = (1.0 - amount) * (k as f64 + 0.5) + amount * src;
let (sx, sy) = if vertical { (x as f32 + 0.5, s as f32) } else { (s as f32, y as f32 + 0.5) };
row[x] = img.sample_bilinear(sx, sy);
row[x] = img.sample_lanczos3(sx, sy);
arow[x] = crate::features::bilinear(alpha, sx, sy);
}
});
+1 -1
View File
@@ -298,7 +298,7 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
let wx = 1.0 - (2.0 * px / fw - 1.0).abs();
let wy = 1.0 - (2.0 * py / fh - 1.0).abs();
let w = (wx * wy).max(1e-6) as f32;
row[tx] = fr.image.sample_bilinear(px as f32, py as f32);
row[tx] = fr.image.sample_lanczos3(px as f32, py as f32);
wrow[tx] = w;
}
});
+3 -1
View File
@@ -2,7 +2,8 @@
//!
//! - [`Image<T>`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`,
//! `Plane = Image<f32>`).
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel.
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel; point sampling for warps
//! ([`sample`]: Lanczos-3 held to its neighbours' range).
//! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius).
//! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius).
//! - [`Histogram`] of display-encoded RGB + luminance.
@@ -13,6 +14,7 @@ pub mod blur;
pub mod histogram;
pub mod morph;
pub mod resample;
pub mod sample;
pub use histogram::{HdrBins, Histogram};
+2 -1
View File
@@ -19,7 +19,8 @@ impl Filter {
Filter::Lanczos3 => 3.0,
}
}
fn eval(self, x: f32) -> f32 {
/// The filter's weight at distance `x` (pixels of the source).
pub(crate) fn eval(self, x: f32) -> f32 {
let x = x.abs();
match self {
Filter::Box => {
+154
View File
@@ -0,0 +1,154 @@
//! Point sampling at continuous coordinates for geometric warps (alignment, projections).
//!
//! [`Rgb32f::sample_lanczos3`] interpolates with a 6 × 6 Lanczos-3 kernel. Bilinear sampling (the
//! [`Rgb32f::sample_bilinear`] next to it) averages two pixels at a half-pixel offset: a [½ ½]
//! filter that passes nothing at the Nyquist frequency, so a frame warped by a fraction of a pixel
//! loses its finest detail. Lanczos-3 keeps most of it. Its negative lobes would overshoot next to
//! a hard edge (a clipped highlight, a star), so each sample is held to the range of the 4 × 4
//! pixels around it, channel by channel: in smooth areas and fine texture that range holds the
//! interpolated value anyway (a 2 × 2 range would cut the peaks of texture near Nyquist that fall
//! between pixels), and at an edge the overshoot goes.
use std::sync::OnceLock;
use crate::Rgb32f;
use crate::resample::Filter;
/// Phases of a pixel the kernel's taps are tabulated for.
const PHASES: usize = 1024;
/// The six Lanczos-3 weights (summing to 1) for a sample `t` (0..1) past the third of the six
/// pixels it reads: pixel `k` (0..6) is at distance `t + 2 − k`.
pub fn lanczos3_taps(t: f32) -> [f32; 6] {
static TABLE: OnceLock<Vec<[f32; 6]>> = OnceLock::new();
let table = TABLE.get_or_init(|| {
(0..=PHASES)
.map(|i| {
let t = i as f32 / PHASES as f32;
let w: [f32; 6] = std::array::from_fn(|k| Filter::Lanczos3.eval(t + 2.0 - k as f32));
let s: f32 = w.iter().sum();
w.map(|v| v / s)
})
.collect()
});
let i = if t.is_finite() { (t.clamp(0.0, 1.0) * PHASES as f32).round() as usize } else { 0 };
table.get(i).copied().unwrap_or([0.0, 0.0, 1.0, 0.0, 0.0, 0.0])
}
impl Rgb32f {
/// Lanczos-3 sample at continuous pixel coordinates (pixel centres at +0.5), held to the range
/// of the 4 × 4 pixels around it (see the module docs). Past the edges the edge pixels repeat;
/// a coordinate that isn't finite reads the nearest edge, as [`Rgb32f::sample_bilinear`] does.
pub fn sample_lanczos3(&self, x: f32, y: f32) -> [f32; 3] {
let (w, h) = (self.width, self.height);
if w == 0 || h == 0 {
return [0.0; 3];
}
let fx = x.clamp(-1.0, w as f32 + 1.0) - 0.5;
let fy = y.clamp(-1.0, h as f32 + 1.0) - 0.5;
let (fx, fy) = (if fx.is_finite() { fx } else { 0.0 }, if fy.is_finite() { fy } else { 0.0 });
let (x0, y0) = (fx.floor(), fy.floor());
let (wx, wy) = (lanczos3_taps(fx - x0), lanczos3_taps(fy - y0));
let (x0, y0) = (x0 as isize, y0 as isize);
let col: [usize; 6] = std::array::from_fn(|k| (x0 + k as isize - 2).clamp(0, w as isize - 1) as usize);
let row: [usize; 6] = std::array::from_fn(|k| (y0 + k as isize - 2).clamp(0, h as isize - 1) as usize * w);
let px = |r: usize, c: usize| self.data.get(r + c).copied().unwrap_or_default();
let mut acc = [0f32; 3];
// and the range of the 4 × 4 pixels around the sample (taps 1 to 4 on each axis)
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for (j, (r, &wr)) in row.iter().zip(&wy).enumerate() {
let mut line = [0f32; 3];
for (i, (&c, &wc)) in col.iter().zip(&wx).enumerate() {
let p = px(*r, c);
for ch in 0..3 {
line[ch] += wc * p[ch];
}
if (1..5).contains(&i) && (1..5).contains(&j) {
for ch in 0..3 {
lo[ch] = lo[ch].min(p[ch]);
hi[ch] = hi[ch].max(p[ch]);
}
}
}
for ch in 0..3 {
acc[ch] += wr * line[ch];
}
}
// no finite pixel in the 4 × 4 range (NaN all around): nothing to hold the sample to
std::array::from_fn(|ch| if lo[ch] <= hi[ch] { acc[ch].clamp(lo[ch], hi[ch]) } else { acc[ch] })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// High-pass energy of a row-wise pattern: the mean squared second difference.
fn hp(img: &Rgb32f) -> f64 {
let w = img.width;
let mut s = 0.0;
let mut n = 0;
for y in 4..img.height - 4 {
for x in 4..w - 4 {
let v = |dx: isize| img.get((x as isize + dx) as usize, y)[1] as f64;
s += (2.0 * v(0) - v(-1) - v(1)).powi(2);
n += 1;
}
}
s / n as f64
}
#[test]
fn taps_sum_to_one_and_hit_pixels_exactly() {
for t in [0.0, 0.1, 0.5, 0.77, 1.0] {
let w = lanczos3_taps(t);
assert!((w.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
let w = lanczos3_taps(0.0);
assert!((w[2] - 1.0).abs() < 1e-5 && w.iter().enumerate().all(|(k, v)| k == 2 || v.abs() < 1e-5));
assert_eq!(lanczos3_taps(f32::NAN), lanczos3_taps(0.0));
}
/// A fine pattern moved by half a pixel: bilinear keeps cos²(π f) of its high-pass energy (a
/// third at 0.3 cycles per pixel), Lanczos-3 twice that, and nearly all of it at 0.2.
#[test]
fn a_half_pixel_shift_keeps_fine_detail() {
for (f, lanczos_min, over_bilinear) in [(0.2f32, 0.9, 1.3), (0.3, 0.65, 1.9)] {
let img = Rgb32f::from_fn(64, 16, |x, _| [0.5 + 0.4 * (x as f32 * f * std::f32::consts::TAU).sin(); 3]);
let shifted = |s: &dyn Fn(f32, f32) -> [f32; 3]| Rgb32f::from_fn(64, 16, |x, y| s(x as f32 + 1.0, y as f32 + 0.5));
let bil = shifted(&|x, y| img.sample_bilinear(x, y));
let lan = shifted(&|x, y| img.sample_lanczos3(x, y));
let (e0, eb, el) = (hp(&img), hp(&bil), hp(&lan));
assert!(el > lanczos_min * e0, "{f} c/px: lanczos {el} vs {e0}");
assert!(el > over_bilinear * eb, "{f} c/px: lanczos {el} vs bilinear {eb}");
}
}
/// Next to a hard edge Lanczos-3 would overshoot; the sample stays within its neighbours.
#[test]
fn no_overshoot_at_a_hard_edge() {
let img = Rgb32f::from_fn(16, 4, |x, _| if x < 8 { [0.0; 3] } else { [1.0; 3] });
for i in 0..160 {
let v = img.sample_lanczos3(i as f32 * 0.1, 2.0);
assert!((0.0..=1.0).contains(&v[0]), "{v:?} at {}", i as f32 * 0.1);
}
// pixel centres come back exactly
assert_eq!(img.sample_lanczos3(8.5, 1.5), [1.0; 3]);
assert_eq!(img.sample_lanczos3(7.5, 1.5), [0.0; 3]);
}
#[test]
fn edges_and_odd_coordinates_never_panic() {
let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]);
for (x, y) in [(f32::INFINITY, f32::INFINITY), (f32::NEG_INFINITY, 0.0), (f32::NAN, f32::NAN), (f32::MAX, -1e30), (-5.0, 9.0)] {
let v = img.sample_lanczos3(x, y);
assert!(v.iter().all(|c| c.is_finite()), "{v:?}");
}
assert_eq!(Rgb32f::new(0, 0).sample_lanczos3(1.0, 1.0), [0.0; 3]);
// pixels that are all NaN (a damaged float file) give a NaN sample, never a panic
let nan = Rgb32f::from_fn(8, 8, |_, _| [f32::NAN; 3]);
assert!(nan.sample_lanczos3(4.3, 4.6).iter().all(|v| v.is_nan()));
assert_eq!(img.sample_lanczos3(0.5, 0.5), [0.0, 0.0, 1.0]);
}
}
+48 -7
View File
@@ -172,13 +172,14 @@ pub struct AlbumPicker<'a> {
value: &'a mut Option<u64>,
entries: &'a [AlbumEntry],
width: f32,
empty_text: Option<String>,
}
impl<'a> AlbumPicker<'a> {
/// `id` must be unique among the pickers on screen (it names their widgets and keeps their
/// state apart); `value` is the album picked (`None`: none yet); `entries` in tree order.
pub fn new(id: impl Into<String>, value: &'a mut Option<u64>, entries: &'a [AlbumEntry]) -> Self {
AlbumPicker { id: id.into(), value, entries, width: 180.0 }
AlbumPicker { id: id.into(), value, entries, width: 180.0, empty_text: None }
}
/// The button's width (180 by default).
@@ -187,13 +188,27 @@ impl<'a> AlbumPicker<'a> {
self
}
/// Text for an empty choice (already localized by the caller); the default is "Choose an album…".
pub fn empty_text(mut self, text: impl Into<String>) -> Self {
self.empty_text = Some(text.into());
self
}
/// The button and, while open, the popup. The response is `changed()` when an album was picked.
pub fn show(self, ui: &mut Ui) -> Response {
let AlbumPicker { id, value, entries, width } = self;
self.show_with_choices(ui, |_| false)
}
/// Draw caller-owned choices before the search (e.g. None / New album in the import review).
/// Return true when an extra choice was picked to close the popup. The caller handles those
/// actions; `changed()` still reports only an album picked from the tree or search.
pub fn show_with_choices(self, ui: &mut Ui, choices: impl FnOnce(&mut Ui) -> bool) -> Response {
let AlbumPicker { id, value, entries, width, empty_text } = self;
let chosen = value.filter(|v| entries.iter().any(|e| e.id == *v && !e.folder));
let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |c| path_label(entries, c));
let text = chosen.map_or_else(|| empty_text.unwrap_or_else(|| crate::i18n::tr("Choose an album…").to_string()), |c| path_label(entries, c));
// the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph)
let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" "));
let mut button =
ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text.as_str()).truncate().right_text(" ")).on_hover_text(text);
let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon;
disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color);
register(ui.ctx(), format!("albumPicker:{id}"), button.rect);
@@ -206,8 +221,15 @@ impl<'a> AlbumPicker<'a> {
let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default();
let mut picked: Option<u64> = None;
egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(width.max(260.0));
ui.set_max_width(width.max(260.0) + 120.0);
// A wide popup remembered before a window resize must still fit the current viewport.
let margin = egui::Frame::popup(ui.style()).total_margin().sum().x;
let max_width = (ui.ctx().content_rect().width() - margin).max(1.0);
ui.set_min_width(width.max(260.0).min(max_width));
ui.set_max_width((width.max(260.0) + 120.0).min(max_width));
if choices(ui) {
ui.close();
return;
}
let search_box =
ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY));
register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect);
@@ -227,7 +249,9 @@ impl<'a> AlbumPicker<'a> {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album };
crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon });
let resp = ui.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label));
let resp = ui
.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate())
.on_hover_text(label);
register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect);
let resp = match &e.blocked {
Some(why) => resp.on_disabled_hover_text(why.as_str()),
@@ -390,6 +414,23 @@ mod tests {
assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]);
}
/// Cycles, dangling parents and excessively deep chains finish with bounded paths and depths.
#[test]
fn damaged_parent_chains_are_bounded() {
let mut entries = vec![e(1, "Loop A", Some(2), true, false), e(2, "Loop B", Some(1), true, false), e(3, "Photo", Some(1), false, false)];
set_depths(&mut entries);
assert!(ancestors(&entries, 3).len() <= 3);
assert_eq!(search(&entries, "photo"), vec![2]);
assert_eq!(visible_tree(&entries, &[1, 2]), vec![0, 1, 2]);
let mut deep: Vec<_> = (1..1000).map(|id| e(id, "Deep", Some(id + 1), true, false)).collect();
deep.push(e(1000, "End", None, true, false));
set_depths(&mut deep);
assert!(deep.iter().all(|entry| entry.depth <= 65));
assert!(ancestors(&deep, 1).len() <= 65);
assert!(path_label(&deep, 1).split(" / ").count() <= 66);
assert_eq!(path_label(&[e(1, "Orphan", Some(99), false, false)], 1), "Orphan");
}
/// Big libraries stay quick: 20,000 albums in nested folders are laid out and searched in well
/// under a second (folder paths are worked out once, not per row by scanning every album).
#[test]
+24
View File
@@ -3336,6 +3336,30 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does:
/// untouched Local photos are only forgotten from folders not browsed for the set number of days.
#[test]
fn browsing_from_the_ui_records_the_folder() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id()));
let day = dir.join("2026/10/14");
std::fs::create_dir_all(&day).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
let day_s = day.to_string_lossy().to_string();
assert!(h.app.session.catalog.last_browsed(&root).is_none());
let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.session.visible_cloned().len(), 1);
assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder");
assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in");
let _ = std::fs::remove_dir_all(&dir);
}
/// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by
/// another app, as `library.browse` does (issue #1392).
#[test]
+40 -25
View File
@@ -584,6 +584,12 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
}
app.session.import_probes = out.probes;
if task.browse {
// the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so
// their untouched Local photos aren't forgotten while the folder is in use
if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) {
let shown: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect();
app.session.note_browsed(&root, &shown);
}
// records of files gone from the folder (deleted or moved by another app) that nothing was changed on go,
// as `library.browse` forgets them (issue #1392)
if let Some(b) = app.session.browse.clone() {
@@ -1238,40 +1244,49 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
);
});
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
v.sort_by_key(|(_, n)| n.to_lowercase());
v
};
let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None);
albums.retain(|a| a.folder || !a.smart);
// Catalog changes while the review is open must not leave a hidden, unselectable destination.
d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder));
field(ui, "Album", |ui| {
let cur = match d.album {
Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(),
None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(),
None => crate::i18n::tr("None").into(),
};
crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
d.album = None;
ui.vertical(|ui| {
let mut chosen = d.album;
let mut extra = None;
let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" });
let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums)
.width(ui.available_width())
.empty_text(empty)
.show_with_choices(ui, |ui| {
let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None"));
register(ui.ctx(), "importAlbum:none", none.rect);
if none.clicked() {
extra = Some(false);
}
let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…"));
register(ui.ctx(), "importAlbum:new", new.rect);
if new.clicked() {
extra = Some(true);
}
ui.separator();
extra.is_some()
});
if response.changed() {
d.album = chosen;
d.new_album.clear();
}
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
if let Some(new) = extra {
d.album = None;
if d.new_album.is_empty() {
if !new {
d.new_album.clear();
} else if d.new_album.is_empty() {
d.new_album = crate::i18n::tr("Imported Photos").into();
}
}
for (id, name) in &albums {
if ui.selectable_label(d.album == Some(*id), name).clicked() {
d.album = Some(*id);
d.new_album.clear();
}
if d.album.is_none() && !d.new_album.is_empty() {
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importAlbumName", r.rect);
}
});
if d.album.is_none() && !d.new_album.is_empty() {
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importAlbumName", r.rect);
}
});
field(ui, "Preset", |ui| {
let cur = app
+347 -2
View File
@@ -1,5 +1,5 @@
//! The album picker in the smart-album rule editor: a tree like the sidebar's, a search that
//! narrows it as you type, keys to pick with, and albums that can't be picked shown greyed.
//! The shared album picker in smart rules and the import review: sidebar hierarchy, path search,
//! keyboard choices, and only valid destinations. Import also retains None and New album.
use std::time::Duration;
@@ -12,26 +12,31 @@ use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// Dispatch a public command and return its successful engine result.
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
r["result"].clone()
}
/// Read the album ID returned by a successful creation command.
fn id(v: serde_json::Value) -> u64 {
v["id"].as_u64().expect("an id")
}
/// Click a registered widget through the public control channel and let frames settle.
fn click(h: &mut Headless, id: &str) {
let r = h.request("ui.clickWidget", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.settle(SETTLE);
}
/// Check the current frame registry for an exact widget ID.
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
/// Read the Album rule value from the active smart-rule editor.
fn value(h: &Headless) -> serde_json::Value {
let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") };
let Some(Rule::Field { value, .. }) = rules.rules.first() else { panic!("no rule") };
@@ -126,3 +131,343 @@ fn typing_after_a_folder_click_still_searches() {
h.settle(SETTLE);
assert!(has(&h, &format!("albumPickerItem:{trip}:rules-0")) && !has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "searching for trip");
}
/// The import review reuses the same tree, while accepting only regular album destinations.
mod import_review {
use super::*;
struct Review {
h: Headless,
folder: u64,
nested: u64,
album: u64,
other: u64,
smart: u64,
}
/// Open the standard import review fixture with nested and same-named albums.
fn review() -> Review {
review_at([1300.0, 950.0], 1.0)
}
/// Build the import review fixture at the requested logical size and display density.
fn review_at(size: [f32; 2], density: f32) -> Review {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, size, density);
let folder = id(exec(&mut h, "album.create", json!({"name": "Travel", "folder": true})));
let nested = id(exec(&mut h, "album.create", json!({"name": "2026", "folder": true, "parent": folder})));
let album = id(exec(&mut h, "album.create", json!({"name": "Favorites", "parent": nested, "addSelected": false})));
let other = id(exec(&mut h, "album.create", json!({"name": "Favorites", "addSelected": false})));
let smart = id(exec(&mut h, "album.createSmart", json!({"name": "Smart travel", "parent": folder, "rules": {"rating": 4}})));
h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) });
h.settle(SETTLE);
Review { h, folder, nested, album, other, smart }
}
/// Read the active import review without accepting a silently closed dialog.
fn destination(h: &Headless) -> &crate::import::ImportDialog {
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("import review remains open") };
opts
}
/// #2362: the actual import dialog browses nested folders and remembers the chosen full path.
#[test]
fn import_album_picker_preserves_the_sidebar_hierarchy() {
let Review { mut h, folder, nested, album, other, smart } = review();
click(&mut h, "albumPicker:import-album");
assert!(has(&h, &format!("albumPickerFolder:{folder}:import-album")));
assert!(has(&h, &format!("albumPickerItem:{other}:import-album")));
assert!(!has(&h, &format!("albumPickerItem:{album}:import-album")), "nested album starts folded");
click(&mut h, &format!("albumPickerFolder:{folder}:import-album"));
assert_eq!(destination(&h).album, None, "a folder is for browsing, not a destination");
assert!(!has(&h, &format!("albumPickerItem:{smart}:import-album")), "smart albums cannot receive imports");
click(&mut h, &format!("albumPickerFolder:{nested}:import-album"));
click(&mut h, &format!("albumPickerItem:{album}:import-album"));
assert_eq!(destination(&h).album, Some(album));
assert!(destination(&h).new_album.is_empty());
assert!(h.painted_text().iter().any(|t| t == "Travel / 2026 / Favorites"), "duplicate album names have their full path");
click(&mut h, "albumPicker:import-album");
assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "selected album's ancestors reopen");
}
/// Type through the public control channel and wait for the search or field to update.
fn text(h: &mut Headless, value: &str) {
let r = h.request("ui.text", json!({"text": value}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
}
/// Send a public keyboard event and wait for its resulting UI state.
fn key(h: &mut Headless, key: &str) {
let r = h.request("ui.key", json!({"key": key}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
}
/// Replace the pending new album name using normal field selection and typing.
fn replace_name(h: &mut Headless, name: &str) {
click(h, "field:importAlbumName");
let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T);
assert_eq!(r["ok"], true, "{r}");
text(h, name);
}
/// None, New and an existing album replace each other without retaining a stale pending name.
#[test]
fn import_album_picker_retains_none_new_and_existing_transitions() {
let Review { mut h, album, .. } = review();
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
assert_eq!(destination(&h).album, None);
assert_eq!(destination(&h).new_album, "Imported Photos");
replace_name(&mut h, "Été 東京");
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
assert_eq!(destination(&h).new_album, "Été 東京", "choosing New again keeps the typed name");
click(&mut h, "albumPicker:import-album");
text(&mut h, "travel 2026 fav");
key(&mut h, "Enter");
assert_eq!(destination(&h).album, Some(album));
assert!(destination(&h).new_album.is_empty(), "existing album replaces a pending new album");
assert!(!has(&h, "field:importAlbumName"));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
assert_eq!(destination(&h).album, None);
assert!(destination(&h).new_album.is_empty());
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
replace_name(&mut h, "Leave no orphan album");
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
assert!(destination(&h).new_album.is_empty(), "None clears a pending name too");
}
/// The picker must leave room for a usable name field; a full-width button followed by an
/// inline name field made the resizable dialog grow on every frame, off the native window.
#[test]
fn import_album_picker_keeps_the_new_name_inside_the_dialog() {
let Review { mut h, .. } = review();
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
for _ in 0..20 {
h.step();
}
let name = h.app.widgets.iter().find(|(id, _)| id == "field:importAlbumName").unwrap().1;
assert!(name.width() >= 160.0, "the name field is usable: {name:?}");
assert!(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 950.0)).contains_rect(name), "name stays on screen: {name:?}");
let picker = |h: &Headless| h.app.widgets.iter().find(|(id, _)| id == "albumPicker:import-album").unwrap().1;
let before = picker(&h);
for _ in 0..10 {
h.step();
}
let after = picker(&h);
assert!(
(before.width() - after.width()).abs() < 0.5 && (before.left() - after.left()).abs() < 0.5,
"the dialog stops growing: {before:?} -> {after:?}"
);
}
/// Path search is case-insensitive and Escape preserves review edits and user-provided names.
#[test]
fn import_album_picker_search_escape_and_unicode_names() {
let Review { mut h, folder, album, .. } = review();
let unicode = id(exec(&mut h, "album.create", json!({"name": "La fête 東京", "parent": folder, "addSelected": false})));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
replace_name(&mut h, "Keep this name");
click(&mut h, "albumPicker:import-album");
text(&mut h, "no such album");
assert!(h.painted_text().iter().any(|t| t == "No albums match"));
key(&mut h, "Enter");
assert!(has(&h, "albumPickerSearch:import-album"), "Enter with no result keeps the picker open");
key(&mut h, "Escape");
assert!(!has(&h, "albumPickerSearch:import-album"));
assert_eq!(destination(&h).new_album, "Keep this name", "Escape leaves the review's edits intact");
click(&mut h, "albumPicker:import-album");
text(&mut h, "FÊTE");
assert!(has(&h, &format!("albumPickerItem:{unicode}:import-album")));
assert!(!has(&h, &format!("albumPickerItem:{album}:import-album")));
key(&mut h, "Enter");
assert_eq!(destination(&h).album, Some(unicode));
assert!(h.painted_text().iter().any(|t| t == "Travel / La fête 東京"), "user names are kept verbatim");
}
/// The catalog custom order wins over alphabetical order in the import picker.
#[test]
fn import_album_picker_follows_custom_sidebar_order() {
let Review { mut h, other, .. } = review();
let last_by_name = id(exec(&mut h, "album.create", json!({"name": "Zebra", "addSelected": false})));
exec(&mut h, "album.reorder", json!({"id": last_by_name, "before": other}));
click(&mut h, "albumPicker:import-album");
let top = |id| h.app.widgets.iter().find(|(w, _)| w == &format!("albumPickerItem:{id}:import-album")).unwrap().1.top();
assert!(top(last_by_name) < top(other), "sidebar order takes priority over alphabetical order");
}
/// Match painted text inside the specified control instead of anywhere in the frame.
fn button_text(h: &Headless, id: &str, expected: &str) -> bool {
let rect = h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1;
h.view.painted_shapes().iter().any(|shape| matches!(shape, egui::Shape::Text(t) if t.galley.job.text == expected && rect.contains(t.pos)))
}
/// Long paths and editable names remain usable across themes, viewport sizes and display densities.
#[test]
fn import_album_picker_fits_long_names_across_themes_sizes_and_densities() {
for (size, density) in [([960.0, 760.0], 1.0), ([1366.0, 900.0], 2.0), ([960.0, 760.0], 2.25)] {
let Review { mut h, folder, .. } = review_at(size, density);
let long = "La fête des photographies – 東京 – ".repeat(12);
let album = id(exec(&mut h, "album.create", json!({"name": long, "parent": folder, "addSelected": false})));
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("review") };
opts.album = Some(album);
for theme in ["charcoal", "midnight", "paper", "silver"] {
exec(&mut h, "view.theme", json!({"theme": theme}));
let r = h.request("ui.set", json!({"language": "de"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
let rect = |h: &Headless, name: &str| h.app.widgets.iter().find(|(id, _)| id == name).unwrap().1;
assert!(screen.contains_rect(rect(&h, "albumPicker:import-album")), "{theme} {size:?}: selected long path");
click(&mut h, "albumPicker:import-album");
text(&mut h, "fête");
let row = rect(&h, &format!("albumPickerItem:{album}:import-album"));
assert!(screen.contains_rect(row), "{theme} {size:?}: search result {row:?} inside {screen:?}");
key(&mut h, "Escape");
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
let name = rect(&h, "field:importAlbumName");
assert!(name.width() >= 160.0 && screen.contains_rect(name), "{theme} {size:?}: name {name:?}");
click(&mut h, "albumPicker:import-album");
text(&mut h, "fête");
key(&mut h, "Enter");
assert_eq!(destination(&h).album, Some(album));
}
}
}
/// Every shipped locale paints translated destination choices and initializes a translated new name.
#[test]
fn import_album_picker_localizes_choices_in_every_shipped_language() {
use crate::i18n::Locale;
let Review { mut h, .. } = review();
for locale in Locale::ALL {
for source in
["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"]
{
assert!(!locale.tr(source).is_empty());
if *locale != Locale::En {
assert_ne!(locale.tr(source), source, "{locale:?}: {source}");
}
}
let r = h.request("ui.set", json!({"language": locale.code()}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
assert!(button_text(&h, "albumPicker:import-album", locale.tr("None")), "{locale:?}: localized empty destination");
click(&mut h, "albumPicker:import-album");
assert!(button_text(&h, "importAlbum:new", locale.tr("New album…")), "{locale:?}: localized new-album option");
click(&mut h, "importAlbum:new");
assert_eq!(destination(&h).new_album, locale.tr("Imported Photos"));
assert!(button_text(&h, "albumPicker:import-album", locale.tr("New album")));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
}
}
/// An empty catalog still offers None and New without inventing an existing destination.
#[test]
fn import_album_picker_handles_an_empty_catalog() {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1000.0, 800.0], 1.0);
h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) });
assert!(h.settle(SETTLE));
click(&mut h, "albumPicker:import-album");
assert!(h.painted_text().iter().any(|t| t == "No albums yet"));
click(&mut h, "importAlbum:new");
assert!(has(&h, "field:importAlbumName"));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
assert!(destination(&h).new_album.is_empty());
}
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
/// Remove only the uniquely owned fixture directory after the test session releases it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Write an original procedural PNG in a unique temporary directory for a real import.
fn fixture() -> Fixture {
let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("lc-import-tree-{}-{unique}", std::process::id()));
std::fs::create_dir(&path).unwrap();
let pixels =
lightcraft_raster::Rgba8 { width: 40, height: 30, data: (0..1200).map(|i| [(i % 40 * 6) as u8, 31, (i / 40 * 8) as u8, 255]).collect() };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&pixels), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(path.join("hierarchy.png"), png).unwrap();
Fixture(path)
}
/// A real file scan, the real picker, and the real import worker: the intended album receives
/// the photo, duplicate names never redirect it, and the import is still one undo step.
/// A destination deleted while the review is open falls back to no album before confirmation.
#[test]
fn import_album_picker_delivers_to_the_chosen_destination_and_undoes() {
use lightcraft_catalog::PhotoId;
for choice in ["existing", "new", "none", "deleted"] {
let fixture = fixture();
let Review { mut h, album, other, .. } = review();
let before: std::collections::HashSet<PhotoId> = h.app.session.catalog.photos().map(|p| p.id).collect();
h.app.ui.dialog = None;
exec(&mut h, "file.addPhotos", json!({"paths": [fixture.0.to_string_lossy()], "album": album}));
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))));
assert!(h.settle(SETTLE));
assert_eq!(destination(&h).album, Some(album), "album-context import keeps its preselection");
click(&mut h, "albumPicker:import-album");
assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "preselection opens the right ancestors");
match choice {
"existing" => {
text(&mut h, "travel 2026 fav");
key(&mut h, "Enter");
}
"new" => {
click(&mut h, "importAlbum:new");
replace_name(&mut h, "From card");
}
"deleted" => {
key(&mut h, "Escape");
exec(&mut h, "album.delete", json!({"id": album}));
assert!(h.settle(SETTLE));
assert_eq!(destination(&h).album, None, "a deleted destination matches the displayed None choice");
assert!(button_text(&h, "albumPicker:import-album", "None"));
}
_ => click(&mut h, "importAlbum:none"),
}
let undo = h.app.session.undo.len();
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.step_until(SETTLE, |h| h.app.import.is_none()));
assert!(h.settle(SETTLE));
let added: Vec<PhotoId> = h.app.session.catalog.photos().filter(|p| !before.contains(&p.id)).map(|p| p.id).collect();
assert_eq!(added.len(), 1, "{choice}: exactly the scanned photo is imported");
let receives = |id| h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().photos.contains(&added[0]);
assert!(!receives(other), "same-named album outside the chosen folder is untouched");
match choice {
"existing" => assert!(receives(album)),
"new" => {
let created = h.app.session.catalog.albums().find(|a| a.name == "From card").unwrap();
assert!(created.photos.contains(&added[0]));
assert!(!receives(album));
}
_ => assert!(h.app.session.catalog.albums().all(|a| !a.photos.contains(&added[0]))),
}
assert_eq!(h.app.session.undo.len(), undo + 1, "{choice}: one undo step");
exec(&mut h, "edit.undo", json!({}));
assert!(h.settle(SETTLE));
assert_eq!(h.app.session.catalog.len(), before.len());
if choice == "new" {
assert!(h.app.session.catalog.albums().all(|a| a.name != "From card"), "undo removes the new album too");
}
}
}
}
+1
View File
@@ -116,6 +116,7 @@ fn hovering_another_title_switches_the_open_menu() {
#[test]
fn clicking_another_title_opens_it_in_one_click() {
let mut h = app([1280.0, 703.0]);
assert!(h.step_until(SETTLE, |h| widget(h, "menu:Edit").is_some()), "Edit menu title was not registered");
go(&mut h, "ui.clickWidget", "menu:Edit");
assert_eq!(open_menu(&h).as_deref(), Some("menu:Edit"));
// move away from the titles first so only the click can be what switches
+17 -1
View File
@@ -84,8 +84,24 @@ fn open_and_scan(h: &mut Headless, folder: &str) {
// the rows above it hold one folder each and open by themselves
let row = format!("source:libfolder:{folder}");
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}");
let before = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}"));
let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel");
// Off-screen rows are registered too. Preserve the default layout when this row is already visible.
let before_visible = before.intersect(panel);
let before_drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}"));
if !before_drawn || !before_visible.is_positive() || before_visible.height() < before.height() - 0.5 {
crate::tests_panels::scroll_sidebar(h, panel.center().y - before.center().y);
}
let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}"));
let c = rect.center();
let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel");
let visible = rect.intersect(panel);
let c = visible.center();
let drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}"));
let clips: Vec<_> = h.view.shapes.iter().filter(|s| s.shape.visual_bounding_rect().intersects(rect)).map(|s| s.clip_rect).collect();
assert!(
drawn && visible.is_positive() && visible.height() >= rect.height() - 0.5,
"folder row must be drawn and vertically in view: before={before:?} row={rect:?} panel={panel:?} visible={visible:?} click={c:?} drawn={drawn} paint_clips={clips:?}"
);
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
+28
View File
@@ -17,6 +17,18 @@ callbacks, so the app's loop is paused. Keep the window on screen while you driv
headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that has already started
(a long command blocks frames) still gets the full 60 s.
Connections close after five minutes without incoming bytes while the server is waiting for
input, including in the middle of an unfinished request line. This allows pauses between
automation commands while releasing abandoned connection slots. An idle read timeout gets an
`ok: false` error saying the connection is closing; incomplete input is never dispatched.
This is a socket read inactivity limit, not a total connection lifetime: a peer that keeps
sending bytes can keep it open. Waiting for an already-dispatched command's reply does not
count as idle reading and retains the request timeout above.
Reconnect before sending another request after an idle close. MCP connect mode detects closed
connections before sending and reconnects; if a connection is lost after sending a request,
its outcome remains unknown and the client must not blindly replay it.
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are
optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without
`method`, invalid UTF-8, or a line longer than 4 MiB — gets one error reply
@@ -281,6 +293,22 @@ one pixel.
(clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`
(e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build).
## Import album picker
In the Import Photos review, `albumPicker:import-album` opens the destination picker.
`albumPickerFolder:<folder-id>:import-album` expands or folds a folder; it never chooses a destination.
`albumPickerItem:<album-id>:import-album` selects a regular album. The picker follows sidebar order,
opens the current choice's ancestors and shows its full path on the closed button. Smart albums
are not import destinations. Type into `albumPickerSearch:import-album` to search folder/album paths;
↑ / ↓ and Enter pick a search result, and Escape closes the picker while keeping the review open.
If the chosen album is deleted while the review is open, the destination returns to None before
confirmation instead of submitting the deleted ID.
`importAlbum:none` clears the destination and any pending new album name. `importAlbum:new` keeps
or initializes that name, edited through `field:importAlbumName`. Choosing an existing album clears
the pending name. `ui.dialog.confirm` starts the same import into the selected album ID; engine
`library.import` parameters `album` and `albumName` are unchanged.
## Panel docking
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
+6 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (import review album hierarchy, #2362; no ranked gap closed)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) |
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
## Closed recently (kept for calibration)
- 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes.
- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like
Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export.
- 2026-10-11: Sony ILCA-99M2 profile.
@@ -77,8 +79,10 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged |
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
| 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged |
| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged |
| 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) |
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
+3 -1
View File
@@ -27,7 +27,9 @@ All algorithms are our own implementations from published papers (cited in the m
**HDR** (`hdr.rs`): frames ordered by EXIF exposure (or median luminance); the middle one is the
reference. Auto Align: features on exposure-normalised luminance, RANSAC translation / similarity /
homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured
homography (the simplest model that explains ≥ 97 % of the matches). Frames are resampled with Lanczos-3, held to
the range of their 4 × 4 neighbours so edges don't ring (`lightcraft_raster::sample`); bilinear lost a third of the
finest detail. Exposure ratios are measured
(median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback.
Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max
channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped
File diff suppressed because one or more lines are too long
+13 -4
View File
@@ -31,6 +31,13 @@ model from the format rules in the script (which mirror `crates/raw/src`) plus t
rewrites [raw/coverage.tsv](raw/coverage.tsv) and prints the tables below; `--check` fails when the
committed table is stale.
Folder discovery, desktop file pickers, CLI and MCP share
`lightcraft_engine::import::EXTENSIONS`. The list includes formats that may open only from an
embedded preview or report a decoder error; an extension is not a promise of sensor decoding.
The 2026-10-12 release CLI check of the public DSC-R1 SR2 and DSC-F828 SRF samples found the
same photos and decode status through recursive and direct import. DSC-F828 moves from
unsupported to preview-only in the counts; sensor-decoding readiness is unchanged.
## How it was measured
- **Adobe's list**: helpx "Camera Raw supported cameras" (the live page refuses scripted requests, so
@@ -78,7 +85,7 @@ committed table is stale.
| Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported |
|---|---:|---:|---:|---:|---:|---|
| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
| Sony | 149 | 94 | 43 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 remains classified unsupported in the recorded evidence; DSC-F828 SRF imports as preview-only; DSC-V3 preview not verified |
| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) |
@@ -142,9 +149,10 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus
| ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured |
| ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` |
| ARW | 12-bit compressed of DSLR-A200…A390 | ⬜ | preview (#535) |
| ARW | A100 ARW; SRF (F828, V3) | ⬜ | refused (#702); SRF not imported |
| ARW | A100 ARW | ⬜ | recorded as refused (#702); not rechecked here |
| SRF | DSC-F828; DSC-V3 | ⬜ | discovered during folder import; F828 imports as preview-only, V3 has not been checked on a real file |
| ARW | ARQ pixel shift | ⬜ | no code |
| SR2 | DSC-R1 | ✅ | CC0 3221 sample |
| SR2 | DSC-R1 | ✅ | CC0 3221 sample; folder and direct import both decode sensor data |
| RAF | uncompressed, lossless and lossy compressed; Bayer and X-Trans; GFX 14/16-bit | ✅ | 24 files from 13 bodies exact |
| RAF | pre-X FinePix without a raw IFD | ⬜ | `raf.rs` refuses (26 Adobe models, estimated split) |
| ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 |
@@ -269,7 +277,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
| 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists |
| 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us |
| 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples |
| 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us |
| 7 | Sony A100 regression (#702) and DSC-F828 sensor decoding (preview-only) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us |
| 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples |
| 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder |
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
@@ -292,6 +300,7 @@ Beyond decoding:
| 2026-10-15 | minor | Lossy JPEG XL DNG tiles that declare a non-linear SDR transfer curve (sRGB, BT.709, DCI, gamma) decode with that curve restored instead of falling back to the embedded preview: Lightroom Classic 15.1 LinearRaw DNGs (Rec. 2100 primaries, sRGB curve; 2 private ILCE-7CM2 files) |
| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged |
| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged |
| 2026-10-12 | minor | SR2/SRF folder discovery and shared CLI/MCP extensions; public DSC-R1 sensor import and DSC-F828 preview-only import agree with direct import. F828 reclassified as preview-only (219 preview, 37 unsupported in the current generated coverage); sensor-decoding scores unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) |
| 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) |
| 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% |
+2
View File
@@ -66,6 +66,8 @@ def classify(maker, camera, ext_field):
return "unverified", "ARW", "ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr)"
if e == "SR2":
return "unverified", "SR2", "SR2 decoder"
if e == "SRF":
return "unsupported", "SRF", "SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file"
if e == "RAF":
if RAF_OLD.search(camera):
return "preview", "RAF", "pre-X FinePix RAF without a raw IFD"
+2 -2
View File
@@ -1306,10 +1306,10 @@ Sony A6400 (ILCE-6400) ARW 2018 verified issue #535: raw.pixls.us bulk check of
Sony A6500 (ILCE-6500) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony A6600 (ILCE-6600) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony A6700 (ILCE-6700) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the import list
Sony DSC-F828 SRF 2003 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md)
Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony DSC-V3 SRF 2005 unsupported SRF not in the import list
Sony DSC-V3 SRF 2005 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file
Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check)
Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Can't render this file because it contains an unexpected character in line 86 and column 19.
+3 -3
View File
@@ -465,10 +465,10 @@ Sony A900 (DSLR-A900) verified issue #535: raw.pixls.us bulk check of all 100 So
Sony A99 (SLT-A99V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies
Sony A99 II (ILCA-99M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony Alpha 1 (ILCE-1)* verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony DSC-F828 unsupported SRF not in the import list (the probe accepts it again since #774, issue #702); raw not decoded
Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md)
Sony DSC-HX95 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony DSC-HX99 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony DSC-V3 unsupported SRF not in the import list
Sony DSC-V3 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file
Sony FX3 (ILME-FX3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony FX30 (ILME-FX30) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
Sony NEX-3 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
@@ -528,7 +528,7 @@ Samsung TL350 (WB2000) verified corpus: crates/raw/tests/olympus_samsung_corpus.
Samsung NX1000 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check)
Samsung NX1100 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check)
Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo
Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list
Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md)
Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Can't render this file because it contains an unexpected character in line 2 and column 3.
+18
View File
@@ -1,5 +1,12 @@
# What's new in LightCraft
## 2026-10-21
### Sharper resampling in HDR and panorama merges
- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a
photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around
hard edges such as clipped highlights.
## 2026-10-19
### Hidden Local locations come back one at a time
@@ -26,6 +33,11 @@
## 2026-10-15
### Photo Merge takes each photo once
- A merge that names the same photo more than once (through `ids` on the control channel, MCP or the CLI) now merges
it once (issue #1947). Naming one photo three times no longer "merges" it with itself, and a long repeated list no
longer decodes the photo again for every entry.
### Constrain Crop tightens the crop you see
- Geometry ▸ Constrain Crop now changes the crop itself, as in Lightroom (issue #1927). Ticking it, or moving a
Geometry slider, Upright or a lens correction while it is ticked, shrinks the crop about its centre until the blank
@@ -202,6 +214,12 @@
⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.
`mask.brushStroke {connect: true}` does the same for agents.
### Local folders you browse are kept
- Browsing a folder under Local now records it as browsed, so the photos in folders you use aren't forgotten after
the number of days set in Settings → Performance. Only the `library.browse` command recorded it before, so a
folder browsed every day from the sidebar was forgotten anyway and read again from scratch on the next visit,
which is slow on a network share.
### Hard-edged Heal and Clone spots
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.