Merge origin/main into #522 (keep both new masking tests)

This commit is contained in:
Brandon Thomas 2026-10-09 20:38:01 -04:00
commit 4c4e9b154f
74 changed files with 3440 additions and 242 deletions

View File

@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).

View File

@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).

1
Cargo.lock generated
View File

@ -6824,6 +6824,7 @@ dependencies = [
"brotli",
"flate2",
"serde_json",
"sha2",
]
[[package]]

View File

@ -305,7 +305,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |

View File

@ -23,7 +23,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
@ -47,8 +47,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
checked on the corpus); then ORF, then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
@ -175,7 +175,7 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
lossy ones decode but no real file has verified them), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
@ -188,7 +188,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.

View File

@ -342,7 +342,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
let mut server = Server::new(backend).with_command_tools(!compact);
let stdin = std::io::stdin();
server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {

View File

@ -24,13 +24,21 @@
<script type="module">
import init, { start, command } from "./lightcraft_web.js";
(async () => {
// The Chinese font is a separate file: embedding it would exceed the host's WASM size limit.
// Font-free development builds can still run, with the same missing-glyph warning as before.
const chineseFont = fetch("./lightcraft_zh_hans.otf")
.then(async response => response.ok ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array())
.catch(() => new Uint8Array());
// Compile once; the render workers (worker.js) instantiate the same compiled module.
const module = await WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm"));
const [module, font] = await Promise.all([
WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm")),
chineseFont,
]);
globalThis.lightcraftModule = module;
await init({ module_or_path: module });
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
globalThis.lightcraft = { command };
start();
start(font);
})().catch((e) => {
console.error(e);
const el = document.getElementById("lightcraft_loading");

View File

@ -11,8 +11,9 @@
//!
//! Restoring never deletes anything: the library files go to a new library folder in browser
//! storage, originals that aren't stored yet are added (they're named by content, so existing
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
//! the restored library. The library that was there before stays in storage.
//! ones are identical; a restored one must match its content hash, see [`verify_original`]),
//! and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to the restored library.
//! The library that was there before stays in storage.
//!
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
@ -241,6 +242,16 @@ pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
Ok(())
}
/// Check that an original's bytes are the photo its storage key names: originals are stored
/// under their content hash, and a restore that kept bytes under another photo's hash would
/// give that photo's catalog record a different image (the zip checksum can't tell).
pub fn verify_original(entry: &ZipEntry, hash: &str, data: &[u8]) -> Result<(), String> {
if crate::store::content_hash(data) != hash {
return Err(format!("{}: the file doesn't match its content hash (damaged or altered backup)", entry.name));
}
Ok(())
}
/// A name for the restored library's folder.
pub fn restored_dir_name(now_ms: f64) -> String {
format!("library-restored-{}", now_ms.max(0.0) as u64)
@ -283,6 +294,27 @@ mod tests {
assert!(find_central(b"not a zip at all, just some text").is_err());
}
#[test]
fn originals_must_match_their_content_hash() {
let red: &[u8] = b"red corner photo";
let blue: &[u8] = b"blue corner photo";
let hash = crate::store::content_hash(red);
let name = original_entry(&hash, "IMG 1.png");
let key = restore_key(&name, "d").unwrap();
let key_hash = key.strip_prefix("originals/").unwrap();
assert_eq!(key_hash, hash);
// the entry's own bytes pass; other bytes under the same name (valid zip CRC) don't
let zip = build(&[(name.as_str(), blue)]);
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
let (off, size) = find_central(tail).unwrap();
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
let entry = &entries[0];
verify(entry, blue).unwrap();
let err = verify_original(entry, key_hash, blue).unwrap_err();
assert!(err.contains(&name), "{err}");
verify_original(entry, key_hash, red).unwrap();
}
#[test]
fn restore_keys_are_safe() {
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));

View File

@ -14,7 +14,7 @@ use wasm_bindgen_futures::JsFuture;
use crate::backend::Backend;
use crate::backup::{
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
valid_hash, verify,
valid_hash, verify, verify_original,
};
use crate::files::{Files, LIBRARY_FILES};
use crate::store::storage_key;
@ -224,6 +224,9 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, St
let start = data_offset(e, &local)?;
let data = read_range(&file, start, start + e.size).await?;
verify(e, &data)?;
if let Some(hash) = key.strip_prefix("originals/") {
verify_original(e, hash, &data)?;
}
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
written += 1;
}

View File

@ -378,7 +378,7 @@ struct WebApp {
}
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
let Boot { opts, backend, files, index, lib_dir } = boot;
let originals = Originals::default();
let t = perf_now();
@ -423,7 +423,8 @@ impl WebApp {
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
let frozen = Rc::new(Cell::new(false));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
.with_chinese_font(chinese_font);
let ui_written = files.get("ui.json").unwrap_or_default();
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
app.ui = ui;
@ -656,12 +657,15 @@ impl eframe::App for WebApp {
}
}
/// Start the app (called by `index.html` after instantiating the module).
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
#[wasm_bindgen]
pub fn start() {
pub fn start(chinese_font: Vec<u8>) {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
if chinese_font.is_empty() {
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
}
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
// one tab per library: two would each keep their own copy and overwrite each other's saves
@ -682,7 +686,7 @@ pub fn start() {
};
let boot = boot(opts).await;
let runner = eframe::WebRunner::new();
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot, chinese_font))))).await;
match r {
Ok(()) => {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {

View File

@ -290,6 +290,39 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
c
}
/// Explorer's `/select,<path>` switch (issue #272). Explorer parses its own command line and only
/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`),
/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that
/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain
/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line
/// always holds exactly this one argument.
fn explorer_select_arg(path: &str) -> String {
let path = path.replace('/', "\\").replace('"', "");
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(unc) => format!(r"\\{unc}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(),
};
format!("/select,\"{path}\"")
}
/// `explorer /select,"<path>"`. Rust's own argument quoting wraps the whole switch in quotes
/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't
/// recognise, so on Windows the switch goes on the command line exactly as built.
fn explorer_select_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("explorer");
let arg = explorer_select_arg(path);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.raw_arg(arg);
}
#[cfg(not(windows))]
{
c.arg(arg);
}
c
}
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
/// for Help ▸ Open Log Folder.
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
@ -377,8 +410,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").args(["-R", path]).status()
} else if cfg!(target_os = "windows") {
let win_path = path.replace('/', "\\");
std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
explorer_select_command(path).status()
} else {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
std::process::Command::new("xdg-open").arg(dir).status()
@ -928,6 +960,23 @@ mod tests {
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
}
/// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and
/// without the verbatim prefix, even when the path has spaces (else it opens Documents).
#[test]
fn explorer_select_quotes_the_path() {
assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#);
assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#);
assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#);
assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#);
assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#);
// a `"` can't end the quoted path and add arguments to the raw command line
assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#);
let c = explorer_select_command(r"C:\My Photos\a.jpg");
assert_eq!(c.get_program(), "explorer");
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]);
}
#[test]
fn a_control_port_that_is_not_a_number_is_ignored() {
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));

View File

@ -21,12 +21,12 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; embedded only in builds made with `CRAFT_FONTS_DIR` (all official releases); the web (wasm32) build embeds BIZ UDPGothic Regular only |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) are not in this repository. They live in
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).

View File

@ -155,7 +155,7 @@ impl Catalog {
let date_of = |id: &PhotoId| -> String {
let Some(p) = self.photo(*id) else { return String::new() };
let d = match key {
SortKey::CaptureDate => Some(p.date()),
SortKey::CaptureDate => p.captured.as_deref(),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
@ -222,7 +222,7 @@ mod tests {
}
let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::<Vec<_>>();
let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day);
// the undated photo falls back to its import date
// the undated photo is grouped under Unknown Date
assert_eq!(
shape(&day),
vec![
@ -230,16 +230,20 @@ mod tests {
("2026-09-29".into(), 2, 1),
("2026-08-01".into(), 3, 1),
("2025-12-31".into(), 4, 1),
("2026-10-01".into(), 5, 1)
(String::new(), 5, 1)
]
);
assert_eq!(day[0].label, "Wednesday, 30 September 2026");
assert_eq!(day[4].label, "Unknown Date");
assert_eq!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Auto), day);
assert_eq!(
shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Month)),
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1)]
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Month)),
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1), (String::new(), 5, 1)]
);
assert_eq!(
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Year)),
vec![("2026".into(), 0, 4), ("2025".into(), 4, 1), (String::new(), 5, 1)]
);
assert_eq!(shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Year)), vec![("2026".into(), 0, 4), ("2025".into(), 4, 1)]);
assert!(c.date_runs(&ids, SortKey::FileName, GroupBy::Day).is_empty());
assert!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::None).is_empty());
// edit date: unedited photos have no date

View File

@ -336,13 +336,13 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
self.develops_raw()
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@ -450,6 +450,7 @@ mod edited_tests {
("a.raw", "RAW", true),
("a.raf", "rAf", true),
("a.cr3", "cr3", true),
("a.srw", "SRW", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");

View File

@ -141,7 +141,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"person" | "who" => has_person(p, val),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
_ => false,
@ -284,12 +284,18 @@ impl Filter {
return false;
}
if let Some(from) = &self.date_from
&& p.date() < from.as_str()
&& match p.captured.as_deref() {
Some(c) => c < from.as_str(),
None => true,
}
{
return false;
}
if let Some(to) = &self.date_to
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
&& match p.captured.as_deref() {
Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(),
None => true,
}
{
return false;
}
@ -299,7 +305,7 @@ impl Filter {
return false;
}
if let Some(d) = &self.date
&& !p.date().starts_with(d.as_str())
&& !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str()))
{
return false;
}
@ -344,7 +350,7 @@ impl Catalog {
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.date().cmp(b.date()),
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
@ -363,7 +369,7 @@ impl Catalog {
use std::collections::BTreeMap;
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
for p in self.photos().filter(|p| p.in_library()) {
let d = p.date();
let Some(d) = p.captured.as_deref() else { continue };
if d.len() >= 10 {
let (months, days) = years.entry(&d[..4]).or_default();
*months.entry(&d[..7]).or_default() += 1;

View File

@ -456,3 +456,48 @@ fn random_sort_has_no_date_headers() {
let ids = many(&mut c, 5);
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
}
#[test]
fn undated_photos_group_under_unknown_date_and_sort_together() {
let mut c = Catalog::new();
let add_photo = |c: &mut Catalog, name: &str, captured: Option<&str>, imported: &str| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 0 }, name, "JPEG", 100, 100, imported);
p.captured = captured.map(str::to_string);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let p_dated1 = add_photo(&mut c, "dated1.jpg", Some("2026-05-10T12:00:00"), "2026-10-01T00:00:00");
let p_dated2 = add_photo(&mut c, "dated2.jpg", Some("2026-05-20T12:00:00"), "2026-10-01T00:00:00");
let p_undated1 = add_photo(&mut c, "undated1.jpg", None, "2026-10-05T00:00:00");
let p_undated2 = add_photo(&mut c, "undated2.jpg", None, "2026-10-02T00:00:00");
// Descending sort (default, newest first): dated photos first (newest to oldest), undated photos at the end
let desc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() });
assert_eq!(desc, vec![p_dated2, p_dated1, p_undated1, p_undated2]);
// Ascending sort (oldest first): undated photos at the beginning, dated photos from oldest to newest
let asc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() });
assert_eq!(asc, vec![p_undated2, p_undated1, p_dated1, p_dated2]);
// date_runs groups undated photos under Unknown Date
let runs = c.date_runs(&desc, SortKey::CaptureDate, GroupBy::Day);
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].key, "2026-05-20");
assert_eq!(runs[1].key, "2026-05-10");
assert_eq!(runs[2].key, "");
assert_eq!(runs[2].label, "Unknown Date");
assert_eq!(runs[2].count, 2);
// date_groups sidebar tree only counts photos with capture dates
let groups = c.date_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].year, "2026");
assert_eq!(groups[0].count, 2);
// Filter by capture date prefix does not match undated photos
let filter_day = Filter { date: Some("2026-10-05".into()), ..Default::default() };
assert_eq!(c.query(&filter_day, &Sort::default()), vec![]);
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
}

View File

@ -88,11 +88,16 @@ pub struct Profile {
pub id: String,
/// Creative profile amount 0..200 (%).
pub amount: f64,
/// Render the raw's own local tone mapping (DNG `ProfileGainTableMap`, Apple ProRAW), as the
/// camera does. Off: rendered as Lightroom Classic does, without it. Omitted when off, so
/// settings without it keep their JSON (and hash).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub camera_local_tone: bool,
}
impl Default for Profile {
fn default() -> Self {
Self { id: "lc.color".into(), amount: 100.0 }
Self { id: "lc.color".into(), amount: 100.0, camera_local_tone: false }
}
}

View File

@ -13,19 +13,22 @@ fn main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
let mut embedded = 0;
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) {
match craft_fonts(&dir) {
match craft_fonts(&dir, required) {
Ok(entries) => {
embedded = entries.lines().count();
src.push_str(&entries);
}
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
Err(e) if required => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
} else if required {
println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing");
}
src.push_str("];\n");
// Without them the app still runs and every language still selectable, so Chinese and Japanese
@ -58,17 +61,21 @@ fn resolve(dir: PathBuf) -> PathBuf {
}
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &Path) -> Result<String, String> {
fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
let mut has_japanese_ui = false;
let mut has_chinese_ui = false;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
continue;
}
@ -82,5 +89,11 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
path.display().to_string(),
);
}
if required && !has_japanese_ui {
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
}
if required && !wasm && !has_chinese_ui {
return Err("required Simplified Chinese UI face Noto Sans CJK SC Regular (Hans) is missing from fonts/manifest.txt".into());
}
Ok(out)
}

View File

@ -33,7 +33,15 @@ pub(crate) const PROFILE_PROXY: usize = 192;
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(
format,
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
RawFormat::Arw
| RawFormat::Nef
| RawFormat::Nrw
| RawFormat::Rw2
| RawFormat::Raf
| RawFormat::Cr3
| RawFormat::Cr2
| RawFormat::Pef
| RawFormat::Srw
)
}
@ -402,19 +410,38 @@ const EDGE_CONTRAST: f32 = 3.0;
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
/// valid when the camera JPEG's geometry differs slightly from the raw's.
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_pairs_ordered(sensor, reference, colour, &[ToneFit::Quantile, ToneFit::ConditionalMedian])
}
/// [`fit_pairs_with`] with the tone fits to try, in order: the first whose look passes the
/// acceptance gates is used. Quantile matching comes first because it cannot produce flat steps;
/// the conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds)
/// its slightly lower held-out error is what clears the gates, and a photo must not lose its look
/// to the change of fit. The whole search (all pixels, then away from edges, with and without the
/// camera profile's colour) runs with one tone fit before the next is tried, so the fallback
/// accepts exactly what the previous releases accepted.
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<CameraLook> {
let has_profile = colour.is_some();
// A different picture style can make a camera profile fail the gates. Preserve the
// photo's own colour fit before resorting to the neutral fallback.
fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
order.iter().find_map(|&tone| {
// A different picture style can make a camera profile fail the gates. Preserve the
// photo's own colour fit before resorting to the neutral fallback.
fit_candidate(sensor, reference, colour.clone(), tone).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None, tone)).flatten())
})
}
/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
/// the pixels away from edges (issue #232).
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, tone: ToneFit) -> Option<CameraLook> {
fit_pairs_on(sensor, reference, colour.clone(), None, tone).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST), tone))
}
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
fn fit_pairs_on(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: Option<(Mat3, Option<HsvTable>)>,
edge_limit: Option<f32>,
tone_fit: ToneFit,
) -> Option<CameraLook> {
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
@ -437,7 +464,7 @@ fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Optio
};
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let Some(curve) = fit_tone(tone_pairs) else { continue };
let Some(curve) = tone_fit.fit(tone_pairs) else { continue };
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
if let Some(tone) = fit_chroma(&bright, &look) {
look.tone = tone;
@ -468,10 +495,11 @@ fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Optio
.sum();
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})",
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})",
(before / samples as f64).sqrt(),
(after / samples as f64).sqrt(),
if edge_limit.is_some() { ", away from edges" } else { "" }
if edge_limit.is_some() { ", away from edges" } else { "" },
if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" }
);
}
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
@ -681,12 +709,68 @@ fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<Camer
(after.is_finite() && after < before).then_some(fitted)
}
/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ToneFit {
/// [`fit_tone`]: no flat steps, no pixel-exact registration needed.
Quantile,
/// [`fit_tone_conditional`]: the earlier fit, kept as the fallback.
ConditionalMedian,
}
impl ToneFit {
fn fit(self, pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
match self {
ToneFit::Quantile => fit_tone(pairs),
ToneFit::ConditionalMedian => fit_tone_conditional(pairs),
}
}
}
/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs
/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th
/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the
/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact
/// registration and is increasing by construction.
///
/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by
/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a
/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins.
/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments
/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the
/// mean flattens both ends of the curve (lifted blacks, dull highlights).
fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
return None;
}
let mut xs: Vec<f64> = pairs.iter().map(|p| p.0).collect();
let mut ys: Vec<f64> = pairs.iter().map(|p| p.1).collect();
xs.sort_by(f64::total_cmp);
ys.sort_by(f64::total_cmp);
let n = xs.len();
let mut knots = [[0.0; 2]; 32];
for (i, knot) in knots.iter_mut().enumerate() {
let (a, b) = (i * n / 32, (i + 1) * n / 32);
let mid = a + (b - a) / 2;
*knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32];
}
if knots[31][0] < knots[0][0] * 1.5 {
return None;
}
CameraTone::new(knots)
}
fn median(values: &mut [f64]) -> Option<f64> {
values.sort_by(f64::total_cmp);
values.get(values.len() / 2).copied()
}
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population
/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the
/// per-pixel pairs closely when the two images are registered, which is why it can clear the
/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes
/// the pooling leaves flat steps (issue #475).
fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
return None;
}
@ -1021,7 +1105,17 @@ mod tests {
#[test]
fn supported_raws_get_a_file_local_look() {
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
let local = [
RawFormat::Arw,
RawFormat::Nef,
RawFormat::Nrw,
RawFormat::Rw2,
RawFormat::Raf,
RawFormat::Cr3,
RawFormat::Cr2,
RawFormat::Pef,
RawFormat::Srw,
];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
assert!(!file_local_look(RawFormat::Dng));
@ -1228,4 +1322,77 @@ mod tests {
reference.data.truncate(8);
assert!(fit_pairs(&sensor, &reference).is_none());
}
/// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a
/// lens-corrected preview): the fitted curve follows the camera's and has no flat steps.
#[test]
fn tone_fit_has_no_steps_on_a_misregistered_jpeg() {
let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x);
let (w, h) = (96usize, 64usize);
let mut seed = 0x2545_f491_u32;
let mut scene = Vec::with_capacity(w * h);
for _ in 0..w * h {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp());
}
let pairs: Vec<(f64, f64)> =
(0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect();
let curve = fit_tone(pairs).unwrap();
let mut sorted = scene.clone();
sorted.sort_by(f64::total_cmp);
let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]);
let mut x = lo;
while x < hi {
let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32));
assert!(b > a, "flat step at {x}: {a} -> {b}");
assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x));
x *= 1.02;
}
}
/// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of
/// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin
/// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit.
fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
let mut seed = 0x9e37_79b9_u32;
let mut next = move || {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
f32::from((seed >> 16) as u16) / 32768.0 - 1.0
};
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
let ev = 0.05 + (next() + 1.0) * 0.2;
*src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next());
*dst = p.map(|v| v * scale);
}
(sensor, reference)
}
/// Quantile matching is tried first; the conditional-median fit is the fallback and is used
/// only where the quantile look misses the acceptance gates, so no photo loses its look.
#[test]
fn tone_fit_order_quantile_first_conditional_median_as_fallback() {
// Clean data: both fits pass, the default order uses the quantile curve (increasing).
let (sensor, reference) = noisy_look(0.0);
let default = fit_pairs(&sensor, &reference).expect("clean look");
let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes");
assert_eq!(default.tone, quantile.tone);
// Noisy data: the quantile fit misses the gates, the conditional median still passes.
let (sensor, reference) = noisy_look(0.33);
assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates");
let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes");
let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look");
assert_eq!(default.tone, conditional.tone);
// The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data.
let (sensor, reference) = noisy_look(0.0);
let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap();
let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap();
assert_eq!(swapped.tone, conditional.tone);
}
}

View File

@ -661,9 +661,19 @@ pub fn specs() -> Vec<CommandSpec> {
),
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let vis = s.visible_cloned();
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
Ok(v)
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {

View File

@ -358,11 +358,22 @@ fn load_bytes_now(
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
});
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let local_tone = local_tone(&raw);
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((
img,
twin,
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
SourceInfo {
raw: true,
as_shot_temp: temp,
as_shot_tint: tint,
lens,
relative_wb: relative,
camera_color,
camera_tone,
mattes,
local_tone,
},
));
}
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
@ -372,6 +383,18 @@ fn load_bytes_now(
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
}
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
let map = raw.color.profile.gain_table_map.clone()?;
let a = raw.active_area;
let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64);
let c = raw.develop_crop(a.width, a.height);
let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah];
let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation };
Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement }))
}
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
@ -958,6 +981,59 @@ mod tests {
assert_eq!(before.data, after.data);
}
/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's
/// active area, whatever the photo's orientation), and only when it's on.
#[test]
fn dng_gain_table_map_renders_when_asked() {
use lightcraft_pipeline::{RenderRequest, render};
use lightcraft_raw::gaintable::GainTableMap;
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&plain).unwrap();
// ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour
raw.color.profile.gain_table_map = Some(GainTableMap {
points_v: 1,
points_h: 2,
points_n: 1,
spacing_v: 1.0,
spacing_h: 1.0,
origin_v: 0.0,
origin_h: 0.0,
weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0],
gamma: 1.0,
gains: vec![1.0, 4.0],
});
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let (img, info) = load_bytes(&with_map, 64).unwrap();
let (_, plain_info) = load_bytes(&plain, 64).unwrap();
assert!(info.local_tone.is_some() && plain_info.local_tone.is_none());
let req = RenderRequest::fit(64, 64);
let mut s = lightcraft_develop::DevelopSettings::default();
// (darker, so that ×4 never reaches white)
s.light.exposure = -3.0;
let off = render(&img, &info, &s, &req).image;
assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing");
s.profile.camera_local_tone = true;
assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)");
let on = render(&img, &info, &s, &req).image;
// brightness gained, per column: none at the left edge, most at the right
let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::<f32>();
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
let last = on.width - 1;
assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0));
assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last));
// the photo turned upside down: the map follows the raw, so the brightened side swaps
s.orientation = lightcraft_geom::Orientation::Rotate180;
s.profile.camera_local_tone = false;
let off = render(&img, &info, &s, &req).image;
s.profile.camera_local_tone = true;
let on = render(&img, &info, &s, &req).image;
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last));
// a rendered (non-raw) source never gets it
let jpeg = SourceInfo { raw: false, ..info.clone() };
assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s));
}
#[test]
fn unsupported_raw_falls_back_to_embedded_preview() {
let b = cr3_with_preview(48, 32);

View File

@ -32,9 +32,9 @@ use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
"jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 21;
pub const RENDER_CACHE_VERSION: u64 = 22;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@ -209,6 +209,7 @@ impl DecodedSource {
/// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.
pub fn bytes(&self) -> usize {
let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4);
self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
}

View File

@ -1,6 +1,6 @@
use serde_json::{Value, json};
use crate::{Session, command_specs};
use crate::{Selection, Session, command_specs};
fn demo() -> Session {
Session::with_demo()
@ -157,6 +157,47 @@ fn filters_and_delete_restore() {
assert_eq!(s.visible().len(), 24);
}
#[test]
fn deleting_photos_selects_a_neighbour_in_the_current_view() {
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
for at_end in [false, true] {
let mut s = demo();
s.execute("library.sort", &params).unwrap();
s.execute("library.filter", &json!({"rating": 2})).unwrap();
let visible = s.visible_cloned();
assert!(visible.len() > 5);
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
let deleted = &visible[at..at + 2];
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
s.execute("edit.undo", &json!({})).unwrap();
assert!(deleted.iter().all(|id| !s.catalog.photo(*id).unwrap().deleted));
s.execute("edit.redo", &json!({})).unwrap();
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
}
}
let mut s = demo();
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
assert!(s.visible().is_empty());
assert_eq!(s.selection, Selection::default());
}
#[test]
fn deleting_explicit_targets_keeps_the_surviving_selection() {
let mut s = demo();
let visible = s.visible_cloned();
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
let before = s.selection.clone();
s.execute("photo.delete", &json!({"ids": [visible[3]]})).unwrap();
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
s.execute("photo.delete", &json!({"ids": [visible[9]]})).unwrap();
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
}
#[test]
fn masks_crop_and_render() {
let mut s = demo();

View File

@ -738,8 +738,8 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho
let (w, h) = (plan.w, plan.h);
let n = w * h;
let s = &*plan.settings;
let img = if lightcraft_pipeline::lin_needs_cpu(s) {
// defringe / spot removal: CPU
let img = if lightcraft_pipeline::lin_needs_cpu(s, info) {
// defringe / spot removal / local tone mapping: CPU
let mut img = match host.sampled.take() {
Some(i) => i,
None => cx.read_rgb(sampled, w, h),

View File

@ -15,8 +15,13 @@ pub trait Backend {
fn has_ui(&self) -> bool;
/// Short human description ("headless", "remote 127.0.0.1:7980").
fn describe(&self) -> String;
/// Progress/cancellation for the next headless batch export. Other backends ignore it.
fn set_progress(&mut self, _hook: Option<ProgressHook>) {}
}
/// `(completed photos, total photos, next filename)`; false stops before the next photo.
pub type ProgressHook = Box<dyn FnMut(usize, usize, &str) -> bool + Send>;
/// A running LightCraft app, reached through its loopback control port.
pub struct Remote {
addr: String,

View File

@ -8,18 +8,19 @@ use lightcraft_engine::catalog::PhotoId;
use lightcraft_raster::Rgba8;
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::backend::{Backend, ProgressHook};
/// File extensions recognised as photos when expanding folders.
pub const PHOTO_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
"bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl",
"gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
];
/// Headless backend: a [`Session`] with filesystem hooks.
pub struct Headless {
pub session: Session,
progress: Option<ProgressHook>,
}
impl Drop for Headless {
@ -37,7 +38,7 @@ impl Default for Headless {
impl Headless {
pub fn new(session: Session) -> Self {
Self { session }
Self { session, progress: None }
}
/// A headless session with the procedurally generated demo library.
@ -60,7 +61,7 @@ impl Headless {
/// (see `lightcraft_engine::export::ExportOptions::from_json`, plus `ids`, `dir`, `path`).
/// With `path` and no `format`, the format follows the path's extension.
fn export(&mut self, p: &Value) -> Result<Value, String> {
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch};
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch};
let p = &self.session.export_params(p)?;
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
if !ExportOptions::has_size_param(p) {
@ -82,10 +83,28 @@ impl Headless {
};
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
let write = &mut lightcraft_engine::export::write_file;
let files =
export_batch(&mut self.session, &ids, &opts, &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, write, &|path| {
Path::new(path).exists()
})?;
let items = prepare_batch(&mut self.session, &ids, &opts)?;
let total = items.len();
let mut hook = self.progress.take();
let mut cancelled = false;
let files = run_batch(
items,
&opts,
&Destination { dir: dir.to_string(), exact: exact.map(str::to_string) },
write,
&|path| Path::new(path).exists(),
true,
&mut |done, name| {
cancelled = hook.as_mut().is_some_and(|h| !h(done, total, name));
!cancelled
},
)?;
if !cancelled && let Some(hook) = hook.as_mut() {
cancelled = !hook(total, total, "");
}
if cancelled {
return Err("cancelled".into());
}
// Single-photo exports also report path/width/height at the top level (back-compat).
let mut out = files.first().cloned().unwrap_or_else(|| json!({}));
out["files"] = json!(files);
@ -94,6 +113,10 @@ impl Headless {
}
impl Backend for Headless {
fn set_progress(&mut self, hook: Option<ProgressHook>) {
self.progress = hook;
}
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let p = if params.is_null() { json!({}) } else { params };
match method {

View File

@ -21,7 +21,7 @@ mod headless;
mod server;
mod tools;
pub use backend::{Backend, Remote};
pub use backend::{Backend, ProgressHook, Remote};
pub use base64::{base64_decode, base64_encode};
pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image};
pub use server::{PROTOCOL_VERSION, Server};

165
crates/mcp/src/progress.rs Normal file
View File

@ -0,0 +1,165 @@
//! Stdio progress and cancellation between photos. The backend keeps its session on the
//! serving thread; only stdin and stdout move to transport threads. Other calls wait in order.
use std::collections::VecDeque;
use std::io::{BufRead, Write};
use std::sync::atomic::Ordering;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use super::{Server, response};
use crate::backend::ProgressHook;
type Input = Option<std::io::Result<String>>;
#[derive(Clone)]
pub(super) struct Wire {
inbox: Arc<Mutex<Receiver<Input>>>,
out: Sender<String>,
deferred: Arc<Mutex<VecDeque<Input>>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl Server {
pub(super) fn serve_wire(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> {
std::thread::scope(|scope| {
let (in_tx, in_rx) = std::sync::mpsc::channel::<Input>();
let (out_tx, out_rx) = std::sync::mpsc::channel::<String>();
let wire = Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, deferred: Arc::new(Mutex::new(VecDeque::new())) };
let writer = std::thread::Builder::new().name("mcp-output".into()).spawn_scoped(scope, move || -> std::io::Result<()> {
let mut output = output;
for line in out_rx {
output.write_all(line.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
Ok(())
})?;
let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || {
for line in input.lines() {
let failed = line.is_err();
if in_tx.send(Some(line)).is_err() || failed {
return;
}
}
let _ = in_tx.send(None);
}) {
Ok(reader) => reader,
Err(error) => {
drop(wire);
let _ = writer.join();
return Err(error);
}
};
self.wire = Some(wire.clone());
let served = (|| -> std::io::Result<()> {
loop {
let queued = lock(&wire.deferred).pop_front();
let line = match queued {
Some(line) => line,
None => lock(&wire.inbox).recv().ok().flatten(),
};
let Some(line) = line else { return Ok(()) };
if let Some(reply) = self.handle_line(&line?) {
wire.out.send(reply).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?;
}
}
})();
self.wire = None;
drop(wire);
let written = writer.join().map_err(|_| std::io::Error::other("MCP output thread panicked")).and_then(|r| r);
let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked"));
served.and(written).and(read)
})
}
/// Called at photo boundaries, including completion. EOF finishes the pending export;
/// cancellation stops before the next photo. No file scan or output deletion is needed:
/// the existing writer replaces each whole file atomically.
pub(super) fn progress_hook(&self) -> Option<ProgressHook> {
let wire = self.wire.clone()?;
let (id, token) = self.current.clone()?;
let suppress = self.suppress.clone();
let mut closed = false;
let mut last = None;
let mut reported: Option<Instant> = None;
Some(Box::new(move |done, total, name| {
while !closed {
let next = lock(&wire.inbox).try_recv();
let line = match next {
Ok(Some(Ok(line))) => line,
Ok(Some(Err(error))) => {
lock(&wire.deferred).push_back(Some(Err(error)));
closed = true;
break;
}
Ok(None) | Err(TryRecvError::Disconnected) => {
lock(&wire.deferred).push_back(None);
closed = true;
break;
}
Err(TryRecvError::Empty) => break,
};
let msg: Value = serde_json::from_str(&line).unwrap_or(Value::Null);
match msg.get("method").and_then(Value::as_str) {
Some("notifications/cancelled") if msg.get("id").is_none() && msg["params"]["requestId"] == id => {
suppress.store(true, Ordering::Relaxed);
}
Some("ping")
if msg.get("id").is_some_and(|i| i.is_string() || i.is_number())
&& msg.get("params").is_none_or(|p| p.is_null() || p.is_object()) =>
{
let _ = wire.out.send(response(msg["id"].clone(), json!({})).to_string());
}
_ => lock(&wire.deferred).push_back(Some(Ok(line))),
}
}
if suppress.load(Ordering::Relaxed) {
return false;
}
if let Some(token) = &token
&& total > 0
&& last.is_none_or(|last| done > last)
&& (done == total || reported.is_none_or(|at| at.elapsed() >= Duration::from_millis(100)))
{
last = Some(done);
reported = Some(Instant::now());
let message = if done < total {
format!("Exporting {name} ({} of {total})", done.saturating_add(1))
} else {
format!("Exported {total} photo(s)")
};
let notification = json!({"jsonrpc":"2.0","method":"notifications/progress",
"params":{"progressToken":token,"progress":done,"total":total,"message":message}});
let _ = wire.out.send(notification.to_string());
}
true
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_lock_recovers_after_panic() {
let state = Arc::new(Mutex::new(VecDeque::new()));
let held = state.clone();
let _ = std::thread::spawn(move || {
held.lock().unwrap().push_back(7);
let _guard = held.lock().unwrap();
panic!("synthetic transport panic");
})
.join();
assert_eq!(lock(&state).pop_front(), Some(7));
lock(&state).push_back(8);
assert_eq!(lock(&state).pop_front(), Some(8));
}
}

View File

@ -1,11 +1,16 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[path = "progress.rs"]
mod progress;
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::tools::{call_tool, tool_definitions};
use crate::tools::{ToolResult, call_tool, check_args, doc_inspect, tool_definitions};
/// The MCP revision we implement.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
@ -22,11 +27,13 @@ const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \
Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \
Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \
render_photo to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \
coordinates 0..1 with the origin at the top-left. Every action is a command: list_commands shows ids and parameter \
docs; run_command runs any of them. Edits are undoable (undo / redo).";
render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \
coordinates 0..1 with the origin at the top-left. Every action is a command: command_list shows ids and parameter \
docs; command_run {id, params} runs any of them; command_batch runs steps in order. Inspect with doc_inspect. Edits are undoable (undo / redo).";
/// Resource URIs.
pub const DOCUMENT_URI: &str = "lightcraft://document";
pub const COMMANDS_URI: &str = "lightcraft://commands";
pub const LIBRARY_URI: &str = "lightcraft://library";
pub const PHOTOS_URI: &str = "lightcraft://photos";
pub const PHOTO_URI: &str = "lightcraft://photo/active";
@ -38,6 +45,9 @@ pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
command_tools: bool,
wire: Option<progress::Wire>,
current: Option<(Value, Option<Value>)>,
suppress: Arc<AtomicBool>,
}
fn response(id: Value, result: Value) -> Value {
@ -54,7 +64,7 @@ fn resource(uri: &str, name: &str, title: &str, description: &str) -> Value {
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false, command_tools: true }
Self { backend, initialized: false, command_tools: true, wire: None, current: None, suppress: Arc::new(AtomicBool::new(false)) }
}
/// Whether `tools/list` includes one `cmd_*` tool per registered command (default true).
@ -75,16 +85,8 @@ impl Server {
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
pub fn serve(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> {
self.serve_wire(input, output)
}
/// Handle one line; returns the reply line (None for notifications and blank lines).
@ -131,8 +133,19 @@ impl Server {
if !(params.is_object() || params.is_null()) {
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
}
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
let token = params.get("_meta").and_then(|m| m.get("progressToken")).filter(|t| t.is_string() || t.is_number()).cloned();
self.current = Some((id.clone(), token));
self.suppress.store(false, Ordering::Relaxed);
let result = self.request(method, &params);
self.current = None;
if self.suppress.swap(false, Ordering::Relaxed) {
return None;
}
Some(match result {
Ok(mut r) => {
modern_result_fields(method, &params, &mut r);
response(id, r)
}
Err((code, m)) => error(id, code, m),
})
}
@ -158,13 +171,33 @@ impl Server {
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions(self.backend.as_mut(), self.command_tools)})),
"tools/list" => {
let command_tools = self.command_tools;
let tools = self.guarded(|b| tool_definitions(b, command_tools)).map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"tools": tools}))
}
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
check_args(name, &args).map_err(|e| (INVALID_PARAMS, e))?;
let long = matches!(name, "export" | "cmd_app_export")
|| (name == "command_run" && args["id"] == "app.export")
|| (name == "run_command" && args["command"] == "app.export");
let hook = if long && !self.backend.has_ui() { self.progress_hook() } else { None };
let result = self.guarded(|b| {
b.set_progress(hook);
call_tool(b, name, &args)
});
let clear = self.guarded(|b| b.set_progress(None));
let result = match (result, clear) {
(Ok(result), Ok(())) => result,
(Err(error), _) | (_, Err(error)) => ToolResult::error(error),
};
Ok(result.to_value())
}
"resources/list" => Ok(json!({"resources": [
resource(DOCUMENT_URI, "document", "Library document", "Library state and counts (same as doc_inspect)"),
resource(COMMANDS_URI, "commands", "Command catalog", "Every command (same as command_list)"),
resource(LIBRARY_URI, "library", "Library state", "Current source, filter, sort, selection, active photo/mask and undo/redo labels (library.state)"),
resource(PHOTOS_URI, "photos", "Photos in view", "The photos in the current view with ids, ratings, flags and metadata (catalog.query)"),
resource(PHOTO_URI, "active-photo", "Active photo", "Everything about the active photo: file, metadata, develop settings, albums, history (photo.inspect)"),
@ -175,6 +208,7 @@ impl Server {
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let cmd = match uri {
DOCUMENT_URI | COMMANDS_URI => "",
LIBRARY_URI => "library.state",
PHOTOS_URI => "catalog.query",
PHOTO_URI => "photo.inspect",
@ -182,10 +216,47 @@ impl Server {
CONTROLS_URI => "develop.controls",
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
};
let v = self.backend.call("engine.execute", json!({"command": cmd, "params": {}})).map_err(|e| (INTERNAL_ERROR, e))?;
let v = self
.guarded(|b| match uri {
DOCUMENT_URI => doc_inspect(b),
COMMANDS_URI => b.call("engine.commands", json!({})),
_ => b.call("engine.execute", json!({"command": cmd, "params": {}})),
})
.and_then(|v| v)
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}
}
impl Server {
/// Escaped backend panics become errors; subsequent requests can keep using the session.
fn guarded<T>(&mut self, f: impl FnOnce(&mut dyn Backend) -> T) -> Result<T, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self.backend.as_mut()))).map_err(|p| {
let message =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
format!("internal error: {message}")
})
}
}
/// Per-request protocol metadata leaves initialize-based clients' result shapes unchanged.
fn modern_result_fields(method: &str, params: &Value, result: &mut Value) {
let modern = params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28");
let ttl = match method {
"tools/list" | "resources/list" | "resources/templates/list" => Some(600_000),
"resources/read" => Some(0),
"tools/call" => None,
_ => return,
};
let Some(obj) = result.as_object_mut().filter(|_| modern) else {
return;
};
obj.insert("resultType".into(), json!("complete"));
if let Some(ttl) = ttl {
obj.insert("ttlMs".into(), json!(ttl));
obj.insert("cacheScope".into(), json!("private"));
}
}

View File

@ -139,6 +139,19 @@ fn export_refuses_unknown_params_and_bad_values() {
assert!(e.contains("did you mean `quality`"), "{e}");
}
/// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped).
#[test]
fn export_tool_forwards_its_advertised_options() {
let mut b = Headless::demo();
let base = std::env::temp_dir().join(format!("lc-mcp-export-fwd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let r = call_tool(&mut b, "export", &json!({"dir": base.to_string_lossy(), "subfolder": "sub", "longEdge": 64, "format": "png"}));
assert!(!r.is_error, "{r:?}");
let files: Vec<_> = std::fs::read_dir(base.join("sub")).unwrap().collect();
assert_eq!(files.len(), 1, "written into the subfolder");
let _ = std::fs::remove_dir_all(&base);
}
/// The `import` helper moves: renamed into the folder template, the source removed.
#[test]
fn import_tool_moves_with_a_folder_template() {
@ -215,7 +228,7 @@ fn resources() {
let mut s = server();
let r = rpc(&mut s, 1, "resources/list", json!({}));
let list = r["result"]["resources"].as_array().unwrap();
assert_eq!(list.len(), 5);
assert_eq!(list.len(), 7);
for (i, res) in list.iter().enumerate() {
let uri = res["uri"].as_str().unwrap();
let r = rpc(&mut s, 10 + i as u64, "resources/read", json!({"uri": uri}));
@ -245,3 +258,116 @@ fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() {
assert!(r.is_error, "{r:?}");
assert_eq!(b.session.active(), Some(first));
}
#[test]
fn core_tools_have_hints_and_reject_unknown_keys() {
for compact in [false, true] {
let mut s = server().with_command_tools(!compact);
let list = rpc(&mut s, 1, "tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "list_commands", "run_command", "render_photo"]
{
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
assert!(!t["title"].as_str().unwrap().is_empty());
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
}
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "query_photos", "run_command"] {
let r = rpc(&mut s, 2, "tools/call", json!({"name":name,"arguments":{"misspelled":true}}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("misspelled"), "{r}");
}
let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap();
assert_eq!(preview["annotations"]["readOnlyHint"], true);
assert!(preview["inputSchema"]["properties"].get("path").is_none());
let writer = tools.iter().find(|t| t["name"] == "render_photo").unwrap();
assert_eq!(writer["annotations"]["readOnlyHint"], false);
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
for key in lightcraft_engine::export::OPTION_PARAMS.iter().chain(lightcraft_engine::export::TARGET_PARAMS) {
assert!(export["inputSchema"]["properties"].get(*key).is_some(), "missing export argument {key}");
}
let before = rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"}));
let image = rpc(&mut s, 2, "tools/call", json!({"name":"render_preview","arguments":{"max_side":64}}));
assert_eq!(image["result"]["isError"], false, "{image}");
assert!(image["result"]["content"].as_array().unwrap().iter().any(|c| c["type"] == "image"));
assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"})));
let batch = json!({"steps":[{"id":"catalog.stats"},{"id":"no.such"},{"id":"catalog.stats"}],"stop_on_error":false});
let r = rpc(&mut s, 3, "tools/call", json!({"name":"command_batch","arguments":batch}));
assert_eq!(r["result"]["isError"], true);
assert_eq!(r["result"]["structuredContent"]["completed"], 2);
assert_eq!(r["result"]["structuredContent"]["failed"], 1);
let r = rpc(&mut s, 4, "tools/call", json!({"name":"command_batch","arguments":{"steps":[{"id":"no.such"},{"id":"catalog.stats"}]}}));
assert_eq!(r["result"]["structuredContent"]["completed"], 0);
assert_eq!(r["result"]["structuredContent"]["results"].as_array().unwrap().len(), 1);
}
}
#[test]
fn core_resources_match_tools_after_parse_error() {
let mut s = server();
let bad: Value = serde_json::from_str(&s.handle_line("{bad").unwrap()).unwrap();
assert_eq!(bad["error"]["code"], -32700);
assert!(bad["id"].is_null());
let list = rpc(&mut s, 1, "resources/list", json!({}));
for (uri, tool) in [("lightcraft://document", "doc_inspect"), ("lightcraft://commands", "command_list")] {
assert!(list["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri));
let resource = rpc(&mut s, 2, "resources/read", json!({"uri":uri}));
let call = rpc(&mut s, 3, "tools/call", json!({"name":tool,"arguments":{}}));
let a: Value = serde_json::from_str(resource["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
let b: Value = serde_json::from_str(call["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(a, b);
}
let old = rpc(&mut s, 4, "resources/read", json!({"uri":"lightcraft://library"}));
assert!(old["result"]["contents"].is_array(), "{old}");
}
#[test]
fn escaped_tool_panic_is_an_error_and_session_survives() {
struct Faulty {
inner: Headless,
fail: bool,
}
impl Backend for Faulty {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
if method == "engine.execute" && params["command"] == "catalog.stats" && std::mem::take(&mut self.fail) {
panic!("synthetic backend panic");
}
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"test".into()
}
}
let mut s = Server::new(Box::new(Faulty { inner: Headless::demo(), fail: true }));
let params = json!({"name":"run_command","arguments":{"command":"catalog.stats"}});
let r = rpc(&mut s, 1, "tools/call", params.clone());
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r.to_string().contains("synthetic backend panic"));
assert_eq!(rpc(&mut s, 2, "tools/call", params)["result"]["isError"], false);
assert!(rpc(&mut s, 3, "ping", json!({}))["result"].is_object());
}
#[test]
fn modern_requests_receive_result_and_cache_fields() {
let mut s = server();
let meta = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"});
for (method, mut params) in [
("tools/list", json!({})),
("resources/list", json!({})),
("resources/templates/list", json!({})),
("resources/read", json!({"uri":"lightcraft://library"})),
] {
params["_meta"] = meta.clone();
let r = rpc(&mut s, 1, method, params);
assert_eq!(r["result"]["resultType"], "complete", "{r}");
assert_eq!(r["result"]["cacheScope"], "private");
assert_eq!(r["result"]["ttlMs"], if method == "resources/read" { 0 } else { 600_000 });
}
assert!(rpc(&mut s, 2, "tools/list", json!({}))["result"].get("resultType").is_none());
}

View File

@ -66,7 +66,7 @@ fn tool(name: &str, title: &str, description: &str, properties: Value, required:
"name": name,
"title": title,
"description": description,
"inputSchema": {"type": "object", "properties": properties, "required": required},
"inputSchema": {"type": "object", "properties": properties, "required": required, "additionalProperties": false},
})
}
@ -78,6 +78,51 @@ fn ids_schema(what: &str) -> Value {
pub fn helper_tools(has_ui: bool) -> Vec<Value> {
let photo_id = json!({"type": "integer", "description": "Photo id (default: the active photo). Makes that photo active first."});
let mut v = vec![
tool(
"command_list",
"List commands",
"Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_<id with . replaced by _>` or via command_run.",
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"},
"enabled_only": {"type": "boolean", "description": "Only commands that are enabled now"}
}),
&[],
),
tool(
"command_run",
"Run command",
"Run any LightCraft command by id with JSON params (see command_list for ids and parameter docs), e.g. {id: \"photo.rate\", params: {rating: 4}}.",
json!({"id": {"type": "string", "description": "Command id"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}),
&["id"],
),
tool(
"command_batch",
"Run commands",
"Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}.",
json!({
"steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}, "required": ["id"], "additionalProperties": false}},
"stop_on_error": {"type": "boolean", "description": "Stop at the first failing step (default true)"}
}),
&["steps"],
),
tool(
"doc_inspect",
"Inspect library",
"The library as JSON: current source, filter, sort, selection, active photo and undo/redo labels (library.state) plus counts (catalog.stats).",
json!({}),
&[],
),
tool(
"render_preview",
"Render preview",
"Render a photo with its current develop settings (cropped) and return it as an image (`max_side` is the long edge; does not change selection or save a file).",
json!({
"id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"},
"max_side": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"},
"format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"},
}),
&[],
),
tool(
"list_commands",
"List commands",
@ -187,6 +232,13 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"},
"path": {"type": "string"},
"dir": {"type": "string"},
"preset": {"type": "string", "description": "Export preset id or name"},
"background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"},
"resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"},
"subfolder": {"type": "string"},
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]},
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
@ -213,6 +265,9 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
];
if has_ui {
v.extend([
tool("ui_inspect", "Inspect UI", "UI state and selection (same as inspect_ui).", json!({}), &[]),
tool("ui_screenshot", "Screenshot", "Return the window as an image without saving a file.",
json!({"maxSize": {"type": "integer"}, "format": {"type": "string", "enum": ["png", "jpeg"]}}), &[]),
tool(
"screenshot",
"Screenshot",
@ -298,12 +353,60 @@ pub fn command_tools(backend: &mut dyn Backend) -> Vec<Value> {
.collect()
}
/// Conservative hints: generated command tools may edit; optional output paths make a helper a writer.
fn annotations(name: &str, title: &str) -> Value {
let read_only = matches!(
name,
"command_list"
| "list_commands"
| "doc_inspect"
| "render_preview"
| "query_photos"
| "list_controls"
| "get_develop"
| "inspect_ui"
| "list_widgets"
| "ui_inspect"
| "ui_screenshot"
);
let files = matches!(name, "export" | "render_photo" | "screenshot");
json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files,
"idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false})
}
/// Helpers have fixed keys; generated command tools retain the registry's free-form params.
pub(crate) fn check_args(name: &str, args: &Value) -> Result<(), String> {
if args.is_null() {
return Ok(());
}
let Some(m) = args.as_object() else {
return Err("tool arguments must be an object".into());
};
let defs = helper_tools(true);
let Some(def) = defs.iter().find(|t| t["name"] == name) else {
return Ok(());
};
let Some(props) = def["inputSchema"]["properties"].as_object() else {
return Ok(());
};
if let Some(bad) = m.keys().find(|k| !props.contains_key(*k)) {
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
return Err(format!("unknown argument \"{bad}\" for {name}; expected: {}", accepted.join(", ")));
}
Ok(())
}
/// All tools: helpers, plus the generated command tools when `with_commands`.
pub fn tool_definitions(backend: &mut dyn Backend, with_commands: bool) -> Vec<Value> {
let mut v = helper_tools(backend.has_ui());
if with_commands {
v.extend(command_tools(backend));
}
for t in &mut v {
let name = t["name"].as_str().unwrap_or_default().to_string();
let title = t["title"].as_str().unwrap_or(&name).to_string();
t["annotations"] = annotations(&name, &title);
}
v
}
@ -445,22 +548,67 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
return ToolResult::from(exec(b, &rest.replace('_', "."), args.clone()));
}
match name {
"list_commands" => {
"list_commands" | "command_list" => {
let filter = s("filter").unwrap_or("").to_lowercase();
let enabled_only = args.get("enabled_only").and_then(Value::as_bool).unwrap_or(false);
ToolResult::from(b.call("engine.commands", json!({})).map(|v| {
match v {
Value::Array(a) if !filter.is_empty() => Value::Array(
Value::Array(a) => Value::Array(
a.into_iter()
.filter(|c| {
c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter)
filter.is_empty()
|| c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter)
|| c["label"].as_str().unwrap_or("").to_lowercase().contains(&filter)
})
.filter(|c| !enabled_only || c["enabled"].as_bool() != Some(false))
.collect(),
),
v => v,
}
}))
}
"command_run" => match s("id") {
Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))),
None => ToolResult::error("missing `id`"),
},
"command_batch" => {
let Some(steps) = args.get("steps").and_then(Value::as_array) else { return ToolResult::error("missing `steps`") };
let stop = args.get("stop_on_error").and_then(Value::as_bool).unwrap_or(true);
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
for st in steps {
let Some(id) = st["id"].as_str() else {
failed += 1;
results.push(json!({"ok": false, "error": "step without `id`"}));
if stop {
break;
}
continue;
};
match exec(b, id, st.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))) {
Ok(r) => {
completed += 1;
results.push(json!({"ok": true, "result": r}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e}));
if stop {
break;
}
}
}
}
let r = json!({"completed": completed, "failed": failed, "results": results});
if failed > 0 { ToolResult { is_error: true, ..ToolResult::json(r) } } else { ToolResult::json(r) }
}
"doc_inspect" => ToolResult::from(doc_inspect(b)),
"render_preview" => {
let mut a = obj(args, &["id", "format"]);
if let Some(m) = args.get("max_side") {
a["size"] = m.clone();
}
render_photo(b, &a)
}
"run_command" => match s("command") {
Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))),
None => ToolResult::error("missing `command`"),
@ -581,12 +729,19 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"watermark",
"colorSpace",
"bitDepth",
"preset",
"background",
"resize",
"subfolder",
"conflict",
"tiffCompression",
"dngCompression",
],
),
)
})),
"screenshot" => screenshot(b, args),
"inspect_ui" => ToolResult::from(b.call("ui.inspect", json!({}))),
"screenshot" | "ui_screenshot" => screenshot(b, args),
"inspect_ui" | "ui_inspect" => ToolResult::from(b.call("ui.inspect", json!({}))),
"set_ui" => ToolResult::from(b.call("ui.set", args.get("state").cloned().unwrap_or(json!({})))),
"list_widgets" => ToolResult::from(b.call("ui.widgets", obj(args, &["filter"]))),
"click" => {
@ -606,3 +761,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
other => ToolResult::error(format!("unknown tool `{other}` (see tools/list)")),
}
}
/// The canonical document resource and tool share the same snapshot.
pub(crate) fn doc_inspect(b: &mut dyn Backend) -> Result<Value, String> {
let library = exec(b, "library.state", json!({}))?;
let stats = exec(b, "catalog.stats", json!({}))?;
Ok(json!({"library": library, "stats": stats}))
}

View File

@ -153,3 +153,115 @@ fn remote_unreachable_is_a_tool_error() {
assert_eq!(r[0]["result"]["isError"], true);
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
if tx.send(value).is_err() {
break;
}
}
});
let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress");
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next();
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
assert_eq!(ids.len(), 8);
let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"}));
request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"});
writeln!(send, "{request}").unwrap();
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
loop {
let value = next();
if value["method"] == "notifications/progress" {
assert_eq!(value["params"]["progressToken"], "photos");
let progress = value["params"]["progress"].as_f64().unwrap();
assert!(progress > last && progress <= 8.0, "{value}");
assert_eq!(value["params"]["total"], 8);
last = progress;
notes += 1;
if !asked {
writeln!(send, "{}", json!({"jsonrpc":"2.0","id":3,"method":"ping"})).unwrap();
asked = true;
}
} else if value["id"] == 3 {
answered = true;
} else {
assert_eq!(value["id"], 2, "{value}");
assert_eq!(value["result"]["isError"], false, "{value}");
assert_eq!(value["result"]["resultType"], "complete");
break;
}
}
assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}");
assert_eq!(last, 8.0);
let out = dir.join("cancelled");
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
request["params"]["_meta"] = json!({"progressToken":44});
writeln!(send, "{request}").unwrap();
loop {
let value = next();
assert_ne!(value["id"], 4, "must be cancellable before completion: {value}");
assert_eq!(value["params"]["progressToken"], 44, "{value}");
if value["params"]["progress"].as_f64().unwrap() > 0.0 {
break;
}
}
writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap();
writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap();
loop {
let value = next();
if value["method"] == "notifications/progress" {
continue;
}
assert_eq!(value["id"], 5, "cancelled request must not reply: {value}");
assert_eq!(value["result"]["isError"], false);
break;
}
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}");
for path in &photos {
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
}
assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained");
drop(send);
server.join().unwrap();
reader.join().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_without_token_finishes_after_eof() {
let dir = std::env::temp_dir().join(format!("lc-mcp-eof-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut headless = Headless::demo();
let ids: Vec<_> = headless.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
let request = call(1, "cmd_app_export", json!({"ids":ids,"dir":dir,"longEdge":64,"format":"png"}));
// Headless sessions remain movable to a worker after installing progress support.
let replies = std::thread::spawn(move || {
let mut s = Server::new(Box::new(headless));
session(&mut s, &[request, call(2, "doc_inspect", json!({}))])
})
.join()
.unwrap();
assert_eq!(replies.len(), 2, "no token means no notifications");
assert_eq!(replies[0]["result"]["isError"], false, "{replies:?}");
assert_eq!(replies[1]["id"], 2, "queued request survives EOF");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}

View File

@ -30,6 +30,7 @@ pub mod dust;
pub mod finish;
pub mod geometry;
pub mod local;
pub mod local_tone;
pub mod lut;
pub mod masks;
pub mod optics;
@ -83,6 +84,9 @@ pub struct SourceInfo {
pub camera_tone: Option<tone::CameraTone>,
/// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them.
pub mattes: Option<Arc<masks::Mattes>>,
/// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the
/// photo's Profile option asks for it ([`local_tone`]).
pub local_tone: Option<Arc<local_tone::LocalTone>>,
}
impl Default for SourceInfo {
@ -96,6 +100,7 @@ impl Default for SourceInfo {
camera_color: None,
camera_tone: None,
mattes: None,
local_tone: None,
}
}
}
@ -416,6 +421,8 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
format!("{eyes:?}"),
// defringe runs in this stage
format!("{:?}", s.optics),
// and the local tone mapping
local_tone::enabled(info, s),
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
src_long,
));
@ -423,16 +430,18 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
}
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
pub fn lin_needs_cpu(s: &DevelopSettings) -> bool {
pub fn lin_needs_cpu(s: &DevelopSettings, info: &SourceInfo) -> bool {
let o = &s.optics;
let defringe = s.section_enabled("optics") && (o.defringe_purple_amount > 0.0 || o.defringe_green_amount > 0.0);
defringe || !s.spots.is_empty()
defringe || !s.spots.is_empty() || local_tone::enabled(info, s)
}
/// The white-balanced, defringed, retouched image (before noise reduction): the CPU part of the
/// scene-linear stage, in place.
pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) {
let s = &*p.settings;
// (before the white balance: the gain's input is the as-shot development, as in the file)
local_tone::apply(img, info, p);
local::white_balance(img, info, s);
optics::defringe(img, s, p.px_per_long / optics::DEFRINGE_REF_LONG);
spots::apply(img, &s.spots, &p.frame, p.px_per_long);

View File

@ -0,0 +1,62 @@
//! The camera's local tone mapping: a raw's DNG `ProfileGainTableMap` (Apple ProRAW), rendered
//! when the photo's Profile option "Camera local tone mapping" is on. Off by default: Lightroom
//! Classic renders ProRAW without it (see `lightcraft_raw::gaintable`).
//!
//! The map is a per-pixel gain that depends on the pixel's position in the raw's active area and
//! on its colour after the baseline exposure (linear ProPhoto). The source the pipeline gets is
//! exactly that colour (scene-linear Rec.2020 after the baseline exposure), so the gain is
//! evaluated here, on the resampled source at output resolution: a preview colours only the pixels
//! it shows. A gain multiplies R, G and B alike, so it commutes with the white balance (a matrix)
//! that follows. Files that also carry a `ProfileLookTable` get the gain after the table instead of
//! before it (DNG 1.7.1); Apple ProRAW has none.
use lightcraft_color::{D50, D65, PROPHOTO, REC2020, bradford};
use lightcraft_geom::Point;
use lightcraft_raster::Rgb32f;
use lightcraft_raw::gaintable::{GainTableMap, SourcePlacement};
use crate::{Plan, SourceInfo};
use lightcraft_develop::DevelopSettings;
/// A raw's gain table map and where its developed source sits in the active area.
#[derive(Clone, Debug, PartialEq)]
pub struct LocalTone {
pub map: GainTableMap,
pub placement: SourcePlacement,
}
/// Whether the render applies the source's local tone mapping.
pub fn enabled(info: &SourceInfo, s: &DevelopSettings) -> bool {
s.profile.camera_local_tone && info.raw && info.local_tone.is_some()
}
/// Multiply the resampled, not yet white-balanced source `img` (the output frame of `p`) by the
/// local tone gain at each pixel. Blank canvas (outside a lens / perspective warp) is left alone.
pub fn apply(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) {
let Some(lt) = info.local_tone.as_deref().filter(|_| enabled(info, &p.settings)) else { return };
let Some(eval) = lt.map.evaluator() else { return };
let (w, h) = (img.width, img.height);
let frame = &p.frame;
let (ow, oh) = (frame.ow.max(1e-9), frame.oh.max(1e-9));
let o2t = frame.out_to_oriented(w, h);
let user = frame.orient.inverse();
let to_prophoto = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()).to_f32();
crate::for_rows(&mut img.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
// output pixel → user-oriented source px → EXIF-oriented source (0..1) → active area
let s = match &frame.warp {
Some(wp) => match wp.frame(&o2t, x, y) {
Some((_, s)) => s,
None => continue,
},
None => o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)),
};
let (u, v) = user.map(s.x / ow, s.y / oh, 1.0, 1.0);
let (ax, ay) = lt.placement.active(u, v);
let m = &to_prophoto;
let q: [f32; 3] = std::array::from_fn(|i| m[i][0] * px[0] + m[i][1] * px[1] + m[i][2] * px[2]);
let g = eval.gain(q, ax, ay);
*px = px.map(|c| c * g);
}
});
}

View File

@ -89,6 +89,39 @@ fn gain_table_map(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> Option<GainTableMa
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
decode_as(bytes, mode, RawFormat::Dng)
}
/// Whether a TIFF that is not a DNG describes a raw image the way DNG does: a full-resolution CFA IFD
/// and DNG's own statement of the white balance (`AsShotNeutral`, in IFD0 or the raw IFD), in a coding
/// [`decode_as`] reads: uncompressed, or lossless JPEG whose scan header the lossless decoder accepts.
/// Such files (Hasselblad 3FR and FFF) carry no DNG version tag, so they are not DNGs, but nothing else
/// in them needs a maker-specific reader. Without the white-balance tag nothing in the file says how
/// to read its colour, and the file stays an undecodable raw.
pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool {
let Some(raw) = raw_ifd(tiff) else { return false };
if !raw.contains(t::AS_SHOT_NEUTRAL) && !tiff.ifds.first().is_some_and(|i| i.contains(t::AS_SHOT_NEUTRAL)) {
return false;
}
let Ok(info) = raw.image() else { return false };
if info.samples_per_pixel != 1 || !(1..=16).contains(&info.bits()) || info.sample_format == 3 {
return false;
}
match info.compression {
t::compression::NONE => true,
t::compression::JPEG => info
.chunks(bytes.len() as u64)
.first()
.and_then(|c| lightcraft_tiff::image::chunk_bytes(bytes, c))
.is_some_and(|src| crate::ljpeg::frame_info(src).is_ok()),
_ => false,
}
}
/// [`decode`] for the TIFF-based raws that follow the DNG layout without being DNGs (`format` says
/// which). They differ in one respect: when the raw IFD states no `CFAPattern`, the layout is RGGB
/// anchored at the sensor origin.
pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<RawImage> {
let tiff = Tiff::parse(bytes)?;
let ifd0 = &tiff.ifds[0];
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?;
@ -152,7 +185,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
[r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize),
_ => return Err(RawError::Corrupt("bad CFARepeatPatternDim".into())),
};
let pat = raw.bytes(t::CFA_PATTERN_EP).ok_or_else(|| RawError::Corrupt("missing CFAPattern".into()))?;
// a TIFF that is not a DNG may leave the layout out: RGGB from the sensor origin
let assumed = [0u8, 1, 1, 2];
let pat = match raw.bytes(t::CFA_PATTERN_EP) {
Some(p) => p,
None if format != RawFormat::Dng && (rows, cols) == (2, 2) => &assumed,
None => return Err(RawError::Corrupt("missing CFAPattern".into())),
};
if pat.len() != rows * cols {
return Err(RawError::Corrupt("CFAPattern size mismatch".into()));
}
@ -178,7 +217,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
let img = RawImage {
format: RawFormat::Dng,
format,
width: w,
height: h,
cpp,

View File

@ -1,8 +1,20 @@
//! DNG `ProfileGainTableMap` (tag 52525, DNG 1.6) and `ProfileGainTableMap2` (tag 52544, DNG 1.7):
//! a coarse grid of 1D gain tables carrying the maker's local tone mapping. Apple ProRAW has one.
//!
//! We read and write the tag (a DNG export keeps it) but don't render it: Lightroom Classic
//! renders Apple ProRAW without the map (see [`crate::profile`]), and so do we.
//! We read and write the tag (a DNG export keeps it) but don't render it by default: Lightroom
//! Classic renders Apple ProRAW without the map (see [`crate::profile`]), and so do we. The photo's
//! "camera local tone mapping" option renders it ([`GainTableMap::gain`], applied by the pipeline),
//! as the camera itself does.
//!
//! Applying it (DNG 1.7.1, `ProfileGainTableMap` → "Description"):
//!
//! - the four tables around a position are interpolated bilinearly; outside the grid the edge
//! tables are replicated. Positions are relative to the active area at pixel centres;
//! - the table input `clamp((R, G, B, min, max) · weights, 0, 1) ^ gamma` is computed in linear
//! RIMM (ProPhoto) after the baseline exposure;
//! - the gain is the table looked up linearly at `input × MapPointsN` ("multiply the table input
//! value by MapPointsN to compute the floating-point table index"), clamped to the last point,
//! and multiplies R, G and B. Results may exceed 1 and aren't clipped.
//!
//! - The grid has `MapPointsV × MapPointsH` tables of `MapPointsN` gains. Its origin and spacing
//! are relative to the active area (1.0 = the active area's height or width).
@ -153,6 +165,135 @@ impl GainTableMap {
}
}
/// Applying the map (see the module docs). Index arithmetic saturates and every lookup is
/// checked: the fields are public and the struct is deserializable, so a hand-built or damaged map
/// must not overflow or panic, whatever it holds.
impl GainTableMap {
/// The gain at relative active-area position (`x`, `y`) for `p`, a linear ProPhoto (RIMM)
/// colour after the baseline exposure. 1.0 for a map that can't be evaluated. For many pixels,
/// [`Self::evaluator`] once and [`GainEval::gain`] per pixel.
pub fn gain(&self, p: [f32; 3], x: f64, y: f64) -> f32 {
self.evaluator().map_or(1.0, |e| e.gain(p, x, y))
}
/// The map ready to evaluate per pixel, or `None` when it can't be (inconsistent sizes).
pub fn evaluator(&self) -> Option<GainEval<'_>> {
let n = self.points_n;
let tables = self.points_v.saturating_mul(self.points_h);
if n == 0 || tables == 0 || tables.saturating_mul(n) != self.gains.len() {
return None;
}
let axis = |points: usize, origin: f64, spacing: f64| Axis {
last: points.saturating_sub(1),
origin,
// (a degenerate axis replicates its first table)
inv: if points > 1 && spacing.is_finite() && spacing > 0.0 { 1.0 / spacing } else { 0.0 },
};
Some(GainEval { map: self, v: axis(self.points_v, self.origin_v, self.spacing_v), h: axis(self.points_h, self.origin_h, self.spacing_h) })
}
}
/// One grid axis, prepared: last table index, origin and inverse spacing (0: one table).
#[derive(Clone, Copy, Debug)]
struct Axis {
last: usize,
origin: f64,
inv: f64,
}
impl Axis {
/// The cell at relative position `pos`: (first table, second table, weight of the second).
/// Outside the grid the edge table is replicated.
#[inline]
fn cell(self, pos: f64) -> (usize, usize, f32) {
if self.last == 0 || self.inv == 0.0 {
return (0, 0, 0.0);
}
let f = (pos - self.origin) * self.inv;
let f = if f.is_nan() { 0.0 } else { f.clamp(0.0, self.last as f64) };
// (≥ 0: `as` truncates like `floor`, which isn't inlined on baseline x86-64)
let i0 = (f as usize).min(self.last);
let i1 = i0.saturating_add(1).min(self.last);
(i0, i1, (f - i0 as f64) as f32)
}
}
/// Grid cell along one axis (see [`Axis::cell`]).
#[cfg(test)]
fn cell(pos: f64, origin: f64, spacing: f64, points: usize) -> (usize, usize, f32) {
let inv = if points > 1 && spacing.is_finite() && spacing > 0.0 { 1.0 / spacing } else { 0.0 };
Axis { last: points.saturating_sub(1), origin, inv }.cell(pos)
}
/// A [`GainTableMap`] prepared for evaluating many pixels.
#[derive(Clone, Copy, Debug)]
pub struct GainEval<'a> {
map: &'a GainTableMap,
v: Axis,
h: Axis,
}
impl GainEval<'_> {
/// [`GainTableMap::gain`].
#[inline]
pub fn gain(&self, p: [f32; 3], x: f64, y: f64) -> f32 {
let m = self.map;
let (v0, v1, fv) = self.v.cell(y);
let (h0, h1, fh) = self.h.cell(x);
// one fractional index for the four tables (≥ 0, so `as` truncates)
let n = m.points_n;
let last = n - 1;
let idx = (input(m, p) * n as f32).min(last as f32);
let i0 = (idx as usize).min(last);
let i1 = i0.saturating_add(1).min(last);
let t = idx - i0 as f32;
// (v ≤ points_v − 1, h ≤ points_h − 1 and i ≤ n − 1, with points_v·points_h·n = gains.len()
// checked by `evaluator`: none of this overflows, and `get` still guards each read)
let at = |v: usize, h: usize| {
let table = m.gains.get((v * m.points_h + h) * n..).unwrap_or(&[]);
let g0 = table.get(i0).copied().unwrap_or(1.0);
let g1 = table.get(i1).copied().unwrap_or(1.0);
g0 + (g1 - g0) * t
};
let (a, b, c, d) = (at(v0, h0), at(v0, h1), at(v1, h0), at(v1, h1));
let top = a + (b - a) * fh;
let bottom = c + (d - c) * fh;
let g = top + (bottom - top) * fv;
if g.is_finite() { g.max(0.0) } else { 1.0 }
}
}
/// The table input for `p` (linear ProPhoto, after the baseline exposure).
#[inline]
fn input(m: &GainTableMap, p: [f32; 3]) -> f32 {
let w = &m.weights;
let lo = p[0].min(p[1]).min(p[2]);
let hi = p[0].max(p[1]).max(p[2]);
let x = w[0] * p[0] + w[1] * p[1] + w[2] * p[2] + w[3] * lo + w[4] * hi;
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
if m.gamma == 1.0 { x } else { x.powf(m.gamma) }
}
/// Where the pixels of a developed source sit in the raw's active area, for [`GainTableMap::gain`].
/// The source is the default crop of the active area (`rect`, relative to the active area),
/// then EXIF-`orientation`ed. Resolution independent.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SourcePlacement {
/// x, y, width, height of the developed crop, relative to the active area.
pub rect: [f64; 4],
/// The EXIF orientation applied to the developed crop.
pub orientation: lightcraft_geom::Orientation,
}
impl SourcePlacement {
/// Relative active-area position of normalized (0..1) coordinates of the oriented source.
pub fn active(&self, u: f64, v: f64) -> (f64, f64) {
let (u, v) = self.orientation.inverse().map(u, v, 1.0, 1.0);
let [x, y, w, h] = self.rect;
(x + u * w, y + v * h)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -322,4 +463,106 @@ mod tests {
assert_eq!(GainTableMap::parse(&b, order, true).unwrap(), g);
}
}
fn map(dims: [usize; 3], spacing: [f64; 2], origin: [f64; 2], weights: [f32; 5], gains: Vec<f32>) -> GainTableMap {
let [points_v, points_h, points_n] = dims;
let [spacing_v, spacing_h] = spacing;
let [origin_v, origin_h] = origin;
GainTableMap { points_v, points_h, points_n, spacing_v, spacing_h, origin_v, origin_h, weights, gamma: 1.0, gains }
}
const LUMA: [f32; 5] = [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0];
#[test]
fn gain_follows_the_table_input_at_input_times_n() {
// one table of 3 points: index = input × 3 (DNG 1.7.1), clamped to the last point
let m = map([1, 1, 3], [1.0, 1.0], [0.0, 0.0], LUMA, vec![1.0, 2.0, 3.0]);
assert_eq!(m.gain([0.0; 3], 0.5, 0.5), 1.0);
assert!((m.gain([1.0 / 3.0; 3], 0.5, 0.5) - 2.0).abs() < 1e-5, "index 1");
assert!((m.gain([1.0 / 6.0; 3], 0.5, 0.5) - 1.5).abs() < 1e-5, "index 0.5: linear in the table");
assert!((m.gain([0.9; 3], 0.5, 0.5) - 3.0).abs() < 1e-5, "index 2.7 clamps to the last point");
// min/max weights and gamma: input = max(R, G, B)², two points
let mut m = map([1, 1, 2], [1.0, 1.0], [0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 1.0], vec![1.0, 3.0]);
m.gamma = 2.0;
// max 0.5 → 0.25 → index 0.5 → ×2
assert!((m.gain([0.1, 0.5, 0.2], 0.0, 0.0) - 2.0).abs() < 1e-5);
}
#[test]
fn tables_interpolate_across_the_grid_and_replicate_at_the_edges() {
// 1 × 2 constant tables at x = 0.25 (×1) and x = 0.75 (×3)
let m = map([1, 2, 1], [1.0, 0.5], [0.0, 0.25], LUMA, vec![1.0, 3.0]);
let g = |x: f64| m.gain([0.2; 3], x, 0.5);
assert!((g(0.25) - 1.0).abs() < 1e-5 && (g(0.5) - 2.0).abs() < 1e-5 && (g(0.75) - 3.0).abs() < 1e-5);
assert!((g(0.0) - 1.0).abs() < 1e-5 && (g(1.0) - 3.0).abs() < 1e-5);
assert!((g(-10.0) - 1.0).abs() < 1e-5 && (g(10.0) - 3.0).abs() < 1e-5);
// vertically too: 2 × 1 at y = 0 (×1) and y = 1 (×2)
let m = map([2, 1, 1], [1.0, 1.0], [0.0, 0.0], LUMA, vec![1.0, 2.0]);
assert!((m.gain([0.2; 3], 0.5, 0.25) - 1.25).abs() < 1e-5);
}
#[test]
fn hand_built_maps_never_panic() {
// review on #273: `(i0 + 1).min(points - 1)` overflowed for points_v = usize::MAX at +inf
let mut m = map([usize::MAX, 1, 1], [1.0, 1.0], [0.0, 0.0], LUMA, vec![2.0]);
assert_eq!(m.gain([0.5; 3], 0.5, f64::INFINITY), 1.0, "inconsistent sizes: no gain");
for (v, h, n, len) in [(usize::MAX, 1, 1, 1), (1, usize::MAX, usize::MAX, 3), (0, 0, 0, 0), (2, 2, 0, 0), (1, 1, 2, 1)] {
m.points_v = v;
m.points_h = h;
m.points_n = n;
m.gains = vec![2.0; len];
for spacing in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e-300] {
m.spacing_v = spacing;
m.spacing_h = spacing;
for p in [[0.5; 3], [f32::NAN; 3], [f32::INFINITY, 0.0, -1.0], [1e30; 3]] {
for (x, y) in [(0.5, 0.5), (f64::INFINITY, f64::NEG_INFINITY), (f64::NAN, f64::NAN)] {
assert!(m.gain(p, x, y).is_finite());
}
}
}
}
// the grid cell itself saturates (the reviewer's case, reached directly)
assert_eq!(cell(f64::INFINITY, 0.0, 1.0, usize::MAX), (usize::MAX - 1, usize::MAX - 1, 0.0));
assert_eq!(cell(f64::NEG_INFINITY, 0.0, 1.0, usize::MAX).0, 0);
// a consistent map with a huge grid and positions far outside it
let m = map([1, 1, 2], [1e-300, 1e-300], [0.0, 0.0], [f32::MAX; 5], vec![1.0, 4.0]);
assert!(m.gain([1e30; 3], f64::INFINITY, -f64::INFINITY).is_finite());
}
#[test]
fn placement_undoes_crop_and_orientation() {
// the source is the right half of the active area, rotated 90° clockwise
let p = SourcePlacement { rect: [0.5, 0.0, 0.5, 1.0], orientation: lightcraft_geom::Orientation::Rotate90 };
// the oriented source's top-left came from the crop's bottom-left
let (x, y) = p.active(0.0, 0.0);
assert!((x - 0.5).abs() < 1e-12 && (y - 1.0).abs() < 1e-12, "{x} {y}");
// its top-right came from the crop's top-left
let (x, y) = p.active(1.0, 0.0);
assert!((x - 0.5).abs() < 1e-12 && y.abs() < 1e-12, "{x} {y}");
let plain = SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal };
assert_eq!(plain.active(0.25, 0.75), (0.25, 0.75));
}
}
/// `GTM_FILE=photo.dng cargo test --release -p lightcraft-raw gain_throughput -- --ignored --nocapture`:
/// the cost of [`GainEval::gain`] per pixel on a real file's map.
#[cfg(test)]
mod bench {
#[test]
#[ignore]
fn gain_throughput() {
let Some(path) = std::env::var_os("GTM_FILE") else { return };
let raw = crate::decode(&std::fs::read(path).unwrap()).unwrap();
let m = raw.color.profile.gain_table_map.expect("the file has no gain table map");
let e = m.evaluator().unwrap();
let n = 3_000_000usize;
let t = std::time::Instant::now();
let mut acc = 0f32;
for i in 0..n {
let (x, y) = ((i % 2048) as f64 / 2048.0, (i / 2048) as f64 / 1536.0);
let v = ((i * 7919) % 1000) as f32 / 1000.0 * 0.5;
acc += e.gain([v, v * 0.9, v * 0.8], x, y);
}
println!("{}×{}×{} map: {:.1} ns/px ({acc})", m.points_v, m.points_h, m.points_n, t.elapsed().as_secs_f64() * 1e9 / n as f64);
}
}

View File

@ -9,17 +9,24 @@
//! tiles decode bit-exactly, whatever bit depth the encoder declared relative to the IFD's
//! `BitsPerSample`. Float samples are returned as decoded.
//!
//! We never request an output colour encoding, so jxl-oxide renders in the codestream's *original*
//! colour encoding: for lossless (non-XYB) data that is the identity, keeping the values in the raw IFD's
//! sample space. Lossy (XYB) tiles are converted from XYB back to that signalled encoding by jxl-oxide;
//! [INFERENCE] DNG writers signal a linear encoding matching the raw data there, so the result is the raw
//! sample space up to the codec's loss — not verified against a real lossy JXL DNG.
//! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity,
//! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear
//! sRGB primaries; the codestream's declared primaries are then restored with a plain 3×3 matrix. We
//! never let jxl-oxide convert to the declared encoding itself: that step gamut-maps and clips to
//! [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of 32768 puts them far above) and
//! desaturates. The result is the linear sample space the DNG's `WhiteLevel` refers to, up to the
//! codec's loss, and can exceed 1.0 or dip below 0.
//!
//! Every tile is decoded with wide (32-bit) modular buffers. A lossy tile whose header sets
//! `modular_16bit_buffers` can still carry values that need more than 16 bits; the narrow buffers then
//! fail with `InvalidAnsStream` or `UnexpectedEof`.
use crate::tiffraw::ChunkPx;
use crate::{RawError, Result};
use jxl_oxide::color::{ColourEncoding, ColourSpace, Primaries, TransferFunction, WhitePoint};
use jxl_oxide::frame::Encoding;
use jxl_oxide::image::BitDepth;
use jxl_oxide::{AllocTracker, JxlImage, JxlThreadPool};
use jxl_oxide::{AllocTracker, EnumColourEncoding, JxlImage, JxlThreadPool, RenderingIntent};
/// Decoder memory (bytes) allowed per sample of a tile. jxl-oxide 0.12 peaks at about three 32-bit
/// buffers per sample: measured ≤ 10.3 bytes for lossless (modular) tiles and ≤ 13.5 bytes per
@ -29,6 +36,99 @@ const BYTES_PER_SAMPLE: usize = 16;
/// on a 16 × 16 tile.
const MARGIN_BYTES: usize = 64 << 10;
/// Linear sRGB primaries with a D65 white point (the space jxl-oxide decodes XYB to) and the matrix that
/// takes a linear colour in it to the codestream's declared primaries (row-major, D65 white point).
type Matrix = [[f64; 3]; 3];
const D65: [f64; 2] = [0.3127, 0.3290];
const SRGB: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
const BT2100: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
fn inverse(m: Matrix) -> Matrix {
let c =
|i: usize, j: usize| m[(i + 1) % 3][(j + 1) % 3] * m[(i + 2) % 3][(j + 2) % 3] - m[(i + 1) % 3][(j + 2) % 3] * m[(i + 2) % 3][(j + 1) % 3];
let det = m[0][0] * c(0, 0) + m[0][1] * c(0, 1) + m[0][2] * c(0, 2);
let mut out = [[0.0; 3]; 3];
for (i, row) in out.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = c(j, i) / det;
}
}
out
}
fn mul(a: Matrix, b: Matrix) -> Matrix {
let mut out = [[0.0; 3]; 3];
for (i, row) in out.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
out
}
/// Linear RGB → XYZ for primaries `p` (xy) and the D65 white point.
fn rgb_to_xyz(p: [[f64; 2]; 3]) -> Matrix {
let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y];
let cols = [xyz(p[0]), xyz(p[1]), xyz(p[2])];
let prim = [[cols[0][0], cols[1][0], cols[2][0]], [1.0, 1.0, 1.0], [cols[0][2], cols[1][2], cols[2][2]]];
let w = xyz(D65);
let inv = inverse(prim);
let s: [f64; 3] = std::array::from_fn(|i| (0..3).map(|k| inv[i][k] * w[k]).sum());
std::array::from_fn(|i| std::array::from_fn(|j| prim[i][j] * s[j]))
}
/// How a chunk is rendered by jxl-oxide.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Output {
/// In the codestream's own colour encoding (non-XYB: the identity).
AsCoded,
/// XYB (lossy) data: linear sRGB primaries from the decoder, then `Some(matrix)` to the declared
/// primaries (`None` when those are sRGB's).
FromXyb(Option<[[f32; 3]; 3]>),
}
/// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour
/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is a
/// linear RGB one with a D65 white point (what a raw DNG writer signals) and no HDR tone mapping would
/// apply, otherwise the samples cannot be restored faithfully.
fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result<Output> {
if !xyb {
return Ok(Output::AsCoded);
}
let unsupported = |what: &str| RawError::Unsupported(format!("lossy JPEG XL tile with {what}"));
if planes != 3 {
return Err(unsupported("a single colour plane"));
}
let ColourEncoding::Enum(e) = encoding else {
return Err(unsupported("an ICC profile instead of a colour encoding"));
};
if e.colour_space != ColourSpace::Rgb || e.tf != TransferFunction::Linear {
return Err(unsupported("a declared encoding that is not linear RGB"));
}
if e.white_point != WhitePoint::D65 {
return Err(unsupported("a white point other than D65"));
}
if intensity_target > 255.0 {
return Err(unsupported("an intensity target above 255 nits"));
}
let primaries = match e.primaries {
Primaries::Srgb => return Ok(Output::FromXyb(None)),
Primaries::Bt2100 => BT2100,
Primaries::P3 => P3,
Primaries::Custom { red, green, blue } => [red, green, blue].map(|c| c.as_float().map(f64::from)),
};
if primaries.iter().any(|p| p[1] <= 0.0) {
return Err(unsupported("invalid primaries"));
}
let m = mul(inverse(rgb_to_xyz(primaries)), rgb_to_xyz(SRGB));
if m.iter().flatten().any(|v| !v.is_finite()) {
return Err(unsupported("degenerate primaries"));
}
Ok(Output::FromXyb(Some(m.map(|r| r.map(|v| v as f32)))))
}
fn corrupt(e: impl std::fmt::Display) -> RawError {
RawError::Corrupt(format!("JPEG XL tile: {e}"))
}
@ -37,7 +137,7 @@ fn corrupt(e: impl std::fmt::Display) -> RawError {
/// nominal `cw × ch`, or the part of it inside the image `vw × vh` (edge tiles), `cpp` colour planes,
/// a sample type matching the IFD. Returns the decoder and the codestream's integer maximum (0 for float).
#[allow(clippy::too_many_arguments)]
fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32)> {
fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32, Output)> {
if !matches!(cpp, 1 | 3) {
return Err(RawError::Unsupported(format!("JPEG XL with {cpp} samples per pixel")));
}
@ -49,8 +149,9 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits
let pixels = cw.saturating_mul(ch);
let budget = |planes: usize| pixels.saturating_mul(planes).saturating_mul(BYTES_PER_SAMPLE);
let tracker = AllocTracker::with_limit(budget(cpp).saturating_add(MARGIN_BYTES));
let img = JxlImage::builder()
let mut img = JxlImage::builder()
.pool(JxlThreadPool::none()) // chunks are already decoded in parallel
.force_wide_buffers(true) // see the module documentation
.alloc_tracker(tracker.clone())
.read(std::io::Cursor::new(src))
.map_err(corrupt)?;
@ -75,7 +176,11 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits
BitDepth::FloatSample { .. } if float => 0.0,
d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))),
};
Ok((img, max))
let output = output_for(header.metadata.xyb_encoded, &header.metadata.colour_encoding, header.metadata.tone_mapping.intensity_target, planes)?;
if matches!(output, Output::FromXyb(_)) {
img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative));
}
Ok((img, max, output))
}
/// Check the first chunk's headers ([`open`]) without decoding pixels (header-only probing).
@ -88,7 +193,7 @@ pub(crate) fn check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp:
/// in-image size `vw × vh` fills the top-left of that, the rest stays 0.
#[allow(clippy::too_many_arguments)]
pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
let (img, max) = open(src, cw, ch, vw, vh, cpp, bits, float)?;
let (img, max, output) = open(src, cw, ch, vw, vh, cpp, bits, float)?;
if img.num_loaded_keyframes() == 0 {
return Err(corrupt("no complete frame"));
}
@ -100,22 +205,116 @@ pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp
}
let n = cw.saturating_mul(ch).saturating_mul(cpp);
let rows = fb.buf().chunks_exact(jw * fc);
// lossy data: restore the declared primaries (see the module documentation)
let matrix = match output {
Output::FromXyb(m) => m,
Output::AsCoded => None,
};
let restore = |s: &[f32], d: &mut [f32]| match matrix {
Some(m) => {
for (d, row) in d.iter_mut().zip(&m) {
*d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2];
}
}
None => d.copy_from_slice(&s[..cpp]),
};
Ok(if float {
let mut out = vec![0f32; n];
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
d.iter_mut().zip(s).for_each(|(d, s)| *d = *s);
restore(s, d);
}
}
ChunkPx::F32(out)
} else {
let mut out = vec![0u16; n];
let mut px = [0f32; 3];
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
restore(s, &mut px[..cpp]);
// `as` saturates (NaN → 0): lossy overshoot clips to the integer range
d.iter_mut().zip(s).for_each(|(d, s)| *d = (s * max).round() as u16);
d.iter_mut().zip(&px).for_each(|(d, s)| *d = (s * max).round() as u16);
}
}
ChunkPx::U16(out)
})
}
#[cfg(test)]
mod tests {
use super::*;
use jxl_oxide::color::Customxy;
fn linear(primaries: Primaries) -> ColourEncoding {
ColourEncoding::Enum(EnumColourEncoding {
colour_space: ColourSpace::Rgb,
white_point: WhitePoint::D65,
primaries,
tf: TransferFunction::Linear,
rendering_intent: RenderingIntent::Perceptual,
})
}
fn matrix(o: Output) -> [[f32; 3]; 3] {
match o {
Output::FromXyb(Some(m)) => m,
other => panic!("expected a matrix, got {other:?}"),
}
}
#[test]
fn data_that_is_not_xyb_is_rendered_as_coded() {
// lossless tiles: whatever the header declares, the decoder must not convert anything
for e in
[linear(Primaries::Srgb), linear(Primaries::Bt2100), ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative))]
{
assert_eq!(output_for(false, &e, 255.0, 3).unwrap(), Output::AsCoded);
assert_eq!(output_for(false, &e, 10_000.0, 1).unwrap(), Output::AsCoded);
}
}
#[test]
fn lossy_data_with_srgb_primaries_needs_no_matrix() {
assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb(None));
}
#[test]
fn lossy_data_is_restored_to_the_declared_primaries() {
// linear sRGB to linear BT.2020/BT.2100 primaries (D65), as published in ITU-R BT.2087
let expect = [[0.6274, 0.3293, 0.0433], [0.0691, 0.9195, 0.0114], [0.0164, 0.0880, 0.8956]];
let m = matrix(output_for(true, &linear(Primaries::Bt2100), 255.0, 3).unwrap());
for (r, e) in m.iter().zip(&expect) {
for (v, e) in r.iter().zip(e) {
assert!((v - e).abs() < 1e-3, "{m:?}");
}
// the white point maps to itself: rows sum to one
assert!((r.iter().sum::<f32>() - 1.0).abs() < 1e-5, "{m:?}");
}
// custom primaries equal to the named ones give the same matrix
let xy = |[x, y]: [f64; 2]| Customxy { x: (x * 1e6) as i32, y: (y * 1e6) as i32 };
let custom = Primaries::Custom { red: xy(BT2100[0]), green: xy(BT2100[1]), blue: xy(BT2100[2]) };
let c = matrix(output_for(true, &linear(custom), 255.0, 3).unwrap());
assert!(c.iter().flatten().zip(m.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?} {m:?}");
// the sRGB primaries lie inside the wider P3 gamut: the full-saturation sRGB green is a valid P3 colour
let inv = matrix(output_for(true, &linear(Primaries::P3), 255.0, 3).unwrap());
let srgb_green_in_p3 = [inv[0][1], inv[1][1], inv[2][1]];
assert!(srgb_green_in_p3.iter().all(|v| (0.0..=1.0).contains(v)), "sRGB green lies inside P3");
}
#[test]
fn lossy_data_that_cannot_be_restored_is_unsupported() {
let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_)));
assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey");
assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map");
assert!(bad(3, ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative)), 255.0), "non-linear");
let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative);
e.white_point = WhitePoint::Dci;
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "white point");
e.white_point = WhitePoint::D65;
e.primaries =
Primaries::Custom { red: Customxy { x: 0, y: 0 }, green: Customxy { x: 300_000, y: 600_000 }, blue: Customxy { x: 150_000, y: 60_000 } };
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "degenerate primaries");
e.colour_space = ColourSpace::Grey;
assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "grey declared");
}
}

View File

@ -96,6 +96,10 @@ pub enum RawFormat {
Mrw,
/// Sigma / Foveon X3F (`FOVb`).
X3f,
/// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a
/// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`):
/// Hasselblad 3FR and FFF. Read by the DNG reader.
CfaTiff,
/// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …).
OtherTiff,
}
@ -114,6 +118,8 @@ impl RawFormat {
| RawFormat::Raf
| RawFormat::Rw2
| RawFormat::Pef
| RawFormat::CfaTiff
| RawFormat::Srw
)
}
}
@ -172,6 +178,9 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if make.starts_with("SAMSUNG") {
return Some(RawFormat::Srw);
}
if dng::is_plain_cfa_tiff(&t, bytes) {
return Some(RawFormat::CfaTiff);
}
if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) {
return Some(RawFormat::OtherTiff);
}
@ -266,6 +275,15 @@ fn thumbnail_shell(t: &Tiff, len: usize) -> Option<ThumbnailShell> {
/// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`].
fn other_tiff_reason(bytes: &[u8]) -> String {
let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok();
// a DNG-style CFA IFD whose lossless JPEG the lossless decoder rejects: say why
if let Some(t) = &t
&& let Some(info) = dng::raw_ifd(t).and_then(|i| i.image().ok())
&& info.compression == lightcraft_tiff::tags::compression::JPEG
&& let Some(src) = info.chunks(bytes.len() as u64).first().and_then(|c| lightcraft_tiff::image::chunk_bytes(bytes, c))
&& let Err(e) = ljpeg::frame_info(src)
{
return format!("raw image coded as lossless JPEG that is not decoded yet ({e})");
}
match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) {
Some(s) => format!(
"{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)",
@ -315,6 +333,8 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
RawFormat::Pef => vendor::pef::decode(bytes, mode),
RawFormat::Orf => vendor::orf::decode(bytes, mode),
RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff),
RawFormat::Srw => vendor::srw::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
}
@ -532,7 +552,8 @@ pub struct ColorData {
pub baseline_sharpness: Option<f64>,
/// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`,
/// `ProfileToneCurve`), applied by [`color`]'s users at render time, and its
/// `ProfileGainTableMap*`, kept (a DNG export writes it back) but not rendered.
/// `ProfileGainTableMap*`, kept (a DNG export writes it back) and rendered only when a photo's
/// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`).
#[serde(default)]
pub profile: profile::ProfileLook,
}
@ -929,6 +950,125 @@ mod tests {
assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff));
}
// --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag ---
/// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`.
fn cfa_samples() -> Vec<u16> {
(0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect()
}
/// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the
/// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and
/// crop). `strip` is the CFA image's data and `compression` its coding.
fn plain_cfa_tiff(compression: u16, strip: Vec<u8>, pattern: Option<[u8; 4]>) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![8]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![6]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![compression]));
raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)]));
raw.set(t::WHITE_LEVEL, Value::Long(vec![60000]));
raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2]));
raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2]));
if let Some(p) = pattern {
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec()));
}
raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] });
let mut ifd0 = rgb_ifd(4, 3);
ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into()));
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)]));
ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)]));
ifd0.add_sub_ifd(raw);
write(&[ifd0])
}
fn le_words(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
/// Known answer: samples, black and white level, crop and colour come from the file's own tags; the
/// layout it does not state is RGGB.
#[test]
fn uncompressed_cfa_tiff_without_a_dng_version_decodes() {
let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None);
assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff));
let r = decode(&bytes).unwrap();
assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16));
assert_eq!(r.data, RawData::U16(cfa_samples()));
assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB");
assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0]));
assert_eq!(r.crop, Rect::new(2, 2, 4, 2));
assert!(color::has_matrix(&r.color));
assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6]));
// headers only: the same description without samples
let i = probe_info(&bytes).unwrap();
assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2)));
}
/// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say).
#[test]
fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() {
let mut raw = IfdBuilder::new();
raw.set(t::IMAGE_WIDTH, Value::Long(vec![8]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![6]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] });
let mut ifd0 = rgb_ifd(4, 3);
ifd0.add_sub_ifd(raw);
assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff));
}
/// A stated `CFAPattern` wins over the RGGB default.
#[test]
fn cfa_tiff_keeps_a_stated_pattern() {
let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0]));
assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR");
}
/// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the
/// whole image, as the 3FR/FFF files have it).
#[test]
fn lossless_jpeg_cfa_tiff_decodes() {
let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0);
let bytes = plain_cfa_tiff(7, strip, None);
assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff));
let r = decode(&bytes).unwrap();
assert_eq!(r.data, RawData::U16(cfa_samples()));
assert_eq!(r.cfa.unwrap().name(), "RGGB");
}
/// A lossless-JPEG scan whose predictor selection value is outside 0 to 7 (the 3FR/FFF files use 8) is
/// not a coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason,
/// and keeps the stand-in preview an unsupported container gets.
#[test]
fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() {
let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0);
let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap();
let ss = sos + 2 + 2 + 1 + 2;
assert_eq!(strip[ss], 1, "selection value byte");
strip[ss] = 8;
assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 8")));
let bytes = plain_cfa_tiff(7, strip, None);
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
assert!(!RawFormat::OtherTiff.is_supported());
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
assert!(why.contains("lossless JPEG") && why.contains("selection value 8"), "{why}");
}
/// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader.
#[test]
fn cfa_tiff_with_another_coding_is_not_claimed() {
let bytes = plain_cfa_tiff(8, vec![0; 96], None);
assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff));
}
/// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another
/// Samsung file stays with the Samsung format.
#[test]

View File

@ -325,8 +325,11 @@ fn parse_header(d: &[u8]) -> Result<Header, RawError> {
}
h.predictor = seg[1 + 2 * ns];
h.pt = seg[3 + 2 * ns] & 15;
if !(0..=7).contains(&h.predictor) {
return Err(err("bad predictor"));
if h.predictor > 7 {
return Err(RawError::Unsupported(format!(
"lossless JPEG scan with predictor selection value {} (T.81 defines 0 to 7)",
h.predictor
)));
}
if h.pt >= h.precision {
return Err(err("bad point transform"));

View File

@ -12,7 +12,8 @@
//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve.
//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1).
//! - `ProfileGainTableMap*` ([`crate::gaintable`]) is read and kept (a DNG export writes it back)
//! but not rendered: Lightroom Classic renders Apple ProRAW without it (measured on the iPhone
//! but not rendered by default (the photo's "Camera local tone mapping" option renders it, in the
//! pipeline): Lightroom Classic renders Apple ProRAW without it (measured on the iPhone
//! 12 Pro corpus file against Lightroom Classic 15.6: with the map the default render is far
//! brighter than Lightroom's, mean ΔE00 19; without it Lightroom's output is a tight function of
//! the hue/saturation map, exposure compensation and tone curve alone).

View File

@ -108,6 +108,10 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
let (c, px) = match r {
Ok(v) => v,
Err(e) => {
// a JPEG XL tile that cannot be decoded is not a partial image: the file stays preview-only
if info.compression == comp::JPEG_XL {
return Err(e);
}
first_err.get_or_insert(e);
continue;
}

View File

@ -12,6 +12,7 @@ pub mod pef;
pub mod raf;
mod rafc;
pub mod rw2;
pub mod srw;
use crate::{BlackLevel, Cfa, Rect};
use lightcraft_tiff::Tiff;

541
crates/raw/src/vendor/srw.rs vendored Normal file
View File

@ -0,0 +1,541 @@
//! Samsung SRW: the variants that store the sensor samples uncompressed.
//!
//! Sources: TIFF 6.0 (container: a big- or little-endian TIFF whose IFD0 points at sub-IFDs, one of them the raw
//! image), Exif 2.3 (`CFAPattern`, `PixelXDimension`, `PixelYDimension`; maker note `0x927c`), the ExifTool Samsung
//! tag-name documentation (`SensorAreas` `0xa010`, `EncryptionKey` `0xa020`, `WB_RGGBLevelsUncorrected` `0xa021`,
//! `WB_RGGBLevelsBlack` `0xa028`, `RawData` `0xa048`) and our own black-box analysis of CC0 samples from raw.pixls.us
//! (NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1, WB2000; every number below was measured on them):
//!
//! - The raw image is the IFD whose `Compression` is a private value (32769..=32773); its `CFAPattern` (`0x828e`)
//! names the 2x2 layout, anchored at the first sample of the stored array. Checked on the files above: the colours
//! of the decoded mosaic follow the camera's JPEG best with exactly that layout (`corpus_samsung_srw`).
//! - Compression 32769 (EX1, WB2000): one 16-bit little-endian word per sample (the TIFF is big-endian, the words are
//! not); the strip holds exactly `width * height * 2` bytes.
//! - Compression 32770 with exactly `width * height * 12 / 8` strip bytes (NX5, NX10, NX11, NX20, NX200, NX210, NX1000,
//! NX1100): 12-bit samples, two in three bytes, rows without padding. The NX5/NX10/NX11 files read them MSB-first,
//! the others LSB-first; the file doesn't say which, the image does: in the wrong order neighbouring same-colour
//! samples differ 5 to 11 times more (10 files, every one decided by that margin), see [`lsb_is_smoother`].
//! - Every other shape of those and of the other private values (32770 with fewer bytes: NX30, NX300, NX2000,
//! EK-GN120; 32772: NX mini, NX3000, NX3300; 32773: NX1, NX500) is a compressed coding: reported as unsupported.
//! - The maker note is a plain IFD whose value offsets count from the start of the note, not of the TIFF. Several
//! vectors are stored offset by `EncryptionKey`, 11 integers in every file (element `i` of a vector uses key
//! `i mod 11`): `WB_RGGBLevelsUncorrected` and `WB_RGGBLevelsBlack` hold `value + key`, `RawData` holds
//! `value - key`, found by trying both signs and keeping the one that gives plausible numbers (green levels
//! 4096 in all but one file, saturation levels 4095 or 16383, black levels 0 to 3, the dark-column level).
//! - `SensorAreas`: the second rectangle (left, top, right, bottom) is the camera JPEG's framing (its size equals the
//! JPEG's in every file but WB2000's), used as the active area. Registered against the JPEG its centre agrees to
//! within 2 pixels (NX10 family; the NX20 family's JPEG is lens-corrected, edges differ).
//! - The Exif picture size is smaller than the framing in one direction only on the WB2000 (16:9 from a 4:3 sensor):
//! the JPEG is the centre of the framing (registration: top offset 366 = framing top 22 + (2736 - 2048) / 2).
//! - `RawData`: four black levels, then saturation levels (R, G1, G2, B twice). The EX1 saturates at 15881, not at its
//! stated 16383 (85786 samples sit on the plateau), so a plateau below the stated level wins.
//! - Black: `WB_RGGBLevelsBlack` when it states one (NX5, NX10, NX11: 2 to 3, the right-hand dark columns measure
//! 3.5 to 4.5); otherwise the dark rows or columns outside the framing (EX1: eight rows at 2.3); otherwise 0.
//! - `WB_RGGBLevelsUncorrected` divided by its mean green is the as-shot gain: the renders of the 12 files match the
//! camera JPEG at median ΔE76 2.2 to 7.6 (10.7 on the backlit WB2000 sample, 5.7 after registration).
use super::{black_from_columns, white_from_data};
use crate::unpack::{read_u16s, unpack_lsb, unpack_msb};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::chunk_bytes;
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, tags as t};
use rayon::prelude::*;
const SENSOR_AREAS: u16 = 0xa010;
const ENCRYPTION_KEY: u16 = 0xa020;
const WB_UNCORRECTED: u16 = 0xa021;
const WB_BLACK: u16 = 0xa028;
const RAW_DATA: u16 = 0xa048;
/// The private `Compression` values of the raw image IFD.
const COMPRESSIONS: std::ops::RangeInclusive<u16> = 32769..=32773;
/// The maker-note IFD (offsets relative to the note's own start).
fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<Ifd> {
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
let opts = lightcraft_tiff::ParseOptions { max_ifds: 4, max_depth: 1, follow_children: false, ..Default::default() };
lightcraft_tiff::parse_ifd_at(bytes, e.offset, tiff.order, e.offset, false, &opts).ok().map(|(i, _)| i)
}
/// A key-offset vector: the entries of `tag` shifted by the maker note's key (`sign` is +1 or -1 times the key).
fn keyed(mn: &Ifd, tag: u16, sign: i64) -> Option<Vec<i64>> {
let key = mn.u64s(ENCRYPTION_KEY).filter(|k| k.len() == 11)?;
let vals = mn.f64s(tag)?;
// 32-bit entries: the stored value wraps modulo 2^32 (a negative number stored as a LONG)
Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add(sign * key[i % 11] as i64) as i32)).collect())
}
/// The raw image's IFD: the one with a private compression value.
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds().into_iter().find(|i| i.u16(t::COMPRESSION).is_some_and(|c| COMPRESSIONS.contains(&c)) && i.contains(t::IMAGE_WIDTH))
}
/// How the samples of an IFD are stored, when uncompressed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Coding {
/// One little-endian 16-bit word per sample.
Words,
/// 12-bit samples, two in three bytes: read MSB-first (the NX5, NX10 and NX11 files) or LSB-first (the later
/// bodies' files); the file does not say which, the smoother image does ([`lsb_is_smoother`]).
Packed12,
}
fn coding(compression: u16, w: usize, h: usize, strip: u64) -> Option<Coding> {
let n = (w as u64).checked_mul(h as u64)?;
match compression {
32769 if strip == n.checked_mul(2)? => Some(Coding::Words),
32770 if w.is_multiple_of(2) && strip == n.checked_mul(12)? / 8 => Some(Coding::Packed12),
_ => None,
}
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let tiff = Tiff::parse(bytes)?;
let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("SRW without IFDs".into()))?;
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("Samsung raw image not found".into()))?;
let info = raw.image()?;
let (w, h) = (info.width as usize, info.height as usize);
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let chunks = info.chunks(bytes.len() as u64);
let strip: u64 = chunks.iter().map(|c| c.len).sum();
let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else {
return Err(RawError::Unsupported(format!(
"Samsung compressed raw (compression {}, {:.2} stored bits per sample)",
info.compression,
strip as f64 * 8.0 / n as f64
)));
};
let data = if mode == Mode::Full {
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?;
let mut d = vec![0u16; n];
match coding {
Coding::Words => read_u16s(src, ByteOrder::Little, &mut d),
Coding::Packed12 => {
let stride = w / 2 * 3;
let unpack: fn(&[u8], u32, &mut [u16]) = if lsb_is_smoother(src, w, h) { unpack_lsb } else { unpack_msb };
d.par_chunks_mut(w).zip(src.par_chunks(stride)).for_each(|(row, s)| unpack(s, 12, row));
}
}
RawData::U16(d)
} else {
RawData::U16(Vec::new())
};
let mn = maker_note(bytes, &tiff);
// the camera JPEG's framing (left, top, right, bottom), when it lies inside the stored array
let active = mn
.as_ref()
.and_then(|m| m.u64s(SENSOR_AREAS))
.filter(|v| v.len() == 8)
.map(|v| (v[4] as usize, v[5] as usize, v[6] as usize, v[7] as usize))
.filter(|&(l, tp, r, b)| l < r && tp < b && r <= w && b <= h)
.map_or(Rect::new(0, 0, w, h), |(l, tp, r, b)| Rect::new(l, tp, r - l, b - tp));
let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => return Err(RawError::Unsupported("Samsung raw without a 2x2 CFAPattern".into())),
};
// saturation levels and black levels (R, G1, G2, B), in the file's own words
let levels = mn.as_ref().and_then(|m| keyed(m, RAW_DATA, 1)).filter(|v| v.len() == 12);
let RawData::U16(samples) = &data else { return Err(RawError::Unsupported("float SRW".into())) };
// saturation: the file's level, or lower where the samples pile up (EX1 clips at 15882 of its stated 16383)
let stated = levels.as_ref().filter(|v| v[4..].iter().all(|&x| x == v[4]) && (255..=65535).contains(&v[4])).map(|v| v[4] as f32);
let bits = stated.map_or((info.bits() as u32).clamp(8, 16), |s| 32 - (s as u32).leading_zeros());
let plateau = (!samples.is_empty()).then(|| white_from_data(&active_rows(samples, w, active), bits));
let white = match (stated, plateau) {
(Some(s), Some(p)) => s.min(p),
(Some(s), None) => s,
(None, Some(p)) => p,
(None, None) => 4095.0,
};
let tagged_black =
mn.as_ref().and_then(|m| keyed(m, WB_BLACK, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| (0..=u16::MAX as i64).contains(&x)));
let black = match tagged_black {
Some(v) if v.iter().any(|&x| x > 0) => {
// RGGB-ordered levels -> per CFA position at the active area origin
let a = cfa.shifted(active.x, active.y);
let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2, v[3]][c as usize] as f32).collect();
BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() }
}
// the file states no black level: measure the masked band around the active area, when there is one
_ => masked_black(samples, w, h, active, white),
};
// as-shot white balance: gains with the greens near 4096 (unity)
let wb = mn.as_ref().and_then(|m| keyed(m, WB_UNCORRECTED, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| x > 0)).map(|v| {
let g = (v[1] + v[2]) as f64 / 2.0;
[(v[0] as f64 / g) as f32, 1.0, (v[3] as f64 / g) as f32]
});
let mut metadata = lightcraft_meta::from_tiff(&tiff);
let crop = picture_crop(&tiff, active);
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
let img = RawImage {
format: RawFormat::Srw,
width: w,
height: h,
cpp: 1,
data,
cfa: Some(cfa),
bits,
black,
white: vec![white],
active_area: active,
crop,
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
color: ColorData::default(),
wb_multipliers: wb,
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
};
img.validate_for(mode)?;
Ok(img)
}
/// Which bit order unpacks 12-bit samples of `w` x `h` (in `src`, `w / 2 * 3` bytes per row) into the image: in
/// the right order neighbouring samples of one colour differ by little, in the wrong one by about the whole range.
/// Compares the summed absolute differences of samples two apart over 64 rows from the middle of the frame.
fn lsb_is_smoother(src: &[u8], w: usize, h: usize) -> bool {
let stride = w / 2 * 3;
let (mut msb, mut lsb) = (0u64, 0u64);
let (mut a, mut b) = (vec![0u16; w], vec![0u16; w]);
for y in (h / 5..h * 4 / 5).step_by((h * 3 / 5 / 64).max(1)) {
let Some(row) = y.checked_mul(stride).and_then(|s| src.get(s..s + stride)) else { break };
unpack_msb(row, 12, &mut a);
unpack_lsb(row, 12, &mut b);
let spread = |v: &[u16]| v.windows(3).map(|p| u64::from(p[0].abs_diff(p[2]))).sum::<u64>();
msb += spread(&a);
lsb += spread(&b);
}
lsb < msb
}
/// The default crop: the whole framing, or, when the Exif picture size is the framing cut in one direction only
/// (a 16:9 picture from a 4:3 sensor), the centred part of that size.
fn picture_crop(tiff: &Tiff, active: Rect) -> Rect {
let whole = Rect::new(0, 0, active.width, active.height);
let Some(exif) = tiff.exif() else { return whole };
let (Some(pw), Some(ph)) = (exif.u32(t::PIXEL_X_DIMENSION), exif.u32(t::PIXEL_Y_DIMENSION)) else { return whole };
let (pw, ph) = (pw as usize, ph as usize);
if pw == 0 || ph == 0 || pw > active.width || ph > active.height || (pw < active.width && ph < active.height) {
return whole;
}
Rect::new((active.width - pw) / 2, (active.height - ph) / 2, pw, ph)
}
/// Every third row of the active area (the saturation statistics need no more).
fn active_rows(d: &[u16], w: usize, a: Rect) -> Vec<u16> {
(a.y..a.y + a.height).step_by(3).filter_map(|y| d.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect()
}
/// Black level from rows or columns outside the active area that are dark (mean below 1.5% of `white`): the
/// longest run of dark lines on the right, else the left, below, above; at least four lines, the two outermost
/// rows and columns of the array left out. Uniform 0 when there is no such run.
fn masked_black(d: &[u16], w: usize, h: usize, a: Rect, white: f32) -> BlackLevel {
if d.is_empty() {
return BlackLevel::uniform(0.0);
}
let limit = f64::from(white) * 0.015;
let col_mean = |x: usize| {
(a.y..a.y + a.height).step_by(7).filter_map(|y| d.get(y * w + x)).map(|&v| f64::from(v)).sum::<f64>() / a.height.div_ceil(7) as f64
};
let row_mean =
|y: usize| (a.x..a.x + a.width).step_by(7).filter_map(|x| d.get(y * w + x)).map(|&v| f64::from(v)).sum::<f64>() / a.width.div_ceil(7) as f64;
// the longest run of consecutive dark lines within `lines`
let longest = |lines: std::ops::Range<usize>, mean: &dyn Fn(usize) -> f64| {
let (mut best, mut start) = (0..0, lines.start);
for i in lines.clone() {
if mean(i) >= limit {
start = i + 1;
} else if i + 1 - start > best.len() {
best = start..i + 1;
}
}
best
};
let cols = a.y..a.y + a.height;
let rows = a.x..a.x + a.width;
let right = longest(a.x + a.width..w.saturating_sub(2), &col_mean);
let left = longest(2..a.x, &col_mean);
let below = longest(a.y + a.height..h.saturating_sub(2), &row_mean);
let above = longest(2..a.y, &row_mean);
if right.len() >= 4 {
black_from_columns(d, w, right, cols, a)
} else if left.len() >= 4 {
black_from_columns(d, w, left, cols, a)
} else if below.len() >= 4 {
black_from_columns(d, w, rows, below, a)
} else if above.len() >= 4 {
black_from_columns(d, w, rows, above, a)
} else {
BlackLevel::uniform(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{decode, probe, probe_info};
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
const KEY: [u32; 11] = [305, 72, 737, 456, 282, 307, 519, 724, 13, 505, 193];
/// A maker note in the Samsung layout: a big-endian IFD of LONG vectors, offsets relative to the note's start.
fn note(entries: &[(u16, Vec<u32>)]) -> Vec<u8> {
let mut head = Vec::new();
let mut tail = Vec::new();
head.extend_from_slice(&(entries.len() as u16).to_be_bytes());
let values_at = 2 + 12 * entries.len() + 4;
for (tag, v) in entries {
head.extend_from_slice(&tag.to_be_bytes());
head.extend_from_slice(&4u16.to_be_bytes());
head.extend_from_slice(&(v.len() as u32).to_be_bytes());
if v.len() == 1 {
head.extend_from_slice(&v[0].to_be_bytes());
} else {
head.extend_from_slice(&((values_at + tail.len()) as u32).to_be_bytes());
tail.extend(v.iter().flat_map(|x| x.to_be_bytes()));
}
}
head.extend_from_slice(&[0; 4]);
head.extend(tail);
head
}
/// Stored form of a vector the way the file keeps it: `plain + key` (`sign` 1) or `plain - key` (`sign` -1).
fn stored(plain: &[i64], sign: i64) -> Vec<u32> {
plain.iter().enumerate().map(|(i, &p)| (p + sign * i64::from(KEY[i % 11])) as u32).collect()
}
fn pack12(v: &[u16]) -> Vec<u8> {
v.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect()
}
fn le_words(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
struct Spec {
w: u32,
h: u32,
compression: u16,
strip: Vec<u8>,
pattern: Option<[u8; 4]>,
note: Option<Vec<u8>>,
picture: Option<(u32, u32)>,
}
fn srw(s: Spec) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![s.w]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![s.h]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
raw.set(t::COMPRESSION, Value::Short(vec![s.compression]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
if let Some(p) = s.pattern {
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec()));
}
raw.set_image(ImageData::Strips { rows_per_strip: s.h, strips: vec![s.strip] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("SAMSUNG".into()));
ifd0.set(t::MODEL, Value::Ascii("NX0".into()));
let mut exif = IfdBuilder::new();
if let Some(n) = s.note {
exif.set(t::MAKER_NOTE, Value::Undefined(n));
}
if let Some((pw, ph)) = s.picture {
exif.set(t::PIXEL_X_DIMENSION, Value::Long(vec![pw]));
exif.set(t::PIXEL_Y_DIMENSION, Value::Long(vec![ph]));
}
ifd0.set_child(t::EXIF_IFD, exif);
ifd0.add_sub_ifd(raw);
TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
/// The camera-stated description every test file carries: JPEG framing, white balance, levels.
fn full_note(area: [u32; 4], white: i64, black: i64) -> Vec<u8> {
let mut levels = vec![0i64; 4];
levels.extend([white; 8]);
note(&[
(SENSOR_AREAS, vec![0, 0, 64, 32, area[0], area[1], area[2], area[3]]),
(ENCRYPTION_KEY, KEY.to_vec()),
(WB_UNCORRECTED, stored(&[8192, 4096, 4096, 6144], 1)),
(WB_BLACK, stored(&[black; 4], 1)),
(RAW_DATA, stored(&levels, -1)),
])
}
/// A smooth gradient with a little texture, `max` the first value that is not reached.
fn ramp(w: u32, h: u32, max: u32) -> Vec<u16> {
(0..w * h).map(|i| ((1000 + (i % w) * 7 + (i / w) * 5 + (i * 13) % 9) % max) as u16).collect()
}
fn pack12_lsb(v: &[u16]) -> Vec<u8> {
v.chunks(2).flat_map(|p| [p[0] as u8, ((p[0] >> 8) as u8) | ((p[1] & 15) << 4) as u8, (p[1] >> 4) as u8]).collect()
}
/// Known answer: the samples, layout, framing, levels and white balance all come from the file's own words.
#[test]
fn packed_12_bit_samples_decode_with_the_maker_note() {
let v = ramp(64, 32, 4096);
let bytes = srw(Spec {
w: 64,
h: 32,
compression: 32770,
strip: pack12(&v),
pattern: Some([2, 1, 1, 0]),
note: Some(full_note([6, 4, 58, 30], 4095, 3)),
picture: None,
});
assert_eq!(probe(&bytes), Some(RawFormat::Srw));
let r = decode(&bytes).unwrap();
assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::Srw, 64, 32, 12));
assert_eq!(r.data, RawData::U16(v));
assert_eq!(r.cfa.as_ref().unwrap().name(), "BGGR");
assert_eq!(r.active_area, Rect::new(6, 4, 52, 26));
assert_eq!(r.crop, Rect::new(0, 0, 52, 26));
assert_eq!((r.black.mean(), r.white.clone()), (3.0, vec![4095.0]));
assert_eq!(r.wb_multipliers, Some([2.0, 1.0, 1.5]));
// headers only: everything but the samples
let i = probe_info(&bytes).unwrap();
assert_eq!((i.active_area, i.wb_multipliers, i.bits), (Rect::new(6, 4, 52, 26), Some([2.0, 1.0, 1.5]), 12));
}
/// The later bodies pack the same 12-bit samples LSB-first; which of the two orders a file uses is found from
/// the samples (the wrong order makes a noisy image), not from the model.
#[test]
fn lsb_first_packing_is_found_from_the_samples() {
let v = ramp(64, 32, 4096);
for (strip, name) in [(pack12(&v), "msb"), (pack12_lsb(&v), "lsb")] {
let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip, pattern: Some([1, 0, 2, 1]), note: None, picture: None });
assert_eq!(decode(&bytes).unwrap().data, RawData::U16(v.clone()), "{name}");
}
}
/// A picture size narrower than the framing in one direction only (16:9 from a 4:3 sensor) is the framing's
/// centre; any other size is ignored.
#[test]
fn the_exif_picture_size_centres_a_one_direction_crop() {
let v = ramp(64, 32, 4096);
let file = |picture| {
srw(Spec {
w: 64,
h: 32,
compression: 32770,
strip: pack12(&v),
pattern: Some([2, 1, 1, 0]),
note: Some(full_note([6, 4, 58, 30], 4095, 0)),
picture,
})
};
// framing 52 x 26
assert_eq!(decode(&file(Some((52, 18)))).unwrap().crop, Rect::new(0, 4, 52, 18));
assert_eq!(decode(&file(Some((40, 26)))).unwrap().crop, Rect::new(6, 0, 40, 26));
let r = decode(&file(Some((52, 18)))).unwrap();
assert_eq!((r.metadata.width, r.metadata.height), (Some(52), Some(18)));
for other in [None, Some((52, 26)), Some((40, 18)), Some((60, 18)), Some((52, 30)), Some((0, 18))] {
assert_eq!(decode(&file(other)).unwrap().crop, Rect::new(0, 0, 52, 26), "{other:?}");
}
}
/// 16-bit words are little-endian although the file is big-endian; a saturation plateau below the stated
/// level wins; dark rows below the framing give the black level when the file states none.
#[test]
fn word_samples_decode_and_measure_what_the_file_does_not_say() {
let (w, h) = (64u32, 40u32);
let mut v: Vec<u16> = (0..w * h).map(|i| 3000 + (i * 91 % 5000) as u16).collect();
// saturation: a plateau at 15882 inside the framing (rows 4..28), two rows of picture beyond it, dark rows 30..40
for i in 0..150usize {
v[(4 + i % 24) * 64 + 8 + i / 24] = 15882;
}
for x in 0..64 * 10 {
v[30 * 64 + x] = 80;
}
let bytes = srw(Spec {
w,
h,
compression: 32769,
strip: le_words(&v),
pattern: Some([0, 1, 1, 2]),
note: Some(full_note([0, 4, 64, 28], 16383, 0)),
picture: None,
});
let r = decode(&bytes).unwrap();
assert_eq!(r.data, RawData::U16(v));
assert_eq!(r.bits, 14);
assert!((15800.0..=15882.0).contains(&r.white[0]), "{:?}", r.white);
assert_eq!(r.black.mean(), 80.0);
assert_eq!(r.cfa.unwrap().name(), "RGGB");
}
/// Files whose strip is not exactly the uncompressed size (or that use another private value) are compressed
/// codings: reported as such, still recognised as SRW.
#[test]
fn compressed_codings_are_reported_not_misread() {
let plain = |compression, strip: Vec<u8>| {
srw(Spec { w: 64, h: 32, compression, strip, pattern: Some([2, 1, 1, 0]), note: Some(full_note([0, 0, 64, 32], 4095, 0)), picture: None })
};
for bytes in [
plain(32770, vec![7; 64 * 32 * 12 / 8 - 3]),
plain(32772, vec![7; 4000]),
plain(32773, pack12(&ramp(64, 32, 4096))),
plain(32769, vec![0; 64 * 32]),
] {
assert_eq!(probe(&bytes), Some(RawFormat::Srw));
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
assert!(why.contains("compressed"), "{why}");
assert!(probe_info(&bytes).is_err());
}
}
/// Without a maker note the samples still decode: whole array, no white balance, depth from the TIFF tags.
#[test]
fn a_file_without_a_maker_note_decodes_with_defaults() {
let v = ramp(64, 32, 4096);
let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&v), pattern: Some([1, 0, 2, 1]), note: None, picture: None });
let r = decode(&bytes).unwrap();
assert_eq!(r.data, RawData::U16(v));
assert_eq!((r.active_area, r.wb_multipliers, r.black.mean()), (Rect::new(0, 0, 64, 32), None, 0.0));
assert_eq!(r.cfa.unwrap().name(), "GRBG");
assert_eq!(r.bits, 12);
}
/// A raw IFD that does not say how its mosaic is laid out is not guessed at.
#[test]
fn a_file_without_a_cfa_pattern_is_unsupported() {
let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&ramp(64, 32, 4096)), pattern: None, note: None, picture: None });
assert!(matches!(decode(&bytes), Err(RawError::Unsupported(w)) if w.contains("CFAPattern")));
}
/// A framing rectangle outside the array, or reversed, is ignored; truncated files fail without panicking.
#[test]
fn hostile_framing_and_truncation_do_not_panic() {
let v = ramp(64, 32, 4096);
for area in [[0, 0, 65, 32], [10, 0, 10, 32], [0, 40, 64, 20]] {
let bytes = srw(Spec {
w: 64,
h: 32,
compression: 32770,
strip: pack12(&v),
pattern: Some([2, 1, 1, 0]),
note: Some(full_note(area, 4095, 0)),
picture: None,
});
assert_eq!(decode(&bytes).unwrap().active_area, Rect::new(0, 0, 64, 32));
}
let bytes = srw(Spec {
w: 64,
h: 32,
compression: 32770,
strip: pack12(&v),
pattern: Some([2, 1, 1, 0]),
note: Some(full_note([0, 0, 64, 32], 4095, 0)),
picture: None,
});
for cut in [10, bytes.len() / 2, bytes.len() - 20] {
let _ = decode(&bytes[..cut]);
let _ = probe_info(&bytes[..cut]);
}
}
}

View File

@ -528,6 +528,63 @@ fn corpus_orf_cfa_patterns() {
eprintln!("ORF CFA layouts checked on {seen} files");
}
/// Samsung SRW (`crates/raw/src/vendor/srw.rs`): the 12-bit packings of the NX10 and NX20 generations (opposite bit
/// orders, told apart from the samples) and the 16-bit words of the EX1 and WB2000 decode to the image of the camera's own
/// JPEG, with the layout, framing, levels and white balance the file's maker note states.
#[test]
fn corpus_samsung_srw() {
let dir = corpus_root().join("raw");
// (file, bits, active area width x height, crop width x height, CFA layout, JPEG shows the whole active area)
let cases = [
("srw-samsung-nx10.srw", 12, (4592, 3056), (4592, 3056), "BGGR", true),
("srw-samsung-nx20.srw", 12, (5472, 3648), (5472, 3648), "GRBG", true),
("srw-samsung-ex1.srw", 14, (3648, 2736), (3648, 2736), "RGGB", true),
];
let mut seen = 0;
for (name, bits, active, crop, cfa, whole) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.format, RawFormat::Srw, "{name}");
assert_eq!(img.bits, bits, "{name}: bits");
assert_eq!((img.active_area.width, img.active_area.height), active, "{name}: active area");
assert_eq!((img.crop.width, img.crop.height), crop, "{name}: crop");
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout");
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
assert!(wb[0] > 1.0 && wb[1] == 1.0 && wb[2] > 0.5, "{name}: white balance {wb:?}");
assert!(img.white_at(0) > (1u32 << bits) as f32 * 0.9 && img.white_at(0) <= ((1u32 << bits) - 1) as f32, "{name}: white {}", img.white_at(0));
assert!(img.black.mean() < 8.0, "{name}: black {}", img.black.mean());
// the mosaic inside the framing holds no marker or padding values above the saturation level
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let a = img.active_area;
let above = (a.y..a.y + a.height)
.map(|y| d[y * img.width + a.x..y * img.width + a.x + a.width].iter().filter(|&&v| f32::from(v) > ((1u32 << bits) - 1) as f32).count())
.sum::<usize>();
assert_eq!(above, 0, "{name}: samples above the saturation level in the active area");
if whole {
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview"));
let (gw, gh) = (24, 18);
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
let layout = img.cfa.clone().unwrap_or_else(|| panic!("{name}: no CFA"));
let green: Vec<f64> = sites.iter().map(|s| (0..4).filter(|&i| layout.pattern[i] == 1).map(|i| s[i]).sum::<f64>()).collect();
let rho = correlation(&ranks(&green), &ranks(&jpeg.iter().map(|j| j[1]).collect::<Vec<_>>()));
let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern);
let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max);
let own = agree(cfa);
eprintln!(
"{name:26} {}x{} {bits}-bit: green rank correlation {rho:.3}, colour agreement {own:.3} (others <= {best_other:.3})",
img.width, img.height
);
assert!(rho > 0.9, "{name}: rank correlation with the camera JPEG {rho}");
assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})");
}
seen += 1;
}
eprintln!("Samsung SRW checked on {seen} files");
}
/// Reference sensor sums and position-weighted sums from black-box decoding, independent
/// of this implementation. These cover complete images, padding, stripe boundaries and
/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`.
@ -598,6 +655,7 @@ fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
"orf" => RawFormat::Orf,
"pef" => RawFormat::Pef,
"raf" => RawFormat::Raf,
"srw" => RawFormat::Srw,
"rw2" | "rwl" | "raw" => RawFormat::Rw2,
_ => continue,
};

View File

@ -669,6 +669,26 @@ fn malformed_jpeg_xl_tiles_are_errors() {
assert!(matches!(lightcraft_raw::probe_info(&one(good, 3)), Err(RawError::Corrupt(_))));
}
/// One undecodable tile makes the whole image an error: it is never returned with a black tile.
#[test]
fn a_jpeg_xl_tile_that_fails_is_an_error_for_the_image() {
use lightcraft_raw::RawError;
let (w, h) = (32, 16);
let px = pattern(w, h, 1, 12);
let tile = |x0: usize| -> Vec<u16> { (0..16).flat_map(|y| px[y * w + x0..y * w + x0 + 16].to_vec()).collect() };
let two = |second: Vec<u8>| {
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(&tile(0), 16, 16, 1, false), second] });
dng(raw, ByteOrder::Little)
};
assert_eq!(decode(&two(jxl_encode(&tile(16), 16, 16, 1, false))).unwrap().data, RawData::U16(px.clone()));
// the second tile is cut short; the first one alone is not an image
let good = jxl_encode(&tile(16), 16, 16, 1, false);
assert!(matches!(decode(&two(good[..good.len() / 2].to_vec())), Err(RawError::Corrupt(_))));
assert!(matches!(decode(&two(vec![0xff, 0x0a, 1, 2, 3])), Err(RawError::Corrupt(_))));
}
/// The decoder's memory is bounded by the tile: a grey tile that also codes an alpha channel needs
/// about twice what one plane may use, and is an error rather than an allocation the header asks
/// for. The same samples without the alpha channel decode.

View File

@ -1174,6 +1174,8 @@
"Remove (H)": "Entfernen (H)",
"Remove CA": "Chromatische Aberration entfernen",
"Remove Chromatic Aberration": "Chromatische Aberration entfernen",
"Camera local tone mapping": "Lokale Tonwertzuordnung der Kamera",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der Raw-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert",
"Remove Disk from Library": "Datenträger aus Bibliothek entfernen",
"Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…",
"Remove Face Box": "Gesichtsrahmen entfernen",

View File

@ -1126,6 +1126,8 @@
"Remove (H)": "Eliminar (H)",
"Remove CA": "Eliminar AC",
"Remove Chromatic Aberration": "Eliminar aberración cromática",
"Camera local tone mapping": "Mapeo tonal local de la cámara",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el raw (Apple ProRAW), como lo renderiza la cámara",
"Remove Disk from Library": "Quitar disco de la biblioteca",
"Remove Disk from Library…": "Quitar disco de la biblioteca…",
"Remove Face Box": "Quitar recuadro de cara",

View File

@ -1087,6 +1087,8 @@
"Remove (H)": "除去(H)",
"Remove CA": "色収差を除去",
"Remove Chromatic Aberration": "色収差を除去",
"Camera local tone mapping": "カメラのローカルトーンマッピング",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "RAW に保存されたローカルトーンマッピング(Apple ProRAW)をカメラと同じように適用",
"Remove from Album": "アルバムから削除",
"Remove from Favorites": "お気に入りから削除",
"Remove from Local": "ローカルから除く",

View File

@ -1137,6 +1137,8 @@
"Remove (H)": "Remover (H)",
"Remove CA": "Remover AC",
"Remove Chromatic Aberration": "Remover aberração cromática",
"Camera local tone mapping": "Mapeamento tonal local da câmera",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar o mapeamento tonal local salvo no raw (Apple ProRAW), como a câmera o renderiza",
"Remove Disk from Library": "Remover disco da biblioteca",
"Remove Disk from Library…": "Remover disco da biblioteca…",
"Remove Face Box": "Remover caixa de rosto",

View File

@ -939,6 +939,8 @@
"Remove (H)": "Удалить (H)",
"Remove CA": "Убрать ХА",
"Remove Chromatic Aberration": "Убрать хроматические аберрации",
"Camera local tone mapping": "Локальная тональная коррекция камеры",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Применить локальную тональную коррекцию, сохранённую в raw (Apple ProRAW), как её рендерит камера",
"Remove from Album": "Удалить из альбома",
"Remove from Favorites": "Удалить из избранного",
"Remove from Local": "Убрать из «Локального»",
@ -1103,6 +1105,8 @@
"Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода",
"Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея",
"Show file names": "Показывать имена файлов",
"Show in Explorer": "Показать в Проводнике",
"Show in File Manager": "Показать в файловом менеджере",
"Show in Finder": "Показать в Finder",
"Show on Map": "Показать на карте",
"Show overlay (O)": "Показать наложение (O)",

View File

@ -1138,6 +1138,8 @@
"Remove (H)": "移除 (H)",
"Remove CA": "移除色差",
"Remove Chromatic Aberration": "移除色差",
"Camera local tone mapping": "相机局部色调映射",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "按相机的渲染方式应用 raw 中保存的局部色调映射(Apple ProRAW)",
"Remove from Album": "从相册中移除",
"Remove from Favorites": "从收藏夹中移除",
"Remove from Local": "从本地移除",

View File

@ -1138,6 +1138,8 @@
"Remove (H)": "移除(H)",
"Remove CA": "移除色差",
"Remove Chromatic Aberration": "移除色差",
"Camera local tone mapping": "相機局部色調映射",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "按相機的渲染方式套用 raw 中儲存的局部色調映射(Apple ProRAW)",
"Remove from Album": "從相簿移除",
"Remove from Favorites": "從喜好項目移除",
"Remove from Local": "從本機移除",

View File

@ -206,6 +206,8 @@ pub struct LightcraftApp {
/// The language the installed fonts were built for: the CJK fallback order follows the UI
/// language's script, so switching language reinstalls them.
font_language: i18n::Locale,
/// Browser font loaded separately from WASM so the module stays below its host's size limit.
chinese_font: Option<std::sync::Arc<egui::FontData>>,
fonts_ready: bool,
last_time: f64,
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
@ -291,6 +293,7 @@ impl LightcraftApp {
synthetic_mods_release: false,
styled: false,
font_language: i18n::Locale::En,
chinese_font: None,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
@ -322,6 +325,14 @@ impl LightcraftApp {
}
}
/// Supply the browser's separately downloaded Simplified Chinese font before the first frame.
pub fn with_chinese_font(mut self, bytes: Vec<u8>) -> Self {
if !bytes.is_empty() {
self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes)));
}
self
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
// keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them
@ -690,7 +701,7 @@ impl LightcraftApp {
fn logic_inner(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts(ctx);
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
theme::apply(ctx);
// File → Add from Device lists cards scanned in the background: show hot-plugs
let repaint = ctx.clone();
@ -704,7 +715,7 @@ impl LightcraftApp {
} else if self.font_language != self.ui.language {
// Shared Han characters take the active language's forms (Japanese faces for 日本語,
// the Simplified Chinese face for 简体中文): rebuild the fallback order.
theme::install_fonts(ctx);
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
self.font_language = self.ui.language;
} else {
self.fonts_ready = true;

View File

@ -1434,6 +1434,22 @@ fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &Canvas
}
}
/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units,
/// so undo the same scaling and rotation used to draw its outline before testing containment.
fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false };
if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 {
return false;
}
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let (s, co) = angle.to_radians().sin_cos();
let x = (dx * co + dy * s) / rx;
let y = (-dx * s + dy * co) / ry;
x * x + y * y <= 1.0
}
/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask
/// component (click selects its mask, drag moves the component), and brush painting. The mask
/// itself shows as a rendered overlay ([`view_overlay`]).
@ -1608,8 +1624,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| {
// anywhere on the photo drags the selected linear gradient
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
// Handles take precedence; otherwise grab the selected radial component under
// the press, including components after the first in compound masks.
let at = map.norm(q);
if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) {
return Some((m.id, ci, 0));
}
// anywhere on the photo drags the selected linear gradient
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
});
if let Some((mask, comp, handle)) = grip

View File

@ -763,7 +763,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(crate::i18n::tr_format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.label(crate::i18n::tr_format!(
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
crate::theme::font_credits(app.chinese_font.is_some())
));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)

View File

@ -153,6 +153,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
control(app, ui, &d, "profile.amount", true);
ui.add_space(6.0);
}
// a raw carrying its own local tone mapping (Apple ProRAW's gain table map): off renders it as
// Lightroom Classic does, on as the camera does
if app.session.source_info(id).local_tone.is_some() {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
let mut on = d.profile.camera_local_tone;
let r = ui.checkbox(&mut on, crate::i18n::tr("Camera local tone mapping"));
crate::widgets::register(ui.ctx(), "check:profile.cameraLocalTone", r.rect);
if r.on_hover_text(crate::i18n::tr("Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it")).changed() {
let _ = app.run("develop.merge", json!({"settings": {"profile": {"camera_local_tone": on}}, "label": "Camera Local Tone Mapping"}));
}
});
}
divider(ui);
section(app, ui, &d, "light", "Light", |app, ui, d| {

View File

@ -1246,7 +1246,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
ui.close();
}
if app.services.reveal.is_some()
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
&& let Some(f) = app.services.reveal.as_mut()
{
let _ = f(path);

View File

@ -79,6 +79,78 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() {
h.settle(SETTLE);
}
#[test]
fn radial_body_drag_moves_rotated_components_and_undoes_once() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "luminanceRange"}));
exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.45, 0.5], "rx": 0.22, "ry": 0.1, "angle": 40}));
let before = develop(&h);
// Well away from the pin and edge/rotation grips, inside a rotated ellipse.
pointer(
&mut h,
json!([{"kind": "down", "x": 0.51, "y": 0.56}, {"kind": "drag", "x": 0.54, "y": 0.58},
{"kind": "drag", "x": 0.61, "y": 0.66}, {"kind": "up", "x": 0.61, "y": 0.66}]),
);
let after = develop(&h);
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &after.masks[0].components[1].shape else { panic!("radial") };
assert!((center.x - 0.55).abs() < 0.005 && (center.y - 0.6).abs() < 0.005, "{center:?}");
let MaskShape::Radial { rx: old_rx, ry: old_ry, angle: old_angle, feather: old_feather, invert: old_invert, .. } =
&before.masks[0].components[1].shape
else {
panic!("radial")
};
assert_eq!((rx, ry, angle, feather, invert), (old_rx, old_ry, old_angle, old_feather, old_invert));
assert_eq!(after.masks[0].components[0], before.masks[0].components[0], "only the hit component moves");
exec(&mut h, "edit.undo", json!({}));
assert_eq!(develop(&h), before, "one undo reverses the entire drag");
exec(&mut h, "edit.redo", json!({}));
assert_eq!(develop(&h), after);
// The bounding-box corner is outside the ellipse, even for an inverted radial mask.
exec(&mut h, "mask.component", json!({"component": 1, "action": "invert"}));
let outside_before = develop(&h);
pointer(
&mut h,
json!([{"kind": "down", "x": 0.35, "y": 0.75}, {"kind": "drag", "x": 0.4, "y": 0.8},
{"kind": "up", "x": 0.4, "y": 0.8}]),
);
assert_eq!(develop(&h), outside_before, "outside the ellipse must not move it");
// Hiding pins does not disable dragging the selected ellipse body.
exec(&mut h, "view.maskPins", json!({"show": false}));
pointer(
&mut h,
json!([{"kind": "down", "x": 0.61, "y": 0.66}, {"kind": "drag", "x": 0.66, "y": 0.71},
{"kind": "up", "x": 0.66, "y": 0.71}]),
);
let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[1].shape.clone() else { panic!("radial") };
assert!((center.x - 0.6).abs() < 0.005 && (center.y - 0.65).abs() < 0.005, "{center:?}");
}
#[test]
fn radial_body_drag_keeps_resize_and_rotation_handles() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.18, "ry": 0.1}));
for handle in [1, 5] {
let widget = format!("maskHandle:1:0:{handle}");
let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1;
let image = h.app.image_rect.unwrap();
let q = rect.center();
let x = (q.x - image.left()) / image.width();
let y = (q.y - image.top()) / image.height();
pointer(
&mut h,
json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.03, "y": y + 0.03},
{"kind": "up", "x": x + 0.03, "y": y + 0.03}]),
);
let MaskShape::Radial { center, rx, angle, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") };
assert!((center.x - 0.5).abs() < 0.005 && (center.y - 0.5).abs() < 0.005, "a handle must not translate the mask");
if handle == 1 {
assert!(rx > 0.19, "resize grip changes radius");
} else {
assert!(angle.abs() > 1.0, "rotation grip changes angle");
}
}
}
#[test]
fn mask_overlay_keys_and_pins() {
use lightcraft_pipeline::{MaskView, Overlay};

View File

@ -84,6 +84,33 @@ fn grid_keeps_the_users_scroll_position() {
assert!(grid_y(&h) < y2, "scrolled up to the second photo");
}
#[test]
fn deleting_photos_does_not_jump_the_grid_to_the_top() {
for view in ["photoGrid", "squareGrid"] {
for count in [1, 3] {
let mut h = demo(view);
let ids = h.app.session.visible_cloned();
let at = ids.len() / 2;
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": &ids[at..at + count]}}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 30);
let y = grid_y(&h);
assert!(y > 600.0, "selection is well below the top: {view}, {y}");
let r = h.request("engine.execute", json!({"command": "photo.delete"}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 60);
let next = ids[at + count];
assert!(grid_y(&h) > 600.0, "deleting scrolled to the top: {view}, {} → {}", y, grid_y(&h));
assert_eq!(h.app.session.active(), Some(next), "{view}, count {count}");
let canvas = h.app.canvas_rect.unwrap();
assert!(widget(&h, &format!("thumb:{}", next.0)).intersects(canvas), "the next survivor is in view");
let after = grid_y(&h);
idle(&mut h, 60);
assert_eq!(grid_y(&h), after, "idle frames preserve the new position");
}
}
}
#[test]
fn filmstrip_wheel_scrolls_and_keeps_its_position() {
let mut h = demo("detail");

View File

@ -6,6 +6,7 @@ use std::sync::Arc;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
pub const FONT_SEMIBOLD: &str = "semibold";
const WEB_CHINESE_FONT: &str = "web Noto Sans CJK SC";
/// Shared colour-label palette for badges, thumbnail surrounds and feedback.
pub fn label_color(label: lightcraft_catalog::ColorLabel) -> Color32 {
@ -129,14 +130,24 @@ impl Tokens {
}
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
install_fonts_with_chinese(ctx, None);
}
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last
/// fallback of every family — the active language's own script first, so shared Han characters keep
/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as
/// boxes.
/// Install the browser's separately downloaded Chinese face alongside the embedded faces.
pub fn install_fonts_with_chinese(ctx: &egui::Context, chinese: Option<&Arc<FontData>>) {
ctx.set_fonts(font_definitions_with_chinese(lightcraft_engine::CRAFT_FONTS, chinese));
}
/// Inter remains the default UI face. Egui's default faces and craft-fonts cover other scripts;
/// the browser's separate Chinese face precedes Japanese faces within the CJK fallback list.
/// Without craft-fonts or that separate face, CJK text shows boxes.
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions {
font_definitions_with_chinese(craft, None)
}
/// The web build keeps the large Simplified Chinese face outside the size-limited WASM module.
/// Prefer it for Chinese UI text, including Traditional Chinese until it has its own face.
pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFont], chinese: Option<&Arc<FontData>>) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
@ -147,12 +158,24 @@ pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontD
.map(craft_font_name)
.collect::<Vec<String>>()
};
let (regular, bold) = (fallback("Regular"), fallback("Bold"));
let (mut regular, mut bold) = (fallback("Regular"), fallback("Bold"));
for name in regular.iter().chain(bold.iter()) {
if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) {
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes)));
}
}
let script = crate::i18n::language().script();
let prefer_chinese = chinese.is_some() && matches!(script, "Hans" | "Hant");
if let Some(font) = chinese {
fonts.font_data.insert(WEB_CHINESE_FONT.into(), font.clone());
if prefer_chinese {
regular.insert(0, WEB_CHINESE_FONT.into());
bold.insert(0, WEB_CHINESE_FONT.into());
} else {
regular.push(WEB_CHINESE_FONT.into());
bold.push(WEB_CHINESE_FONT.into());
}
}
let defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["Inter".to_string()];
prop.extend(defaults.iter().cloned());
@ -162,19 +185,29 @@ pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontD
semi.extend(defaults);
semi.extend(bold);
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
let mono = fonts.families.entry(FontFamily::Monospace).or_default();
let mut mono_fallback = fallback("Regular");
if prefer_chinese {
mono_fallback.insert(0, WEB_CHINESE_FONT.into());
}
if chinese.is_some() && !prefer_chinese {
mono_fallback.push(WEB_CHINESE_FONT.into());
}
mono.extend(mono_fallback);
fonts
}
/// The embedded font families for the About box: Inter, plus the craft-fonts families when built
/// with them.
pub fn font_credits() -> String {
/// The font families used by this app instance, including the browser's separately loaded face.
pub fn font_credits(chinese_loaded: bool) -> String {
let mut families = vec!["Inter"];
for f in lightcraft_engine::CRAFT_FONTS {
if !families.contains(&f.family) {
families.push(f.family);
}
}
if chinese_loaded && !families.contains(&"Noto Sans CJK SC") {
families.push("Noto Sans CJK SC");
}
families.join(" / ")
}
@ -225,3 +258,88 @@ pub fn apply(ctx: &egui::Context) {
s.animation_time = 0.08;
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::{Locale, set_language};
#[test]
fn native_chinese_face_falls_back_after_default_faces() {
static FACES: &[lightcraft_engine::CraftFont] = &[
lightcraft_engine::CraftFont {
family: "BIZ UDPGothic",
style: "Regular",
scripts: &["Jpan", "Latn"],
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
},
lightcraft_engine::CraftFont {
family: "Noto Sans CJK SC",
style: "Regular",
scripts: &["Hans", "Latn"],
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
},
];
set_language(Locale::ZhHans);
let definitions = font_definitions(FACES);
let defaults = FontDefinitions::default();
let default_prop = defaults.families.get(&FontFamily::Proportional).expect("default proportional fonts");
let chinese = "craft-fonts Noto Sans CJK SC Regular";
for (family, primary) in [(FontFamily::Proportional, "Inter"), (FontFamily::Name(FONT_SEMIBOLD.into()), "Inter-SemiBold")] {
let names = definitions.families.get(&family).expect("UI font family");
assert_eq!(names.first().map(String::as_str), Some(primary));
assert_eq!(names.get(1), default_prop.first());
assert!(names.iter().position(|name| name == chinese).is_some_and(|position| position > default_prop.len()));
}
let mono = definitions.families.get(&FontFamily::Monospace).expect("monospace font family");
let default_mono = defaults.families.get(&FontFamily::Monospace).expect("default monospace fonts");
assert_eq!(mono.first(), default_mono.first());
assert!(mono.iter().position(|name| name == chinese).is_some_and(|position| position >= default_mono.len()));
set_language(Locale::En);
}
#[test]
fn separate_web_chinese_face_is_available_in_every_ui_family() {
// Simulate the WASM build, which embeds only the Japanese face. The Chinese face arrives
// as a separate file and must precede that face when the UI uses Chinese.
static JAPANESE: &[lightcraft_engine::CraftFont] = &[lightcraft_engine::CraftFont {
family: "BIZ UDPGothic",
style: "Regular",
scripts: &["Jpan", "Latn"],
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
}];
let embedded = lightcraft_engine::CRAFT_FONTS.iter().find(|font| font.family == "Noto Sans CJK SC" && font.style == "Regular");
let fallback: &'static [u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let source = embedded.map_or(fallback, |font| font.bytes);
let chinese = Arc::new(FontData::from_static(source));
set_language(Locale::ZhHans);
let definitions = font_definitions_with_chinese(JAPANESE, Some(&chinese));
let default_count = FontDefinitions::default().families.get(&FontFamily::Proportional).map_or(0, Vec::len);
for (family, primary) in [(FontFamily::Proportional, "Inter"), (FontFamily::Name(FONT_SEMIBOLD.into()), "Inter-SemiBold")] {
let names = definitions.families.get(&family).expect("UI font family");
assert_eq!(names.first().map(String::as_str), Some(primary));
assert_eq!(names.get(1 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT));
}
assert!(definitions.families.get(&FontFamily::Monospace).is_some_and(|names| names.contains(&WEB_CHINESE_FONT.to_string())));
if embedded.is_some() {
let ctx = egui::Context::default();
ctx.set_fonts(definitions);
let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {});
frame.textures_delta.clear();
ctx.fonts_mut(|fonts| {
for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] {
let id = FontId::new(13.0, family);
assert!("简体中文字".chars().all(|ch| fonts.has_glyph(&id, ch)), "{id:?}");
}
});
}
set_language(Locale::Ja);
let japanese = font_definitions_with_chinese(JAPANESE, Some(&chinese));
let names = japanese.families.get(&FontFamily::Proportional).expect("proportional fonts");
assert_eq!(names.get(1 + default_count).map(String::as_str), Some("craft-fonts BIZ UDPGothic Regular"));
assert_eq!(names.get(2 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT));
set_language(Locale::En);
}
}

View File

@ -1,6 +1,6 @@
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@ -10,7 +10,7 @@ Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity**
The JPEG's colourfulness also depends on brightness, as a camera's per-channel tone curve makes it: relative to a luminance-only curve, shadows were up to 1.4× more colourful and highlights above 0.85 luminance bleached to 0.1–0.3×, so warm sunlit rock rendered cream instead of white (+4 b* in the brightest fifth of the picture). A **chroma curve** (8 nodes over display luminance, `CameraTone::chroma`) is fitted to that ratio, including the bright pixels the colour and tone fits leave out, and applied in the finish stage right after the tone map (CPU and GPU), scaling chroma toward luminance so luminance is kept. It is kept only when it lowers the held-out error; smart previews carry it in their header, and older headers load without one.
Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately: 32 equal-population bins with robust median knots and pooled-adjacent-violators isotonic regression. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder.
Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately, by quantile matching: knot *i* pairs the median of the *i*-th 1/32 of the sorted scene luminances with the median of the *i*-th 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each scene quantile to the same JPEG quantile; this needs no pixel-exact registration and is increasing by construction. The earlier fit (the JPEG's median per scene-luminance bin, then pooled-adjacent-violators isotonic regression) broke on lens-corrected, sharpened JPEGs of busy scenes: neighbouring bins disagreed by more than the gap between them, pooling turned them into flat steps (13 of 31 segments on a CC0 Z 6II forest shot) that rendered as posterised patches with hard edges, and regression toward the mean flattened both ends of the curve (issue #475, also #211, #454). On the 1,781 files of the CC0 archive that render, 384 of the 1,023 accepted fits had at least one flat segment (1,073 in total, up to 14 of 31); with quantile matching 1,017 of the 1,023 have none. The quantile fit is slightly worse by least squares than the conditional medians (median held-out RMS 0.0331 against 0.0330, worst +0.013), and on 7 files (compacts, bridge cameras, early Micro Four Thirds) the look cleared the acceptance gates only with the old curve. The search therefore runs once with the quantile fit (all pixels, away from edges, with and without the camera profile's colour) and, only when no look passes, once more with the earlier conditional-median fit, kept as `fit_tone_conditional`; the gates are the same, so no file loses its look. Those 7 files render exactly as before, and 6 of them still have flat segments (35 in total). The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder.
The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged.
@ -85,6 +85,11 @@ An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `Color
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
### Samsung SRW
Samsung SRW decoding (uncompressed files) supplies the as-shot white-balance multipliers from the maker note and no colour
matrix, so SRW takes the same fit, gates and relative white balance.
### Canon CR2 and Pentax PEF
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening

View File

@ -12,9 +12,9 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
`cargo::warning` 提示这一点。
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
原生桌面版不受影响。
- 网页版将 Noto Sans CJK SC 作为独立的 `lightcraft_zh_hans.otf` 文件随站点分发,页面启动时加载;
这样可显示简体中文,也避免把约 16 MB 字体放进受单文件大小限制的 WASM。部署时需上传整个
`target/web/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护

View File

@ -22,7 +22,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。
未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
Web 建置在 WASM 中嵌入 BIZ UDPGothic Regular,並另外載入 Noto Sans CJK SC;繁中文字形仍採用簡中規範寫法。
## 自動化與維護

View File

@ -71,6 +71,39 @@ Every stdio MCP client takes the same shape: a `command` plus `args`. For exampl
During development you can also point the client at `cargo run --release -p lightcraft-cli -- mcp`
(with `"cwd"` set to the repository), at the cost of a slower start.
## Shared core tools
These follow the same conventions as [filmcraft #28](https://github.com/storytold/filmcraft/pull/28).
They are listed in both full and compact mode; existing documented helpers remain listed too.
There are no hidden compatibility aliases.
| Tool | Arguments | Result |
|---|---|---|
| `command_list` | `filter?`, `enabled_only?` | Command catalog |
| `command_run` | `id`, `params?` | Command result |
| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | Counts and per-step results; stops on error by default |
| `doc_inspect` | none | Library state and catalog counts |
| `render_preview` | `id?`, `max_side?`, `format?` | Image; neither changes selection nor saves a file |
Each edit in a batch has its own undo step. Connect mode also lists `ui_inspect` and
`ui_screenshot` (the latter has no output-path argument). The existing `inspect_ui`,
`screenshot`, `list_commands`, `run_command` and `render_photo` keep their documented arguments.
Ports and connect mode are unchanged.
Every listed tool has a title and all four MCP hints. Generated command tools are conservatively
marked as edits. Helpers with optional output paths are marked as writers; `export` is not
idempotent because the default conflict policy chooses a new filename on repeated calls.
Unknown helper argument keys return JSON-RPC `-32602` naming the key and accepted arguments.
Generated `cmd_*` tools and nested command params remain free-form: the registry has prose
parameter docs, not machine-readable schemas. Existing command validation (including strict
export options) is preserved. Escaped tool panics return `isError: true`, and the session keeps
serving; backend panics during resource reads or tool listing return an internal error.
The headless backend owns its session directly, without a session mutex.
`lightcraft://document` and `lightcraft://commands` contain JSON matching `doc_inspect` and
`command_list`. Existing resources remain available. Requests declaring MCP 2026-07-28 in
per-request `_meta` receive `resultType: complete` and list/read cache hints; reads are not cached.
## Tools
### Helpers
@ -186,3 +219,20 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d
(`Remote`) against a stand-in control server; also import → render → JPEG export of a real file.
- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`;
`commands`.
## Export progress and cancellation
Headless direct `export`, `command_run` / `run_command` with `app.export`, and `cmd_app_export`
calls report photo-count progress when `params._meta.progressToken` is a string or number.
Notifications are strictly increasing, at most ten per second plus the final total. No token
means no notifications. `ping` is answered at photo boundaries; other requests wait in order
until the export ends. EOF lets a pending export finish and preserves queued requests.
`notifications/cancelled` with `params.requestId` stops the matching export before its next
photo, suppressing its response. Unknown/completed request ids are ignored. A photo already
being processed finishes first: completed photos remain, each written with the existing atomic
file writer, and there are no partial files to delete. Unrelated outputs and earlier exports
are untouched. A failed export is an `isError` tool result. Transport mutexes recover poisoning.
Connect mode and exports inside `command_batch` remain synchronous and do not report MCP
progress or cancellation. Use a direct headless export call for this behavior.

View File

@ -142,7 +142,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
@ -168,7 +169,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back |
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Deletion retains surviving selected photos; if none remain, selects the next visible survivor (previous at the end) in the current source/filter/sort instead of jumping to the first photo; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) |
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow |
@ -295,7 +296,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
@ -354,7 +355,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider |
| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) |
| LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data |
@ -527,7 +528,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |

View File

@ -57,7 +57,9 @@ workflow, and bump it (in `release.yml`, five jobs, and `freebsd.yml`) whenever
shipped language needs: a stale pin still builds, it just ships tofu (issue #319: v0.4.0 pinned a revision
from before Noto Sans CJK SC, so Simplified Chinese had no glyphs). Before a release, check the pin against
craft-fonts' `fonts/manifest.txt` and run `CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n`,
whose glyph-coverage test fails when a language's characters have no face.
whose glyph-coverage test checks the characters in available faces. `CRAFT_FONTS_REQUIRED=1` now also makes
the native build fail if the pinned manifest lacks Noto Sans CJK SC Regular (`Hans`) or BIZ UDPGothic Regular;
the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a separate asset.
## Signing credentials

View File

@ -52,13 +52,14 @@ codecs) at opt-level 3 and the rest (egui, eframe, serde, glue) at "s". What goe
is the brotli column: 3.1 MB, 11 % less than the old build's brotli size and 41 % less than its
gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further.
**Fonts.** The browser has no system fonts to fall back on, so Japanese text comes from
**Fonts.** The browser has no system fonts to fall back on, so CJK text comes from
[craft-fonts](https://github.com/storytold/craft-fonts), the optional `CRAFT_FONTS_DIR` build
input (`CRAFT_FONTS_DIR=../craft-fonts cargo xtask web`; release builds always set it). On wasm32
`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI, and the watermark fallback), so
the module stays well under Cloudflare's 25 MiB per-file limit: measured 2026-10-06, 17.1 MB
without craft-fonts and 21.8 MB with it (brotli 3.8 MB / 6.3 MB). Without it the web build
works, but Japanese text has no glyphs.
`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI and watermark fallback). The web
bundle also ships Noto Sans CJK SC Regular as `lightcraft_zh_hans.otf` and loads it before the UI
starts. Keeping the 16 MB Chinese face separate leaves the module under Cloudflare's 25 MiB
per-file limit (the older Japanese-only module measured 21.8 MB). Without `CRAFT_FONTS_DIR`, the
web build still works, but Chinese and Japanese text lack glyphs.
## Deploying: headers
@ -111,9 +112,10 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
(`originals/<hash>/<file name>`, so an unzipped backup is browsable; entries are stored, not
compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a
new library folder (`library-restored-<time>/`; originals already stored are skipped, every
entry's checksum is verified) and only then switches to it (`active-library` names the
folder in use) and reloads: the previous library stays in storage. Both are web-only
(`file.backupLibrary`, `file.restoreLibrary`; the desktop library is a folder).
entry's checksum is verified, and each restored original must match its content hash) and
only then switches to it (`active-library` names the folder in use) and reloads: the previous
library stays in storage. Both are web-only (`file.backupLibrary`, `file.restoreLibrary`; the
desktop library is a folder).
- A failed save (quota exceeded, storage cleared) is not silent: the catalog then refuses new
writes, so commands report `saved in memory but not written`, the top bar shows the unsaved
warning (as on the desktop, see `docs/control-protocol.md`), and saving is retried every

View File

@ -2,6 +2,11 @@
## October 2026
### Masking
- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated
ellipses and radial components in compound masks. Edge and rotation handles keep their existing
functions, and one Undo restores the entire drag.
### Albums tree
- Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.
- Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…
@ -32,13 +37,19 @@
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
### RAW decoding
- Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1
and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their
camera JPEG. Photos already imported pick the change up on Reload.
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
- JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);
lossy tiles decode too, but no real file has checked them yet. A JPEG XL preview stored in the DNG is used like an
lossy tiles decode too, keeping the raw values above 1.0 instead of clipping them (checked on one real file). A tile that cannot be decoded makes the file open from its embedded
preview, with the reason, instead of showing a black tile. A JPEG XL preview stored in the DNG is used like an
embedded JPEG.
- Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is
exported or converted to DNG. It is not applied to the render: Lightroom Classic renders ProRAW without it.
exported or converted to DNG. It is not applied by default: Lightroom Classic renders ProRAW without it. To see a
ProRAW the way the iPhone renders it, turn on Profile ▸ Camera local tone mapping (shown for photos that carry the
map); the option is per photo, so presets and Copy Settings carry it.
- iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select
Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it
(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.

View File

@ -15,6 +15,8 @@ static site in `lightcraft-web-<version>/`:
| `lightcraft_web.js` | wasm-bindgen glue (generated, ES module) |
| `lightcraft_web_bg.wasm` | The app, about 13 MB (about 3 MB with brotli) |
| `worker.js` | Starts the render workers (each runs the same module) |
| `lightcraft_zh_hans.otf` | Simplified Chinese UI font, included when built with `CRAFT_FONTS_DIR` |
| `OFL-noto-sans-cjk-sc.txt` | The bundled Chinese font's SIL Open Font License |
| `*.gz`, `*.br` | Precompressed copies of the files above (optional to serve) |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |

View File

@ -10,6 +10,8 @@ workspace = true
[dependencies]
serde_json = { workspace = true }
# corpus download checksums
sha2 = { workspace = true }
# precompressed web bundle (`cargo xtask web`); pure Rust
flate2 = "1"
brotli = { version = "8", default-features = false, features = ["std"] }

View File

@ -9,8 +9,8 @@ use std::process::Command;
/// Extensions treated as assets (lower-case).
const ASSET_EXT: &[&str] = &[
"png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "icns", "bmp", "ttf", "otf", "woff", "woff2", "wav", "mp3", "ogg", "flac", "mp4",
"mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "heic", "jxl", "psd", "cube", "icc", "icm",
"xmp",
"mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "heic", "jxl", "psd", "cube", "icc",
"icm", "xmp",
];
/// Adobe-specific formats we never ship (camera/lens profiles, Lightroom templates).
const FORBIDDEN_EXT: &[&str] = &["dcp", "lcp", "lrtemplate", "lrcat", "lrsmcol", "aco", "ase", "abr"];

View File

@ -12,7 +12,7 @@ mod stats;
mod version;
mod web;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
const USAGE: &str = "\
@ -35,7 +35,7 @@ commands:
--serve serves it on http://127.0.0.1:<port> (default 8080)
ci fmt --check, clippy -D warnings, heif, 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/
show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
";
@ -305,162 +305,451 @@ fn cmd_ci() -> Result<(), String> {
}
/// 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).
/// CC0 raw samples from raw.pixls.us (each verified CC0 on the site).
/// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it.
/// One per format / compression variant we decode or deliberately report as unsupported (preview only).
const RAW_SAMPLES: &[(&str, &str)] = &[
const RAW_SAMPLES: &[(&str, &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",
"250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f",
),
(
"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",
"ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd",
),
(
"arw-sony-a7m4-14bit.arw",
"https://raw.pixls.us/getfile.php/6936/nice/Sony%20-%20ILCE-7M4%20-%2014bit%20%283:2%29.ARW",
"639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2",
),
("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"),
(
"arw-sony-a7m4-lossless-l.arw",
"https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW",
"851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6",
),
(
"arw-sony-a7m4-lossless-m.arw",
"https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW",
"d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571",
),
(
"arw-sony-a7m4-lossless-s.arw",
"https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW",
"cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223",
),
// 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"),
(
"arw-sony-rx100m3.arw",
"https://raw.pixls.us/data/Sony/DSC-RX100M3/DSC00734.ARW",
"cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138",
),
(
"arw-sony-rx100.arw",
"https://raw.pixls.us/data/Sony/DSC-RX100/DSC00838.ARW",
"579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b",
),
(
"arw-sony-a7rm2-12bit-uncompressed.arw",
"https://raw.pixls.us/data/Sony/ILCE-7RM2/12-bit-uncompressed.ARW",
"71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39",
),
// 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"),
("cr3-canon-m50-raw.cr3", "https://raw.pixls.us/getfile.php/4657/nice/Canon%20-%20EOS%20M50%20-%203:2.CR3"),
("cr3-canon-r100-raw.cr3", "https://raw.pixls.us/getfile.php/7896/nice/Canon%20-%20EOS%20R100%20-%20RAW%20%283:2%29.CR3"),
("cr3-canon-r100-craw.cr3", "https://raw.pixls.us/getfile.php/7897/nice/Canon%20-%20EOS%20R100%20-%20CRAW%20%283:2%29.CR3"),
("cr3-canon-r8-raw.cr3", "https://raw.pixls.us/getfile.php/6585/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3"),
("cr3-canon-r8-craw.cr3", "https://raw.pixls.us/getfile.php/6587/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3"),
(
"cr2-canon-40d.cr2",
"https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2",
"775c806358fedddec7622113a5e399a3330bd5a65e35e37ae1108d8bf58067be",
),
(
"cr2-canon-550d.cr2",
"https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2",
"f390e0ba566f0be9af2d8e7f955b815996630c0c3cb13fcfb95d98baec1890f6",
),
(
"cr2-canon-5d2.cr2",
"https://raw.pixls.us/data/Canon/EOS%205D%20Mark%20II/08.canon.raw.cr2",
"12b3ad6aed9fbcd19ba3bfffcd0769aa5a041d610205eee840bf4b2c420dffae",
),
(
"cr2-canon-5dsr.cr2",
"https://raw.pixls.us/data/Canon/EOS%205DS%20R/_DSR2002.CR2",
"01b94b0586419d64894ccdf2e7b9f1defeedc7b51dfcd306c622fe784181a9b8",
),
(
"cr2-canon-6d.cr2",
"https://raw.pixls.us/data/Canon/EOS%206D/EOS_6D_RAW.CR2",
"360842779d08fe4805b4a2fe4979f06fdc6da982c85c3acb181f25bcf4c9f536",
),
(
"cr2-canon-7d.cr2",
"https://raw.pixls.us/data/Canon/EOS%207D/RAW_CANON_EOS_7D-raw.CR2",
"b5e47c5fcf7332ac03e0134926f17a338a42e68c1fd7f83e16f45f4b767544e8",
),
(
"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",
"a79065e78a6c5bdbc8726e786ade02d54a7d496cc3cb4504d804c924a3d999da",
),
(
"cr2-canon-5d3.cr2",
"https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2",
"ec069b178b9383d80c72f8cdc1b79bd54ca451e7c20793b109d8ad73bef6bdf6",
),
(
"cr2-canon-80d.cr2",
"https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2",
"8f28465cee09844ffad95dbd3ec91923717a329c5ec4f3f405f5b4fce83aaea3",
),
(
"cr3-canon-m50-craw.cr3",
"https://raw.pixls.us/getfile.php/2663/nice/Canon%20-%20EOS%20M50%20-%20CRAW%20%283:2%29.CR3",
"15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c",
),
(
"cr3-canon-m50-raw.cr3",
"https://raw.pixls.us/getfile.php/4657/nice/Canon%20-%20EOS%20M50%20-%203:2.CR3",
"1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058",
),
(
"cr3-canon-r100-raw.cr3",
"https://raw.pixls.us/getfile.php/7896/nice/Canon%20-%20EOS%20R100%20-%20RAW%20%283:2%29.CR3",
"6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521",
),
(
"cr3-canon-r100-craw.cr3",
"https://raw.pixls.us/getfile.php/7897/nice/Canon%20-%20EOS%20R100%20-%20CRAW%20%283:2%29.CR3",
"0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942",
),
(
"cr3-canon-r8-raw.cr3",
"https://raw.pixls.us/getfile.php/6585/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3",
"7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c",
),
(
"cr3-canon-r8-craw.cr3",
"https://raw.pixls.us/getfile.php/6587/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3",
"df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812",
),
(
"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",
"0608c9f277307b745530e61b589c465540611df22f9a1fe2d01237c5b2b4c13b",
),
(
"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",
"3118116735d4f6dd01fd9d6a80ee9aac52a2c2debdc6dcd112bd5e3059044a55",
),
(
"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",
"159326856c29073e845c3c5a9ecf98c6474f43ca15798a88ad5e2baecd0664b7",
),
// Apple ProRAW (iPhone 12 Pro, iOS 14.3): LinearRaw LJ92 tiles, ProfileToneCurve, ProfileGainTableMap, a sky matte.
// sha256 e91e77a4533ed7cce551d83330676ea5c47dd5e55fb38adda7819366afdbdfc2
("dng-apple-iphone12pro-proraw.dng", "https://raw.pixls.us/data/Apple/iPhone%2012%20Pro/IMG_1361.DNG"),
(
"dng-apple-iphone12pro-proraw.dng",
"https://raw.pixls.us/data/Apple/iPhone%2012%20Pro/IMG_1361.DNG",
"e91e77a4533ed7cce551d83330676ea5c47dd5e55fb38adda7819366afdbdfc2",
),
(
"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",
"1d77dcc6cdb839b10c1948f0b4471984ab48cf15e973fb77520584cee72b603e",
),
(
"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",
"bc29ac8a37c800346749b9a5a1adc2722357f7f0e49df05a7104454881604b92",
),
(
"dng-canon-5d3-16bit.dng",
"https://raw.pixls.us/getfile.php/885/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2016bit%2016bit%20RAW.dng",
"6275226bfa2d86ba479130999d045ea0b8da6be4b011130c9eb91cd6a0eb2233",
),
(
"dng-google-pixel2xl.dng",
"https://raw.pixls.us/getfile.php/2206/nice/Google%20-%20Pixel%202%20XL%20-%2016bit%20%284:3%29.dng",
"5541093a369f0487fef92bcd1f5fef1be6e4e3cec2aa4d52ad38d2211f3309d7",
),
(
"dng-ricoh-gr3.dng",
"https://raw.pixls.us/getfile.php/3115/nice/Ricoh%20-%20GR%20III%20-%2014bit%20%283:2%29.DNG",
"05513ee72f7cac4165534c9f64995412084f414a033c80c473c79fd94154292e",
),
("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",
"3ce328830942381648be26b372a54b317fdb27b63c1aa9c2ee1fce076a2e1b4f",
),
(
"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",
"2fb2f1b87a3b89de5ba9e56527f21a912ca624f2fa2a594cef108ee8d7180412",
),
(
"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",
"986db2bf9ed08cf095bb94cfbaf02f0e89d123edad28b63c05133e7815749486",
),
(
"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",
"1f4e58a961d3d317fa39a7e1c8948b825651e968c8cd06e49e4239f612faccad",
),
(
"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",
"430b4f1be4e53a011861b63294ad19fdc0353ec3ea62104f77d4c193e3dd3fc8",
),
(
"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",
"a175d5892304e79282add07eacf4bdc498ca3f050af48cf989c080355b4735ec",
),
(
"orf-olympus-e1.orf",
"https://raw.pixls.us/getfile.php/1800/nice/Olympus%20-%20E-1%20-%2016bit%20%284:3%29.ORF",
"042286653fbae5b085bef4a4e626145385ea6e82e817f166b4904abcef64457c",
),
(
"orf-olympus-e400.orf",
"https://raw.pixls.us/getfile.php/2151/nice/Olympus%20-%20E-400%20-%2016bit%20%284:3%29.ORF",
"1354e0ac98ea90820227650031d8215637a2aaabd15de995c5a1e00b1986261b",
),
(
"orf-olympus-em1.orf",
"https://raw.pixls.us/getfile.php/1051/nice/Olympus%20-%20E-M1%20-%2016bit%20%284:3%29.orf",
"19ba17e778ae802d4039f0e88bb1cd68825780788b8058c479067e32b9a63eda",
),
(
"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",
"11d90cbb564ad2c660c7a8d5d81d99c2c018aa27fdec45ed6c461fa5d24f942b",
),
(
"orf-olympus-xz2.orf",
"https://raw.pixls.us/getfile.php/1432/nice/Olympus%20-%20XZ-2%20-%2012bit%20%284:3%29.orf",
"675fefe7c9e99281783b987889d723b5f46692f5c20e19fed5e422c7c6d2636b",
),
(
"pef-pentax-k10d.pef",
"https://raw.pixls.us/getfile.php/2239/nice/Pentax%20-%20K10D%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF",
"e35ae4154a468be3154f5f462e884ba5941f010d3e8f23d347fbec14809f44d3",
),
(
"pef-pentax-k3.pef",
"https://raw.pixls.us/getfile.php/1075/nice/Pentax%20-%20K-3%20-%2014bit%20%283:2%29.PEF",
"abfb3907b53734b5f353e8de0c6173470c7aad07f9cecc10f723d222a49dec9a",
),
(
"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",
"e579e7360c35e512f9c3a5c0eb9519575b87b489c6f86be82590f7706a7ed17b",
),
// Samsung SRW, one file per packing: 12-bit MSB-first (NX10), 12-bit LSB-first (NX20), 16-bit words (EX1).
(
"srw-samsung-nx10.srw",
"https://raw.pixls.us/getfile.php/5913/nice/Samsung%20-%20NX10%20-%2012bit%20%283:2%29.SRW",
"dead7b4ad83b739ff7b8a1218882589f66cb14efc09f79eeba7a1dcbf7abd29f",
),
(
"srw-samsung-nx20.srw",
"https://raw.pixls.us/getfile.php/5370/nice/Samsung%20-%20NX20%20-%2012bit%20%283:2%29.SRW",
"8a367db506b8f7a6661a6f33a730467cfb3bfd7e46020a9f0df702330a7b45bb",
),
(
"srw-samsung-ex1.srw",
"https://raw.pixls.us/getfile.php/1204/nice/Samsung%20-%20EX1%20-%2014bit%20%284:3%29.SRW",
"a084890fd9d7995ceb746c18ecf5be48c95baf14286d5c0c480cc3daa25f3e37",
),
("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",
"58ad687b23138c3c49ebeb6c1f80daf13185349be364b947e2bf27e39a462d2f",
),
(
"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",
"46daaf8c2b07f40bd01f737181d048a497bb0a87615a1fde65809fdd5de86bf1",
),
(
"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",
"0fa01fa1a674bcfa40f659225b4e66c5bedf15d2f78407ca1966dc8f38cd0e9f",
),
(
"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",
"afe552f4a2794c7aa2376dd826165354b243dcf6f2296b01505dded8c8e4be76",
),
("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",
"a23045101b2912e64f154100d646c228c0d16585d21e95e0d1a006e537f7784c",
),
(
"raw-panasonic-fz50.raw",
"https://raw.pixls.us/getfile.php/2234/nice/Panasonic%20-%20DMC-FZ50%20-%204:3.RAW",
"a776a941758214bf4d1342e2b89ed756bfcad5877e038cf9df3bdda502c432ea",
),
(
"raw-panasonic-fz8.raw",
"https://raw.pixls.us/getfile.php/2282/nice/Panasonic%20-%20DMC-FZ8%20-%204:3.RAW",
"a11f1534f3092a4d5f2b57d182f6b06f8d26cf766eb463c3e02b26a338b63b8b",
),
(
"rw2-panasonic-fz1000m2-4x3.rw2",
"https://raw.pixls.us/getfile.php/4706/nice/Panasonic%20-%20DC-FZ10002%20-%204:3.RW2",
"ab30922774bd829a992471d89eeefe1e829565a886d1477958130a2fa4fb77a1",
),
("raw-panasonic-fz50.raw", "https://raw.pixls.us/getfile.php/2234/nice/Panasonic%20-%20DMC-FZ50%20-%204:3.RAW"),
("raw-panasonic-fz8.raw", "https://raw.pixls.us/getfile.php/2282/nice/Panasonic%20-%20DMC-FZ8%20-%204:3.RAW"),
("rw2-panasonic-fz1000m2-4x3.rw2", "https://raw.pixls.us/getfile.php/4706/nice/Panasonic%20-%20DC-FZ10002%20-%204:3.RW2"),
// Fujifilm predictive compression: older X-Trans, 40 MP X-Trans, 14/16-bit GFX; both compression modes.
(
"raf-fuji-gfx100-3773.raf",
"https://raw.pixls.us/getfile.php/3773/nice/Fujifilm%20-%20GFX%20100%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF",
"018781e126842596fcf3876b3fb6cd8973dc3936ccc753ee8648618be8889287",
),
(
"raf-fuji-gfx100-3775.raf",
"https://raw.pixls.us/getfile.php/3775/nice/Fujifilm%20-%20GFX%20100%20-%2014bit%2014bit%20compressed%20%284:3%29.RAF",
"0e7dad9819a748bb3d3be26bbf5770d4968e2d80b9cb41d97036bf7c27f7bfe7",
),
(
"raf-fuji-gfx100rf-8091.raf",
"https://raw.pixls.us/getfile.php/8091/nice/Fujifilm%20-%20GFX100RF%20-%2016bit%20compressed%20%284:3%29.RAF",
"14bed5ac8191d6f2a7c82984e3bf6470ad4740df329293eac42b8018cc6a668a",
),
("raf-fuji-gfx100rf-8091.raf", "https://raw.pixls.us/getfile.php/8091/nice/Fujifilm%20-%20GFX100RF%20-%2016bit%20compressed%20%284:3%29.RAF"),
(
"raf-fuji-gfx100s-4495.raf",
"https://raw.pixls.us/getfile.php/4495/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20lossless%20compressed%20%284:3%29.RAF",
"a7bf8f061b603f7ef5fd29276eb6b2ac57355ca2f589a340117bfff0d03c065c",
),
(
"raf-fuji-gfx100s-4503.raf",
"https://raw.pixls.us/getfile.php/4503/nice/Fujifilm%20-%20GFX100S%20-%2016bit%2016bit%20compressed%20%284:3%29.RAF",
"1bb4608b61861a3d9e85b0ca6c00689b8ba4620cbe8579374d958c21ed08edf0",
),
(
"raf-fuji-gfx50s-1435.raf",
"https://raw.pixls.us/getfile.php/1435/nice/Fujifilm%20-%20GFX%2050S%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"a7124d13910e0241a6b035154bc1fcdb7be4439371d043b1e1950a0251feb51e",
),
(
"raf-fuji-xe5-8509.raf",
"https://raw.pixls.us/getfile.php/8509/nice/Fujifilm%20-%20X-E5%20-%2014bit%20lossy%20compressed%20%283:2%29.RAF",
"28f56cc9784164209e92fae209bdcc78267bd692aa2fb964595485f7e93692ab",
),
(
"raf-fuji-xh2-6001.raf",
"https://raw.pixls.us/getfile.php/6001/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"caeadb2666f6c5d9bbe32fbe692c2079b6a7e028f8514a13173f360656d08507",
),
(
"raf-fuji-xh2-6002.raf",
"https://raw.pixls.us/getfile.php/6002/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"109be38f59963c9b8ff289ebe1f5ec9168c52f609e5b601432bd194520b4605b",
),
(
"raf-fuji-xm5-7748.raf",
"https://raw.pixls.us/getfile.php/7748/nice/Fujifilm%20-%20X-M5%20-%2014bit%20compressed%20%283:2%29.RAF",
"c76ce546e15727abb4ba201471010c554403176963a1a6b8cc017462606429e5",
),
(
"raf-fuji-xt2-865.raf",
"https://raw.pixls.us/getfile.php/865/nice/Fujifilm%20-%20X-T2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"f756ad5be551a8b3e1b503a8a6f7e8e637fba519c0b0f8c79dd58e94b0287602",
),
("raf-fuji-xe5-8509.raf", "https://raw.pixls.us/getfile.php/8509/nice/Fujifilm%20-%20X-E5%20-%2014bit%20lossy%20compressed%20%283:2%29.RAF"),
("raf-fuji-xh2-6001.raf", "https://raw.pixls.us/getfile.php/6001/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xh2-6002.raf", "https://raw.pixls.us/getfile.php/6002/nice/Fujifilm%20-%20X-H2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xm5-7748.raf", "https://raw.pixls.us/getfile.php/7748/nice/Fujifilm%20-%20X-M5%20-%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt2-865.raf", "https://raw.pixls.us/getfile.php/865/nice/Fujifilm%20-%20X-T2%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
(
"raf-fuji-xt4-3914.raf",
"https://raw.pixls.us/getfile.php/3914/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.RAF",
"07ea642656074834ffe5c661870a612ffa2e80fc1218a05166ee966bbe5f6fae",
),
(
"raf-fuji-xt4-3918.raf",
"https://raw.pixls.us/getfile.php/3918/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"871836938ffa695f4c41765ddb107c2703ce755f7f3ced7e10f9e04746f1734e",
),
(
"raf-fuji-xt5-6122.raf",
"https://raw.pixls.us/getfile.php/6122/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"b2a067e7fda16338f15c2532caa12b238b2415dc1c75a96a6d24d3445cfd0ae3",
),
(
"raf-fuji-xt5-6123.raf",
"https://raw.pixls.us/getfile.php/6123/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF",
"cddd7ae0c43f9280876e5fdcbdf8878718972ebd3d034d8affc8cfc6752d33dc",
),
(
"raf-fuji-xt50-7807.raf",
"https://raw.pixls.us/getfile.php/7807/nice/Fujifilm%20-%20X-T50%20-%2014bit%20compressed%20%283:2%29.RAF",
"3a8a9ac6d92274969aaa1f55ea669191b98bb8f588d00ec8ec17afd8b3963dea",
),
(
"rw2-panasonic-g9-b.rw2",
"https://raw.pixls.us/getfile.php/2348/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2",
"16282357ce0143ff7d36737f82074662db7aa0790ecd309196f406baf0f586a7",
),
(
"rw2-panasonic-g9.rw2",
"https://raw.pixls.us/getfile.php/2585/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2",
"7a56babeb19f9bde3eaa532a4c8d1b11637a0f550963a1182ec845722c65583d",
),
(
"rw2-panasonic-gh1.rw2",
"https://raw.pixls.us/getfile.php/1323/nice/Panasonic%20-%20DMC-GH1%20-%204:3.RW2",
"2b846f932c665f196af9af97b18469730331b60b2c62811969be7db245ce6cce",
),
(
"rw2-panasonic-gh5.rw2",
"https://raw.pixls.us/getfile.php/1517/nice/Panasonic%20-%20DC-GH5%20-%204:3.RW2",
"dae2281df393d8c3c1b8c513d00985f1834ae0e69ce18fbd571370c25d95d22b",
),
(
"rw2-panasonic-gh5m2.rw2",
"https://raw.pixls.us/getfile.php/5082/nice/Panasonic%20-%20DC-GH5M2%20-%204:3.RW2",
"0ea0efd2a9442d8c68508e4196e29f1da4577407dc3df5add4cf360f70493c7b",
),
(
"rw2-panasonic-gh5s.rw2",
"https://raw.pixls.us/getfile.php/2603/nice/Panasonic%20-%20DC-GH5S%20-%204:3.RW2",
"b617151876e62b492641cef14b8c98ee3797e5014d24c6af1c780715a501fa53",
),
(
"rw2-panasonic-gh6.rw2",
"https://raw.pixls.us/getfile.php/5876/nice/Panasonic%20-%20DC-GH6%20-%204:3.RW2",
"07996bca904a178d02c5f56ee03a8dce775a0e1150c1beeaec51d72ff5e700d0",
),
(
"rw2-panasonic-gx80.rw2",
"https://raw.pixls.us/getfile.php/1569/nice/Panasonic%20-%20DMC-GX80%20-%204:3.RW2",
"6e9a419c70c2124630912b2934027c635d3871e3d22dd91fed0655d58cfb5382",
),
(
"rw2-panasonic-s1.rw2",
"https://raw.pixls.us/getfile.php/3038/nice/Panasonic%20-%20DC-S1%20-%203:2.RW2",
"45309a096d6010281e2fa310e6ec2fe1bcc02947fa37b1a2414cfc7377ce4ac6",
),
(
"rw2-panasonic-s5-format7.rw2",
"https://raw.pixls.us/getfile.php/6339/nice/Panasonic%20-%20DC-S5%20-%203:2.RW2",
"3615c85207097180b231f0c89d018eb3f1a71988780612921eb0a05246bca706",
),
(
"rw2-panasonic-s5m2.rw2",
"https://raw.pixls.us/getfile.php/7790/nice/Panasonic%20-%20DC-S5M2%20-%2014bit%20%283:2%29.RW2",
"53a171521a7f9af6910b4ab61c5a4a42de0cceb24166e15b07eee1e00675028f",
),
(
"rw2-panasonic-s9.rw2",
"https://raw.pixls.us/getfile.php/7702/nice/Panasonic%20-%20DC-S9%20-%203:2.RW2",
"290ad5b4e396cb241a59459b2613d2b0659a03ff654e5d5368b3376d35812e50",
),
(
"rwl-leica-dlux7.rwl",
"https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL",
"4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c",
),
("raf-fuji-xt4-3918.raf", "https://raw.pixls.us/getfile.php/3918/nice/Fujifilm%20-%20X-T4%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt5-6122.raf", "https://raw.pixls.us/getfile.php/6122/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt5-6123.raf", "https://raw.pixls.us/getfile.php/6123/nice/Fujifilm%20-%20X-T5%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF"),
("raf-fuji-xt50-7807.raf", "https://raw.pixls.us/getfile.php/7807/nice/Fujifilm%20-%20X-T50%20-%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-gh1.rw2", "https://raw.pixls.us/getfile.php/1323/nice/Panasonic%20-%20DMC-GH1%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-gh5m2.rw2", "https://raw.pixls.us/getfile.php/5082/nice/Panasonic%20-%20DC-GH5M2%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-gh6.rw2", "https://raw.pixls.us/getfile.php/5876/nice/Panasonic%20-%20DC-GH6%20-%204:3.RW2"),
("rw2-panasonic-gx80.rw2", "https://raw.pixls.us/getfile.php/1569/nice/Panasonic%20-%20DMC-GX80%20-%204:3.RW2"),
("rw2-panasonic-s1.rw2", "https://raw.pixls.us/getfile.php/3038/nice/Panasonic%20-%20DC-S1%20-%203:2.RW2"),
("rw2-panasonic-s5-format7.rw2", "https://raw.pixls.us/getfile.php/6339/nice/Panasonic%20-%20DC-S5%20-%203:2.RW2"),
("rw2-panasonic-s5m2.rw2", "https://raw.pixls.us/getfile.php/7790/nice/Panasonic%20-%20DC-S5M2%20-%2014bit%20%283:2%29.RW2"),
("rw2-panasonic-s9.rw2", "https://raw.pixls.us/getfile.php/7702/nice/Panasonic%20-%20DC-S9%20-%203:2.RW2"),
("rwl-leica-dlux7.rwl", "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL"),
];
fn cmd_corpus(download: bool) -> Result<(), String> {
@ -479,16 +768,41 @@ Tests that use a corpus skip cleanly when it is absent.
}
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 mut bad = Vec::new();
for (name, url, sha256) in RAW_SAMPLES {
let out = dest.join(name);
if out.exists() {
continue;
if !out.exists() {
let mut curl = Command::new("curl");
curl.args(["-fsSL", "-o"]).arg(&out).arg(url);
run(curl, &format!("curl {url}"))?;
}
let actual = file_sha256(&out)?;
if actual != *sha256 {
// delete it, so the next run downloads it again instead of trusting it
std::fs::remove_file(&out).map_err(|e| format!("remove {}: {e}", out.display()))?;
bad.push(format!("{name}: sha256 is {actual}, expected {sha256} (file deleted; source {url})"));
}
let mut curl = Command::new("curl");
curl.args(["-fsSL", "-o"]).arg(&out).arg(url);
run(curl, &format!("curl {url}"))?;
}
Ok(())
if bad.is_empty() {
return Ok(());
}
Err(format!(
"{} corpus file(s) do not match their pinned sha256:
{}",
bad.len(),
bad.join(
"
"
)
))
}
fn file_sha256(path: &Path) -> Result<String, String> {
use sha2::{Digest, Sha256};
let mut file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
let mut hash = Sha256::new();
std::io::copy(&mut file, &mut hash).map_err(|e| format!("read {}: {e}", path.display()))?;
Ok(format!("{:x}", hash.finalize()))
}
#[cfg(test)]
@ -507,3 +821,19 @@ mod ci_jobs_tests {
assert_eq!(ci_jobs(None, 2), 2);
}
}
#[cfg(test)]
mod corpus_tests {
use super::RAW_SAMPLES;
#[test]
fn every_sample_has_a_unique_name_and_a_sha256() {
let mut names: Vec<_> = RAW_SAMPLES.iter().map(|s| s.0).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), RAW_SAMPLES.len(), "duplicate file name");
for (name, _, sha256) in RAW_SAMPLES {
assert!(sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')), "{name}: not a lowercase sha256");
}
}
}

View File

@ -13,6 +13,8 @@ use std::process::Command;
use crate::{cargo, metadata, root, run as step};
const TARGET: &str = "wasm32-unknown-unknown";
const CHINESE_FONT_FILE: &str = "lightcraft_zh_hans.otf";
const CHINESE_FONT_LICENSE_FILE: &str = "OFL-noto-sans-cjk-sc.txt";
/// Files copied from `apps/lightcraft-web/` into the bundle as they are.
const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
@ -20,6 +22,51 @@ const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
/// Bundle files that get precompressed copies.
const COMPRESSED: [&str; 4] = ["index.html", "worker.js", "lightcraft_web.js", "lightcraft_web_bg.wasm"];
/// The Noto face is shipped beside the WASM rather than embedded in it: hosts such as Cloudflare
/// Pages cap individual files at 25 MiB, while the complete site can contain larger assets.
fn chinese_font_in_manifest(manifest: &str) -> Option<(&str, &str)> {
manifest.lines().find_map(|line| {
let fields: Vec<_> = line.split('|').map(str::trim).collect();
if fields.len() >= 6 && fields[0] == "Noto Sans CJK SC" && fields[1] == "Regular" && !fields[2].is_empty() && !fields[5].is_empty() {
Some((fields[2], fields[5]))
} else {
None
}
})
}
fn copy_chinese_font(out: &Path) -> Result<bool, String> {
// A previous font-enabled build must not leave an unlicensed/stale asset in a font-free one.
for name in [CHINESE_FONT_FILE, CHINESE_FONT_LICENSE_FILE, "lightcraft_zh_hans.otf.gz", "lightcraft_zh_hans.otf.br"] {
let path = out.join(name);
match std::fs::remove_file(&path) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(format!("{}: {e}", path.display())),
}
}
let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR") else {
if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() {
return Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into());
}
return Ok(false);
};
let dir = PathBuf::from(dir);
let dir = if dir.is_absolute() { dir } else { root().join(dir) };
let manifest_path = dir.join("fonts/manifest.txt");
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
let (font, licence) =
chinese_font_in_manifest(&manifest).ok_or_else(|| format!("{}: Noto Sans CJK SC Regular is missing", manifest_path.display()))?;
let licence = dir.join(licence);
let licence_target = out.join(CHINESE_FONT_LICENSE_FILE);
std::fs::copy(&licence, &licence_target).map_err(|e| format!("{} → {}: {e}", licence.display(), licence_target.display()))?;
let source = dir.join(font);
let target = out.join(CHINESE_FONT_FILE);
std::fs::copy(&source, &target).map_err(|e| format!("{} → {}: {e}", source.display(), target.display()))?;
println!("web Chinese font: {} → {}", source.display(), target.display());
Ok(true)
}
/// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly).
fn locked_bindgen_version() -> Result<String, String> {
let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?;
@ -84,10 +131,11 @@ pub fn run(args: &[&str]) -> Result<(), String> {
for f in STATIC_FILES {
std::fs::copy(root().join("apps/lightcraft-web").join(f), out.join(f)).map_err(|e| format!("copy {f}: {e}"))?;
}
let chinese_font = copy_chinese_font(&out)?;
// precompressed copies (skipped for --dev: they'd only slow the edit loop down)
println!("\n{:<26} {:>12} {:>12} {:>12}", "file", "bytes", "gzip -9", "brotli -11");
for f in COMPRESSED {
for f in COMPRESSED.into_iter().chain(chinese_font.then_some(CHINESE_FONT_FILE)) {
let p = out.join(f);
let raw = std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?;
let (gz, br) = if dev {
@ -138,6 +186,8 @@ fn mime(path: &Path) -> &'static str {
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"otf" => "font/otf",
"txt" => "text/plain; charset=utf-8",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
@ -242,6 +292,16 @@ mod tests {
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");
assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8");
assert_eq!(mime(Path::new("worker.js")), "text/javascript; charset=utf-8");
assert_eq!(mime(Path::new(CHINESE_FONT_FILE)), "font/otf");
}
#[test]
fn web_bundle_selects_the_chinese_face_from_the_font_manifest() {
let manifest = "# family | style | path | scripts | licence | licence file\n\
BIZ UDPGothic | Regular | fonts/biz.ttf | Jpan,Latn | OFL-1.1 | fonts/biz/OFL.txt\n\
Noto Sans CJK SC | Regular | fonts/noto/NotoSansCJKsc-Regular.otf | Hans,Latn | OFL-1.1 | fonts/noto/OFL.txt\n";
assert_eq!(chinese_font_in_manifest(manifest), Some(("fonts/noto/NotoSansCJKsc-Regular.otf", "fonts/noto/OFL.txt")));
assert_eq!(chinese_font_in_manifest("BIZ UDPGothic | Regular | fonts/biz.ttf | Jpan,Latn | OFL-1.1 | fonts/biz/OFL.txt"), None);
}
#[test]