Merge origin/main into fix/arw-saturated-hues

Conflicts with issue #150 (NEF colour from the camera JPEG): the per-file
look, its hue/saturation/value table and chroma curve, and camera
profiles now apply to every `file_local_look` format (ARW, NEF, NRW);
`calibrate` also collects NEF/NRW. Render cache version 11 (main 9,
branch 10).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Harald HoyerandClaude Opus 5.5 committed 2026-10-07 13:51:08 +02:00
commit 623122c1a9
77 files changed
+5917 -366

No files matched your search

+39 -33
View File
@@ -87,7 +87,7 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW): up to N files spread over the folders (default 300; 0 = all), pooled per
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -154,7 +154,7 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("arw"));
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
@@ -532,39 +532,39 @@ fn run(args: &[String]) -> Result<(), String> {
if steps.is_empty() {
return Err("run: no command given (see `lightcraft-cli commands`)".into());
}
let mut backend: Box<dyn Backend> = match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?,
)
}
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
let mut backend: Box<dyn Backend> =
match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(Remote::connect(&addr).map_err(|e| {
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
})?)
}
Box::new(h)
}
};
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
}
}
Box::new(h)
}
};
let mut out = std::io::stdout().lock();
let mut failed = 0;
for s in &steps {
@@ -590,6 +590,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
}
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
/// no port, so a hint is never built from a guess.
fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
+10
View File
@@ -324,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() {
assert_eq!(lines[0]["ok"], true);
let _ = std::fs::remove_dir_all(&lib);
}
/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port
/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
#[test]
fn connect_failure_hint_names_the_attempted_port() {
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
assert!(!err.contains("--control 7980"), "{err}");
}