mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge track r2-t05 into next
This commit is contained in:
+54
@@ -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)).
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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% |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user