Files
lightcraft/xtask/src/main.rs
T
Trey ReynoldsandClaude Opus 5.5 22c6aa6918 Issue #150: Raw: NEF colour from the camera JPEG; 12-bit NEF black level
LightCraft has no colour matrix for Nikon cameras. Because of this, NEF
files showed colours that were too gray and too green.

- Colour: LightCraft now calculates a colour matrix and a tone curve from
  the JPEG preview in each NEF file. Sony ARW files use the same
  procedure and the same quality checks. White balance is relative to
  the as-shot look, as for ARW files.
- One function, camera_preview::file_local_look, selects the formats for
  the fit and for the relative white balance. Photo::relative_wb in the
  catalog uses the same formats.
- Black level: Nikon stores the black level (maker note 0x003d) in
  14-bit units, also in 12-bit files. The decoder read it as a 12-bit
  value. Because of this, 12-bit files from the D750, D780, D850, D7500
  and Z 50 were too dark. Now the decoder divides the value by 4 for
  12-bit files.
- The corpus gets the D7500 12-bit and 14-bit lossless samples. A test
  compares their levels. The render cache version increases to 9.

Results on 13 NEF files that LightCraft can decode: LightCraft accepted
the fit for all 13 files. The mean colour difference from the camera
JPEG (CIE76 dE) decreased from 13-46 to 2.9-7.8. One file with mixed
light improved by a small quantity only (12.7 to 12.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:26:45 -05:00

352 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Workspace tooling: `cargo xtask <command>`.
//!
//! Pure Rust (std + serde_json; flate2/brotli for the web bundle). External tools (`cargo`, `curl`, `tar`) are
//! invoked through `std::process::Command`.
mod assets;
mod bench;
mod ico;
mod layers;
mod parity;
mod stats;
mod version;
mod web;
use std::path::PathBuf;
use std::process::{Command, ExitCode};
const USAGE: &str = "\
usage: cargo xtask <command>
commands:
assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets
bench [FILE] [--strict] [--threshold PCT]
run the render benchmark, append to target/bench/history.jsonl, compare CPU time with
the previous run (default input: corpus/raw/arw-sony-a7m3-compressed.arw)
ico <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
layers enforce the crate dependency layering (plan/architecture.md §3)
parity [--write]
check docs/parity.md (every cmd:/ctl: id and path it cites exists) and print the
Lightroom parity summary; --write refreshes the summary table in the document
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app)
web [--serve [port]] [--dev]
build the browser app (apps/lightcraft-web) into <target>/web/;
--serve serves it on http://127.0.0.1:<port> (default 8080)
ci fmt --check, clippy -D warnings, test, parity refs, layers, assets, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
Some("layers") => cmd_layers(),
Some("assets") => assets::run(&root()),
Some("bench") => bench::run(&root(), &rest),
Some("parity") => parity::run(&root(), rest.contains(&"--write")),
Some("wasm") => cmd_wasm(),
Some("web") => web::run(&rest),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Workspace root (parent of the xtask crate).
pub fn root() -> PathBuf {
// `cargo run` sets it at run time; prefer that over the compile-time value, which goes stale
// when the checkout moves and the cached xtask binary isn't rebuilt
let dir = std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from).filter(|d| d.join("Cargo.toml").is_file());
let dir = dir.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")));
dir.parent().expect("xtask has a parent dir").to_path_buf()
}
pub fn cargo() -> Command {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.current_dir(root());
c
}
pub fn run(mut cmd: Command, what: &str) -> Result<(), String> {
eprintln!("$ {what}");
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
}
pub fn metadata() -> Result<serde_json::Value, String> {
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
if !out.status.success() {
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
}
fn cmd_layers() -> Result<(), String> {
let crates = layers::from_metadata(&metadata()?)?;
println!("Dependency layering (plan/architecture.md §3)\n");
println!("{:<28} {:<14} workspace deps", "crate", "layer");
for c in &crates {
let ws: Vec<String> = c
.deps
.iter()
.filter(|d| d.workspace)
.map(|d| {
let k = match d.kind {
layers::DepKind::Normal => "",
layers::DepKind::Dev => " (dev)",
layers::DepKind::Build => " (build)",
};
format!("{}{k}", layers::short_name(&d.name))
})
.collect();
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
}
let violations = layers::check(&crates);
println!();
if violations.is_empty() {
println!("OK: {} crates, no layering violations.", crates.len());
Ok(())
} else {
println!("{} violation(s):", violations.len());
for v in &violations {
println!(" - {v}");
}
Err(format!("{} layering violation(s)", violations.len()))
}
}
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
/// the egui shell and the web app.
fn wasm_set() -> Result<Vec<String>, String> {
let crates = layers::from_metadata(&metadata()?)?;
Ok(crates
.into_iter()
.filter(|c| match layers::classify(&c.name) {
Some(layers::Class::Layer(l)) => l <= 5,
Some(layers::Class::Standalone) => true,
_ => false,
})
.map(|c| c.name)
.chain(std::iter::once("lightcraft-web".to_string()))
.collect())
}
fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
let mut results = Vec::new();
for pkg in &set {
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
results.push((pkg.clone(), ok));
}
println!("\nwasm32-unknown-unknown check:");
for (p, ok) in &results {
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
}
let failed = results.iter().filter(|r| !r.1).count();
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
}
fn cmd_ci() -> Result<(), String> {
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
let steps: Vec<Step> = vec![
(
"fmt",
Box::new(|| {
let mut c = cargo();
c.args(["fmt", "--all", "--", "--check"]);
run(c, "cargo fmt --all -- --check")
}),
),
(
"clippy",
Box::new(|| {
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
}),
),
(
"test",
Box::new(|| {
let mut c = cargo();
c.args(["test", "--workspace"]);
run(c, "cargo test --workspace")
}),
),
("parity", Box::new(|| parity::run(&root(), false))),
("layers", Box::new(cmd_layers)),
("assets", Box::new(|| assets::run(&root()))),
("wasm", Box::new(cmd_wasm)),
];
let mut done = Vec::new();
for (name, f) in &steps {
eprintln!("\n=== ci: {name} ===");
if let Err(e) = f() {
println!("\nCI summary:");
for d in &done {
println!(" ok {d}");
}
println!(" FAIL {name}: {e}");
for (n, _) in steps.iter().skip(done.len() + 1) {
println!(" skip {n}");
}
return Err(format!("ci failed at `{name}`"));
}
done.push(*name);
}
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
Ok(())
}
/// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only).
/// CC0 raw samples from raw.pixls.us (each verified CC0 on the site; files matched by their published SHA-256).
/// One per format / compression variant we decode or deliberately report as unsupported (preview only).
const RAW_SAMPLES: &[(&str, &str)] = &[
(
"arw-sony-a7m3-compressed.arw",
"https://raw.pixls.us/getfile.php/2414/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW",
),
(
"arw-sony-a7m3-uncompressed.arw",
"https://raw.pixls.us/getfile.php/2418/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.ARW",
),
("arw-sony-a7m4-14bit.arw", "https://raw.pixls.us/getfile.php/6936/nice/Sony%20-%20ILCE-7M4%20-%2014bit%20%283:2%29.ARW"),
// lossless compressed (Compression 7): L is 2×2 CFA cells per LJ92 sample; M and S are subsampled
("arw-sony-a7m4-lossless-l.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW"),
("arw-sony-a7m4-lossless-m.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW"),
("arw-sony-a7m4-lossless-s.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW"),
// pre-2017 bodies: white balance only in the enciphered maker note, black level only in the SR2SubIFD (#148)
("arw-sony-rx100m3.arw", "https://raw.pixls.us/data/Sony/DSC-RX100M3/DSC00734.ARW"),
("arw-sony-rx100.arw", "https://raw.pixls.us/data/Sony/DSC-RX100/DSC00838.ARW"),
("arw-sony-a7rm2-12bit-uncompressed.arw", "https://raw.pixls.us/data/Sony/ILCE-7RM2/12-bit-uncompressed.ARW"),
// CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples
("cr2-canon-40d.cr2", "https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2"),
("cr2-canon-550d.cr2", "https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2"),
("cr2-canon-5d2.cr2", "https://raw.pixls.us/data/Canon/EOS%205D%20Mark%20II/08.canon.raw.cr2"),
("cr2-canon-5dsr.cr2", "https://raw.pixls.us/data/Canon/EOS%205DS%20R/_DSR2002.CR2"),
("cr2-canon-6d.cr2", "https://raw.pixls.us/data/Canon/EOS%206D/EOS_6D_RAW.CR2"),
("cr2-canon-7d.cr2", "https://raw.pixls.us/data/Canon/EOS%207D/RAW_CANON_EOS_7D-raw.CR2"),
("cr2-canon-5d3-sraw2.cr2", "https://raw.pixls.us/getfile.php/773/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%20sRAW2%20%28sRAW%29.CR2"),
("cr2-canon-5d3.cr2", "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2"),
("cr2-canon-80d.cr2", "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2"),
("cr3-canon-m50-craw.cr3", "https://raw.pixls.us/getfile.php/2663/nice/Canon%20-%20EOS%20M50%20-%20CRAW%20%283:2%29.CR3"),
(
"dng-adobe-canon-5d3-linear-lj92.dng",
"https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG",
),
(
"dng-adobe-canon-5d3-lj92.dng",
"https://raw.pixls.us/getfile.php/1024/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20Lossless%20JPEG%20compression%20%283:2%29.DNG",
),
(
"dng-adobe-canon-5d3-lossy.dng",
"https://raw.pixls.us/getfile.php/1023/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%20%283:2%29.DNG",
),
(
"dng-canon-5d3-14bit-small.dng",
"https://raw.pixls.us/getfile.php/2204/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2014bit%2014bit%20%282.3471882640587%29.dng",
),
("dng-canon-5d3-16bit-169.dng", "https://raw.pixls.us/getfile.php/2649/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%20%2816:9%29.dng"),
("dng-canon-5d3-16bit.dng", "https://raw.pixls.us/getfile.php/885/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20RAW.dng"),
("dng-google-pixel2xl.dng", "https://raw.pixls.us/getfile.php/2206/nice/Google%20-%20Pixel%202%20XL%20-%2016bit%20%284:3%29.dng"),
("dng-ricoh-gr3.dng", "https://raw.pixls.us/getfile.php/3115/nice/Ricoh%20-%20GR%20III%20-%2014bit%20%283:2%29.DNG"),
(
"nef-nikon-d5100-lossless.nef",
"https://raw.pixls.us/getfile.php/1597/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef",
),
(
"nef-nikon-d5100-uncompressed.nef",
"https://raw.pixls.us/getfile.php/1598/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.nef",
),
(
"nef-nikon-d7000-lossy12.nef",
"https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF",
),
(
"nef-nikon-d7500-lossless12.nef",
"https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF",
),
(
"nef-nikon-d7500-lossless14.nef",
"https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF",
),
(
"nrw-nikon-b700-uncompressed.nrw",
"https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW",
),
("orf-olympus-e1.orf", "https://raw.pixls.us/getfile.php/1800/nice/Olympus%20-%20E-1%20-%2016bit%20%284:3%29.ORF"),
("orf-olympus-e400.orf", "https://raw.pixls.us/getfile.php/2151/nice/Olympus%20-%20E-400%20-%2016bit%20%284:3%29.ORF"),
("orf-olympus-em1.orf", "https://raw.pixls.us/getfile.php/1051/nice/Olympus%20-%20E-M1%20-%2016bit%20%284:3%29.orf"),
("orf-olympus-em10iii.orf", "https://raw.pixls.us/getfile.php/1787/nice/Olympus%20-%20E-M10%20Mark%20III%20-%2016bit%20%284:3%29.ORF"),
("orf-olympus-xz2.orf", "https://raw.pixls.us/getfile.php/1432/nice/Olympus%20-%20XZ-2%20-%2012bit%20%284:3%29.orf"),
("pef-pentax-k10d.pef", "https://raw.pixls.us/getfile.php/2239/nice/Pentax%20-%20K10D%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF"),
("pef-pentax-k3.pef", "https://raw.pixls.us/getfile.php/1075/nice/Pentax%20-%20K-3%20-%2014bit%20%283:2%29.PEF"),
("pef-pentax-k5iis.pef", "https://raw.pixls.us/getfile.php/1198/nice/Pentax%20-%20K-5%20II%20s%20-%2014bit%20%283:2%29.PEF"),
(
"raf-fuji-xa2-12bit-bayer.raf",
"https://raw.pixls.us/getfile.php/2883/nice/Fujifilm%20-%20X-A2%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF",
),
(
"raf-fuji-xa5-14bit-bayer.raf",
"https://raw.pixls.us/getfile.php/2526/nice/Fujifilm%20-%20X-A5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF",
),
("raf-fuji-xe1-12bit.raf", "https://raw.pixls.us/getfile.php/3098/nice/Fujifilm%20-%20X-E1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF"),
("raf-fuji-xt20-14bit.raf", "https://raw.pixls.us/getfile.php/1177/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF"),
(
"raf-fuji-xt20-compressed.raf",
"https://raw.pixls.us/getfile.php/1178/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
),
("rw2-panasonic-g9-b.rw2", "https://raw.pixls.us/getfile.php/2348/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2"),
("rw2-panasonic-g9.rw2", "https://raw.pixls.us/getfile.php/2585/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2"),
("rw2-panasonic-gh5.rw2", "https://raw.pixls.us/getfile.php/1517/nice/Panasonic%20-%20DC-GH5%20-%204:3.RW2"),
("rw2-panasonic-gh5s.rw2", "https://raw.pixls.us/getfile.php/2603/nice/Panasonic%20-%20DC-GH5S%20-%204:3.RW2"),
("rw2-panasonic-gx80.rw2", "https://raw.pixls.us/getfile.php/1569/nice/Panasonic%20-%20DMC-GX80%20-%204:3.RW2"),
];
fn cmd_corpus(download: bool) -> Result<(), String> {
let corpus = root().join("corpus");
println!(
"Test corpora live under {} (git-ignored, never committed).
Tests that use a corpus skip cleanly when it is absent.
corpus/raw/ raw.pixls.us samples (CC0) — lightcraft-raw decodes every file.
corpus/images/ CC0 / public-domain JPEG/PNG/TIFF/HEIC samples (optional)
",
corpus.display()
);
if !download {
return Ok(());
}
let dest = corpus.join("raw");
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
for (name, url) in RAW_SAMPLES {
let out = dest.join(name);
if out.exists() {
continue;
}
let mut curl = Command::new("curl");
curl.args(["-fsSL", "-o"]).arg(&out).arg(url);
run(curl, &format!("curl {url}"))?;
}
Ok(())
}